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