1use std::collections::BTreeSet;
41use std::path::{Path, PathBuf};
42
43use serde::{Deserialize, Serialize};
44
45use crate::HarnessId;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum SkillScope {
52 Managed,
54 User,
56 Project,
58 Plugin,
60 Bundled,
62}
63
64impl SkillScope {
65 pub const fn as_str(self) -> &'static str {
67 match self {
68 Self::Managed => "managed",
69 Self::User => "user",
70 Self::Project => "project",
71 Self::Plugin => "plugin",
72 Self::Bundled => "bundled",
73 }
74 }
75
76 pub fn parse(value: &str) -> Option<Self> {
78 match value {
79 "managed" => Some(Self::Managed),
80 "user" => Some(Self::User),
81 "project" => Some(Self::Project),
82 "plugin" => Some(Self::Plugin),
83 "bundled" => Some(Self::Bundled),
84 _ => None,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct SkillRow {
92 pub name: String,
94 pub harness: HarnessId,
96 pub scope: SkillScope,
98 pub location: PathBuf,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub description: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub version: Option<String>,
106 pub enabled: Option<bool>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(default)]
114pub struct SkillHomes {
115 pub claude_code: PathBuf,
117 pub codex: PathBuf,
119 pub opencode: PathBuf,
122 pub pi: PathBuf,
124 pub hermes: PathBuf,
126 pub openclaw: PathBuf,
129 pub agents: PathBuf,
131}
132
133fn home_dir() -> PathBuf {
134 std::env::var_os("HOME")
135 .map(PathBuf::from)
136 .unwrap_or_else(|| PathBuf::from("."))
137}
138
139impl Default for SkillHomes {
140 fn default() -> Self {
141 let home = home_dir();
142 Self {
143 claude_code: std::env::var_os("CLAUDE_CONFIG_DIR")
144 .map(PathBuf::from)
145 .unwrap_or_else(|| home.join(".claude")),
146 codex: std::env::var_os("CODEX_HOME")
147 .map(PathBuf::from)
148 .unwrap_or_else(|| home.join(".codex")),
149 opencode: std::env::var_os("OPENCODE_CONFIG_DIR")
150 .map(PathBuf::from)
151 .unwrap_or_else(|| {
152 std::env::var_os("XDG_CONFIG_HOME")
153 .map(PathBuf::from)
154 .unwrap_or_else(|| home.join(".config"))
155 .join("opencode")
156 }),
157 pi: std::env::var_os("PI_CODING_AGENT_DIR")
158 .map(PathBuf::from)
159 .unwrap_or_else(|| home.join(".pi").join("agent")),
160 hermes: std::env::var_os("HERMES_HOME")
161 .map(PathBuf::from)
162 .unwrap_or_else(|| home.join(".hermes")),
163 openclaw: std::env::var_os("OPENCLAW_STATE_DIR")
164 .map(PathBuf::from)
165 .or_else(|| {
166 std::env::var_os("OPENCLAW_HOME")
167 .map(|root| PathBuf::from(root).join(".openclaw"))
168 })
169 .unwrap_or_else(|| home.join(".openclaw")),
170 agents: home.join(".agents"),
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
177#[serde(default)]
178pub struct SkillsQuery {
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub harness: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub scope: Option<SkillScope>,
185 #[serde(skip_serializing_if = "Option::is_none")]
188 pub cwd: Option<PathBuf>,
189 pub homes: SkillHomes,
191}
192
193pub const SKILL_HARNESSES: &[&str] = &[
195 HarnessId::CLAUDE_CODE,
196 HarnessId::CODEX,
197 HarnessId::OPENCODE,
198 HarnessId::PI,
199 HarnessId::HERMES,
200 HarnessId::OPENCLAW,
201];
202
203const MAX_GROUP_DEPTH: usize = 3;
207const MAX_ANCESTORS: usize = 32;
209const FRONTMATTER_READ_BYTES: usize = 8 * 1024;
211const MAX_ROWS_PER_ROOT: usize = 512;
213
214const SKIPPED_DIRS: &[&str] = &["node_modules", "target", ".git", "scripts", "references"];
216
217pub fn list_skills(query: &SkillsQuery) -> Vec<SkillRow> {
221 let cwd = query
222 .cwd
223 .clone()
224 .or_else(|| std::env::current_dir().ok())
225 .unwrap_or_else(|| PathBuf::from("."));
226 let mut rows = Vec::new();
227 let mut seen: BTreeSet<(String, PathBuf)> = BTreeSet::new();
228 for harness in SKILL_HARNESSES {
229 if let Some(wanted) = query.harness.as_deref() {
230 if wanted != *harness {
231 continue;
232 }
233 }
234 let id = HarnessId::new(*harness);
235 for (scope, root) in skill_roots(*harness, &query.homes, &cwd) {
236 if query.scope.is_some_and(|wanted| wanted != scope) {
237 continue;
238 }
239 let mut found = Vec::new();
240 collect_root(&id, scope, &root, 0, &mut found);
241 for row in found {
242 if seen.insert((row.harness.as_str().to_string(), row.location.clone())) {
243 rows.push(row);
244 }
245 }
246 }
247 }
248 apply_codex_enablement(&query.homes, &mut rows);
249 rows.sort_by(|a, b| {
250 a.harness
251 .as_str()
252 .cmp(b.harness.as_str())
253 .then(a.scope.cmp(&b.scope))
254 .then(a.name.cmp(&b.name))
255 .then(a.location.cmp(&b.location))
256 });
257 rows
258}
259
260pub fn skill_roots(harness: &str, homes: &SkillHomes, cwd: &Path) -> Vec<(SkillScope, PathBuf)> {
263 let mut roots: Vec<(SkillScope, PathBuf)> = Vec::new();
264 match harness {
265 HarnessId::CLAUDE_CODE => {
266 for managed in claude_managed_roots() {
267 roots.push((SkillScope::Managed, managed));
268 }
269 roots.push((SkillScope::User, homes.claude_code.join("skills")));
270 for plugin in claude_plugin_roots(&homes.claude_code) {
271 roots.push((SkillScope::Plugin, plugin));
272 }
273 for project in project_roots(cwd, &[&[".claude", "skills"]]) {
274 roots.push((SkillScope::Project, project));
275 }
276 }
277 HarnessId::CODEX => {
278 roots.push((SkillScope::Managed, PathBuf::from("/etc/codex/skills")));
279 roots.push((
280 SkillScope::Bundled,
281 homes.codex.join("skills").join(".system"),
282 ));
283 roots.push((SkillScope::User, homes.agents.join("skills")));
284 roots.push((SkillScope::User, homes.codex.join("skills")));
285 for project in project_roots(cwd, &[&[".agents", "skills"]]) {
286 roots.push((SkillScope::Project, project));
287 }
288 }
289 HarnessId::OPENCODE => {
290 roots.push((SkillScope::User, homes.opencode.join("skill")));
291 roots.push((SkillScope::User, homes.opencode.join("skills")));
292 for project in project_roots(cwd, &[&[".opencode", "skill"], &[".opencode", "skills"]])
293 {
294 roots.push((SkillScope::Project, project));
295 }
296 }
297 HarnessId::PI => {
298 roots.push((SkillScope::User, homes.pi.join("skills")));
299 roots.push((SkillScope::User, homes.agents.join("skills")));
300 for project in project_roots(cwd, &[&[".pi", "skills"], &[".agents", "skills"]]) {
301 roots.push((SkillScope::Project, project));
302 }
303 }
304 HarnessId::HERMES => {
305 roots.push((SkillScope::User, homes.hermes.join("skills")));
306 for profile in hermes_profile_roots(&homes.hermes) {
307 roots.push((SkillScope::User, profile));
308 }
309 }
310 HarnessId::OPENCLAW => {
311 roots.push((SkillScope::Managed, homes.openclaw.join("skills")));
312 roots.push((SkillScope::Plugin, homes.openclaw.join("plugin-skills")));
313 roots.push((SkillScope::User, homes.agents.join("skills")));
314 let workspace = homes.openclaw.join("workspace");
315 roots.push((SkillScope::Project, workspace.join("skills")));
316 roots.push((
317 SkillScope::Project,
318 workspace.join(".agents").join("skills"),
319 ));
320 }
321 _ => {}
322 }
323 roots.retain(|(_, root)| root.is_dir());
324 roots
325}
326
327pub fn writable_skill_roots(
337 harness: &str,
338 scope: SkillScope,
339 homes: &SkillHomes,
340 cwd: &Path,
341) -> Vec<PathBuf> {
342 if !matches!(scope, SkillScope::User | SkillScope::Project) {
343 return Vec::new();
344 }
345 let project = |markers: &[&[&str]]| -> Vec<PathBuf> {
346 markers
347 .iter()
348 .map(|marker| {
349 let mut root = cwd.to_path_buf();
350 for segment in *marker {
351 root = root.join(segment);
352 }
353 root
354 })
355 .collect()
356 };
357 match (harness, scope) {
358 (HarnessId::CLAUDE_CODE, SkillScope::User) => vec![homes.claude_code.join("skills")],
359 (HarnessId::CLAUDE_CODE, SkillScope::Project) => project(&[&[".claude", "skills"]]),
360 (HarnessId::CODEX, SkillScope::User) => {
361 vec![homes.agents.join("skills"), homes.codex.join("skills")]
362 }
363 (HarnessId::CODEX, SkillScope::Project) => project(&[&[".agents", "skills"]]),
364 (HarnessId::OPENCODE, SkillScope::User) => {
365 vec![homes.opencode.join("skill"), homes.opencode.join("skills")]
366 }
367 (HarnessId::OPENCODE, SkillScope::Project) => {
368 project(&[&[".opencode", "skill"], &[".opencode", "skills"]])
369 }
370 (HarnessId::PI, SkillScope::User) => {
371 vec![homes.pi.join("skills"), homes.agents.join("skills")]
372 }
373 (HarnessId::PI, SkillScope::Project) => {
374 project(&[&[".pi", "skills"], &[".agents", "skills"]])
375 }
376 _ => Vec::new(),
377 }
378}
379
380pub fn declared_skill_name(dir: &Path) -> Option<String> {
386 let manifest = dir.join("SKILL.md");
387 if !manifest.is_file() {
388 return None;
389 }
390 let front = read_frontmatter(&manifest);
391 front
392 .get("name")
393 .map(String::as_str)
394 .map(str::trim)
395 .filter(|value| !value.is_empty())
396 .map(str::to_string)
397 .or_else(|| {
398 dir.file_name()
399 .and_then(|name| name.to_str())
400 .map(str::to_string)
401 })
402}
403
404fn claude_managed_roots() -> Vec<PathBuf> {
406 #[cfg(target_os = "macos")]
407 {
408 vec![PathBuf::from(
409 "/Library/Application Support/ClaudeCode/skills",
410 )]
411 }
412 #[cfg(not(target_os = "macos"))]
413 {
414 vec![PathBuf::from("/etc/claude-code/skills")]
415 }
416}
417
418fn claude_plugin_roots(claude_home: &Path) -> Vec<PathBuf> {
421 let cache = claude_home.join("plugins").join("cache");
422 let mut roots = Vec::new();
423 for marketplace in child_dirs(&cache) {
424 for plugin in child_dirs(&marketplace) {
425 for version in child_dirs(&plugin) {
426 let skills = version.join("skills");
427 if skills.is_dir() {
428 roots.push(skills);
429 }
430 }
431 }
432 }
433 roots
434}
435
436fn hermes_profile_roots(hermes_home: &Path) -> Vec<PathBuf> {
439 child_dirs(&hermes_home.join("profiles"))
440 .into_iter()
441 .map(|profile| profile.join("skills"))
442 .filter(|root| root.is_dir())
443 .collect()
444}
445
446fn child_dirs(dir: &Path) -> Vec<PathBuf> {
447 let Ok(entries) = std::fs::read_dir(dir) else {
448 return Vec::new();
449 };
450 let mut out: Vec<PathBuf> = entries
451 .flatten()
452 .map(|entry| entry.path())
453 .filter(|path| path.is_dir())
454 .collect();
455 out.sort();
456 out
457}
458
459fn project_roots(cwd: &Path, markers: &[&[&str]]) -> Vec<PathBuf> {
466 let mut roots = Vec::new();
467 let mut seen = BTreeSet::new();
468 for ancestor in cwd.ancestors().take(MAX_ANCESTORS) {
469 for marker in markers {
470 let mut root = ancestor.to_path_buf();
471 for segment in *marker {
472 root = root.join(segment);
473 }
474 if root.is_dir() && seen.insert(root.clone()) {
475 roots.push(root);
476 }
477 }
478 if ancestor.join(".git").exists() {
479 break;
480 }
481 }
482 roots
483}
484
485fn collect_root(
489 harness: &HarnessId,
490 scope: SkillScope,
491 root: &Path,
492 depth: usize,
493 out: &mut Vec<SkillRow>,
494) {
495 if out.len() >= MAX_ROWS_PER_ROOT {
496 return;
497 }
498 for dir in child_dirs(root) {
499 if out.len() >= MAX_ROWS_PER_ROOT {
500 return;
501 }
502 let Some(name) = dir.file_name().and_then(|name| name.to_str()) else {
503 continue;
504 };
505 if SKIPPED_DIRS.contains(&name) || name.starts_with('.') {
506 continue;
507 }
508 let manifest = dir.join("SKILL.md");
509 if manifest.is_file() {
510 out.push(read_skill(harness, scope, &dir, name, &manifest));
511 continue;
512 }
513 let before = out.len();
514 if depth + 1 < MAX_GROUP_DEPTH {
515 collect_root(harness, scope, &dir, depth + 1, out);
516 }
517 if out.len() == before {
518 out.push(SkillRow {
521 name: name.to_string(),
522 harness: harness.clone(),
523 scope,
524 location: dir.clone(),
525 description: None,
526 version: None,
527 enabled: None,
528 });
529 }
530 }
531}
532
533fn read_skill(
534 harness: &HarnessId,
535 scope: SkillScope,
536 dir: &Path,
537 dir_name: &str,
538 manifest: &Path,
539) -> SkillRow {
540 let front = read_frontmatter(manifest);
541 SkillRow {
542 name: front
543 .get("name")
544 .map(String::as_str)
545 .map(str::trim)
546 .filter(|value| !value.is_empty())
547 .unwrap_or(dir_name)
548 .to_string(),
549 harness: harness.clone(),
550 scope,
551 location: dir.to_path_buf(),
552 description: front.get("description").map(|value| one_line(value)),
553 version: front
554 .get("version")
555 .map(|value| value.trim().to_string())
556 .filter(|value| !value.is_empty()),
557 enabled: frontmatter_enabled(&front),
558 }
559}
560
561fn frontmatter_enabled(front: &std::collections::BTreeMap<String, String>) -> Option<bool> {
565 if let Some(value) = front.get("enabled") {
566 return parse_bool(value);
567 }
568 if let Some(value) = front.get("disable-model-invocation") {
569 return parse_bool(value).map(|disabled| !disabled);
570 }
571 None
572}
573
574fn parse_bool(value: &str) -> Option<bool> {
575 match value
576 .trim()
577 .trim_matches(['"', '\''])
578 .to_ascii_lowercase()
579 .as_str()
580 {
581 "true" | "yes" | "on" => Some(true),
582 "false" | "no" | "off" => Some(false),
583 _ => None,
584 }
585}
586
587fn one_line(value: &str) -> String {
588 value.split_whitespace().collect::<Vec<_>>().join(" ")
589}
590
591pub(crate) fn read_frontmatter(manifest: &Path) -> std::collections::BTreeMap<String, String> {
596 let mut out = std::collections::BTreeMap::new();
597 let Ok(text) = std::fs::read_to_string(manifest) else {
598 return out;
599 };
600 let head: String = text.chars().take(FRONTMATTER_READ_BYTES).collect();
601 let mut lines = head.lines();
602 match lines.next().map(str::trim) {
603 Some("---") => {}
604 _ => return out,
605 }
606 let mut pending_block: Option<String> = None;
612 for line in lines {
613 let trimmed = line.trim_end();
614 if trimmed.trim() == "---" || trimmed.trim() == "..." {
615 break;
616 }
617 if trimmed.is_empty() || trimmed.trim_start().starts_with('#') {
618 continue;
619 }
620 if trimmed.starts_with(char::is_whitespace) {
621 let item = trimmed.trim();
622 if let (Some(key), Some(item)) = (pending_block.as_ref(), item.strip_prefix("- ")) {
623 let item = item.trim().trim_matches(['"', '\'']).trim().to_string();
624 if !item.is_empty() {
625 out.entry(key.clone())
626 .and_modify(|v| {
627 if !v.is_empty() {
628 v.push_str(", ");
629 }
630 v.push_str(&item);
631 })
632 .or_insert(item);
633 }
634 }
635 continue;
636 }
637 pending_block = None;
638 let Some((key, value)) = trimmed.split_once(':') else {
639 continue;
640 };
641 let key = key.trim().to_ascii_lowercase();
642 let value = value.trim().trim_matches(['"', '\'']).trim().to_string();
643 if key.is_empty() {
644 continue;
645 }
646 if value.is_empty() {
647 pending_block = Some(key);
648 continue;
649 }
650 out.entry(key).or_insert(value);
651 }
652 out
653}
654
655pub(crate) fn frontmatter_list(value: &str) -> Vec<String> {
661 value
662 .trim()
663 .trim_start_matches('[')
664 .trim_end_matches(']')
665 .split(',')
666 .map(|item| item.trim().trim_matches(['"', '\'']).trim().to_string())
667 .filter(|item| !item.is_empty())
668 .collect()
669}
670
671fn apply_codex_enablement(homes: &SkillHomes, rows: &mut [SkillRow]) {
676 let config = homes.codex.join("config.toml");
677 let Ok(text) = std::fs::read_to_string(&config) else {
678 return;
679 };
680 let Ok(doc) = text.parse::<toml::Value>() else {
681 return;
682 };
683 let Some(skills) = doc.get("skills") else {
684 return;
685 };
686 let bundled = skills
687 .get("bundled")
688 .and_then(|value| value.get("enabled"))
689 .and_then(toml::Value::as_bool);
690 let entries: Vec<(Option<String>, Option<PathBuf>, bool)> = skills
691 .get("config")
692 .and_then(toml::Value::as_array)
693 .map(|array| {
694 array
695 .iter()
696 .filter_map(|entry| {
697 let enabled = entry.get("enabled").and_then(toml::Value::as_bool)?;
698 let name = entry
699 .get("name")
700 .and_then(toml::Value::as_str)
701 .map(str::to_string);
702 let path = entry
703 .get("path")
704 .and_then(toml::Value::as_str)
705 .map(PathBuf::from);
706 Some((name, path, enabled))
707 })
708 .collect()
709 })
710 .unwrap_or_default();
711 for row in rows.iter_mut() {
712 if row.harness.as_str() != HarnessId::CODEX {
713 continue;
714 }
715 if row.scope == SkillScope::Bundled {
716 if let Some(enabled) = bundled {
717 row.enabled = Some(enabled);
718 }
719 }
720 for (name, path, enabled) in &entries {
721 let matches_name = name.as_deref() == Some(row.name.as_str());
722 let matches_path = path.as_deref() == Some(row.location.as_path());
723 if matches_name || matches_path {
724 row.enabled = Some(*enabled);
725 }
726 }
727 }
728}
729
730const MAX_NESTED_DEPTH: usize = 3;
753
754const MAX_NESTED_DIRS: usize = 400;
757
758pub const MAX_SKILL_BODY_BYTES: usize = 64 * 1024;
760
761const ARGUMENTS_TOKEN: &str = "$ARGUMENTS";
764
765#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
767pub struct LoopSkill {
768 pub name: String,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
775 pub description: Option<String>,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub version: Option<String>,
779 pub scope: SkillScope,
781 pub dir: PathBuf,
783 pub manifest: PathBuf,
785 pub model_invocable: bool,
790 #[serde(default, skip_serializing_if = "Vec::is_empty")]
796 pub allowed_tools: Vec<String>,
797 #[serde(default, skip_serializing_if = "Vec::is_empty")]
802 pub argument_names: Vec<String>,
803 #[serde(default, skip_serializing_if = "Option::is_none")]
807 pub argument_hint: Option<String>,
808}
809
810impl LoopSkill {
811 pub fn index_line(&self) -> String {
813 let mut line = match self.description.as_deref() {
814 Some(description) if !description.is_empty() => {
815 format!("- {}: {description}", self.name)
816 }
817 _ => format!("- {}", self.name),
818 };
819 if let Some(hint) = self.argument_hint.as_deref().filter(|h| !h.is_empty()) {
822 line.push_str(&format!(" (arguments: {hint})"));
823 } else if !self.argument_names.is_empty() {
824 line.push_str(&format!(" (arguments: {})", self.argument_names.join(" ")));
825 }
826 line
827 }
828
829 pub fn body(&self, arguments: &str) -> std::io::Result<String> {
834 let text = std::fs::read_to_string(&self.manifest)?;
835 Ok(substitute_arguments(
836 &strip_frontmatter(&text),
837 arguments,
838 &self.argument_names,
839 ))
840 }
841
842 pub fn body_with_shell(
849 &self,
850 arguments: &str,
851 shell: &ShellInjection,
852 ) -> std::io::Result<String> {
853 let body = self.body(arguments)?;
854 Ok(shell.expand(&body, &self.allowed_tools))
855 }
856}
857
858pub(crate) fn strip_frontmatter(text: &str) -> String {
861 let body = match text.strip_prefix("---") {
862 Some(rest) => match rest.split_once("\n---") {
863 Some((_, after)) => after
864 .trim_start_matches(['-', '\r'])
865 .trim_start_matches('\n'),
866 None => text,
867 },
868 None => text,
869 };
870 let body = body.trim();
871 if body.len() <= MAX_SKILL_BODY_BYTES {
872 return body.to_string();
873 }
874 let mut cut = MAX_SKILL_BODY_BYTES;
875 while cut > 0 && !body.is_char_boundary(cut) {
876 cut -= 1;
877 }
878 format!("{}\n\n[skill body truncated]", &body[..cut])
879}
880
881fn substitute_arguments(body: &str, arguments: &str, argument_names: &[String]) -> String {
889 let positional: Vec<&str> = arguments.split_whitespace().collect();
890 let mut out = body.to_string();
891 for (index, name) in argument_names.iter().enumerate() {
892 let token = format!("${name}");
893 if !out.contains(&token) {
894 continue;
895 }
896 out = out.replace(&token, positional.get(index).copied().unwrap_or(""));
897 }
898 for (index, value) in positional.iter().enumerate() {
899 let token = format!("{ARGUMENTS_TOKEN}[{index}]");
900 if out.contains(&token) {
901 out = out.replace(&token, value);
902 }
903 }
904 out = out.replace(ARGUMENTS_TOKEN, arguments);
905 for index in 1..=9usize {
906 let token = format!("${index}");
907 if !out.contains(&token) {
908 continue;
909 }
910 out = out.replace(&token, positional.get(index - 1).copied().unwrap_or(""));
911 }
912 out
913}
914
915const MAX_INJECTED_OUTPUT_BYTES: usize = 8 * 1024;
931
932const SHELL_INJECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
934
935const MAX_INJECTED_COMMANDS: usize = 16;
938
939#[derive(Debug, Clone)]
942pub struct ShellInjection {
943 enabled: bool,
944 cwd: PathBuf,
945 rules: crate::permissions::RuleSet,
946 default: crate::permissions::Decision,
947}
948
949impl ShellInjection {
950 pub fn from_config(config: &crate::Config) -> Self {
953 Self {
954 enabled: config.skills_shell_injection,
955 cwd: config.cwd.clone(),
956 rules: crate::permissions::rules_for_config(config),
957 default: crate::permissions::default_decision(config, "bash"),
960 }
961 }
962
963 pub fn disabled() -> Self {
966 Self {
967 enabled: false,
968 cwd: PathBuf::from("."),
969 rules: crate::permissions::RuleSet::default(),
970 default: crate::permissions::Decision::Ask,
971 }
972 }
973
974 pub fn is_enabled(&self) -> bool {
976 self.enabled
977 }
978
979 pub fn expand(&self, body: &str, allowed_tools: &[String]) -> String {
989 if !self.enabled || !(body.contains("!`") || body.contains("```!")) {
990 return body.to_string();
991 }
992 let mut rules = self.rules.clone();
993 rules
994 .allow
995 .extend(allowed_tools_to_allow_rules(allowed_tools));
996 let mut out = String::with_capacity(body.len());
997 let mut rest = body;
998 let mut ran = 0usize;
999 while let Some((before, command, after, closing)) = next_injection(rest) {
1000 out.push_str(before);
1001 ran += 1;
1002 if ran > MAX_INJECTED_COMMANDS {
1003 out.push_str(&format!(
1004 "[supercode: shell injection stopped after {MAX_INJECTED_COMMANDS} commands]"
1005 ));
1006 out.push_str(closing);
1007 rest = after;
1008 continue;
1009 }
1010 out.push_str(&self.run_one(&rules, &command));
1011 out.push_str(closing);
1012 rest = after;
1013 }
1014 out.push_str(rest);
1015 out
1016 }
1017
1018 fn run_one(&self, rules: &crate::permissions::RuleSet, command: &str) -> String {
1020 use crate::permissions::Decision;
1021 let command = command.trim();
1022 if command.is_empty() {
1023 return String::new();
1024 }
1025 let decision = crate::permissions::evaluate_command(rules, "bash", command, self.default);
1026 if decision != Decision::Allow {
1027 return format!(
1028 "[supercode: `{command}` was not run — permissions engine: {decision:?}. \
1029 Allow it with a permission rule or the body's own `allowed-tools`.]"
1030 );
1031 }
1032 match run_injected_command(&self.cwd, command) {
1033 Ok(text) => text,
1034 Err(e) => format!("[supercode: `{command}` failed: {e}]"),
1035 }
1036 }
1037}
1038
1039fn allowed_tools_to_allow_rules(entries: &[String]) -> Vec<String> {
1047 let mut out = Vec::new();
1048 for entry in entries {
1049 let entry = entry.trim();
1050 if entry.is_empty() {
1051 continue;
1052 }
1053 let (tool, subject) = match entry.split_once('(') {
1054 Some((tool, rest)) => match rest.strip_suffix(')') {
1055 Some(subject) => (tool.trim(), Some(subject.trim())),
1056 None => continue,
1057 },
1058 None => (entry, None),
1059 };
1060 let tool = tool.to_ascii_lowercase();
1064 if !matches!(tool.as_str(), "bash" | "shell" | "powershell") {
1065 continue;
1066 }
1067 match subject {
1068 None => out.push("bash".to_string()),
1069 Some(subject) => {
1070 let glob = subject.replace(":*", "*");
1071 out.push(format!("bash({glob})"));
1072 }
1073 }
1074 }
1075 out
1076}
1077
1078fn next_injection(text: &str) -> Option<(&str, String, &str, &'static str)> {
1083 let inline = text.find("!`");
1084 let block = text.find("```!");
1085 match (inline, block) {
1086 (Some(i), Some(b)) if b < i => split_block(text, b),
1087 (Some(i), _) => split_inline(text, i),
1088 (None, Some(b)) => split_block(text, b),
1089 (None, None) => None,
1090 }
1091}
1092
1093fn split_inline(text: &str, at: usize) -> Option<(&str, String, &str, &'static str)> {
1094 let after_open = &text[at + 2..];
1095 let end = after_open.find('`')?;
1096 Some((
1097 &text[..at],
1098 after_open[..end].to_string(),
1099 &after_open[end + 1..],
1100 "",
1101 ))
1102}
1103
1104fn split_block(text: &str, at: usize) -> Option<(&str, String, &str, &'static str)> {
1105 let after_open = &text[at + 4..];
1106 let body_start = after_open.find('\n')? + 1;
1107 let body = &after_open[body_start..];
1108 let end = body.find("```")?;
1109 let after = &body[end + 3..];
1110 Some((&text[..at], body[..end].trim().to_string(), after, ""))
1111}
1112
1113fn run_injected_command(cwd: &Path, command: &str) -> std::io::Result<String> {
1121 use std::process::{Command, Stdio};
1122 let mut child = Command::new("sh")
1123 .arg("-c")
1124 .arg(command)
1125 .current_dir(cwd)
1126 .stdin(Stdio::null())
1127 .stdout(Stdio::piped())
1128 .stderr(Stdio::piped())
1129 .spawn()?;
1130 let deadline = std::time::Instant::now() + SHELL_INJECTION_TIMEOUT;
1131 loop {
1132 match child.try_wait()? {
1133 Some(_) => break,
1134 None if std::time::Instant::now() >= deadline => {
1135 let _ = child.kill();
1136 let _ = child.wait();
1137 return Ok(format!(
1138 "[supercode: `{command}` timed out after {}s]",
1139 SHELL_INJECTION_TIMEOUT.as_secs()
1140 ));
1141 }
1142 None => std::thread::sleep(std::time::Duration::from_millis(10)),
1143 }
1144 }
1145 let output = child.wait_with_output()?;
1146 let mut text = String::from_utf8_lossy(&output.stdout).trim().to_string();
1147 if !output.status.success() {
1148 let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1149 if !err.is_empty() {
1150 if !text.is_empty() {
1151 text.push('\n');
1152 }
1153 text.push_str(&err);
1154 }
1155 }
1156 if text.len() > MAX_INJECTED_OUTPUT_BYTES {
1157 let mut cut = MAX_INJECTED_OUTPUT_BYTES;
1158 while cut > 0 && !text.is_char_boundary(cut) {
1159 cut -= 1;
1160 }
1161 text.truncate(cut);
1162 text.push_str("\n[output truncated]");
1163 }
1164 Ok(text)
1165}
1166
1167pub fn find_skill<'a>(skills: &'a [LoopSkill], name: &str) -> Option<&'a LoopSkill> {
1173 let wanted = name.trim().trim_start_matches(['/', '$']).trim();
1174 if wanted.is_empty() {
1175 return None;
1176 }
1177 if let Some(hit) = skills.iter().find(|skill| skill.name == wanted) {
1178 return Some(hit);
1179 }
1180 if let Some(hit) = skills
1181 .iter()
1182 .find(|skill| skill.name.eq_ignore_ascii_case(wanted))
1183 {
1184 return Some(hit);
1185 }
1186 let mut leaves = skills.iter().filter(|skill| {
1187 skill
1188 .name
1189 .rsplit_once(':')
1190 .is_some_and(|(_, leaf)| leaf.eq_ignore_ascii_case(wanted))
1191 });
1192 let first = leaves.next()?;
1193 match leaves.next() {
1194 Some(_) => None,
1197 None => Some(first),
1198 }
1199}
1200
1201pub fn render_skill(skill: &LoopSkill, body: &str) -> String {
1205 format!(
1206 "# Skill: {}\n(loaded from {})\n\n{body}",
1207 skill.name,
1208 skill.dir.display()
1209 )
1210}
1211
1212const IMPLICIT_STOPWORDS: &[&str] = &[
1215 "about", "after", "again", "their", "there", "these", "those", "which", "while", "would",
1216 "should", "could", "every", "other", "using", "when", "with", "that", "this", "from", "into",
1217];
1218
1219pub fn implicit_skill_match<'a>(skills: &'a [LoopSkill], text: &str) -> Option<&'a LoopSkill> {
1228 let haystack: BTreeSet<String> = text
1229 .split(|c: char| !c.is_alphanumeric() && c != '-')
1230 .map(|word| word.to_ascii_lowercase())
1231 .filter(|word| word.len() >= 4)
1232 .collect();
1233 if haystack.is_empty() {
1234 return None;
1235 }
1236 let mut best: Option<(usize, &LoopSkill)> = None;
1237 for skill in skills.iter().filter(|skill| skill.model_invocable) {
1238 let name = skill.name.to_ascii_lowercase();
1239 if haystack.contains(&name) {
1240 return Some(skill);
1241 }
1242 let Some(description) = skill.description.as_deref() else {
1243 continue;
1244 };
1245 let hits = description
1246 .split(|c: char| !c.is_alphanumeric() && c != '-')
1247 .map(|word| word.to_ascii_lowercase())
1248 .filter(|word| word.len() >= 5 && !IMPLICIT_STOPWORDS.contains(&word.as_str()))
1249 .collect::<BTreeSet<String>>()
1250 .into_iter()
1251 .filter(|word| haystack.contains(word))
1252 .count();
1253 if hits >= 2 && best.is_none_or(|(previous, _)| hits > previous) {
1254 best = Some((hits, skill));
1255 }
1256 }
1257 best.map(|(_, skill)| skill)
1258}
1259
1260pub fn load_loop_skills(
1270 harness: &str,
1271 homes: &SkillHomes,
1272 cwd: &Path,
1273 extra_dirs: &[PathBuf],
1274) -> Vec<LoopSkill> {
1275 let id = HarnessId::new(harness);
1276 let mut roots: Vec<(SkillScope, PathBuf)> = extra_dirs
1277 .iter()
1278 .filter(|root| root.is_dir())
1279 .map(|root| (SkillScope::Project, root.clone()))
1280 .collect();
1281 roots.extend(skill_roots(harness, homes, cwd));
1282
1283 let mut out: Vec<LoopSkill> = Vec::new();
1284 let mut seen_names: BTreeSet<String> = BTreeSet::new();
1285 let mut seen_dirs: BTreeSet<PathBuf> = BTreeSet::new();
1286 for (scope, root) in roots {
1287 let mut found = Vec::new();
1288 collect_root(&id, scope, &root, 0, &mut found);
1289 let qualifier = plugin_qualifier(scope, &root);
1290 for row in found {
1291 push_loop_skill(
1292 row,
1293 qualifier.as_deref(),
1294 &mut seen_names,
1295 &mut seen_dirs,
1296 &mut out,
1297 );
1298 }
1299 }
1300 if harness == HarnessId::CLAUDE_CODE {
1301 for (qualifier, root) in nested_claude_roots(cwd) {
1302 let mut found = Vec::new();
1303 collect_root(&id, SkillScope::Project, &root, 0, &mut found);
1304 for row in found {
1305 push_loop_skill(
1306 row,
1307 Some(qualifier.as_str()),
1308 &mut seen_names,
1309 &mut seen_dirs,
1310 &mut out,
1311 );
1312 }
1313 }
1314 for (scope, root) in command_roots(homes, cwd) {
1321 collect_command_root(scope, &root, &mut seen_names, &mut out);
1322 }
1323 }
1324 out
1325}
1326
1327fn command_roots(homes: &SkillHomes, cwd: &Path) -> Vec<(SkillScope, PathBuf)> {
1331 let mut roots = vec![(SkillScope::User, homes.claude_code.join("commands"))];
1332 for root in project_roots(cwd, &[&[".claude", "commands"]]) {
1333 roots.push((SkillScope::Project, root));
1334 }
1335 roots.into_iter().filter(|(_, r)| r.is_dir()).collect()
1336}
1337
1338const MAX_COMMAND_DEPTH: usize = 1;
1342
1343fn collect_command_root(
1347 scope: SkillScope,
1348 root: &Path,
1349 seen_names: &mut BTreeSet<String>,
1350 out: &mut Vec<LoopSkill>,
1351) {
1352 collect_command_dir(scope, root, None, 0, seen_names, out);
1353}
1354
1355fn collect_command_dir(
1356 scope: SkillScope,
1357 dir: &Path,
1358 qualifier: Option<&str>,
1359 depth: usize,
1360 seen_names: &mut BTreeSet<String>,
1361 out: &mut Vec<LoopSkill>,
1362) {
1363 let Ok(entries) = std::fs::read_dir(dir) else {
1364 return;
1365 };
1366 let mut files: Vec<PathBuf> = Vec::new();
1367 let mut dirs: Vec<PathBuf> = Vec::new();
1368 for entry in entries.flatten() {
1369 let path = entry.path();
1370 if path.is_dir() {
1371 dirs.push(path);
1372 } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
1373 files.push(path);
1374 }
1375 }
1376 files.sort();
1377 dirs.sort();
1378 for file in files {
1379 push_command_file(scope, &file, qualifier, seen_names, out);
1380 }
1381 if depth >= MAX_COMMAND_DEPTH {
1382 return;
1383 }
1384 for child in dirs {
1385 let Some(label) = child.file_name().and_then(|n| n.to_str()) else {
1386 continue;
1387 };
1388 if label.starts_with('.') {
1389 continue;
1390 }
1391 let label = label.to_string();
1392 collect_command_dir(scope, &child, Some(&label), depth + 1, seen_names, out);
1393 }
1394}
1395
1396fn push_command_file(
1400 scope: SkillScope,
1401 file: &Path,
1402 qualifier: Option<&str>,
1403 seen_names: &mut BTreeSet<String>,
1404 out: &mut Vec<LoopSkill>,
1405) {
1406 let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else {
1407 return;
1408 };
1409 let front = read_frontmatter(file);
1410 let bare = front
1411 .get("name")
1412 .cloned()
1413 .unwrap_or_else(|| stem.to_string());
1414 let name = match qualifier {
1415 Some(prefix) => format!("{prefix}:{bare}"),
1416 None => bare,
1417 };
1418 if !seen_names.insert(name.clone()) {
1419 return;
1420 }
1421 out.push(LoopSkill {
1422 name,
1423 description: front.get("description").map(|d| one_line(d)),
1424 version: front.get("version").cloned(),
1425 scope,
1426 dir: file.parent().unwrap_or(file).to_path_buf(),
1427 manifest: file.to_path_buf(),
1428 model_invocable: frontmatter_enabled(&front).unwrap_or(true),
1429 allowed_tools: front
1430 .get("allowed-tools")
1431 .map(|v| frontmatter_list(v))
1432 .unwrap_or_default(),
1433 argument_names: front
1434 .get("arguments")
1435 .map(|v| frontmatter_list(v))
1436 .unwrap_or_default(),
1437 argument_hint: front.get("argument-hint").cloned(),
1438 });
1439}
1440
1441pub fn load_for_config(config: &crate::Config) -> Vec<LoopSkill> {
1446 if !config.skills_enabled {
1447 return Vec::new();
1448 }
1449 let Some(harness) = config.skills_harness.as_deref() else {
1450 return Vec::new();
1451 };
1452 load_loop_skills(
1453 harness,
1454 &SkillHomes::default(),
1455 &config.cwd,
1456 &config.skills_dirs,
1457 )
1458}
1459
1460fn push_loop_skill(
1465 row: SkillRow,
1466 qualifier: Option<&str>,
1467 seen_names: &mut BTreeSet<String>,
1468 seen_dirs: &mut BTreeSet<PathBuf>,
1469 out: &mut Vec<LoopSkill>,
1470) {
1471 let manifest = row.location.join("SKILL.md");
1472 if !manifest.is_file() {
1473 return;
1474 }
1475 let name = match qualifier {
1476 Some(prefix) => format!("{prefix}:{}", row.name),
1477 None => row.name.clone(),
1478 };
1479 if !seen_dirs.insert(row.location.clone()) || !seen_names.insert(name.clone()) {
1480 return;
1481 }
1482 let front = read_frontmatter(&manifest);
1483 out.push(LoopSkill {
1484 name,
1485 description: row.description,
1486 version: row.version,
1487 scope: row.scope,
1488 dir: row.location,
1489 model_invocable: row.enabled.unwrap_or(true),
1490 allowed_tools: front
1491 .get("allowed-tools")
1492 .map(|v| frontmatter_list(v))
1493 .unwrap_or_default(),
1494 argument_names: front
1495 .get("arguments")
1496 .map(|v| frontmatter_list(v))
1497 .unwrap_or_default(),
1498 argument_hint: front.get("argument-hint").cloned(),
1499 manifest,
1500 });
1501}
1502
1503fn plugin_qualifier(scope: SkillScope, root: &Path) -> Option<String> {
1507 if scope != SkillScope::Plugin {
1508 return None;
1509 }
1510 root.parent()
1511 .and_then(Path::parent)
1512 .and_then(|dir| dir.file_name())
1513 .and_then(|name| name.to_str())
1514 .map(str::to_string)
1515}
1516
1517fn nested_claude_roots(cwd: &Path) -> Vec<(String, PathBuf)> {
1522 let mut out = Vec::new();
1523 let mut visited = 0usize;
1524 let mut frontier: Vec<(String, PathBuf)> = child_dirs(cwd)
1525 .into_iter()
1526 .filter_map(|dir| nested_candidate(&dir))
1527 .collect();
1528 for _ in 0..MAX_NESTED_DEPTH {
1529 let mut next = Vec::new();
1530 for (label, dir) in frontier {
1531 visited += 1;
1532 if visited > MAX_NESTED_DIRS {
1533 return out;
1534 }
1535 let root = dir.join(".claude").join("skills");
1536 if root.is_dir() {
1537 out.push((label.clone(), root));
1538 }
1539 for child in child_dirs(&dir) {
1540 if let Some((_, child_dir)) = nested_candidate(&child) {
1541 next.push((label.clone(), child_dir));
1542 }
1543 }
1544 }
1545 if next.is_empty() {
1546 break;
1547 }
1548 frontier = next;
1549 }
1550 out
1551}
1552
1553fn nested_candidate(dir: &Path) -> Option<(String, PathBuf)> {
1556 let name = dir.file_name().and_then(|name| name.to_str())?;
1557 if name.starts_with('.') || SKIPPED_DIRS.contains(&name) {
1558 return None;
1559 }
1560 Some((name.to_string(), dir.to_path_buf()))
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565 use super::*;
1566
1567 fn fixtures() -> PathBuf {
1568 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
1569 }
1570
1571 fn empty_homes(root: &Path) -> SkillHomes {
1572 let void = root.join("__absent__");
1573 SkillHomes {
1574 claude_code: void.clone(),
1575 codex: void.clone(),
1576 opencode: void.clone(),
1577 pi: void.clone(),
1578 hermes: void.clone(),
1579 openclaw: void.clone(),
1580 agents: void,
1581 }
1582 }
1583
1584 #[test]
1585 fn hermes_categories_flatten_and_frontmatter_wins() {
1586 let fixtures = fixtures();
1587 let mut homes = empty_homes(&fixtures);
1588 homes.hermes = fixtures.join("hermes_home");
1589 let rows = list_skills(&SkillsQuery {
1590 harness: Some(HarnessId::HERMES.into()),
1591 cwd: Some(fixtures.join("hermes_home")),
1592 homes,
1593 ..SkillsQuery::default()
1594 });
1595 let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
1596 assert!(names.contains(&"arxiv-search"), "{names:?}");
1597 assert!(names.contains(&"bare-skill"), "{names:?}");
1598 let arxiv = rows.iter().find(|row| row.name == "arxiv-search").unwrap();
1599 assert_eq!(arxiv.version.as_deref(), Some("1.4.0"));
1600 assert_eq!(arxiv.scope, SkillScope::User);
1601 assert!(arxiv
1602 .description
1603 .as_deref()
1604 .unwrap_or_default()
1605 .contains("arXiv"));
1606 let bare = rows.iter().find(|row| row.name == "bare-skill").unwrap();
1607 assert_eq!(bare.description, None);
1608 assert_eq!(bare.enabled, None);
1609 }
1610
1611 #[test]
1612 fn openclaw_managed_root_is_read() {
1613 let fixtures = fixtures();
1614 let mut homes = empty_homes(&fixtures);
1615 homes.openclaw = fixtures.join("openclaw_home");
1616 let rows = list_skills(&SkillsQuery {
1617 harness: Some(HarnessId::OPENCLAW.into()),
1618 cwd: Some(fixtures.join("openclaw_home")),
1619 homes,
1620 ..SkillsQuery::default()
1621 });
1622 assert_eq!(rows.len(), 1, "{rows:?}");
1623 assert_eq!(rows[0].name, "clawhub-demo");
1624 assert_eq!(rows[0].scope, SkillScope::Managed);
1625 assert_eq!(rows[0].enabled, Some(false));
1626 assert_eq!(rows[0].version.as_deref(), Some("0.3.1"));
1627 }
1628
1629 #[test]
1630 fn scope_filter_selects_one_class() {
1631 let fixtures = fixtures();
1632 let mut homes = empty_homes(&fixtures);
1633 homes.hermes = fixtures.join("hermes_home");
1634 let base = SkillsQuery {
1635 harness: Some(HarnessId::HERMES.into()),
1636 cwd: Some(fixtures.join("hermes_home")),
1637 homes,
1638 ..SkillsQuery::default()
1639 };
1640 let managed = list_skills(&SkillsQuery {
1641 scope: Some(SkillScope::Managed),
1642 ..base.clone()
1643 });
1644 assert!(managed.is_empty(), "{managed:?}");
1645 let user = list_skills(&SkillsQuery {
1646 scope: Some(SkillScope::User),
1647 ..base
1648 });
1649 assert!(!user.is_empty());
1650 assert!(
1651 user.iter().all(|row| row.scope == SkillScope::User),
1652 "{user:?}"
1653 );
1654 }
1655
1656 #[test]
1659 fn one_listing_spans_harnesses() {
1660 let fixtures = fixtures();
1661 let mut homes = empty_homes(&fixtures);
1662 homes.hermes = fixtures.join("hermes_home");
1663 homes.openclaw = fixtures.join("openclaw_home");
1664 let rows = list_skills(&SkillsQuery {
1665 cwd: Some(fixtures.join("openclaw_home")),
1666 homes,
1667 ..SkillsQuery::default()
1668 });
1669 let harnesses: BTreeSet<&str> = rows.iter().map(|row| row.harness.as_str()).collect();
1670 assert!(harnesses.contains(HarnessId::HERMES), "{harnesses:?}");
1671 assert!(harnesses.contains(HarnessId::OPENCLAW), "{harnesses:?}");
1672 }
1673}