1pub mod skill_http;
2pub mod skill_tool;
3use anyhow::{Context, Result};
4use directories::UserDirs;
5use reqwest::Url;
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::io::Cursor;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::time::{Duration, SystemTime};
12
13use zip::ZipArchive;
14
15pub mod audit;
16pub mod bundle;
17pub mod constants;
18pub mod creator;
19pub mod document;
20pub mod frontmatter;
21pub mod improver;
22pub mod reference;
23pub mod scaffold;
24pub mod service;
25mod suggestions;
26pub mod testing;
27
28pub use bundle::{BundleError, BundleSummary};
29pub use document::{DocumentParseError, SkillDocument};
30pub use frontmatter::SkillFrontmatter;
31pub use reference::{SkillRef, SkillRefError};
32pub use scaffold::{ScaffoldError, ScaffoldOptions};
33pub use service::{RemoveMode, ServiceError, SkillSummary, SkillsService};
34pub(crate) use suggestions::render_missing_skill_install_suggestion;
35
36const OPEN_SKILLS_REPO_URL: &str = "https://github.com/besoeasy/open-skills";
37const OPEN_SKILLS_SYNC_MARKER: &str = ".zeroclaw-open-skills-sync";
38const OPEN_SKILLS_SYNC_INTERVAL_SECS: u64 = 60 * 60 * 24 * 7;
39
40const CLAWHUB_DOMAIN: &str = "clawhub.ai";
42const CLAWHUB_WWW_DOMAIN: &str = "www.clawhub.ai";
43const CLAWHUB_DOWNLOAD_API: &str = "https://clawhub.ai/api/v1/download";
44const MAX_CLAWHUB_ZIP_BYTES: u64 = 50 * 1024 * 1024; const SKILLS_REGISTRY_REPO_URL: &str = "https://github.com/zeroclaw-labs/zeroclaw-skills";
48const SKILLS_REGISTRY_DIR_NAME: &str = "skills-registry";
49const SKILLS_REGISTRY_SYNC_MARKER: &str = ".zeroclaw-skills-registry-sync";
50const SKILLS_REGISTRY_SYNC_INTERVAL_SECS: u64 = 60 * 60 * 24;
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Skill {
57 pub name: String,
58 pub description: String,
59 pub version: String,
60 #[serde(default)]
61 pub author: Option<String>,
62 #[serde(default)]
63 pub tags: Vec<String>,
64 #[serde(default)]
65 pub tools: Vec<SkillTool>,
66 #[serde(default)]
67 pub prompts: Vec<String>,
68 #[serde(skip)]
69 pub location: Option<PathBuf>,
70}
71
72impl ::zeroclaw_api::attribution::Attributable for Skill {
73 fn role(&self) -> ::zeroclaw_api::attribution::Role {
74 ::zeroclaw_api::attribution::Role::Skill
75 }
76 fn alias(&self) -> &str {
77 &self.name
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SkillTool {
84 pub name: String,
85 pub description: String,
86 pub kind: String,
88 #[serde(default)]
90 pub command: String,
91 #[serde(default)]
92 pub args: HashMap<String, String>,
93 #[serde(default)]
97 pub target: Option<String>,
98 #[serde(default, alias = "default_args")]
105 pub locked_args: HashMap<String, String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110struct SkillManifest {
111 skill: SkillMeta,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
118 forge: Option<ForgeMetadata>,
119 #[serde(default)]
120 tools: Vec<SkillTool>,
121 #[serde(default)]
122 prompts: Vec<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127struct SkillMeta {
128 name: String,
129 description: String,
130 #[serde(default = "default_version")]
131 version: String,
132 #[serde(default)]
133 author: Option<String>,
134 #[serde(default)]
135 tags: Vec<String>,
136 #[serde(default)]
137 prompts: Vec<String>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148struct ForgeMetadata {
149 #[serde(default)]
151 source: Option<String>,
152 #[serde(default)]
154 owner: Option<String>,
155 #[serde(default)]
157 language: Option<String>,
158 #[serde(default)]
160 license: Option<bool>,
161 #[serde(default)]
163 stars: Option<u64>,
164 #[serde(default)]
167 updated_at: Option<String>,
168 #[serde(default)]
170 requirements: BTreeMap<String, toml::Value>,
171 #[serde(default)]
178 metadata: BTreeMap<String, toml::Value>,
179}
180
181#[derive(Debug, Clone, Default)]
182struct SkillMarkdownMeta {
183 name: Option<String>,
184 description: Option<String>,
185 version: Option<String>,
186 author: Option<String>,
187 tags: Vec<String>,
188}
189
190fn default_version() -> String {
191 "0.1.0".to_string()
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum SkillTier {
209 Official,
210 Community,
211 Featured,
212 Unknown,
213}
214
215#[derive(Debug, Deserialize)]
216struct RegistryIndex {
217 #[serde(default)]
218 skills: Vec<RegistryEntry>,
219}
220
221#[derive(Debug, Deserialize)]
222struct RegistryEntry {
223 name: String,
224 #[serde(default)]
225 version: Option<String>,
226 #[serde(default)]
227 tags: Vec<String>,
228}
229
230fn tier_from_tags(tags: &[String]) -> SkillTier {
231 let has = |needle: &str| tags.iter().any(|t| t.eq_ignore_ascii_case(needle));
232 if has("Official") {
233 SkillTier::Official
234 } else if has("Community") {
235 SkillTier::Community
236 } else if has("Featured") {
237 SkillTier::Featured
238 } else {
239 SkillTier::Unknown
240 }
241}
242
243pub fn lookup_registry_skill_tier(registry_dir: &Path, name: &str) -> (SkillTier, Option<String>) {
247 let path = registry_dir.join("registry.json");
248 let Ok(data) = std::fs::read_to_string(&path) else {
249 return (SkillTier::Unknown, None);
250 };
251 let Ok(index) = serde_json::from_str::<RegistryIndex>(&data) else {
252 return (SkillTier::Unknown, None);
253 };
254 let Some(entry) = index.skills.into_iter().find(|e| e.name == name) else {
255 return (SkillTier::Unknown, None);
256 };
257 (tier_from_tags(&entry.tags), entry.version)
258}
259
260fn install_tier_banner_key(tier: SkillTier) -> &'static str {
267 match tier {
268 SkillTier::Official => "cli-skills-install-tier-official",
269 SkillTier::Community | SkillTier::Featured | SkillTier::Unknown => {
270 "cli-skills-install-tier-community"
271 }
272 }
273}
274
275pub fn build_install_tier_banner(name: &str, version: Option<&str>, tier: SkillTier) -> String {
276 let version_label = version.unwrap_or("?");
277 let args = [("name", name), ("version", version_label)];
278 let key = install_tier_banner_key(tier);
279 let mut banner = crate::i18n::get_required_cli_string_with_args(key, &args);
280 if !banner.ends_with('\n') {
281 banner.push('\n');
282 }
283 banner
284}
285
286pub fn print_install_tier_banner(name: &str, version: Option<&str>, tier: SkillTier) {
288 print!("{}", build_install_tier_banner(name, version, tier));
289}
290
291fn warn_skipped_skill(path: &Path, summary: &str, allow_scripts: bool) {
296 let scripts_blocked = summary.contains("script-like files are blocked");
297 if scripts_blocked && !allow_scripts {
298 ::zeroclaw_log::record!(
299 WARN,
300 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
301 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
302 &format!(
303 "skipping skill directory {}: {summary}. \
304 To allow script files in skills, set `skills.allow_scripts = true` in your config.",
305 path.display().to_string()
306 )
307 );
308 eprintln!(
309 "warning: skill '{}' was skipped because it contains script files. \
310 Set `skills.allow_scripts = true` in your zeroclaw config to enable it.",
311 path.file_name()
312 .map(|n| n.to_string_lossy().into_owned())
313 .unwrap_or_else(|| path.display().to_string()),
314 );
315 } else {
316 ::zeroclaw_log::record!(
317 WARN,
318 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
319 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
320 &format!(
321 "skipping insecure skill directory {}: {summary}",
322 path.display().to_string()
323 )
324 );
325 }
326}
327
328fn warn_metadata_drift(skill_dir: &Path, toml_skill: &Skill, md_path: &Path) {
329 if !md_path.exists() {
330 return;
331 }
332 let Ok(md_content) = std::fs::read_to_string(md_path) else {
333 return;
334 };
335 let parsed = parse_skill_markdown(&md_content);
336 let dir_name = skill_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
337
338 if let Some(ref md_name) = parsed.meta.name
339 && md_name != &toml_skill.name
340 {
341 ::zeroclaw_log::record!(
342 WARN,
343 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
344 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
345 &format!(
346 "skill '{}': name mismatch between TOML ('{}') and SKILL.md ('{}')",
347 dir_name, toml_skill.name, md_name
348 )
349 );
350 }
351 if let Some(ref md_desc) = parsed.meta.description {
352 let md_desc = md_desc.trim();
353 if !md_desc.is_empty() && md_desc != ">-" && md_desc != toml_skill.description.trim() {
354 ::zeroclaw_log::record!(
355 WARN,
356 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
357 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
358 &format!(
359 "skill '{}': description mismatch between TOML and SKILL.md — TOML takes precedence",
360 dir_name
361 )
362 );
363 }
364 }
365}
366
367pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> {
369 load_skills_with_open_skills_config(workspace_dir, None, None, None)
370}
371
372pub fn load_skills_with_config(
374 workspace_dir: &Path,
375 config: &zeroclaw_config::schema::Config,
376) -> Vec<Skill> {
377 #[allow(unused_mut)]
378 let mut skills = load_skills_with_open_skills_config(
379 workspace_dir,
380 Some(config.skills.open_skills_enabled),
381 config.skills.open_skills_dir.as_deref(),
382 Some(config.skills.allow_scripts),
383 );
384
385 #[cfg(feature = "plugins-wasm")]
386 skills.extend(load_plugin_skills_from_config(config));
387
388 skills
389}
390
391pub fn load_skills_for_agent(
400 workspace_dir: &Path,
401 config: &zeroclaw_config::schema::Config,
402 agent_alias: &str,
403) -> Vec<Skill> {
404 let mut skills = load_skills_with_config(workspace_dir, config);
405 let Some(agent) = config.agent(agent_alias) else {
406 return skills;
407 };
408 if agent.skill_bundles.is_empty() {
409 return skills;
410 }
411 let install_root = config.install_root_dir();
412 let allow_scripts = config.skills.allow_scripts;
413 let mut seen: std::collections::HashSet<String> =
414 skills.iter().map(|s| s.name.clone()).collect();
415 for bundle_alias in &agent.skill_bundles {
416 let bundle = match config.skill_bundles.get(bundle_alias) {
417 Some(b) => b,
418 None => {
419 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": agent_alias, "bundle": bundle_alias, "bundle_alias": bundle_alias})), "skipping skill bundle: [skill_bundles.] is not configured");
420 continue;
421 }
422 };
423 let dir = match zeroclaw_config::skill_bundles::resolve_directory(
424 config,
425 &install_root,
426 bundle_alias,
427 ) {
428 Ok(d) => d,
429 Err(e) => {
430 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": agent_alias, "bundle": bundle_alias, "e": e.to_string()})), "skipping skill bundle: ");
431 continue;
432 }
433 };
434 let include: std::collections::HashSet<&str> =
435 bundle.include.iter().map(String::as_str).collect();
436 let exclude: std::collections::HashSet<&str> =
437 bundle.exclude.iter().map(String::as_str).collect();
438 for skill in load_skills_from_directory(&dir, allow_scripts) {
439 if !include.is_empty() && !include.contains(skill.name.as_str()) {
440 continue;
441 }
442 if exclude.contains(skill.name.as_str()) {
443 continue;
444 }
445 if seen.insert(skill.name.clone()) {
449 skills.push(skill);
450 }
451 }
452 }
453 skills
454}
455
456pub fn load_skills_with_open_skills_settings(
458 workspace_dir: &Path,
459 open_skills_enabled: bool,
460 open_skills_dir: Option<&str>,
461 allow_scripts: bool,
462) -> Vec<Skill> {
463 load_skills_with_open_skills_config(
464 workspace_dir,
465 Some(open_skills_enabled),
466 open_skills_dir,
467 Some(allow_scripts),
468 )
469}
470
471fn load_skills_with_open_skills_config(
472 workspace_dir: &Path,
473 config_open_skills_enabled: Option<bool>,
474 config_open_skills_dir: Option<&str>,
475 config_allow_scripts: Option<bool>,
476) -> Vec<Skill> {
477 let mut skills = Vec::new();
478 let allow_scripts = config_allow_scripts.unwrap_or(false);
479
480 if let Some(open_skills_dir) =
481 ensure_open_skills_repo(config_open_skills_enabled, config_open_skills_dir)
482 {
483 skills.extend(load_open_skills(&open_skills_dir, allow_scripts));
484 }
485
486 skills.extend(load_workspace_skills(workspace_dir, allow_scripts));
487 skills
488}
489
490fn load_workspace_skills(workspace_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
491 let skills_dir = workspace_dir.join("skills");
492 load_skills_from_directory(&skills_dir, allow_scripts)
493}
494
495pub fn load_skills_from_directory(skills_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
496 if !skills_dir.exists() {
497 return Vec::new();
498 }
499
500 let mut skills = Vec::new();
501
502 let Ok(entries) = std::fs::read_dir(skills_dir) else {
503 return skills;
504 };
505
506 for entry in entries.flatten() {
507 let path = entry.path();
508 if !path.is_dir() {
509 continue;
510 }
511
512 match audit::audit_skill_directory_with_options(
513 &path,
514 audit::SkillAuditOptions { allow_scripts },
515 ) {
516 Ok(report) if report.is_clean() => {}
517 Ok(report) => {
518 let summary = report.summary();
519 warn_skipped_skill(&path, &summary, allow_scripts);
520 continue;
521 }
522 Err(err) => {
523 ::zeroclaw_log::record!(
524 WARN,
525 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
526 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
527 &format!(
528 "skipping unauditable skill directory {}: {err}",
529 path.display().to_string()
530 )
531 );
532 continue;
533 }
534 }
535
536 let skill_toml_path = path.join("SKILL.toml");
538 let manifest_toml_path = path.join("manifest.toml");
539 let md_path = path.join("SKILL.md");
540
541 let toml_path = if skill_toml_path.exists() {
542 Some(skill_toml_path)
543 } else if manifest_toml_path.exists() {
544 Some(manifest_toml_path)
545 } else {
546 None
547 };
548
549 if let Some(toml_path) = toml_path {
550 match load_skill_toml(&toml_path) {
551 Ok(skill) => {
552 warn_metadata_drift(&path, &skill, &md_path);
553 skills.push(skill);
554 }
555 Err(e) => {
556 ::zeroclaw_log::record!(
557 WARN,
558 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
559 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
560 .with_attrs(::serde_json::json!({
561 "path": toml_path.display().to_string(),
562 "error": format!("{}", e),
563 })),
564 "failed to load SKILL.toml — skill directory skipped"
565 );
566 }
567 }
568 } else if md_path.exists()
569 && let Ok(skill) = load_skill_md(&md_path, &path)
570 {
571 skills.push(skill);
572 }
573 }
574
575 skills
576}
577
578fn finalize_open_skill(mut skill: Skill) -> Skill {
579 if !skill.tags.iter().any(|tag| tag == "open-skills") {
580 skill.tags.push("open-skills".to_string());
581 }
582 if skill.author.is_none() {
583 skill.author = Some("besoeasy/open-skills".to_string());
584 }
585 skill
586}
587
588fn load_open_skills_from_directory(skills_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
589 if !skills_dir.exists() {
590 return Vec::new();
591 }
592
593 let mut skills = Vec::new();
594
595 let Ok(entries) = std::fs::read_dir(skills_dir) else {
596 return skills;
597 };
598
599 for entry in entries.flatten() {
600 let path = entry.path();
601 if !path.is_dir() {
602 continue;
603 }
604
605 match audit::audit_skill_directory_with_options(
606 &path,
607 audit::SkillAuditOptions { allow_scripts },
608 ) {
609 Ok(report) if report.is_clean() => {}
610 Ok(report) => {
611 let summary = report.summary();
612 warn_skipped_skill(&path, &summary, allow_scripts);
613 continue;
614 }
615 Err(err) => {
616 ::zeroclaw_log::record!(
617 WARN,
618 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
619 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
620 &format!(
621 "skipping unauditable open-skill directory {}: {err}",
622 path.display().to_string()
623 )
624 );
625 continue;
626 }
627 }
628
629 let skill_toml_path = path.join("SKILL.toml");
630 let manifest_toml_path = path.join("manifest.toml");
631 let md_path = path.join("SKILL.md");
632
633 let toml_path = if skill_toml_path.exists() {
634 Some(skill_toml_path)
635 } else if manifest_toml_path.exists() {
636 Some(manifest_toml_path)
637 } else {
638 None
639 };
640
641 if let Some(toml_path) = toml_path {
642 match load_skill_toml(&toml_path) {
643 Ok(skill) => {
644 warn_metadata_drift(&path, &skill, &md_path);
645 skills.push(finalize_open_skill(skill));
646 }
647 Err(e) => {
648 ::zeroclaw_log::record!(
649 WARN,
650 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
651 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
652 .with_attrs(::serde_json::json!({
653 "path": toml_path.display().to_string(),
654 "error": format!("{}", e),
655 })),
656 "failed to load SKILL.toml — skill directory skipped"
657 );
658 }
659 }
660 } else if md_path.exists()
661 && let Ok(skill) = load_open_skill_md(&md_path)
662 {
663 skills.push(skill);
664 }
665 }
666
667 skills
668}
669
670fn load_open_skills(repo_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
671 let nested_skills_dir = repo_dir.join("skills");
675 if nested_skills_dir.is_dir() {
676 return load_open_skills_from_directory(&nested_skills_dir, allow_scripts);
677 }
678
679 let mut skills = Vec::new();
680
681 let Ok(entries) = std::fs::read_dir(repo_dir) else {
682 return skills;
683 };
684
685 for entry in entries.flatten() {
686 let path = entry.path();
687 if !path.is_file() {
688 continue;
689 }
690
691 let is_markdown = path
692 .extension()
693 .and_then(|ext| ext.to_str())
694 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
695 if !is_markdown {
696 continue;
697 }
698
699 let is_readme = path
700 .file_name()
701 .and_then(|name| name.to_str())
702 .is_some_and(|name| name.eq_ignore_ascii_case("README.md"));
703 if is_readme {
704 continue;
705 }
706
707 match audit::audit_open_skill_markdown(&path, repo_dir) {
708 Ok(report) if report.is_clean() => {}
709 Ok(report) => {
710 ::zeroclaw_log::record!(
711 WARN,
712 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
713 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
714 &format!(
715 "skipping insecure open-skill file {}: {}",
716 path.display().to_string(),
717 report.summary()
718 )
719 );
720 continue;
721 }
722 Err(err) => {
723 ::zeroclaw_log::record!(
724 WARN,
725 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
726 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
727 &format!(
728 "skipping unauditable open-skill file {}: {err}",
729 path.display().to_string()
730 )
731 );
732 continue;
733 }
734 }
735
736 if let Ok(skill) = load_open_skill_md(&path) {
737 skills.push(skill);
738 }
739 }
740
741 skills
742}
743
744fn parse_open_skills_enabled(raw: &str) -> Option<bool> {
745 match raw.trim().to_ascii_lowercase().as_str() {
746 "1" | "true" | "yes" | "on" => Some(true),
747 "0" | "false" | "no" | "off" => Some(false),
748 _ => None,
749 }
750}
751
752fn open_skills_enabled_from_sources(
753 config_open_skills_enabled: Option<bool>,
754 env_override: Option<&str>,
755) -> bool {
756 if let Some(raw) = env_override {
757 if let Some(enabled) = parse_open_skills_enabled(raw) {
758 return enabled;
759 }
760 if !raw.trim().is_empty() {
761 ::zeroclaw_log::record!(
762 WARN,
763 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
764 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
765 "Ignoring invalid ZEROCLAW_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)"
766 );
767 }
768 }
769
770 config_open_skills_enabled.unwrap_or(false)
771}
772
773fn open_skills_enabled(config_open_skills_enabled: Option<bool>) -> bool {
774 let env_override = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED").ok();
775 open_skills_enabled_from_sources(config_open_skills_enabled, env_override.as_deref())
776}
777
778fn resolve_open_skills_dir_from_sources(
779 env_dir: Option<&str>,
780 config_dir: Option<&str>,
781 home_dir: Option<&Path>,
782) -> Option<PathBuf> {
783 let parse_dir = |raw: &str| {
784 let trimmed = raw.trim();
785 if trimmed.is_empty() {
786 None
787 } else {
788 Some(PathBuf::from(trimmed))
789 }
790 };
791
792 if let Some(env_dir) = env_dir.and_then(parse_dir) {
793 return Some(env_dir);
794 }
795 if let Some(config_dir) = config_dir.and_then(parse_dir) {
796 return Some(config_dir);
797 }
798 home_dir.map(|home| home.join("open-skills"))
799}
800
801fn resolve_open_skills_dir(config_open_skills_dir: Option<&str>) -> Option<PathBuf> {
802 let env_dir = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR").ok();
803 let home_dir = UserDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
804 resolve_open_skills_dir_from_sources(
805 env_dir.as_deref(),
806 config_open_skills_dir,
807 home_dir.as_deref(),
808 )
809}
810
811fn ensure_open_skills_repo(
812 config_open_skills_enabled: Option<bool>,
813 config_open_skills_dir: Option<&str>,
814) -> Option<PathBuf> {
815 if !open_skills_enabled(config_open_skills_enabled) {
816 return None;
817 }
818
819 let repo_dir = resolve_open_skills_dir(config_open_skills_dir)?;
820
821 if !repo_dir.exists() {
822 if !clone_open_skills_repo(&repo_dir) {
823 return None;
824 }
825 let _ = mark_open_skills_synced(&repo_dir);
826 return Some(repo_dir);
827 }
828
829 if should_sync_open_skills(&repo_dir) {
830 if pull_open_skills_repo(&repo_dir) {
831 let _ = mark_open_skills_synced(&repo_dir);
832 } else {
833 ::zeroclaw_log::record!(
834 WARN,
835 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
836 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
837 &format!(
838 "open-skills update failed; using local copy from {}",
839 repo_dir.display().to_string()
840 )
841 );
842 }
843 }
844
845 Some(repo_dir)
846}
847
848fn clone_open_skills_repo(repo_dir: &Path) -> bool {
849 if let Some(parent) = repo_dir.parent()
850 && let Err(err) = std::fs::create_dir_all(parent)
851 {
852 ::zeroclaw_log::record!(
853 WARN,
854 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
855 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
856 &format!(
857 "failed to create open-skills parent directory {}: {err}",
858 parent.display().to_string()
859 )
860 );
861 return false;
862 }
863
864 let output = Command::new("git")
865 .args(["clone", "--depth", "1", OPEN_SKILLS_REPO_URL])
866 .arg(repo_dir)
867 .output();
868
869 match output {
870 Ok(result) if result.status.success() => {
871 ::zeroclaw_log::record!(
872 INFO,
873 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
874 &format!(
875 "initialized open-skills at {}",
876 repo_dir.display().to_string()
877 )
878 );
879 true
880 }
881 Ok(result) => {
882 let stderr = String::from_utf8_lossy(&result.stderr);
883 ::zeroclaw_log::record!(
884 WARN,
885 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
886 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
887 .with_attrs(::serde_json::json!({"stderr": stderr})),
888 "failed to clone open-skills: "
889 );
890 false
891 }
892 Err(err) => {
893 ::zeroclaw_log::record!(
894 WARN,
895 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
896 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
897 .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
898 "failed to run git clone for open-skills"
899 );
900 false
901 }
902 }
903}
904
905fn pull_open_skills_repo(repo_dir: &Path) -> bool {
906 if !repo_dir.join(".git").exists() {
908 return true;
909 }
910
911 let output = Command::new("git")
912 .arg("-C")
913 .arg(repo_dir)
914 .args(["pull", "--ff-only"])
915 .output();
916
917 match output {
918 Ok(result) if result.status.success() => true,
919 Ok(result) => {
920 let stderr = String::from_utf8_lossy(&result.stderr);
921 ::zeroclaw_log::record!(
922 WARN,
923 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
924 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
925 .with_attrs(::serde_json::json!({"stderr": stderr})),
926 "failed to pull open-skills updates: "
927 );
928 false
929 }
930 Err(err) => {
931 ::zeroclaw_log::record!(
932 WARN,
933 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
934 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
935 .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
936 "failed to run git pull for open-skills"
937 );
938 false
939 }
940 }
941}
942
943fn should_sync_open_skills(repo_dir: &Path) -> bool {
944 let marker = repo_dir.join(OPEN_SKILLS_SYNC_MARKER);
945 let Ok(metadata) = std::fs::metadata(marker) else {
946 return true;
947 };
948 let Ok(modified_at) = metadata.modified() else {
949 return true;
950 };
951 let Ok(age) = SystemTime::now().duration_since(modified_at) else {
952 return true;
953 };
954
955 age >= Duration::from_secs(OPEN_SKILLS_SYNC_INTERVAL_SECS)
956}
957
958fn mark_open_skills_synced(repo_dir: &Path) -> Result<()> {
959 std::fs::write(repo_dir.join(OPEN_SKILLS_SYNC_MARKER), b"synced")?;
960 Ok(())
961}
962
963fn load_skill_toml(path: &Path) -> Result<Skill> {
965 let content = std::fs::read_to_string(path)?;
966 let manifest: SkillManifest = toml::from_str(&content)?;
967
968 let mut prompts = manifest.skill.prompts;
973 prompts.extend(manifest.prompts);
974
975 Ok(Skill {
976 name: manifest.skill.name,
977 description: manifest.skill.description,
978 version: manifest.skill.version,
979 author: manifest.skill.author,
980 tags: manifest.skill.tags,
981 tools: manifest.tools,
982 prompts,
983 location: Some(path.to_path_buf()),
984 })
985}
986
987fn load_skill_md(path: &Path, dir: &Path) -> Result<Skill> {
989 let content = std::fs::read_to_string(path)?;
990 let parsed = parse_skill_markdown(&content);
991 let name = dir
992 .file_name()
993 .and_then(|n| n.to_str())
994 .unwrap_or("unknown")
995 .to_string();
996
997 Ok(Skill {
998 name: parsed.meta.name.unwrap_or(name),
999 description: parsed
1000 .meta
1001 .description
1002 .filter(|value| !value.trim().is_empty())
1003 .unwrap_or_else(|| extract_description(&parsed.body)),
1004 version: parsed.meta.version.unwrap_or_else(default_version),
1005 author: parsed.meta.author,
1006 tags: parsed.meta.tags,
1007 tools: Vec::new(),
1008 prompts: vec![parsed.body],
1009 location: Some(path.to_path_buf()),
1010 })
1011}
1012
1013fn load_open_skill_md(path: &Path) -> Result<Skill> {
1014 let content = std::fs::read_to_string(path)?;
1015 let parsed = parse_skill_markdown(&content);
1016 let file_stem = path
1017 .file_stem()
1018 .and_then(|n| n.to_str())
1019 .unwrap_or("open-skill")
1020 .to_string();
1021 let name = if file_stem.eq_ignore_ascii_case("skill") {
1022 path.parent()
1023 .and_then(|dir| dir.file_name())
1024 .and_then(|name| name.to_str())
1025 .unwrap_or(&file_stem)
1026 .to_string()
1027 } else {
1028 file_stem
1029 };
1030 Ok(finalize_open_skill(Skill {
1031 name: parsed.meta.name.unwrap_or(name),
1032 description: parsed
1033 .meta
1034 .description
1035 .filter(|value| !value.trim().is_empty())
1036 .unwrap_or_else(|| extract_description(&parsed.body)),
1037 version: parsed
1038 .meta
1039 .version
1040 .unwrap_or_else(|| "open-skills".to_string()),
1041 author: parsed
1042 .meta
1043 .author
1044 .or_else(|| Some("besoeasy/open-skills".to_string())),
1045 tags: parsed.meta.tags,
1046 tools: Vec::new(),
1047 prompts: vec![parsed.body],
1048 location: Some(path.to_path_buf()),
1049 }))
1050}
1051
1052struct ParsedSkillMarkdown {
1053 meta: SkillMarkdownMeta,
1054 body: String,
1055}
1056
1057fn parse_skill_markdown(content: &str) -> ParsedSkillMarkdown {
1058 if let Some((frontmatter, body)) = split_skill_frontmatter(content) {
1059 let meta = parse_simple_frontmatter(&frontmatter);
1060 return ParsedSkillMarkdown { meta, body };
1061 }
1062
1063 ParsedSkillMarkdown {
1064 meta: SkillMarkdownMeta::default(),
1065 body: content.to_string(),
1066 }
1067}
1068
1069fn parse_simple_frontmatter(s: &str) -> SkillMarkdownMeta {
1073 let mut meta = SkillMarkdownMeta::default();
1074 let mut collecting_tags = false;
1075 let mut collecting_multiline: Option<String> = None;
1076 let mut multiline_parts: Vec<String> = Vec::new();
1077
1078 let flush_multiline = |key: &str, parts: &[String], meta: &mut SkillMarkdownMeta| {
1079 let joined = parts.join(" ");
1080 let val = joined.trim();
1081 if !val.is_empty() {
1082 match key {
1083 "description" => meta.description = Some(val.to_string()),
1084 "name" => meta.name = Some(val.to_string()),
1085 _ => {}
1086 }
1087 }
1088 };
1089
1090 for line in s.lines() {
1091 if let Some(ref key) = collecting_multiline {
1093 if line.starts_with(' ') || line.starts_with('\t') || line.trim().is_empty() {
1097 multiline_parts.push(line.trim().to_string());
1098 continue;
1099 }
1100 flush_multiline(key, &multiline_parts, &mut meta);
1101 collecting_multiline = None;
1102 multiline_parts.clear();
1103 }
1104
1105 if collecting_tags {
1107 let trimmed = line.trim();
1108 if let Some(item) = trimmed.strip_prefix("- ") {
1109 let tag = item.trim().trim_matches('"').trim_matches('\'');
1110 if !tag.is_empty() {
1111 meta.tags.push(tag.to_string());
1112 }
1113 continue;
1114 }
1115 collecting_tags = false;
1117 }
1118 let Some((key, val)) = line.split_once(':') else {
1119 continue;
1120 };
1121 let key = key.trim();
1122 let val = val.trim().trim_matches('"').trim_matches('\'');
1123 if val == ">-" || val == ">" || val == "|" || val == "|-" {
1125 collecting_multiline = Some(key.to_string());
1126 multiline_parts.clear();
1127 continue;
1128 }
1129 match key {
1130 "name" => meta.name = Some(val.to_string()),
1131 "description" => meta.description = Some(val.to_string()),
1132 "version" => meta.version = Some(val.to_string()),
1133 "author" => meta.author = Some(val.to_string()),
1134 "tags" => {
1135 if val.is_empty() {
1136 collecting_tags = true;
1138 } else {
1139 let val = val.trim_start_matches('[').trim_end_matches(']');
1141 meta.tags = val
1142 .split(',')
1143 .map(|t| t.trim().trim_matches('"').trim_matches('\'').to_string())
1144 .filter(|t| !t.is_empty())
1145 .collect();
1146 }
1147 }
1148 _ => {}
1149 }
1150 }
1151 if let Some(ref key) = collecting_multiline {
1152 flush_multiline(key, &multiline_parts, &mut meta);
1153 }
1154 meta
1155}
1156
1157fn split_skill_frontmatter(content: &str) -> Option<(String, String)> {
1158 let normalized = content.replace("\r\n", "\n");
1159 let rest = normalized.strip_prefix("---\n")?;
1160 if let Some(idx) = rest.find("\n---\n") {
1161 let frontmatter = rest[..idx].to_string();
1162 let body = rest[idx + 5..].to_string();
1163 return Some((frontmatter, body));
1164 }
1165 if let Some(frontmatter) = rest.strip_suffix("\n---") {
1166 return Some((frontmatter.to_string(), String::new()));
1167 }
1168 None
1169}
1170
1171fn extract_description(content: &str) -> String {
1172 content
1173 .lines()
1174 .find(|line| !line.starts_with('#') && !line.trim().is_empty())
1175 .unwrap_or("No description")
1176 .trim()
1177 .to_string()
1178}
1179
1180fn append_xml_escaped(out: &mut String, text: &str) {
1181 for ch in text.chars() {
1182 match ch {
1183 '&' => out.push_str("&"),
1184 '<' => out.push_str("<"),
1185 '>' => out.push_str(">"),
1186 '"' => out.push_str("""),
1187 '\'' => out.push_str("'"),
1188 _ => out.push(ch),
1189 }
1190 }
1191}
1192
1193fn write_xml_text_element(out: &mut String, indent: usize, tag: &str, value: &str) {
1194 for _ in 0..indent {
1195 out.push(' ');
1196 }
1197 out.push('<');
1198 out.push_str(tag);
1199 out.push('>');
1200 append_xml_escaped(out, value);
1201 out.push_str("</");
1202 out.push_str(tag);
1203 out.push_str(">\n");
1204}
1205
1206fn resolve_skill_location(skill: &Skill, workspace_dir: &Path) -> PathBuf {
1207 skill.location.clone().unwrap_or_else(|| {
1208 workspace_dir
1209 .join("skills")
1210 .join(&skill.name)
1211 .join("SKILL.md")
1212 })
1213}
1214
1215fn render_skill_location(skill: &Skill, workspace_dir: &Path, prefer_relative: bool) -> String {
1216 let location = resolve_skill_location(skill, workspace_dir);
1217 if prefer_relative && let Ok(relative) = location.strip_prefix(workspace_dir) {
1218 return relative.display().to_string();
1219 }
1220 location.display().to_string()
1221}
1222
1223pub fn skills_to_prompt(skills: &[Skill], workspace_dir: &Path) -> String {
1225 skills_to_prompt_with_mode(
1226 skills,
1227 workspace_dir,
1228 zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
1229 )
1230}
1231
1232pub fn skills_to_prompt_with_mode(
1234 skills: &[Skill],
1235 workspace_dir: &Path,
1236 mode: zeroclaw_config::schema::SkillsPromptInjectionMode,
1237) -> String {
1238 use std::fmt::Write;
1239
1240 if skills.is_empty() {
1241 return String::new();
1242 }
1243
1244 let mut prompt = match mode {
1245 zeroclaw_config::schema::SkillsPromptInjectionMode::Full => String::from(
1246 "## Available Skills\n\n\
1247 Skill instructions and tool metadata are preloaded below.\n\
1248 Follow these instructions directly; do not read skill files at runtime unless the user asks.\n\n\
1249 <available_skills>\n",
1250 ),
1251 zeroclaw_config::schema::SkillsPromptInjectionMode::Compact => String::from(
1252 "## Available Skills\n\n\
1253 Skill summaries are preloaded below to keep context compact.\n\
1254 Skill instructions are loaded on demand: call `read_skill(name)` with the skill's `<name>` when you need the full skill file.\n\
1255 The `location` field is included for reference.\n\n\
1256 <available_skills>\n",
1257 ),
1258 };
1259
1260 for skill in skills {
1261 let _ = writeln!(prompt, " <skill>");
1262 write_xml_text_element(&mut prompt, 4, "name", &skill.name);
1263 write_xml_text_element(&mut prompt, 4, "description", &skill.description);
1264 let location = render_skill_location(
1265 skill,
1266 workspace_dir,
1267 matches!(
1268 mode,
1269 zeroclaw_config::schema::SkillsPromptInjectionMode::Compact
1270 ),
1271 );
1272 write_xml_text_element(&mut prompt, 4, "location", &location);
1273
1274 if matches!(
1278 mode,
1279 zeroclaw_config::schema::SkillsPromptInjectionMode::Full
1280 ) && !skill.prompts.is_empty()
1281 {
1282 let _ = writeln!(prompt, " <instructions>");
1283 for instruction in &skill.prompts {
1284 write_xml_text_element(&mut prompt, 6, "instruction", instruction);
1285 }
1286 let _ = writeln!(prompt, " </instructions>");
1287 }
1288
1289 if !skill.tools.is_empty() {
1290 let registered: Vec<_> = skill
1294 .tools
1295 .iter()
1296 .filter(|t| matches!(t.kind.as_str(), "shell" | "script" | "http" | "builtin"))
1297 .collect();
1298 let unregistered: Vec<_> = skill
1299 .tools
1300 .iter()
1301 .filter(|t| !matches!(t.kind.as_str(), "shell" | "script" | "http" | "builtin"))
1302 .collect();
1303
1304 if !registered.is_empty() {
1305 let _ = writeln!(
1306 prompt,
1307 " <callable_tools hint=\"These are registered as callable tool specs. Invoke them directly by name ({{}}__{{}}) instead of using shell.\">"
1308 );
1309 for tool in ®istered {
1310 let _ = writeln!(prompt, " <tool>");
1311 write_xml_text_element(
1312 &mut prompt,
1313 8,
1314 "name",
1315 &format!("{}__{}", skill.name, tool.name),
1316 );
1317 write_xml_text_element(&mut prompt, 8, "description", &tool.description);
1318 let _ = writeln!(prompt, " </tool>");
1319 }
1320 let _ = writeln!(prompt, " </callable_tools>");
1321 }
1322
1323 if !unregistered.is_empty() {
1324 let _ = writeln!(prompt, " <tools>");
1325 for tool in &unregistered {
1326 let _ = writeln!(prompt, " <tool>");
1327 write_xml_text_element(&mut prompt, 8, "name", &tool.name);
1328 write_xml_text_element(&mut prompt, 8, "description", &tool.description);
1329 write_xml_text_element(&mut prompt, 8, "kind", &tool.kind);
1330 let _ = writeln!(prompt, " </tool>");
1331 }
1332 let _ = writeln!(prompt, " </tools>");
1333 }
1334 }
1335
1336 let _ = writeln!(prompt, " </skill>");
1337 }
1338
1339 prompt.push_str("</available_skills>");
1340 prompt
1341}
1342
1343pub fn skills_to_tools(
1354 skills: &[Skill],
1355 security: std::sync::Arc<crate::security::SecurityPolicy>,
1356) -> Vec<Box<dyn zeroclaw_api::tool::Tool>> {
1357 skills_to_tools_with_context(skills, security, &[])
1358}
1359
1360fn resolve_elevated_tool(
1372 skill_name: &str,
1373 tool: &SkillTool,
1374 kind_label: &str,
1375 resolution_registry: &[std::sync::Arc<dyn zeroclaw_api::tool::Tool>],
1376) -> Option<Box<dyn zeroclaw_api::tool::Tool>> {
1377 let Some(target_name) = tool.target.as_deref() else {
1378 ::zeroclaw_log::record!(
1379 WARN,
1380 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1381 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1382 &format!(
1383 "Skill tool {}.{} has kind='{}' but no 'target' field, skipping",
1384 skill_name, tool.name, kind_label
1385 )
1386 );
1387 return None;
1388 };
1389 match resolution_registry.iter().find(|t| t.name() == target_name) {
1390 Some(target) => Some(Box::new(crate::skills::skill_tool::SkillBuiltinTool::new(
1391 skill_name,
1392 tool,
1393 std::sync::Arc::clone(target),
1394 tool.locked_args.clone(),
1395 ))),
1396 None => {
1397 ::zeroclaw_log::record!(
1398 WARN,
1399 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1400 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1401 &format!(
1402 "Skill tool {}.{} targets {} '{}' which was not found in the \
1403 resolution registry (for MCP, use the prefixed name \
1404 '{{server}}__{{tool}}' and ensure the server is connected), skipping",
1405 skill_name, tool.name, kind_label, target_name
1406 )
1407 );
1408 None
1409 }
1410 }
1411}
1412
1413pub fn skills_to_tools_with_context(
1414 skills: &[Skill],
1415 security: std::sync::Arc<crate::security::SecurityPolicy>,
1416 unfiltered_registry: &[std::sync::Arc<dyn zeroclaw_api::tool::Tool>],
1417) -> Vec<Box<dyn zeroclaw_api::tool::Tool>> {
1418 let mut tools: Vec<Box<dyn zeroclaw_api::tool::Tool>> = Vec::new();
1419 for skill in skills {
1420 for tool in &skill.tools {
1421 match tool.kind.as_str() {
1422 "shell" | "script" => {
1423 let inner = crate::skills::skill_tool::SkillShellTool::new(
1424 &skill.name,
1425 tool,
1426 security.clone(),
1427 );
1428 tools.push(Box::new(zeroclaw_tools::wrappers::RateLimitedTool::new(
1429 inner,
1430 security.clone(),
1431 )));
1432 }
1433 "http" => {
1434 tools.push(Box::new(crate::skills::skill_http::SkillHttpTool::new(
1435 &skill.name,
1436 tool,
1437 )));
1438 }
1439 "builtin" => {
1440 if let Some(t) =
1441 resolve_elevated_tool(&skill.name, tool, "builtin", unfiltered_registry)
1442 {
1443 tools.push(t);
1444 }
1445 }
1446 "mcp" => {
1447 if let Some(t) =
1448 resolve_elevated_tool(&skill.name, tool, "MCP", unfiltered_registry)
1449 {
1450 tools.push(t);
1451 }
1452 }
1453 other => {
1454 ::zeroclaw_log::record!(
1455 WARN,
1456 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1457 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1458 &format!(
1459 "Unknown skill tool kind '{}' for {}.{}, skipping",
1460 other, skill.name, tool.name
1461 )
1462 );
1463 }
1464 }
1465 }
1466 }
1467 tools
1468}
1469
1470pub fn skills_dir(workspace_dir: &Path) -> PathBuf {
1472 workspace_dir.join("skills")
1473}
1474
1475pub fn init_skills_dir(workspace_dir: &Path) -> Result<()> {
1477 let dir = skills_dir(workspace_dir);
1478 std::fs::create_dir_all(&dir)?;
1479
1480 let readme = dir.join("README.md");
1481 if !readme.exists() {
1482 std::fs::write(
1483 &readme,
1484 "# ZeroClaw Skills\n\n\
1485 Each subdirectory is a skill. Create a `SKILL.toml` or `SKILL.md` file inside.\n\n\
1486 ## SKILL.toml format\n\n\
1487 ```toml\n\
1488 [skill]\n\
1489 name = \"my-skill\"\n\
1490 description = \"What this skill does\"\n\
1491 version = \"0.1.0\"\n\
1492 author = \"your-name\"\n\
1493 tags = [\"productivity\", \"automation\"]\n\n\
1494 [[tools]]\n\
1495 name = \"my_tool\"\n\
1496 description = \"What this tool does\"\n\
1497 kind = \"shell\"\n\
1498 command = \"echo hello\"\n\
1499 ```\n\n\
1500 ## SKILL.md format (simpler)\n\n\
1501 Just write a markdown file with instructions for the agent.\n\
1502 Optional YAML frontmatter is supported for `name`, `description`, `version`, `author`, and `tags`.\n\
1503 The agent will read it and follow the instructions.\n\n\
1504 ## Installing community skills\n\n\
1505 ```bash\n\
1506 zeroclaw skills install <source>\n\
1507 zeroclaw skills list\n\
1508 ```\n",
1509 )?;
1510 }
1511
1512 Ok(())
1513}
1514
1515fn is_clawhub_host(host: &str) -> bool {
1516 host.eq_ignore_ascii_case(CLAWHUB_DOMAIN) || host.eq_ignore_ascii_case(CLAWHUB_WWW_DOMAIN)
1517}
1518
1519fn parse_clawhub_url(source: &str) -> Option<Url> {
1520 let parsed = Url::parse(source).ok()?;
1521 match parsed.scheme() {
1522 "https" | "http" => {}
1523 _ => return None,
1524 }
1525
1526 if !parsed.host_str().is_some_and(is_clawhub_host) {
1527 return None;
1528 }
1529
1530 Some(parsed)
1531}
1532
1533pub fn is_clawhub_source(source: &str) -> bool {
1534 if source.starts_with("clawhub:") {
1535 return true;
1536 }
1537 parse_clawhub_url(source).is_some()
1538}
1539
1540fn clawhub_download_url(source: &str) -> Result<String> {
1541 if let Some(slug) = source.strip_prefix("clawhub:") {
1543 let slug = slug.trim().trim_end_matches('/');
1544 if slug.is_empty() || slug.contains('/') {
1545 anyhow::bail!(
1546 "invalid clawhub source '{}': expected 'clawhub:<slug>' (no slashes in slug)",
1547 source
1548 );
1549 }
1550 return Ok(format!("{CLAWHUB_DOWNLOAD_API}?slug={slug}"));
1551 }
1552
1553 if let Some(parsed) = parse_clawhub_url(source) {
1555 let path = parsed
1556 .path_segments()
1557 .into_iter()
1558 .flatten()
1559 .collect::<Vec<_>>()
1560 .join("/");
1561
1562 if path.is_empty() {
1563 anyhow::bail!("could not extract slug from ClawhHub URL: {source}");
1564 }
1565
1566 return Ok(format!("{CLAWHUB_DOWNLOAD_API}?slug={path}"));
1567 }
1568
1569 anyhow::bail!("unrecognised ClawhHub source format: {source}")
1570}
1571
1572fn normalize_skill_name(s: &str) -> String {
1573 s.to_lowercase()
1574 .chars()
1575 .map(|c| if c == '-' { '_' } else { c })
1576 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
1577 .collect()
1578}
1579
1580fn clawhub_skill_dir_name(source: &str) -> Result<String> {
1581 if let Some(slug) = source.strip_prefix("clawhub:") {
1582 let slug = slug.trim().trim_end_matches('/');
1583 let base = slug.rsplit('/').next().unwrap_or(slug);
1584 let name = normalize_skill_name(base);
1585 return Ok(if name.is_empty() {
1586 "skill".to_string()
1587 } else {
1588 name
1589 });
1590 }
1591
1592 let parsed = parse_clawhub_url(source).ok_or_else(|| {
1593 ::zeroclaw_log::record!(
1594 WARN,
1595 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1596 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1597 .with_attrs(::serde_json::json!({"source": source})),
1598 "skill install rejected: invalid clawhub URL"
1599 );
1600 anyhow::Error::msg(format!("invalid clawhub URL: {source}"))
1601 })?;
1602
1603 let path = parsed
1604 .path_segments()
1605 .into_iter()
1606 .flatten()
1607 .collect::<Vec<_>>();
1608
1609 let base = path.last().copied().unwrap_or("skill");
1610 let name = normalize_skill_name(base);
1611 Ok(if name.is_empty() {
1612 "skill".to_string()
1613 } else {
1614 name
1615 })
1616}
1617
1618pub fn is_git_source(source: &str) -> bool {
1619 if is_clawhub_source(source) {
1621 return false;
1622 }
1623 is_git_scheme_source(source, "https://")
1624 || is_git_scheme_source(source, "http://")
1625 || is_git_scheme_source(source, "ssh://")
1626 || is_git_scheme_source(source, "git://")
1627 || is_git_scp_source(source)
1628}
1629
1630fn is_git_scheme_source(source: &str, scheme: &str) -> bool {
1631 let Some(rest) = source.strip_prefix(scheme) else {
1632 return false;
1633 };
1634 if rest.is_empty() || rest.starts_with('/') {
1635 return false;
1636 }
1637
1638 let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
1639 !host.is_empty()
1640}
1641
1642fn is_git_scp_source(source: &str) -> bool {
1643 let Some((user_host, remote_path)) = source.split_once(':') else {
1646 return false;
1647 };
1648 if remote_path.is_empty() {
1649 return false;
1650 }
1651 if source.contains("://") {
1652 return false;
1653 }
1654
1655 let Some((user, host)) = user_host.split_once('@') else {
1656 return false;
1657 };
1658 !user.is_empty()
1659 && !host.is_empty()
1660 && !user.contains('/')
1661 && !user.contains('\\')
1662 && !host.contains('/')
1663 && !host.contains('\\')
1664}
1665
1666fn snapshot_skill_children(skills_path: &Path) -> Result<HashSet<PathBuf>> {
1667 let mut paths = HashSet::new();
1668 for entry in std::fs::read_dir(skills_path)? {
1669 let entry = entry?;
1670 paths.insert(entry.path());
1671 }
1672 Ok(paths)
1673}
1674
1675fn detect_newly_installed_directory(
1676 skills_path: &Path,
1677 before: &HashSet<PathBuf>,
1678) -> Result<PathBuf> {
1679 let mut created = Vec::new();
1680 for entry in std::fs::read_dir(skills_path)? {
1681 let entry = entry?;
1682 let path = entry.path();
1683 if !before.contains(&path) && path.is_dir() {
1684 created.push(path);
1685 }
1686 }
1687
1688 match created.len() {
1689 1 => Ok(created.remove(0)),
1690 0 => anyhow::bail!(
1691 "Unable to determine installed skill directory after clone (no new directory found)"
1692 ),
1693 _ => anyhow::bail!(
1694 "Unable to determine installed skill directory after clone (multiple new directories found)"
1695 ),
1696 }
1697}
1698
1699fn enforce_skill_security_audit(
1700 skill_path: &Path,
1701 allow_scripts: bool,
1702) -> Result<audit::SkillAuditReport> {
1703 let report = audit::audit_skill_directory_with_options(
1704 skill_path,
1705 audit::SkillAuditOptions { allow_scripts },
1706 )?;
1707 if report.is_clean() {
1708 return Ok(report);
1709 }
1710
1711 anyhow::bail!("Skill security audit failed: {}", report.summary());
1712}
1713
1714fn remove_git_metadata(skill_path: &Path) -> Result<()> {
1715 let git_dir = skill_path.join(".git");
1716 if git_dir.exists() {
1717 std::fs::remove_dir_all(&git_dir)
1718 .with_context(|| format!("failed to remove {}", git_dir.display().to_string()))?;
1719 }
1720 Ok(())
1721}
1722
1723fn copy_dir_recursive_secure(src: &Path, dest: &Path) -> Result<()> {
1724 let src_meta = std::fs::symlink_metadata(src)
1725 .with_context(|| format!("failed to read metadata for {}", src.display().to_string()))?;
1726 if src_meta.file_type().is_symlink() {
1727 anyhow::bail!(
1728 "Refusing to copy symlinked skill source path: {}",
1729 src.display()
1730 );
1731 }
1732 if !src_meta.is_dir() {
1733 anyhow::bail!(
1734 "Skill source must be a directory: {}",
1735 src.display().to_string()
1736 );
1737 }
1738
1739 std::fs::create_dir_all(dest).with_context(|| {
1740 format!(
1741 "failed to create destination {}",
1742 dest.display().to_string()
1743 )
1744 })?;
1745 for entry in std::fs::read_dir(src)? {
1746 let entry = entry?;
1747 let src_path = entry.path();
1748 let dest_path = dest.join(entry.file_name());
1749 let metadata = std::fs::symlink_metadata(&src_path).with_context(|| {
1750 format!(
1751 "failed to read metadata for {}",
1752 src_path.display().to_string()
1753 )
1754 })?;
1755
1756 if metadata.file_type().is_symlink() {
1757 anyhow::bail!(
1758 "Refusing to copy symlink within skill source: {}",
1759 src_path.display()
1760 );
1761 }
1762
1763 if metadata.is_dir() {
1764 copy_dir_recursive_secure(&src_path, &dest_path)?;
1765 } else if metadata.is_file() {
1766 std::fs::copy(&src_path, &dest_path).with_context(|| {
1767 format!(
1768 "failed to copy skill file from {} to {}",
1769 src_path.display().to_string(),
1770 dest_path.display()
1771 )
1772 })?;
1773 }
1774 }
1775
1776 Ok(())
1777}
1778
1779pub fn install_local_skill_source(
1780 source: &str,
1781 skills_path: &Path,
1782 allow_scripts: bool,
1783) -> Result<(PathBuf, usize)> {
1784 let source_path = PathBuf::from(source);
1785 if !source_path.exists() {
1786 anyhow::bail!("Source path does not exist: {source}");
1787 }
1788
1789 let source_path = source_path
1790 .canonicalize()
1791 .with_context(|| format!("failed to canonicalize source path {source}"))?;
1792 let _ = enforce_skill_security_audit(&source_path, allow_scripts)?;
1793
1794 let name = source_path
1795 .file_name()
1796 .context("Source path must include a directory name")?;
1797 let dest = skills_path.join(name);
1798 if dest.exists() {
1799 anyhow::bail!(
1800 "Destination skill already exists: {}",
1801 dest.display().to_string()
1802 );
1803 }
1804
1805 if let Err(err) = copy_dir_recursive_secure(&source_path, &dest) {
1806 let _ = std::fs::remove_dir_all(&dest);
1807 return Err(err);
1808 }
1809
1810 match enforce_skill_security_audit(&dest, allow_scripts) {
1811 Ok(report) => Ok((dest, report.files_scanned)),
1812 Err(err) => {
1813 let _ = std::fs::remove_dir_all(&dest);
1814 Err(err)
1815 }
1816 }
1817}
1818
1819pub fn install_git_skill_source(
1820 source: &str,
1821 skills_path: &Path,
1822 allow_scripts: bool,
1823) -> Result<(PathBuf, usize)> {
1824 let before = snapshot_skill_children(skills_path)?;
1825 let output = std::process::Command::new("git")
1826 .args(["clone", "--depth", "1", source])
1827 .current_dir(skills_path)
1828 .output()?;
1829 if !output.status.success() {
1830 let stderr = String::from_utf8_lossy(&output.stderr);
1831 anyhow::bail!("Git clone failed: {stderr}");
1832 }
1833
1834 let installed_dir = detect_newly_installed_directory(skills_path, &before)?;
1835 remove_git_metadata(&installed_dir)?;
1836 match enforce_skill_security_audit(&installed_dir, allow_scripts) {
1837 Ok(report) => Ok((installed_dir, report.files_scanned)),
1838 Err(err) => {
1839 let _ = std::fs::remove_dir_all(&installed_dir);
1840 Err(err)
1841 }
1842 }
1843}
1844
1845pub async fn install_clawhub_skill_source(
1846 source: &str,
1847 skills_path: &Path,
1848 allow_scripts: bool,
1849) -> Result<(PathBuf, usize)> {
1850 let download_url = clawhub_download_url(source)
1851 .with_context(|| format!("invalid ClawhHub source: {source}"))?;
1852 let skill_dir_name = clawhub_skill_dir_name(source)?;
1853 let installed_dir = skills_path.join(&skill_dir_name);
1854 if installed_dir.exists() {
1855 anyhow::bail!(
1856 "Destination skill already exists: {}",
1857 installed_dir.display()
1858 );
1859 }
1860
1861 let client = reqwest::Client::builder()
1862 .timeout(Duration::from_secs(30))
1863 .build()?;
1864
1865 let resp = client
1866 .get(&download_url)
1867 .send()
1868 .await
1869 .with_context(|| format!("failed to fetch zip from {download_url}"))?;
1870
1871 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
1872 anyhow::bail!("ClawhHub rate limit reached (HTTP 429). Wait a moment and retry.");
1873 }
1874 if !resp.status().is_success() {
1875 anyhow::bail!("ClawhHub download failed (HTTP {})", resp.status());
1876 }
1877
1878 let bytes = resp.bytes().await?.to_vec();
1879 if bytes.len() as u64 > MAX_CLAWHUB_ZIP_BYTES {
1880 anyhow::bail!(
1881 "ClawhHub zip rejected: too large ({} bytes > {})",
1882 bytes.len(),
1883 MAX_CLAWHUB_ZIP_BYTES
1884 );
1885 }
1886
1887 std::fs::create_dir_all(&installed_dir)?;
1888
1889 let cursor = Cursor::new(bytes);
1890 let mut archive = ZipArchive::new(cursor).context("downloaded content is not a valid zip")?;
1891
1892 for i in 0..archive.len() {
1893 let mut entry = archive.by_index(i)?;
1894 let raw_name = entry.name().to_string();
1895
1896 if raw_name.is_empty()
1897 || raw_name.contains("..")
1898 || raw_name.starts_with('/')
1899 || raw_name.contains('\\')
1900 || raw_name.contains(':')
1901 {
1902 let _ = std::fs::remove_dir_all(&installed_dir);
1903 anyhow::bail!("zip entry contains unsafe path: {raw_name}");
1904 }
1905
1906 let out_path = installed_dir.join(&raw_name);
1907 if entry.is_dir() {
1908 std::fs::create_dir_all(&out_path)?;
1909 continue;
1910 }
1911
1912 if let Some(parent) = out_path.parent() {
1913 std::fs::create_dir_all(parent)?;
1914 }
1915
1916 let mut out_file = std::fs::File::create(&out_path).with_context(|| {
1917 format!(
1918 "failed to create extracted file: {}",
1919 out_path.display().to_string()
1920 )
1921 })?;
1922 std::io::copy(&mut entry, &mut out_file)?;
1923 }
1924
1925 let has_manifest = installed_dir.join("SKILL.md").exists()
1926 || installed_dir.join("SKILL.toml").exists()
1927 || installed_dir.join("manifest.toml").exists();
1928 if !has_manifest {
1929 std::fs::write(
1930 installed_dir.join("SKILL.toml"),
1931 format!(
1932 "[skill]\nname = \"{}\"\ndescription = \"ClawhHub installed skill\"\nversion = \"0.1.0\"\n",
1933 skill_dir_name
1934 ),
1935 )?;
1936 }
1937
1938 match enforce_skill_security_audit(&installed_dir, allow_scripts) {
1939 Ok(report) => Ok((installed_dir, report.files_scanned)),
1940 Err(err) => {
1941 let _ = std::fs::remove_dir_all(&installed_dir);
1942 Err(err)
1943 }
1944 }
1945}
1946
1947pub fn is_registry_source(source: &str) -> bool {
1950 if source.is_empty() {
1951 return false;
1952 }
1953 if source.contains('/') || source.contains('\\') || source.contains("..") {
1954 return false;
1955 }
1956 if source.contains("://") || source.contains(':') {
1957 return false;
1958 }
1959 if source.starts_with('.') || source.starts_with('~') {
1960 return false;
1961 }
1962 source
1963 .bytes()
1964 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1965}
1966
1967fn clone_skills_registry(registry_dir: &Path, repo_url: &str) -> Result<()> {
1968 if let Some(parent) = registry_dir.parent() {
1969 std::fs::create_dir_all(parent).with_context(|| {
1970 format!(
1971 "failed to create registry parent: {}",
1972 parent.display().to_string()
1973 )
1974 })?;
1975 }
1976
1977 let output = Command::new("git")
1978 .args(["clone", "--depth", "1", repo_url])
1979 .arg(registry_dir)
1980 .output()
1981 .context("failed to run git clone for skills registry")?;
1982
1983 if !output.status.success() {
1984 let stderr = String::from_utf8_lossy(&output.stderr);
1985 anyhow::bail!("failed to clone skills registry: {stderr}");
1986 }
1987
1988 ::zeroclaw_log::record!(
1989 INFO,
1990 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1991 &format!(
1992 "cloned skills registry to {}",
1993 registry_dir.display().to_string()
1994 )
1995 );
1996 mark_skills_registry_synced(registry_dir)?;
1997 Ok(())
1998}
1999
2000fn pull_skills_registry(registry_dir: &Path) -> bool {
2001 if !registry_dir.join(".git").exists() {
2002 return true;
2003 }
2004
2005 let output = Command::new("git")
2006 .arg("-C")
2007 .arg(registry_dir)
2008 .args(["pull", "--ff-only"])
2009 .output();
2010
2011 match output {
2012 Ok(result) if result.status.success() => true,
2013 Ok(result) => {
2014 let stderr = String::from_utf8_lossy(&result.stderr);
2015 ::zeroclaw_log::record!(
2016 WARN,
2017 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2018 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2019 .with_attrs(::serde_json::json!({"stderr": stderr})),
2020 "failed to pull skills registry updates: "
2021 );
2022 false
2023 }
2024 Err(err) => {
2025 ::zeroclaw_log::record!(
2026 WARN,
2027 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2028 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2029 .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
2030 "failed to run git pull for skills registry"
2031 );
2032 false
2033 }
2034 }
2035}
2036
2037fn should_sync_skills_registry(registry_dir: &Path) -> bool {
2038 let marker = registry_dir.join(SKILLS_REGISTRY_SYNC_MARKER);
2039 let Ok(metadata) = std::fs::metadata(marker) else {
2040 return true;
2041 };
2042 let Ok(modified_at) = metadata.modified() else {
2043 return true;
2044 };
2045 let Ok(age) = SystemTime::now().duration_since(modified_at) else {
2046 return true;
2047 };
2048 age >= Duration::from_secs(SKILLS_REGISTRY_SYNC_INTERVAL_SECS)
2049}
2050
2051fn mark_skills_registry_synced(registry_dir: &Path) -> Result<()> {
2052 std::fs::write(registry_dir.join(SKILLS_REGISTRY_SYNC_MARKER), b"synced")?;
2053 Ok(())
2054}
2055
2056fn ensure_skills_registry(workspace_dir: &Path, registry_url: Option<&str>) -> Result<PathBuf> {
2057 let registry_dir = workspace_dir.join(SKILLS_REGISTRY_DIR_NAME);
2058 let repo_url = registry_url.unwrap_or(SKILLS_REGISTRY_REPO_URL);
2059
2060 if !registry_dir.exists() {
2061 clone_skills_registry(®istry_dir, repo_url)?;
2062 return Ok(registry_dir);
2063 }
2064
2065 if should_sync_skills_registry(®istry_dir) {
2066 if pull_skills_registry(®istry_dir) {
2067 let _ = mark_skills_registry_synced(®istry_dir);
2068 } else {
2069 ::zeroclaw_log::record!(
2070 WARN,
2071 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2072 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2073 &format!(
2074 "skills registry update failed; using local copy from {}",
2075 registry_dir.display().to_string()
2076 )
2077 );
2078 }
2079 }
2080
2081 Ok(registry_dir)
2082}
2083
2084fn list_registry_skill_names(registry_dir: &Path) -> Vec<String> {
2085 let skills_parent = registry_dir.join("skills");
2086 let Ok(entries) = std::fs::read_dir(&skills_parent) else {
2087 return vec![];
2088 };
2089 let mut names: Vec<String> = entries
2090 .filter_map(|e| e.ok())
2091 .filter(|e| e.path().is_dir())
2092 .filter_map(|e| e.file_name().into_string().ok())
2093 .collect();
2094 names.sort();
2095 names
2096}
2097
2098pub fn install_registry_skill_source(
2099 source: &str,
2100 skills_path: &Path,
2101 allow_scripts: bool,
2102 workspace_dir: &Path,
2103 registry_url: Option<&str>,
2104 suppress_tier_banner: bool,
2105) -> Result<(PathBuf, usize)> {
2106 let registry_dir = ensure_skills_registry(workspace_dir, registry_url)?;
2107 let skill_dir = registry_dir.join("skills").join(source);
2108
2109 if !skill_dir.is_dir() {
2110 let available = list_registry_skill_names(®istry_dir);
2111 if available.is_empty() {
2112 anyhow::bail!("skill '{source}' not found in the registry and no skills are available");
2113 }
2114 anyhow::bail!(
2115 "skill '{source}' not found in the registry.\nAvailable skills: {}",
2116 available.join(", ")
2117 );
2118 }
2119
2120 if !suppress_tier_banner {
2121 let (tier, version) = lookup_registry_skill_tier(®istry_dir, source);
2122 print_install_tier_banner(source, version.as_deref(), tier);
2123 }
2124
2125 install_local_skill_source(
2126 skill_dir.to_str().with_context(|| {
2127 format!(
2128 "registry path is not valid UTF-8: {}",
2129 skill_dir.display().to_string()
2130 )
2131 })?,
2132 skills_path,
2133 allow_scripts,
2134 )
2135}
2136
2137#[cfg(feature = "plugins-wasm")]
2146pub fn load_plugin_skills_from_config(config: &zeroclaw_config::schema::Config) -> Vec<Skill> {
2147 if !config.plugins.enabled {
2148 return Vec::new();
2149 }
2150
2151 let plugins_dir = expand_plugins_dir(&config.plugins.plugins_dir);
2152 let parent = match plugins_dir.parent() {
2153 Some(p) => p.to_path_buf(),
2154 None => return Vec::new(),
2155 };
2156
2157 let signature_mode = zeroclaw_plugins::host::PluginHost::parse_signature_mode(
2158 &config.plugins.security.signature_mode,
2159 );
2160 let trusted_keys = config.plugins.security.trusted_publisher_keys.clone();
2161
2162 let host = match zeroclaw_plugins::host::PluginHost::with_security(
2163 &parent,
2164 signature_mode,
2165 trusted_keys,
2166 ) {
2167 Ok(host) => host,
2168 Err(err) => {
2169 ::zeroclaw_log::record!(
2170 WARN,
2171 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2172 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2173 .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
2174 "failed to discover plugin skills"
2175 );
2176 return Vec::new();
2177 }
2178 };
2179
2180 let allow_scripts = config.skills.allow_scripts;
2181 let mut skills = Vec::new();
2182 for (manifest, skills_dir) in host.skill_plugin_details() {
2183 for raw in load_skills_from_directory(&skills_dir, allow_scripts) {
2184 skills.push(namespace_plugin_skill(&manifest.name, raw));
2185 }
2186 }
2187 skills
2188}
2189
2190#[cfg(feature = "plugins-wasm")]
2191fn expand_plugins_dir(plugins_dir: &str) -> PathBuf {
2192 if let Some(rest) = plugins_dir.strip_prefix("~/")
2193 && let Some(dirs) = UserDirs::new()
2194 {
2195 return dirs.home_dir().join(rest);
2196 }
2197 PathBuf::from(plugins_dir)
2198}
2199
2200#[cfg(feature = "plugins-wasm")]
2201fn namespace_plugin_skill(plugin_name: &str, mut skill: Skill) -> Skill {
2202 let qualified = format!("plugin:{}/{}", plugin_name, skill.name);
2203 skill.name = qualified;
2204 let plugin_tag = format!("plugin:{plugin_name}");
2205 if !skill.tags.iter().any(|t| t == &plugin_tag) {
2206 skill.tags.push(plugin_tag);
2207 }
2208 skill
2209}
2210
2211#[cfg(test)]
2212mod registry_tests {
2213 use super::*;
2214
2215 #[test]
2216 fn parse_simple_frontmatter_keeps_blank_line_in_block_scalar() {
2217 let frontmatter = "name: x\ndescription: >-\n para one\n\n para two\n";
2220 let meta = parse_simple_frontmatter(frontmatter);
2221 let desc = meta.description.expect("description should be parsed");
2222 assert!(
2223 desc.contains("para one"),
2224 "first paragraph missing: {desc:?}"
2225 );
2226 assert!(
2227 desc.contains("para two"),
2228 "second paragraph after blank line was truncated: {desc:?}"
2229 );
2230 assert_eq!(meta.name.as_deref(), Some("x"));
2231 }
2232
2233 #[test]
2234 fn parse_simple_frontmatter_block_scalar_stops_at_next_key() {
2235 let frontmatter = "description: >-\n hello\n world\nversion: 1.2.3\n";
2237 let meta = parse_simple_frontmatter(frontmatter);
2238 assert_eq!(meta.description.as_deref(), Some("hello world"));
2239 assert_eq!(meta.version.as_deref(), Some("1.2.3"));
2240 }
2241
2242 #[test]
2243 fn test_is_registry_source_accepts_bare_names() {
2244 assert!(is_registry_source("auto-coder"));
2245 assert!(is_registry_source("web-researcher"));
2246 assert!(is_registry_source("telegram-assistant"));
2247 assert!(is_registry_source("data_analyst"));
2248 assert!(is_registry_source("ci-helper"));
2249 assert!(is_registry_source("selfimproving"));
2250 }
2251
2252 #[test]
2253 fn test_is_registry_source_rejects_empty() {
2254 assert!(!is_registry_source(""));
2255 }
2256
2257 #[test]
2258 fn test_is_registry_source_rejects_paths() {
2259 assert!(!is_registry_source("./my-skill"));
2260 assert!(!is_registry_source("../my-skill"));
2261 assert!(!is_registry_source("/abs/path"));
2262 assert!(!is_registry_source("skills/auto-coder"));
2263 assert!(!is_registry_source("some\\path"));
2264 assert!(!is_registry_source("~/.zeroclaw/skills/foo"));
2265 }
2266
2267 #[test]
2268 fn test_is_registry_source_rejects_urls() {
2269 assert!(!is_registry_source("https://github.com/foo/bar"));
2270 assert!(!is_registry_source("http://example.com"));
2271 assert!(!is_registry_source("ssh://git@host/repo"));
2272 assert!(!is_registry_source("git://host/repo"));
2273 assert!(!is_registry_source("git@github.com:user/repo"));
2274 }
2275
2276 #[test]
2277 fn test_is_registry_source_rejects_clawhub() {
2278 assert!(!is_registry_source("clawhub:my-skill"));
2279 }
2280
2281 #[test]
2282 fn test_is_registry_source_rejects_traversal() {
2283 assert!(!is_registry_source(".."));
2284 assert!(!is_registry_source("foo..bar"));
2285 }
2286
2287 #[test]
2288 fn test_is_registry_source_rejects_special_chars() {
2289 assert!(!is_registry_source(".hidden"));
2290 assert!(!is_registry_source("~tilde"));
2291 }
2292
2293 #[test]
2294 fn tier_from_tags_recognizes_official() {
2295 assert_eq!(
2296 tier_from_tags(&["Official".into(), "Featured".into()]),
2297 SkillTier::Official
2298 );
2299 assert_eq!(tier_from_tags(&["official".into()]), SkillTier::Official);
2301 }
2302
2303 #[test]
2304 fn tier_from_tags_recognizes_community() {
2305 assert_eq!(tier_from_tags(&["Community".into()]), SkillTier::Community);
2306 }
2307
2308 #[test]
2309 fn tier_from_tags_recognizes_featured_only() {
2310 assert_eq!(tier_from_tags(&["Featured".into()]), SkillTier::Featured);
2311 }
2312
2313 #[test]
2314 fn tier_from_tags_falls_back_to_unknown_when_no_tier_tag() {
2315 assert_eq!(tier_from_tags(&[]), SkillTier::Unknown);
2316 assert_eq!(
2317 tier_from_tags(&["productivity".into(), "automation".into()]),
2318 SkillTier::Unknown
2319 );
2320 }
2321
2322 fn english_tier_banner(name: &str, version: Option<&str>, tier: SkillTier) -> String {
2325 let version_label = version.unwrap_or("?");
2326 let args = [("name", name), ("version", version_label)];
2327 let mut banner =
2328 crate::i18n::get_english_cli_string_with_args(install_tier_banner_key(tier), &args);
2329 if !banner.ends_with('\n') {
2330 banner.push('\n');
2331 }
2332 banner
2333 }
2334
2335 #[test]
2336 fn build_install_tier_banner_official_is_single_line() {
2337 let banner = english_tier_banner("auto-coder", Some("0.3.0"), SkillTier::Official);
2338 assert!(banner.contains("Official (zeroclaw-labs maintained)"));
2339 assert!(banner.contains("Installing auto-coder v0.3.0"));
2340 assert!(!banner.contains("not audited"));
2341 assert_eq!(banner.lines().count(), 1);
2343 }
2344
2345 #[test]
2346 fn build_install_tier_banner_community_warns() {
2347 let banner = english_tier_banner("discord-moderator", Some("0.1.2"), SkillTier::Community);
2348 assert!(banner.contains("Community submission"));
2349 assert!(banner.contains("not audited by ZeroClaw"));
2350 assert!(banner.contains("zeroclaw skills audit discord-moderator"));
2351 }
2352
2353 #[test]
2354 fn build_install_tier_banner_featured_uses_community_warning() {
2355 let banner = english_tier_banner("hand-picked", Some("1.0"), SkillTier::Featured);
2356 assert!(banner.contains("Community submission"));
2357 assert!(banner.contains("not audited by ZeroClaw"));
2358 }
2359
2360 #[test]
2361 fn build_install_tier_banner_unknown_falls_back_to_community() {
2362 let banner = english_tier_banner("legacy", None, SkillTier::Unknown);
2363 assert!(banner.contains("Community submission"));
2364 assert!(banner.contains("not audited by ZeroClaw"));
2365 assert!(banner.contains("v?"));
2367 }
2368
2369 #[test]
2370 fn lookup_registry_skill_tier_resolves_from_registry_json() {
2371 let tmp = tempfile::TempDir::new().unwrap();
2372 let json = r#"{
2373 "version": 1,
2374 "skills": [
2375 { "name": "auto-coder", "version": "0.3.0", "tags": ["Official", "Featured"] },
2376 { "name": "discord-moderator", "version": "0.1.2", "tags": ["Community"] },
2377 { "name": "hand-picked", "version": "1.0.0", "tags": ["Featured"] },
2378 { "name": "untagged", "version": "0.0.1", "tags": ["productivity"] }
2379 ]
2380 }"#;
2381 std::fs::write(tmp.path().join("registry.json"), json).unwrap();
2382
2383 assert_eq!(
2384 lookup_registry_skill_tier(tmp.path(), "auto-coder"),
2385 (SkillTier::Official, Some("0.3.0".to_string()))
2386 );
2387 assert_eq!(
2388 lookup_registry_skill_tier(tmp.path(), "discord-moderator"),
2389 (SkillTier::Community, Some("0.1.2".to_string()))
2390 );
2391 assert_eq!(
2392 lookup_registry_skill_tier(tmp.path(), "hand-picked"),
2393 (SkillTier::Featured, Some("1.0.0".to_string()))
2394 );
2395 assert_eq!(
2397 lookup_registry_skill_tier(tmp.path(), "untagged"),
2398 (SkillTier::Unknown, Some("0.0.1".to_string()))
2399 );
2400 assert_eq!(
2402 lookup_registry_skill_tier(tmp.path(), "missing"),
2403 (SkillTier::Unknown, None)
2404 );
2405 }
2406
2407 #[test]
2408 fn lookup_registry_skill_tier_handles_missing_index() {
2409 let tmp = tempfile::TempDir::new().unwrap();
2410 assert_eq!(
2411 lookup_registry_skill_tier(tmp.path(), "anything"),
2412 (SkillTier::Unknown, None)
2413 );
2414 }
2415
2416 #[test]
2417 fn lookup_registry_skill_tier_handles_malformed_json() {
2418 let tmp = tempfile::TempDir::new().unwrap();
2419 std::fs::write(tmp.path().join("registry.json"), "{ not json").unwrap();
2420 assert_eq!(
2421 lookup_registry_skill_tier(tmp.path(), "anything"),
2422 (SkillTier::Unknown, None)
2423 );
2424 }
2425}
2426
2427#[cfg(test)]
2428mod prompts_section_tests {
2429 use super::*;
2430 use tempfile::TempDir;
2431
2432 fn write_manifest(dir: &Path, toml: &str) -> std::path::PathBuf {
2433 let p = dir.join("SKILL.toml");
2434 std::fs::write(&p, toml).unwrap();
2435 p
2436 }
2437
2438 #[test]
2439 fn prompts_inside_skill_section_are_loaded() {
2440 let tmp = TempDir::new().unwrap();
2441 let path = write_manifest(
2442 tmp.path(),
2443 r#"
2444[skill]
2445name = "probe"
2446description = "test"
2447version = "0.1.0"
2448prompts = ["If asked about XYZZY, respond YES"]
2449"#,
2450 );
2451 let skill = load_skill_toml(&path).unwrap();
2452 assert_eq!(
2453 skill.prompts,
2454 vec!["If asked about XYZZY, respond YES".to_string()]
2455 );
2456 }
2457
2458 #[test]
2459 fn prompts_at_root_level_still_work() {
2460 let tmp = TempDir::new().unwrap();
2461 let path = write_manifest(
2462 tmp.path(),
2463 r#"
2464[skill]
2465name = "probe"
2466description = "test"
2467version = "0.1.0"
2468
2469prompts = ["legacy root-level prompt"]
2470"#,
2471 );
2472 let skill = load_skill_toml(&path).unwrap();
2473 assert_eq!(skill.prompts, vec!["legacy root-level prompt".to_string()]);
2474 }
2475
2476 #[test]
2477 fn prompts_in_both_locations_are_merged_skill_first() {
2478 let tmp = TempDir::new().unwrap();
2482 let path = write_manifest(
2483 tmp.path(),
2484 r#"
2485prompts = ["from-root"]
2486
2487[skill]
2488name = "probe"
2489description = "test"
2490version = "0.1.0"
2491prompts = ["from-skill-section"]
2492"#,
2493 );
2494 let skill = load_skill_toml(&path).unwrap();
2495 assert_eq!(
2496 skill.prompts,
2497 vec!["from-skill-section".to_string(), "from-root".to_string(),]
2498 );
2499 }
2500}
2501
2502#[cfg(test)]
2503mod skill_manifest_tests {
2504 use super::*;
2505
2506 #[test]
2507 fn parses_valid_skill_manifest() {
2508 let toml_str = r#"
2509[skill]
2510name = "x"
2511description = "y"
2512"#;
2513 let manifest: SkillManifest =
2514 toml::from_str(toml_str).expect("valid manifest should parse");
2515 assert_eq!(manifest.skill.name, "x");
2516 assert_eq!(manifest.skill.description, "y");
2517 assert_eq!(manifest.skill.version, "0.1.0");
2518 assert!(manifest.tools.is_empty());
2519 assert!(manifest.prompts.is_empty());
2520 }
2521
2522 #[test]
2523 fn rejects_unknown_field_in_skill_block() {
2524 let toml_str = r#"
2525[skill]
2526name = "x"
2527description = "y"
2528descriptin = "oops"
2529"#;
2530 let err = toml::from_str::<SkillManifest>(toml_str)
2531 .expect_err("unknown field in [skill] should be rejected");
2532 let msg = err.to_string();
2533 assert!(
2534 msg.contains("descriptin"),
2535 "error should mention the unknown field 'descriptin'; got: {msg}"
2536 );
2537 }
2538
2539 #[test]
2544 fn accepts_prompts_in_skill_block_with_strictness() {
2545 let toml_str = r#"
2546[skill]
2547name = "x"
2548description = "y"
2549prompts = ["one", "two"]
2550"#;
2551 let manifest: SkillManifest = toml::from_str(toml_str)
2552 .expect("manifest with prompts in [skill] should parse under deny_unknown_fields");
2553 assert_eq!(
2554 manifest.skill.prompts,
2555 vec!["one".to_string(), "two".to_string()]
2556 );
2557 }
2558
2559 #[test]
2562 fn parses_skill_without_forge_block() {
2563 let toml_str = r#"
2564[skill]
2565name = "hand-authored"
2566description = "no forge block"
2567"#;
2568 let manifest: SkillManifest =
2569 toml::from_str(toml_str).expect("manifest without [forge] should parse cleanly");
2570 assert!(
2571 manifest.forge.is_none(),
2572 "forge should be None when [forge] is absent"
2573 );
2574 assert_eq!(manifest.skill.name, "hand-authored");
2575 }
2576
2577 #[test]
2581 fn parses_skill_with_forge_block() {
2582 let toml_str = r#"
2583[skill]
2584name = "auto-integrated"
2585description = "from skillforge"
2586
2587[forge]
2588source = "https://github.com/user/auto-integrated"
2589owner = "user"
2590language = "Rust"
2591license = true
2592stars = 42
2593updated_at = "2026-04-30"
2594
2595[forge.requirements]
2596runtime = "zeroclaw >= 0.1"
2597
2598[forge.metadata]
2599auto_integrated = true
2600forge_timestamp = "2026-04-30T12:00:00Z"
2601"#;
2602 let manifest: SkillManifest =
2603 toml::from_str(toml_str).expect("manifest with [forge] block should parse cleanly");
2604 let forge = manifest
2605 .forge
2606 .expect("forge should be Some when [forge] is present");
2607 assert_eq!(
2608 forge.source.as_deref(),
2609 Some("https://github.com/user/auto-integrated")
2610 );
2611 assert_eq!(forge.owner.as_deref(), Some("user"));
2612 assert_eq!(forge.language.as_deref(), Some("Rust"));
2613 assert_eq!(forge.license, Some(true));
2614 assert_eq!(forge.stars, Some(42));
2615 assert_eq!(forge.updated_at.as_deref(), Some("2026-04-30"));
2616 assert_eq!(
2617 forge.requirements.get("runtime").and_then(|v| v.as_str()),
2618 Some("zeroclaw >= 0.1"),
2619 );
2620 assert_eq!(
2621 forge
2622 .metadata
2623 .get("auto_integrated")
2624 .and_then(|v| v.as_bool()),
2625 Some(true),
2626 );
2627 }
2628
2629 #[test]
2633 fn rejects_unknown_field_in_forge_block() {
2634 let toml_str = r#"
2635[skill]
2636name = "x"
2637description = "y"
2638
2639[forge]
2640source = "https://github.com/user/x"
2641licence = true
2642"#;
2643 let err = toml::from_str::<SkillManifest>(toml_str)
2644 .expect_err("unknown field in [forge] should be rejected");
2645 let msg = err.to_string();
2646 assert!(
2647 msg.contains("licence"),
2648 "error should mention the unknown field 'licence'; got: {msg}"
2649 );
2650 }
2651
2652 #[test]
2658 fn integrate_round_trip_emits_top_level_forge() {
2659 use crate::skillforge::scout::{ScoutResult, ScoutSource};
2660 use chrono::Utc;
2661 let candidate = ScoutResult {
2662 name: "round-trip".into(),
2663 url: "https://github.com/user/round-trip".into(),
2664 description: "round-trip test".into(),
2665 stars: 7,
2666 language: Some("Rust".into()),
2667 updated_at: Some(Utc::now()),
2668 source: ScoutSource::GitHub,
2669 owner: "user".into(),
2670 has_license: true,
2671 };
2672
2673 let tmp = tempfile::TempDir::new().unwrap();
2675 let integrator = crate::skillforge::integrate::Integrator::new(
2676 tmp.path().to_string_lossy().into_owned(),
2677 );
2678 let skill_dir = integrator.integrate(&candidate).unwrap();
2679 let toml_str = std::fs::read_to_string(skill_dir.join("SKILL.toml")).unwrap();
2680
2681 let manifest: SkillManifest = toml::from_str(&toml_str).unwrap_or_else(|e| {
2682 panic!(
2683 "integrator output must parse against SkillManifest with strict SkillMeta + ForgeMetadata; \
2684 got error: {e}\n--- toml ---\n{toml_str}"
2685 )
2686 });
2687 let forge = manifest
2688 .forge
2689 .expect("integrator must emit a [forge] table");
2690 assert_eq!(forge.owner.as_deref(), Some("user"));
2691 assert_eq!(forge.stars, Some(7));
2692 assert_eq!(forge.license, Some(true));
2693 assert!(
2694 forge
2695 .source
2696 .as_deref()
2697 .is_some_and(|s| s.contains("round-trip")),
2698 "forge.source should carry the upstream URL"
2699 );
2700 assert_eq!(manifest.skill.name, "round-trip");
2706 assert_eq!(manifest.skill.description, "round-trip test");
2707 }
2708
2709 #[test]
2719 fn workspace_swallow_site_skips_invalid_toml_without_panicking() {
2720 use tempfile::TempDir;
2721 let tmp = TempDir::new().unwrap();
2722 let skills_dir = tmp.path().join("skills");
2723 std::fs::create_dir_all(&skills_dir).unwrap();
2724
2725 let bad_dir = skills_dir.join("bad-skill");
2727 std::fs::create_dir_all(&bad_dir).unwrap();
2728 std::fs::write(
2729 bad_dir.join("SKILL.toml"),
2730 r#"
2731[skill]
2732name = "bad"
2733description = "has a typo"
2734descriptin = "oops"
2735"#,
2736 )
2737 .unwrap();
2738
2739 let good_dir = skills_dir.join("good-skill");
2741 std::fs::create_dir_all(&good_dir).unwrap();
2742 std::fs::write(
2743 good_dir.join("SKILL.toml"),
2744 r#"
2745[skill]
2746name = "good"
2747description = "fine"
2748"#,
2749 )
2750 .unwrap();
2751
2752 let skills = load_skills_from_directory(&skills_dir, false);
2753 let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
2755 assert!(
2756 names.contains(&"good"),
2757 "good skill must load; got: {names:?}"
2758 );
2759 assert!(
2760 !names.contains(&"bad"),
2761 "bad skill must be skipped, not silently accepted; got: {names:?}"
2762 );
2763 }
2764
2765 #[test]
2768 fn open_skills_swallow_site_skips_invalid_toml_without_panicking() {
2769 use tempfile::TempDir;
2770 let tmp = TempDir::new().unwrap();
2771 let skills_dir = tmp.path().join("open-skills");
2772 std::fs::create_dir_all(&skills_dir).unwrap();
2773
2774 let bad_dir = skills_dir.join("bad-open-skill");
2775 std::fs::create_dir_all(&bad_dir).unwrap();
2776 std::fs::write(
2777 bad_dir.join("SKILL.toml"),
2778 r#"
2779[skill]
2780name = "bad-open"
2781description = "has a typo"
2782autor = "oops"
2783"#,
2784 )
2785 .unwrap();
2786
2787 let good_dir = skills_dir.join("good-open-skill");
2788 std::fs::create_dir_all(&good_dir).unwrap();
2789 std::fs::write(
2790 good_dir.join("SKILL.toml"),
2791 r#"
2792[skill]
2793name = "good-open"
2794description = "fine"
2795"#,
2796 )
2797 .unwrap();
2798
2799 let skills = load_open_skills_from_directory(&skills_dir, false);
2800 let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
2801 assert!(
2802 names.contains(&"good-open"),
2803 "good open-skill must load; got: {names:?}"
2804 );
2805 assert!(
2806 !names.contains(&"bad-open"),
2807 "bad open-skill must be skipped, not silently accepted; got: {names:?}"
2808 );
2809 }
2810}