use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::HarnessId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SkillScope {
Managed,
User,
Project,
Plugin,
Bundled,
}
impl SkillScope {
pub const fn as_str(self) -> &'static str {
match self {
Self::Managed => "managed",
Self::User => "user",
Self::Project => "project",
Self::Plugin => "plugin",
Self::Bundled => "bundled",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"managed" => Some(Self::Managed),
"user" => Some(Self::User),
"project" => Some(Self::Project),
"plugin" => Some(Self::Plugin),
"bundled" => Some(Self::Bundled),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillRow {
pub name: String,
pub harness: HarnessId,
pub scope: SkillScope,
pub location: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SkillHomes {
pub claude_code: PathBuf,
pub codex: PathBuf,
pub opencode: PathBuf,
pub pi: PathBuf,
pub hermes: PathBuf,
pub openclaw: PathBuf,
pub agents: PathBuf,
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
impl Default for SkillHomes {
fn default() -> Self {
let home = home_dir();
Self {
claude_code: std::env::var_os("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".claude")),
codex: std::env::var_os("CODEX_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".codex")),
opencode: std::env::var_os("OPENCODE_CONFIG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".config"))
.join("opencode")
}),
pi: std::env::var_os("PI_CODING_AGENT_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".pi").join("agent")),
hermes: std::env::var_os("HERMES_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".hermes")),
openclaw: std::env::var_os("OPENCLAW_STATE_DIR")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("OPENCLAW_HOME")
.map(|root| PathBuf::from(root).join(".openclaw"))
})
.unwrap_or_else(|| home.join(".openclaw")),
agents: home.join(".agents"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct SkillsQuery {
#[serde(skip_serializing_if = "Option::is_none")]
pub harness: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<SkillScope>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
pub homes: SkillHomes,
}
pub const SKILL_HARNESSES: &[&str] = &[
HarnessId::CLAUDE_CODE,
HarnessId::CODEX,
HarnessId::OPENCODE,
HarnessId::PI,
HarnessId::HERMES,
HarnessId::OPENCLAW,
];
const MAX_GROUP_DEPTH: usize = 3;
const MAX_ANCESTORS: usize = 32;
const FRONTMATTER_READ_BYTES: usize = 8 * 1024;
const MAX_ROWS_PER_ROOT: usize = 512;
const SKIPPED_DIRS: &[&str] = &["node_modules", "target", ".git", "scripts", "references"];
pub fn list_skills(query: &SkillsQuery) -> Vec<SkillRow> {
let cwd = query
.cwd
.clone()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
let mut rows = Vec::new();
let mut seen: BTreeSet<(String, PathBuf)> = BTreeSet::new();
for harness in SKILL_HARNESSES {
if let Some(wanted) = query.harness.as_deref() {
if wanted != *harness {
continue;
}
}
let id = HarnessId::new(*harness);
for (scope, root) in skill_roots(*harness, &query.homes, &cwd) {
if query.scope.is_some_and(|wanted| wanted != scope) {
continue;
}
let mut found = Vec::new();
collect_root(&id, scope, &root, 0, &mut found);
for row in found {
if seen.insert((row.harness.as_str().to_string(), row.location.clone())) {
rows.push(row);
}
}
}
}
apply_codex_enablement(&query.homes, &mut rows);
rows.sort_by(|a, b| {
a.harness
.as_str()
.cmp(b.harness.as_str())
.then(a.scope.cmp(&b.scope))
.then(a.name.cmp(&b.name))
.then(a.location.cmp(&b.location))
});
rows
}
pub fn skill_roots(harness: &str, homes: &SkillHomes, cwd: &Path) -> Vec<(SkillScope, PathBuf)> {
let mut roots: Vec<(SkillScope, PathBuf)> = Vec::new();
match harness {
HarnessId::CLAUDE_CODE => {
for managed in claude_managed_roots() {
roots.push((SkillScope::Managed, managed));
}
roots.push((SkillScope::User, homes.claude_code.join("skills")));
for plugin in claude_plugin_roots(&homes.claude_code) {
roots.push((SkillScope::Plugin, plugin));
}
for project in project_roots(cwd, &[&[".claude", "skills"]]) {
roots.push((SkillScope::Project, project));
}
}
HarnessId::CODEX => {
roots.push((SkillScope::Managed, PathBuf::from("/etc/codex/skills")));
roots.push((
SkillScope::Bundled,
homes.codex.join("skills").join(".system"),
));
roots.push((SkillScope::User, homes.agents.join("skills")));
roots.push((SkillScope::User, homes.codex.join("skills")));
for project in project_roots(cwd, &[&[".agents", "skills"]]) {
roots.push((SkillScope::Project, project));
}
}
HarnessId::OPENCODE => {
roots.push((SkillScope::User, homes.opencode.join("skill")));
roots.push((SkillScope::User, homes.opencode.join("skills")));
for project in project_roots(cwd, &[&[".opencode", "skill"], &[".opencode", "skills"]])
{
roots.push((SkillScope::Project, project));
}
}
HarnessId::PI => {
roots.push((SkillScope::User, homes.pi.join("skills")));
roots.push((SkillScope::User, homes.agents.join("skills")));
for project in project_roots(cwd, &[&[".pi", "skills"], &[".agents", "skills"]]) {
roots.push((SkillScope::Project, project));
}
}
HarnessId::HERMES => {
roots.push((SkillScope::User, homes.hermes.join("skills")));
for profile in hermes_profile_roots(&homes.hermes) {
roots.push((SkillScope::User, profile));
}
}
HarnessId::OPENCLAW => {
roots.push((SkillScope::Managed, homes.openclaw.join("skills")));
roots.push((SkillScope::Plugin, homes.openclaw.join("plugin-skills")));
roots.push((SkillScope::User, homes.agents.join("skills")));
let workspace = homes.openclaw.join("workspace");
roots.push((SkillScope::Project, workspace.join("skills")));
roots.push((
SkillScope::Project,
workspace.join(".agents").join("skills"),
));
}
_ => {}
}
roots.retain(|(_, root)| root.is_dir());
roots
}
pub fn writable_skill_roots(
harness: &str,
scope: SkillScope,
homes: &SkillHomes,
cwd: &Path,
) -> Vec<PathBuf> {
if !matches!(scope, SkillScope::User | SkillScope::Project) {
return Vec::new();
}
let project = |markers: &[&[&str]]| -> Vec<PathBuf> {
markers
.iter()
.map(|marker| {
let mut root = cwd.to_path_buf();
for segment in *marker {
root = root.join(segment);
}
root
})
.collect()
};
match (harness, scope) {
(HarnessId::CLAUDE_CODE, SkillScope::User) => vec![homes.claude_code.join("skills")],
(HarnessId::CLAUDE_CODE, SkillScope::Project) => project(&[&[".claude", "skills"]]),
(HarnessId::CODEX, SkillScope::User) => {
vec![homes.agents.join("skills"), homes.codex.join("skills")]
}
(HarnessId::CODEX, SkillScope::Project) => project(&[&[".agents", "skills"]]),
(HarnessId::OPENCODE, SkillScope::User) => {
vec![homes.opencode.join("skill"), homes.opencode.join("skills")]
}
(HarnessId::OPENCODE, SkillScope::Project) => {
project(&[&[".opencode", "skill"], &[".opencode", "skills"]])
}
(HarnessId::PI, SkillScope::User) => {
vec![homes.pi.join("skills"), homes.agents.join("skills")]
}
(HarnessId::PI, SkillScope::Project) => {
project(&[&[".pi", "skills"], &[".agents", "skills"]])
}
_ => Vec::new(),
}
}
pub fn declared_skill_name(dir: &Path) -> Option<String> {
let manifest = dir.join("SKILL.md");
if !manifest.is_file() {
return None;
}
let front = read_frontmatter(&manifest);
front
.get("name")
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
dir.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
})
}
fn claude_managed_roots() -> Vec<PathBuf> {
#[cfg(target_os = "macos")]
{
vec![PathBuf::from(
"/Library/Application Support/ClaudeCode/skills",
)]
}
#[cfg(not(target_os = "macos"))]
{
vec![PathBuf::from("/etc/claude-code/skills")]
}
}
fn claude_plugin_roots(claude_home: &Path) -> Vec<PathBuf> {
let cache = claude_home.join("plugins").join("cache");
let mut roots = Vec::new();
for marketplace in child_dirs(&cache) {
for plugin in child_dirs(&marketplace) {
for version in child_dirs(&plugin) {
let skills = version.join("skills");
if skills.is_dir() {
roots.push(skills);
}
}
}
}
roots
}
fn hermes_profile_roots(hermes_home: &Path) -> Vec<PathBuf> {
child_dirs(&hermes_home.join("profiles"))
.into_iter()
.map(|profile| profile.join("skills"))
.filter(|root| root.is_dir())
.collect()
}
fn child_dirs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
out.sort();
out
}
fn project_roots(cwd: &Path, markers: &[&[&str]]) -> Vec<PathBuf> {
let mut roots = Vec::new();
let mut seen = BTreeSet::new();
for ancestor in cwd.ancestors().take(MAX_ANCESTORS) {
for marker in markers {
let mut root = ancestor.to_path_buf();
for segment in *marker {
root = root.join(segment);
}
if root.is_dir() && seen.insert(root.clone()) {
roots.push(root);
}
}
if ancestor.join(".git").exists() {
break;
}
}
roots
}
fn collect_root(
harness: &HarnessId,
scope: SkillScope,
root: &Path,
depth: usize,
out: &mut Vec<SkillRow>,
) {
if out.len() >= MAX_ROWS_PER_ROOT {
return;
}
for dir in child_dirs(root) {
if out.len() >= MAX_ROWS_PER_ROOT {
return;
}
let Some(name) = dir.file_name().and_then(|name| name.to_str()) else {
continue;
};
if SKIPPED_DIRS.contains(&name) || name.starts_with('.') {
continue;
}
let manifest = dir.join("SKILL.md");
if manifest.is_file() {
out.push(read_skill(harness, scope, &dir, name, &manifest));
continue;
}
let before = out.len();
if depth + 1 < MAX_GROUP_DEPTH {
collect_root(harness, scope, &dir, depth + 1, out);
}
if out.len() == before {
out.push(SkillRow {
name: name.to_string(),
harness: harness.clone(),
scope,
location: dir.clone(),
description: None,
version: None,
enabled: None,
});
}
}
}
fn read_skill(
harness: &HarnessId,
scope: SkillScope,
dir: &Path,
dir_name: &str,
manifest: &Path,
) -> SkillRow {
let front = read_frontmatter(manifest);
SkillRow {
name: front
.get("name")
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(dir_name)
.to_string(),
harness: harness.clone(),
scope,
location: dir.to_path_buf(),
description: front.get("description").map(|value| one_line(value)),
version: front
.get("version")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
enabled: frontmatter_enabled(&front),
}
}
fn frontmatter_enabled(front: &std::collections::BTreeMap<String, String>) -> Option<bool> {
if let Some(value) = front.get("enabled") {
return parse_bool(value);
}
if let Some(value) = front.get("disable-model-invocation") {
return parse_bool(value).map(|disabled| !disabled);
}
None
}
fn parse_bool(value: &str) -> Option<bool> {
match value
.trim()
.trim_matches(['"', '\''])
.to_ascii_lowercase()
.as_str()
{
"true" | "yes" | "on" => Some(true),
"false" | "no" | "off" => Some(false),
_ => None,
}
}
fn one_line(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn read_frontmatter(manifest: &Path) -> std::collections::BTreeMap<String, String> {
let mut out = std::collections::BTreeMap::new();
let Ok(text) = std::fs::read_to_string(manifest) else {
return out;
};
let head: String = text.chars().take(FRONTMATTER_READ_BYTES).collect();
let mut lines = head.lines();
match lines.next().map(str::trim) {
Some("---") => {}
_ => return out,
}
let mut pending_block: Option<String> = None;
for line in lines {
let trimmed = line.trim_end();
if trimmed.trim() == "---" || trimmed.trim() == "..." {
break;
}
if trimmed.is_empty() || trimmed.trim_start().starts_with('#') {
continue;
}
if trimmed.starts_with(char::is_whitespace) {
let item = trimmed.trim();
if let (Some(key), Some(item)) = (pending_block.as_ref(), item.strip_prefix("- ")) {
let item = item.trim().trim_matches(['"', '\'']).trim().to_string();
if !item.is_empty() {
out.entry(key.clone())
.and_modify(|v| {
if !v.is_empty() {
v.push_str(", ");
}
v.push_str(&item);
})
.or_insert(item);
}
}
continue;
}
pending_block = None;
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
let key = key.trim().to_ascii_lowercase();
let value = value.trim().trim_matches(['"', '\'']).trim().to_string();
if key.is_empty() {
continue;
}
if value.is_empty() {
pending_block = Some(key);
continue;
}
out.entry(key).or_insert(value);
}
out
}
pub(crate) fn frontmatter_list(value: &str) -> Vec<String> {
value
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.split(',')
.map(|item| item.trim().trim_matches(['"', '\'']).trim().to_string())
.filter(|item| !item.is_empty())
.collect()
}
fn apply_codex_enablement(homes: &SkillHomes, rows: &mut [SkillRow]) {
let config = homes.codex.join("config.toml");
let Ok(text) = std::fs::read_to_string(&config) else {
return;
};
let Ok(doc) = text.parse::<toml::Value>() else {
return;
};
let Some(skills) = doc.get("skills") else {
return;
};
let bundled = skills
.get("bundled")
.and_then(|value| value.get("enabled"))
.and_then(toml::Value::as_bool);
let entries: Vec<(Option<String>, Option<PathBuf>, bool)> = skills
.get("config")
.and_then(toml::Value::as_array)
.map(|array| {
array
.iter()
.filter_map(|entry| {
let enabled = entry.get("enabled").and_then(toml::Value::as_bool)?;
let name = entry
.get("name")
.and_then(toml::Value::as_str)
.map(str::to_string);
let path = entry
.get("path")
.and_then(toml::Value::as_str)
.map(PathBuf::from);
Some((name, path, enabled))
})
.collect()
})
.unwrap_or_default();
for row in rows.iter_mut() {
if row.harness.as_str() != HarnessId::CODEX {
continue;
}
if row.scope == SkillScope::Bundled {
if let Some(enabled) = bundled {
row.enabled = Some(enabled);
}
}
for (name, path, enabled) in &entries {
let matches_name = name.as_deref() == Some(row.name.as_str());
let matches_path = path.as_deref() == Some(row.location.as_path());
if matches_name || matches_path {
row.enabled = Some(*enabled);
}
}
}
}
const MAX_NESTED_DEPTH: usize = 3;
const MAX_NESTED_DIRS: usize = 400;
pub const MAX_SKILL_BODY_BYTES: usize = 64 * 1024;
const ARGUMENTS_TOKEN: &str = "$ARGUMENTS";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoopSkill {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub scope: SkillScope,
pub dir: PathBuf,
pub manifest: PathBuf,
pub model_invocable: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub argument_names: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub argument_hint: Option<String>,
}
impl LoopSkill {
pub fn index_line(&self) -> String {
let mut line = match self.description.as_deref() {
Some(description) if !description.is_empty() => {
format!("- {}: {description}", self.name)
}
_ => format!("- {}", self.name),
};
if let Some(hint) = self.argument_hint.as_deref().filter(|h| !h.is_empty()) {
line.push_str(&format!(" (arguments: {hint})"));
} else if !self.argument_names.is_empty() {
line.push_str(&format!(" (arguments: {})", self.argument_names.join(" ")));
}
line
}
pub fn body(&self, arguments: &str) -> std::io::Result<String> {
let text = std::fs::read_to_string(&self.manifest)?;
Ok(substitute_arguments(
&strip_frontmatter(&text),
arguments,
&self.argument_names,
))
}
pub fn body_with_shell(
&self,
arguments: &str,
shell: &ShellInjection,
) -> std::io::Result<String> {
let body = self.body(arguments)?;
Ok(shell.expand(&body, &self.allowed_tools))
}
}
pub(crate) fn strip_frontmatter(text: &str) -> String {
let body = match text.strip_prefix("---") {
Some(rest) => match rest.split_once("\n---") {
Some((_, after)) => after
.trim_start_matches(['-', '\r'])
.trim_start_matches('\n'),
None => text,
},
None => text,
};
let body = body.trim();
if body.len() <= MAX_SKILL_BODY_BYTES {
return body.to_string();
}
let mut cut = MAX_SKILL_BODY_BYTES;
while cut > 0 && !body.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[skill body truncated]", &body[..cut])
}
fn substitute_arguments(body: &str, arguments: &str, argument_names: &[String]) -> String {
let positional: Vec<&str> = arguments.split_whitespace().collect();
let mut out = body.to_string();
for (index, name) in argument_names.iter().enumerate() {
let token = format!("${name}");
if !out.contains(&token) {
continue;
}
out = out.replace(&token, positional.get(index).copied().unwrap_or(""));
}
for (index, value) in positional.iter().enumerate() {
let token = format!("{ARGUMENTS_TOKEN}[{index}]");
if out.contains(&token) {
out = out.replace(&token, value);
}
}
out = out.replace(ARGUMENTS_TOKEN, arguments);
for index in 1..=9usize {
let token = format!("${index}");
if !out.contains(&token) {
continue;
}
out = out.replace(&token, positional.get(index - 1).copied().unwrap_or(""));
}
out
}
const MAX_INJECTED_OUTPUT_BYTES: usize = 8 * 1024;
const SHELL_INJECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const MAX_INJECTED_COMMANDS: usize = 16;
#[derive(Debug, Clone)]
pub struct ShellInjection {
enabled: bool,
cwd: PathBuf,
rules: crate::permissions::RuleSet,
default: crate::permissions::Decision,
}
impl ShellInjection {
pub fn from_config(config: &crate::Config) -> Self {
Self {
enabled: config.skills_shell_injection,
cwd: config.cwd.clone(),
rules: crate::permissions::rules_for_config(config),
default: crate::permissions::default_decision(config, "bash"),
}
}
pub fn disabled() -> Self {
Self {
enabled: false,
cwd: PathBuf::from("."),
rules: crate::permissions::RuleSet::default(),
default: crate::permissions::Decision::Ask,
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn expand(&self, body: &str, allowed_tools: &[String]) -> String {
if !self.enabled || !(body.contains("!`") || body.contains("```!")) {
return body.to_string();
}
let mut rules = self.rules.clone();
rules
.allow
.extend(allowed_tools_to_allow_rules(allowed_tools));
let mut out = String::with_capacity(body.len());
let mut rest = body;
let mut ran = 0usize;
while let Some((before, command, after, closing)) = next_injection(rest) {
out.push_str(before);
ran += 1;
if ran > MAX_INJECTED_COMMANDS {
out.push_str(&format!(
"[supercode: shell injection stopped after {MAX_INJECTED_COMMANDS} commands]"
));
out.push_str(closing);
rest = after;
continue;
}
out.push_str(&self.run_one(&rules, &command));
out.push_str(closing);
rest = after;
}
out.push_str(rest);
out
}
fn run_one(&self, rules: &crate::permissions::RuleSet, command: &str) -> String {
use crate::permissions::Decision;
let command = command.trim();
if command.is_empty() {
return String::new();
}
let decision = crate::permissions::evaluate_command(rules, "bash", command, self.default);
if decision != Decision::Allow {
return format!(
"[supercode: `{command}` was not run — permissions engine: {decision:?}. \
Allow it with a permission rule or the body's own `allowed-tools`.]"
);
}
match run_injected_command(&self.cwd, command) {
Ok(text) => text,
Err(e) => format!("[supercode: `{command}` failed: {e}]"),
}
}
}
fn allowed_tools_to_allow_rules(entries: &[String]) -> Vec<String> {
let mut out = Vec::new();
for entry in entries {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (tool, subject) = match entry.split_once('(') {
Some((tool, rest)) => match rest.strip_suffix(')') {
Some(subject) => (tool.trim(), Some(subject.trim())),
None => continue,
},
None => (entry, None),
};
let tool = tool.to_ascii_lowercase();
if !matches!(tool.as_str(), "bash" | "shell" | "powershell") {
continue;
}
match subject {
None => out.push("bash".to_string()),
Some(subject) => {
let glob = subject.replace(":*", "*");
out.push(format!("bash({glob})"));
}
}
}
out
}
fn next_injection(text: &str) -> Option<(&str, String, &str, &'static str)> {
let inline = text.find("!`");
let block = text.find("```!");
match (inline, block) {
(Some(i), Some(b)) if b < i => split_block(text, b),
(Some(i), _) => split_inline(text, i),
(None, Some(b)) => split_block(text, b),
(None, None) => None,
}
}
fn split_inline(text: &str, at: usize) -> Option<(&str, String, &str, &'static str)> {
let after_open = &text[at + 2..];
let end = after_open.find('`')?;
Some((
&text[..at],
after_open[..end].to_string(),
&after_open[end + 1..],
"",
))
}
fn split_block(text: &str, at: usize) -> Option<(&str, String, &str, &'static str)> {
let after_open = &text[at + 4..];
let body_start = after_open.find('\n')? + 1;
let body = &after_open[body_start..];
let end = body.find("```")?;
let after = &body[end + 3..];
Some((&text[..at], body[..end].trim().to_string(), after, ""))
}
fn run_injected_command(cwd: &Path, command: &str) -> std::io::Result<String> {
use std::process::{Command, Stdio};
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let deadline = std::time::Instant::now() + SHELL_INJECTION_TIMEOUT;
loop {
match child.try_wait()? {
Some(_) => break,
None if std::time::Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return Ok(format!(
"[supercode: `{command}` timed out after {}s]",
SHELL_INJECTION_TIMEOUT.as_secs()
));
}
None => std::thread::sleep(std::time::Duration::from_millis(10)),
}
}
let output = child.wait_with_output()?;
let mut text = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !err.is_empty() {
if !text.is_empty() {
text.push('\n');
}
text.push_str(&err);
}
}
if text.len() > MAX_INJECTED_OUTPUT_BYTES {
let mut cut = MAX_INJECTED_OUTPUT_BYTES;
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
text.push_str("\n[output truncated]");
}
Ok(text)
}
pub fn find_skill<'a>(skills: &'a [LoopSkill], name: &str) -> Option<&'a LoopSkill> {
let wanted = name.trim().trim_start_matches(['/', '$']).trim();
if wanted.is_empty() {
return None;
}
if let Some(hit) = skills.iter().find(|skill| skill.name == wanted) {
return Some(hit);
}
if let Some(hit) = skills
.iter()
.find(|skill| skill.name.eq_ignore_ascii_case(wanted))
{
return Some(hit);
}
let mut leaves = skills.iter().filter(|skill| {
skill
.name
.rsplit_once(':')
.is_some_and(|(_, leaf)| leaf.eq_ignore_ascii_case(wanted))
});
let first = leaves.next()?;
match leaves.next() {
Some(_) => None,
None => Some(first),
}
}
pub fn render_skill(skill: &LoopSkill, body: &str) -> String {
format!(
"# Skill: {}\n(loaded from {})\n\n{body}",
skill.name,
skill.dir.display()
)
}
const IMPLICIT_STOPWORDS: &[&str] = &[
"about", "after", "again", "their", "there", "these", "those", "which", "while", "would",
"should", "could", "every", "other", "using", "when", "with", "that", "this", "from", "into",
];
pub fn implicit_skill_match<'a>(skills: &'a [LoopSkill], text: &str) -> Option<&'a LoopSkill> {
let haystack: BTreeSet<String> = text
.split(|c: char| !c.is_alphanumeric() && c != '-')
.map(|word| word.to_ascii_lowercase())
.filter(|word| word.len() >= 4)
.collect();
if haystack.is_empty() {
return None;
}
let mut best: Option<(usize, &LoopSkill)> = None;
for skill in skills.iter().filter(|skill| skill.model_invocable) {
let name = skill.name.to_ascii_lowercase();
if haystack.contains(&name) {
return Some(skill);
}
let Some(description) = skill.description.as_deref() else {
continue;
};
let hits = description
.split(|c: char| !c.is_alphanumeric() && c != '-')
.map(|word| word.to_ascii_lowercase())
.filter(|word| word.len() >= 5 && !IMPLICIT_STOPWORDS.contains(&word.as_str()))
.collect::<BTreeSet<String>>()
.into_iter()
.filter(|word| haystack.contains(word))
.count();
if hits >= 2 && best.is_none_or(|(previous, _)| hits > previous) {
best = Some((hits, skill));
}
}
best.map(|(_, skill)| skill)
}
pub fn load_loop_skills(
harness: &str,
homes: &SkillHomes,
cwd: &Path,
extra_dirs: &[PathBuf],
) -> Vec<LoopSkill> {
let id = HarnessId::new(harness);
let mut roots: Vec<(SkillScope, PathBuf)> = extra_dirs
.iter()
.filter(|root| root.is_dir())
.map(|root| (SkillScope::Project, root.clone()))
.collect();
roots.extend(skill_roots(harness, homes, cwd));
let mut out: Vec<LoopSkill> = Vec::new();
let mut seen_names: BTreeSet<String> = BTreeSet::new();
let mut seen_dirs: BTreeSet<PathBuf> = BTreeSet::new();
for (scope, root) in roots {
let mut found = Vec::new();
collect_root(&id, scope, &root, 0, &mut found);
let qualifier = plugin_qualifier(scope, &root);
for row in found {
push_loop_skill(
row,
qualifier.as_deref(),
&mut seen_names,
&mut seen_dirs,
&mut out,
);
}
}
if harness == HarnessId::CLAUDE_CODE {
for (qualifier, root) in nested_claude_roots(cwd) {
let mut found = Vec::new();
collect_root(&id, SkillScope::Project, &root, 0, &mut found);
for row in found {
push_loop_skill(
row,
Some(qualifier.as_str()),
&mut seen_names,
&mut seen_dirs,
&mut out,
);
}
}
for (scope, root) in command_roots(homes, cwd) {
collect_command_root(scope, &root, &mut seen_names, &mut out);
}
}
out
}
fn command_roots(homes: &SkillHomes, cwd: &Path) -> Vec<(SkillScope, PathBuf)> {
let mut roots = vec![(SkillScope::User, homes.claude_code.join("commands"))];
for root in project_roots(cwd, &[&[".claude", "commands"]]) {
roots.push((SkillScope::Project, root));
}
roots.into_iter().filter(|(_, r)| r.is_dir()).collect()
}
const MAX_COMMAND_DEPTH: usize = 1;
fn collect_command_root(
scope: SkillScope,
root: &Path,
seen_names: &mut BTreeSet<String>,
out: &mut Vec<LoopSkill>,
) {
collect_command_dir(scope, root, None, 0, seen_names, out);
}
fn collect_command_dir(
scope: SkillScope,
dir: &Path,
qualifier: Option<&str>,
depth: usize,
seen_names: &mut BTreeSet<String>,
out: &mut Vec<LoopSkill>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<PathBuf> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if path.extension().and_then(|e| e.to_str()) == Some("md") {
files.push(path);
}
}
files.sort();
dirs.sort();
for file in files {
push_command_file(scope, &file, qualifier, seen_names, out);
}
if depth >= MAX_COMMAND_DEPTH {
return;
}
for child in dirs {
let Some(label) = child.file_name().and_then(|n| n.to_str()) else {
continue;
};
if label.starts_with('.') {
continue;
}
let label = label.to_string();
collect_command_dir(scope, &child, Some(&label), depth + 1, seen_names, out);
}
}
fn push_command_file(
scope: SkillScope,
file: &Path,
qualifier: Option<&str>,
seen_names: &mut BTreeSet<String>,
out: &mut Vec<LoopSkill>,
) {
let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else {
return;
};
let front = read_frontmatter(file);
let bare = front
.get("name")
.cloned()
.unwrap_or_else(|| stem.to_string());
let name = match qualifier {
Some(prefix) => format!("{prefix}:{bare}"),
None => bare,
};
if !seen_names.insert(name.clone()) {
return;
}
out.push(LoopSkill {
name,
description: front.get("description").map(|d| one_line(d)),
version: front.get("version").cloned(),
scope,
dir: file.parent().unwrap_or(file).to_path_buf(),
manifest: file.to_path_buf(),
model_invocable: frontmatter_enabled(&front).unwrap_or(true),
allowed_tools: front
.get("allowed-tools")
.map(|v| frontmatter_list(v))
.unwrap_or_default(),
argument_names: front
.get("arguments")
.map(|v| frontmatter_list(v))
.unwrap_or_default(),
argument_hint: front.get("argument-hint").cloned(),
});
}
pub fn load_for_config(config: &crate::Config) -> Vec<LoopSkill> {
if !config.skills_enabled {
return Vec::new();
}
let Some(harness) = config.skills_harness.as_deref() else {
return Vec::new();
};
load_loop_skills(
harness,
&SkillHomes::default(),
&config.cwd,
&config.skills_dirs,
)
}
fn push_loop_skill(
row: SkillRow,
qualifier: Option<&str>,
seen_names: &mut BTreeSet<String>,
seen_dirs: &mut BTreeSet<PathBuf>,
out: &mut Vec<LoopSkill>,
) {
let manifest = row.location.join("SKILL.md");
if !manifest.is_file() {
return;
}
let name = match qualifier {
Some(prefix) => format!("{prefix}:{}", row.name),
None => row.name.clone(),
};
if !seen_dirs.insert(row.location.clone()) || !seen_names.insert(name.clone()) {
return;
}
let front = read_frontmatter(&manifest);
out.push(LoopSkill {
name,
description: row.description,
version: row.version,
scope: row.scope,
dir: row.location,
model_invocable: row.enabled.unwrap_or(true),
allowed_tools: front
.get("allowed-tools")
.map(|v| frontmatter_list(v))
.unwrap_or_default(),
argument_names: front
.get("arguments")
.map(|v| frontmatter_list(v))
.unwrap_or_default(),
argument_hint: front.get("argument-hint").cloned(),
manifest,
});
}
fn plugin_qualifier(scope: SkillScope, root: &Path) -> Option<String> {
if scope != SkillScope::Plugin {
return None;
}
root.parent()
.and_then(Path::parent)
.and_then(|dir| dir.file_name())
.and_then(|name| name.to_str())
.map(str::to_string)
}
fn nested_claude_roots(cwd: &Path) -> Vec<(String, PathBuf)> {
let mut out = Vec::new();
let mut visited = 0usize;
let mut frontier: Vec<(String, PathBuf)> = child_dirs(cwd)
.into_iter()
.filter_map(|dir| nested_candidate(&dir))
.collect();
for _ in 0..MAX_NESTED_DEPTH {
let mut next = Vec::new();
for (label, dir) in frontier {
visited += 1;
if visited > MAX_NESTED_DIRS {
return out;
}
let root = dir.join(".claude").join("skills");
if root.is_dir() {
out.push((label.clone(), root));
}
for child in child_dirs(&dir) {
if let Some((_, child_dir)) = nested_candidate(&child) {
next.push((label.clone(), child_dir));
}
}
}
if next.is_empty() {
break;
}
frontier = next;
}
out
}
fn nested_candidate(dir: &Path) -> Option<(String, PathBuf)> {
let name = dir.file_name().and_then(|name| name.to_str())?;
if name.starts_with('.') || SKIPPED_DIRS.contains(&name) {
return None;
}
Some((name.to_string(), dir.to_path_buf()))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixtures() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn empty_homes(root: &Path) -> SkillHomes {
let void = root.join("__absent__");
SkillHomes {
claude_code: void.clone(),
codex: void.clone(),
opencode: void.clone(),
pi: void.clone(),
hermes: void.clone(),
openclaw: void.clone(),
agents: void,
}
}
#[test]
fn hermes_categories_flatten_and_frontmatter_wins() {
let fixtures = fixtures();
let mut homes = empty_homes(&fixtures);
homes.hermes = fixtures.join("hermes_home");
let rows = list_skills(&SkillsQuery {
harness: Some(HarnessId::HERMES.into()),
cwd: Some(fixtures.join("hermes_home")),
homes,
..SkillsQuery::default()
});
let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
assert!(names.contains(&"arxiv-search"), "{names:?}");
assert!(names.contains(&"bare-skill"), "{names:?}");
let arxiv = rows.iter().find(|row| row.name == "arxiv-search").unwrap();
assert_eq!(arxiv.version.as_deref(), Some("1.4.0"));
assert_eq!(arxiv.scope, SkillScope::User);
assert!(arxiv
.description
.as_deref()
.unwrap_or_default()
.contains("arXiv"));
let bare = rows.iter().find(|row| row.name == "bare-skill").unwrap();
assert_eq!(bare.description, None);
assert_eq!(bare.enabled, None);
}
#[test]
fn openclaw_managed_root_is_read() {
let fixtures = fixtures();
let mut homes = empty_homes(&fixtures);
homes.openclaw = fixtures.join("openclaw_home");
let rows = list_skills(&SkillsQuery {
harness: Some(HarnessId::OPENCLAW.into()),
cwd: Some(fixtures.join("openclaw_home")),
homes,
..SkillsQuery::default()
});
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!(rows[0].name, "clawhub-demo");
assert_eq!(rows[0].scope, SkillScope::Managed);
assert_eq!(rows[0].enabled, Some(false));
assert_eq!(rows[0].version.as_deref(), Some("0.3.1"));
}
#[test]
fn scope_filter_selects_one_class() {
let fixtures = fixtures();
let mut homes = empty_homes(&fixtures);
homes.hermes = fixtures.join("hermes_home");
let base = SkillsQuery {
harness: Some(HarnessId::HERMES.into()),
cwd: Some(fixtures.join("hermes_home")),
homes,
..SkillsQuery::default()
};
let managed = list_skills(&SkillsQuery {
scope: Some(SkillScope::Managed),
..base.clone()
});
assert!(managed.is_empty(), "{managed:?}");
let user = list_skills(&SkillsQuery {
scope: Some(SkillScope::User),
..base
});
assert!(!user.is_empty());
assert!(
user.iter().all(|row| row.scope == SkillScope::User),
"{user:?}"
);
}
#[test]
fn one_listing_spans_harnesses() {
let fixtures = fixtures();
let mut homes = empty_homes(&fixtures);
homes.hermes = fixtures.join("hermes_home");
homes.openclaw = fixtures.join("openclaw_home");
let rows = list_skills(&SkillsQuery {
cwd: Some(fixtures.join("openclaw_home")),
homes,
..SkillsQuery::default()
});
let harnesses: BTreeSet<&str> = rows.iter().map(|row| row.harness.as_str()).collect();
assert!(harnesses.contains(HarnessId::HERMES), "{harnesses:?}");
assert!(harnesses.contains(HarnessId::OPENCLAW), "{harnesses:?}");
}
}