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