use crate::engine::{Flow, LoadError, Skill};
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_yaml_ng::Value;
use tracing::debug;
const SKILL_FILE_NAME: &str = "SKILL.md";
#[derive(Debug)]
pub enum Target {
Skill(Skill),
Flow(Flow),
}
pub fn discover_target(repo: &Path, name: &str) -> Result<Target> {
let skill_error = match discover_skill(repo, name) {
Ok(skill) => return Ok(Target::Skill(skill)),
Err(err) => err,
};
if !matches!(
skill_error.downcast_ref::<LoadError>(),
Some(LoadError::SkillNotFound(_))
) {
return Err(skill_error);
}
match crate::engine::load_flow(name, repo) {
Ok(flow) => Ok(Target::Flow(flow)),
Err(LoadError::FlowNotFound(_)) => Err(anyhow::anyhow!(
"skill or flow not found: {name}. Run `lf --list` to see available skills."
)),
Err(err) => Err(err.into()),
}
}
pub use crate::engine::builtins::BUILTIN_STEP_CATEGORIES;
pub fn builtin_skills() -> HashSet<String> {
BUILTIN_STEP_CATEGORIES
.iter()
.flat_map(|(_, skills)| skills.iter().map(|s| (*s).to_string()))
.collect()
}
pub fn builtin_skill_description(name: &str) -> String {
crate::engine::builtins::builtin_skill_description(name)
}
pub fn is_skill_interactive(repo: &Path, name: &str) -> bool {
crate::engine::load_skill(name, repo)
.map(|s| s.interactive.unwrap_or(false))
.unwrap_or(false)
}
#[derive(Debug)]
pub struct SkillSource {
pub name: String,
pub prefix: String,
pub path: Option<PathBuf>,
pub skills: Vec<String>,
pub kind: SkillSourceKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSourceKind {
SingleFile,
Npx,
}
pub fn discover_skill_sources(repo: Option<&Path>) -> Vec<SkillSource> {
let mut sources = Vec::new();
if let Some(repo_root) = repo {
let cache_dir = repo_root.join(".agents/skills");
sources.push(SkillSource {
name: "npx skills".to_string(),
prefix: "npx".to_string(),
path: Some(cache_dir.clone()),
skills: discover_npx_cache_skills(&cache_dir),
kind: SkillSourceKind::Npx,
});
}
if let Some(home) = dirs::home_dir() {
let rams_path = home.join(".claude/commands/rams.md");
if rams_path.exists() {
sources.push(SkillSource {
name: "rams.ai".to_string(),
prefix: "rams".to_string(),
path: Some(rams_path.parent().unwrap_or(&home).to_path_buf()),
skills: vec!["rams".to_string()],
kind: SkillSourceKind::SingleFile,
});
}
}
sources
}
fn discover_npx_cache_skills(cache_dir: &Path) -> Vec<String> {
if !cache_dir.exists() {
return Vec::new();
}
let mut skills = Vec::new();
if let Ok(entries) = std::fs::read_dir(cache_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_file = path.join(SKILL_FILE_NAME);
if !skill_file.is_file() || has_loopflow_marker(&skill_file) {
continue;
}
if let Some(name) = path.file_name() {
skills.push(name.to_string_lossy().to_string());
}
}
}
skills.sort();
skills
}
fn normalize_skill_name(dir_name: &str) -> String {
let mut name = dir_name.to_lowercase().replace('_', "-");
if name == "test-driven-development" {
return "tdd".to_string();
}
if let Some(stripped) = name.strip_suffix("ing") {
name = stripped.to_string();
}
if let Some(stripped) = name.strip_suffix('s') {
name = stripped.to_string();
}
name
}
pub fn list_external_skills(sources: &[SkillSource]) -> Vec<(String, String)> {
let mut skills = Vec::new();
for source in sources {
for skill_name in &source.skills {
let prefixed = format!("{}/{}", source.prefix, skill_name);
skills.push((prefixed, source.name.clone()));
}
}
skills.sort();
skills
}
pub fn discover_skill(repo: &Path, name: &str) -> Result<Skill> {
match crate::engine::load_skill(name, repo) {
Ok(skill) => Ok(skill),
Err(LoadError::SkillNotFound(_)) => {
if let Some(skill) = find_external_skill(name, Some(repo)) {
Ok(skill)
} else {
Err(LoadError::SkillNotFound(name.to_string()).into())
}
}
Err(err) => Err(err.into()),
}
}
fn find_external_skill(name: &str, repo: Option<&Path>) -> Option<Skill> {
let (prefix, skill_name) = name.split_once('/')?;
let sources = discover_skill_sources(repo);
for source in &sources {
if source.prefix != prefix {
continue;
}
if source.kind == SkillSourceKind::Npx {
return find_npx_skill(name, skill_name, repo, source.path.as_deref());
}
if !source
.skills
.iter()
.any(|candidate| candidate == skill_name)
{
continue;
}
let prompt_path = find_skill_prompt_path(source, skill_name)?;
return load_skill_from_path(name, &prompt_path);
}
None
}
fn find_npx_skill(
qualified_name: &str,
skill_name: &str,
repo: Option<&Path>,
cache_path: Option<&Path>,
) -> Option<Skill> {
let repo_root = repo?;
let cache_dir = cache_path
.map(Path::to_path_buf)
.unwrap_or_else(|| repo_root.join(".agents/skills"));
if let Some(path) = find_cached_npx_skill(&cache_dir, skill_name) {
return load_skill_from_path(qualified_name, &path);
}
if run_npx_add(skill_name, repo_root) {
if let Some(path) = find_cached_npx_skill(&cache_dir, skill_name) {
return load_skill_from_path(qualified_name, &path);
}
}
let found = run_npx_find(skill_name, repo_root)?;
if !run_npx_add(&found, repo_root) {
return None;
}
let skill_from_qualified = found.split_once('@').map(|(_, skill)| skill);
find_cached_npx_skill(&cache_dir, skill_name)
.or_else(|| skill_from_qualified.and_then(|s| find_cached_npx_skill(&cache_dir, s)))
.or_else(|| find_cached_npx_skill(&cache_dir, &found))
.and_then(|path| load_skill_from_path(qualified_name, &path))
}
fn find_cached_npx_skill(cache_dir: &Path, skill_name: &str) -> Option<PathBuf> {
for candidate in [
Some(cache_dir.join(skill_name).join(SKILL_FILE_NAME)),
Some(
cache_dir
.join(skill_name.replace('/', "-"))
.join(SKILL_FILE_NAME),
),
skill_name
.split('/')
.next_back()
.map(|last_component| cache_dir.join(last_component).join(SKILL_FILE_NAME)),
]
.into_iter()
.flatten()
{
if candidate.is_file() && !has_loopflow_marker(&candidate) {
return Some(candidate);
}
}
let normalized = normalize_skill_name(skill_name);
let Ok(entries) = std::fs::read_dir(cache_dir) else {
return None;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_file = path.join(SKILL_FILE_NAME);
if !skill_file.is_file() || has_loopflow_marker(&skill_file) {
continue;
}
let Some(dir_name) = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
else {
continue;
};
if dir_name == skill_name || normalize_skill_name(&dir_name) == normalized {
return Some(skill_file);
}
}
None
}
fn load_skill_from_path(name: &str, prompt_path: &Path) -> Option<Skill> {
let content = std::fs::read_to_string(prompt_path).ok()?;
Some(Skill {
name: name.to_string(),
content: Some(content),
agent: None,
default_agent: None,
directions: Vec::new(),
action_style: None,
interactive: Some(true),
fast_path: None,
})
}
fn run_npx_add(skill_name: &str, repo_root: &Path) -> bool {
match run_npx(repo_root, &["--yes", "skills", "add", skill_name, "--yes"]) {
Ok(output) => {
if output.status.success() {
return true;
}
debug!(
skill = skill_name,
code = ?output.status.code(),
stderr = %String::from_utf8_lossy(&output.stderr),
"npx skills add failed"
);
false
}
Err(error) => {
debug!(skill = skill_name, error = %error, "failed to run npx skills add");
false
}
}
}
fn run_npx_find(skill_name: &str, repo_root: &Path) -> Option<String> {
let output = match run_npx(repo_root, &["--yes", "skills", "find", skill_name]) {
Ok(output) => output,
Err(error) => {
debug!(skill = skill_name, error = %error, "failed to run npx skills find");
return None;
}
};
if !output.status.success() {
debug!(
skill = skill_name,
code = ?output.status.code(),
stderr = %String::from_utf8_lossy(&output.stderr),
"npx skills find failed"
);
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let result = parse_npx_find_output(&stdout);
debug!(
skill = skill_name,
stdout_len = stdout.len(),
?result,
"npx skills find output"
);
result
}
fn run_npx(repo_root: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
Command::new(npx_binary())
.args(args)
.current_dir(repo_root)
.output()
}
fn parse_npx_find_output(stdout: &str) -> Option<String> {
let stripped = strip_ansi(stdout);
for token in stripped.split_whitespace() {
if token.starts_with('<') && token.ends_with('>') {
continue;
}
let cleaned = token.trim_matches(|c: char| {
c.is_ascii_punctuation() && c != '/' && c != '-' && c != '_' && c != '.' && c != '@'
});
if cleaned.is_empty() {
continue;
}
if let Some(hint) = normalize_qualified_skill(cleaned) {
return Some(hint);
}
if let Some(rest) = cleaned.strip_prefix("https://github.com/") {
return normalize_repo_hint(rest);
}
if let Some(rest) = cleaned.strip_prefix("github.com/") {
return normalize_repo_hint(rest);
}
if is_repo_hint(cleaned) {
return normalize_repo_hint(cleaned);
}
}
None
}
fn normalize_qualified_skill(value: &str) -> Option<String> {
let (repo_part, skill_part) = value.split_once('@')?;
if is_repo_hint(repo_part) && !skill_part.is_empty() && skill_part.chars().all(is_skill_char) {
Some(value.to_string())
} else {
None
}
}
fn normalize_repo_hint(value: &str) -> Option<String> {
let trimmed = value.trim_end_matches(".git").trim_matches('/');
is_repo_hint(trimmed).then(|| trimmed.to_string())
}
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
while let Some(&next) = chars.peek() {
chars.next();
if next.is_ascii_alphabetic() {
break;
}
}
}
} else {
result.push(c);
}
}
result
}
fn is_repo_hint(token: &str) -> bool {
let mut parts = token.split('/');
let Some(owner) = parts.next() else {
return false;
};
let Some(repo) = parts.next() else {
return false;
};
if parts.next().is_some() {
return false;
}
!owner.is_empty()
&& !repo.is_empty()
&& owner.chars().all(is_skill_char)
&& repo.chars().all(is_skill_char)
}
fn is_skill_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')
}
fn npx_binary() -> String {
std::env::var("LF_NPX_BIN").unwrap_or_else(|_| "npx".to_string())
}
fn has_loopflow_marker(skill_path: &Path) -> bool {
let Ok(content) = std::fs::read_to_string(skill_path) else {
return false;
};
let Some((frontmatter, _body)) = crate::engine::flow::split_frontmatter(&content) else {
return false;
};
let Ok(value) = serde_yaml_ng::from_str::<Value>(&frontmatter) else {
return false;
};
let Some(map) = value.as_mapping() else {
return false;
};
map.get(Value::String("loopflow".to_string()))
.and_then(Value::as_bool)
== Some(true)
}
fn find_skill_prompt_path(source: &SkillSource, skill_name: &str) -> Option<PathBuf> {
let source_path = source.path.as_ref()?;
match source.kind {
SkillSourceKind::SingleFile => {
let candidate = source_path.join(format!("{skill_name}.md"));
candidate.is_file().then_some(candidate)
}
SkillSourceKind::Npx => None,
}
}
pub fn list_user_skills(repo: &Path) -> Vec<String> {
list_md_stems(&[repo.join(".lf/skills"), repo.join(".claude/commands")])
}
pub fn list_global_skills() -> Vec<String> {
let Some(home) = dirs::home_dir() else {
return Vec::new();
};
list_md_stems(&[home.join(".lf/skills"), home.join(".claude/commands")])
}
fn list_md_stems(dirs: &[PathBuf]) -> Vec<String> {
let mut names = HashSet::new();
for dir in dirs {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
if let Some(name) = path.file_stem() {
names.insert(name.to_string_lossy().to_string());
}
}
}
}
}
let mut sorted: Vec<_> = names.into_iter().collect();
sorted.sort();
sorted
}
pub type SkillListResult = (Vec<String>, Vec<String>, Vec<String>, Vec<(String, String)>);
pub fn list_all_skills(repo: Option<&Path>) -> SkillListResult {
let builtins = builtin_skills();
let user: HashSet<String> = repo
.map(|r| list_user_skills(r).into_iter().collect())
.unwrap_or_default();
let global: HashSet<String> = list_global_skills().into_iter().collect();
let sources = discover_skill_sources(repo);
let external_skills = list_external_skills(&sources);
let mut external_skill_names = HashSet::new();
for source in &sources {
if source.skills.len() == 1 && source.skills[0] == source.prefix {
external_skill_names.insert(source.skills[0].clone());
}
}
let global_only: Vec<String> = global
.difference(&user)
.filter(|s| !external_skill_names.contains(*s))
.cloned()
.collect();
let builtin_only: Vec<String> = builtins
.difference(&user)
.filter(|s| !global.contains(*s))
.cloned()
.collect();
let mut user_sorted: Vec<_> = user.into_iter().collect();
user_sorted.sort();
let mut global_sorted = global_only;
global_sorted.sort();
let mut builtin_sorted = builtin_only;
builtin_sorted.sort();
(user_sorted, global_sorted, builtin_sorted, external_skills)
}
pub fn list_directions(repo: Option<&Path>) -> Vec<String> {
let mut directions: HashSet<String> = crate::engine::builtins::builtin_direction_names()
.into_iter()
.map(|name| name.to_string())
.collect();
directions.extend(
crate::engine::builtins::builtin_direction_group_names()
.into_iter()
.map(|name| name.to_string()),
);
if let Some(repo) = repo {
directions.extend(list_user_direction_names(repo));
}
let mut list: Vec<_> = directions.into_iter().collect();
list.sort();
list
}
fn list_user_direction_names(repo: &Path) -> HashSet<String> {
let directions_dir = repo.join(".lf/directions");
let mut names: HashSet<String> = list_md_stems(std::slice::from_ref(&directions_dir))
.into_iter()
.collect();
let Ok(entries) = std::fs::read_dir(&directions_dir) else {
return names;
};
let mut group_dirs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(group_name) = path.file_name() {
names.insert(group_name.to_string_lossy().to_string());
}
group_dirs.push(path);
}
}
names.extend(list_md_stems(&group_dirs));
names
}
pub use crate::engine::builtins::BUILTIN_FLOW_CATEGORIES;
pub fn builtin_flow_descriptions() -> HashMap<String, String> {
crate::engine::builtins::builtin_flow_entries()
.map(|(name, content)| {
let desc = format_flow_description(content);
(name.to_string(), desc)
})
.collect()
}
fn format_flow_description(yaml_content: &str) -> String {
let value: serde_yaml_ng::Value = match serde_yaml_ng::from_str(yaml_content) {
Ok(v) => v,
Err(_) => return String::new(),
};
extract_skill_names_from_value(&value).join(" → ")
}
pub fn builtin_flows() -> HashSet<String> {
BUILTIN_FLOW_CATEGORIES
.iter()
.flat_map(|(_, flows)| flows.iter().map(|f| (*f).to_string()))
.collect()
}
#[derive(Debug)]
pub struct FlowInfo {
pub name: String,
pub skill_names: Vec<String>,
}
pub fn list_user_flows(repo: &Path) -> Vec<FlowInfo> {
let mut flows = Vec::new();
let flows_dir = repo.join(".lf/flows");
collect_flows_from_dir(&flows_dir, None, &mut flows);
if let Ok(entries) = std::fs::read_dir(&flows_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(prefix) = path.file_name().and_then(|n| n.to_str()) {
collect_flows_from_dir(&path, Some(prefix), &mut flows);
}
}
}
}
flows.sort_by(|a, b| a.name.cmp(&b.name));
flows
}
fn collect_flows_from_dir(dir: &Path, prefix: Option<&str>, flows: &mut Vec<FlowInfo>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(stem) = path.file_stem() {
let ext = path.extension().map(|e| e.to_string_lossy().to_string());
if matches!(ext.as_deref(), Some("yaml") | Some("yml") | Some("json")) {
let stem = stem.to_string_lossy().to_string();
let name = match prefix {
Some(p) => format!("{p}-{stem}"),
None => stem,
};
let skill_names = extract_flow_skill_names(&path);
flows.push(FlowInfo { name, skill_names });
}
}
}
}
}
fn extract_flow_skill_names(path: &Path) -> Vec<String> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let value: serde_yaml_ng::Value = match serde_yaml_ng::from_str(&content) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
extract_skill_names_from_value(&value)
}
fn extract_skill_names_from_value(value: &serde_yaml_ng::Value) -> Vec<String> {
let mut names = Vec::new();
match value {
serde_yaml_ng::Value::String(s) => {
names.push(s.clone());
}
serde_yaml_ng::Value::Sequence(seq) => {
for item in seq {
names.extend(extract_skill_names_from_value(item));
}
}
serde_yaml_ng::Value::Mapping(map) => {
let initial_len = names.len();
if let Some(skills) = map.get(serde_yaml_ng::Value::String("skills".to_string())) {
return extract_skill_names_from_value(skills);
}
if let Some(skill) = map.get(serde_yaml_ng::Value::String("skill".to_string())) {
if let serde_yaml_ng::Value::String(name) = skill {
names.push(name.clone());
} else if let serde_yaml_ng::Value::Mapping(skill_map) = skill {
if let Some(serde_yaml_ng::Value::String(name)) =
skill_map.get(serde_yaml_ng::Value::String("name".to_string()))
{
names.push(name.clone());
}
}
}
if let Some(serde_yaml_ng::Value::String(name)) =
map.get(serde_yaml_ng::Value::String("flow".to_string()))
{
names.push(name.clone());
}
if let Some(serde_yaml_ng::Value::String(command)) =
map.get(serde_yaml_ng::Value::String("op".to_string()))
{
names.push(format!("op: {command}"));
}
if let Some(xor) = map.get(serde_yaml_ng::Value::String("xor".to_string())) {
names.push("[xor]".to_string());
names.extend(extract_branch_preview(xor));
}
if let Some(loop_value) = map.get(serde_yaml_ng::Value::String("loop".to_string())) {
names.push("loop".to_string());
names.extend(extract_branch_preview(loop_value));
}
if names.len() == initial_len {
for (key, value) in map {
if key.as_str() == Some("description") {
continue;
}
names.extend(extract_skill_names_from_value(value));
}
}
}
_ => {}
}
names
}
fn extract_branch_preview(value: &serde_yaml_ng::Value) -> Vec<String> {
let serde_yaml_ng::Value::Mapping(map) = value else {
return Vec::new();
};
for key in ["branches", "skills", "paths", "exit"] {
if let Some(nested) = map.get(serde_yaml_ng::Value::String(key.to_string())) {
let extracted = extract_skill_names_from_value(nested);
if !extracted.is_empty() {
return extracted;
}
}
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn discover_skill_loads_user_namespaced_override() {
let tmp = TempDir::new().expect("tempdir");
let skills_dir = tmp.path().join(".lf/skills/gstack");
fs::create_dir_all(&skills_dir).expect("create namespaced skills dir");
fs::write(
skills_dir.join("office-hours.md"),
"---\ninteractive: false\ndirections: [gstack]\n---\n# user override\n",
)
.expect("write skill");
let skill = discover_skill(tmp.path(), "gstack/office-hours").expect("discover skill");
assert_eq!(skill.interactive, Some(false));
assert_eq!(skill.directions, vec!["gstack".to_string()]);
assert!(skill
.content
.as_deref()
.expect("content")
.contains("# user override"));
}
#[test]
fn discover_skill_rejects_legacy_colon_form() {
let tmp = TempDir::new().expect("tempdir");
let err = discover_skill(tmp.path(), "gstack:office-hours").unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn list_directions_includes_imported_builtin_directions() {
let directions = list_directions(None);
assert!(directions.contains(&"gstack".to_string()));
assert!(directions.contains(&"openclaw".to_string()));
}
}