use std::env;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use loopflow::engine::builtins::{builtin_flow_names, builtin_skill_names};
use loopflow::engine::load_flow;
use loopflow::lf::discovery::{
builtin_skill_description, builtin_skills, discover_skill, list_all_skills, resolve_definition,
resolve_local_definition, Target, BUILTIN_FLOW_CATEGORIES, BUILTIN_SKILL_CATEGORIES,
};
use tempfile::TempDir;
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
struct HomeGuard {
_lock: std::sync::MutexGuard<'static, ()>,
previous_home: Option<String>,
_temp: TempDir,
}
impl HomeGuard {
fn new() -> Self {
let lock = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|err| err.into_inner());
let temp = TempDir::new().expect("temp home");
let previous_home = env::var("HOME").ok();
env::set_var("HOME", temp.path());
Self {
_lock: lock,
previous_home,
_temp: temp,
}
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
if let Some(prev) = &self.previous_home {
env::set_var("HOME", prev);
} else {
env::remove_var("HOME");
}
}
}
struct EnvVarGuard {
key: String,
previous: Option<String>,
}
impl EnvVarGuard {
fn set(key: &str, value: impl Into<String>) -> Self {
let previous = env::var(key).ok();
env::set_var(key, value.into());
Self {
key: key.to_string(),
previous,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
env::set_var(&self.key, previous);
} else {
env::remove_var(&self.key);
}
}
}
fn write_executable(path: &Path, content: &str) {
fs::write(path, content).expect("write script");
#[cfg(unix)]
{
let mut permissions = fs::metadata(path).expect("metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).expect("chmod");
}
}
#[test]
fn discover_builtin_skills() {
let _home = HomeGuard::new();
let builtins = builtin_skills();
let (_user, _global, builtin_only, _skills) = list_all_skills(None);
for skill in builtins {
assert!(builtin_only.contains(&skill));
}
}
#[test]
fn builtin_catalog_uses_slashes_for_ownership_and_never_underscores() {
let skill_names = builtin_skill_names();
assert!(skill_names.contains(&"wave/operate"));
assert!(!skill_names.iter().any(|name| name.starts_with("project/")));
assert!(skill_names.contains(&"implement"));
assert!(skill_names.iter().all(|name| !name.contains('_')));
assert!(builtin_flow_names().iter().all(|name| !name.contains('_')));
}
#[test]
fn discover_repo_skills() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let skills_dir = repo.path().join(".lf/skills");
std::fs::create_dir_all(&skills_dir).expect("create skills dir");
std::fs::write(skills_dir.join("custom.md"), "# custom").expect("write skill");
std::fs::create_dir_all(skills_dir.join("team")).expect("create skill namespace");
std::fs::write(skills_dir.join("team/review.md"), "# review").expect("write namespaced skill");
let (user_skills, _global, _builtin_only, _skills) = list_all_skills(Some(repo.path()));
assert!(user_skills.contains(&"custom".to_string()));
assert!(user_skills.contains(&"team/review".to_string()));
}
#[test]
fn discover_repo_flows() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let flows_dir = repo.path().join(".lf/flows");
std::fs::create_dir_all(&flows_dir).expect("create flows dir");
std::fs::write(flows_dir.join("ship.yaml"), "- implement\n- gate\n").expect("write flow");
let flows =
loopflow::lf::commands::list::list_children(&["flow".to_string()], repo.path()).unwrap();
let flow = flows.iter().find(|f| f.name == "ship").expect("flow");
assert_eq!(flow.description, "implement → gate");
}
#[test]
fn discover_namespaced_flows_with_slash_names_and_authored_branch_summaries() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let flows_dir = repo.path().join(".lf/flows/gstack");
let skills_dir = repo.path().join(".lf/skills/gstack");
std::fs::create_dir_all(&flows_dir).expect("create namespaced flows dir");
std::fs::create_dir_all(&skills_dir).expect("create namespaced skills dir");
for skill in ["office-hours", "autoplan", "pr-review"] {
std::fs::write(
skills_dir.join(format!("{skill}.md")),
format!("Run {skill}."),
)
.expect("write namespaced skill");
}
std::fs::write(
flows_dir.join("plan-manual.yaml"),
"- gstack/office-hours\n",
)
.expect("write nested flow");
std::fs::write(
flows_dir.join("sprint.yaml"),
r#"
- gstack/office-hours
- xor:
router: gstack/office-hours
paths:
autoplan:
skill: gstack/autoplan
description: "Auto-plan with minimal interaction"
manual:
flow: gstack/plan-manual
description: "Interactive planning"
- implement
- gstack/pr-review
"#,
)
.expect("write flow");
let flows =
loopflow::lf::commands::list::list_children(&["flow".to_string()], repo.path()).unwrap();
let flow = flows
.iter()
.find(|f| f.name == "gstack/sprint")
.expect("flow");
assert_eq!(
flow.description,
"gstack/office-hours → xor[gstack/office-hours]{autoplan: gstack/autoplan | manual: gstack/plan-manual} → implement → gstack/pr-review"
);
}
#[test]
fn repo_skill_shadows_builtin() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let skills_dir = repo.path().join(".lf/skills");
std::fs::create_dir_all(&skills_dir).expect("create skills dir");
std::fs::write(skills_dir.join("qa.md"), "# qa").expect("write skill");
let (user_skills, _global, builtin_only, _skills) = list_all_skills(Some(repo.path()));
assert!(user_skills.contains(&"qa".to_string()));
assert!(!builtin_only.contains(&"qa".to_string()));
}
#[test]
fn resolve_target_finds_skill() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let target = resolve_definition(repo.path(), "debug", None).expect("should find builtin skill");
assert!(matches!(target, Target::Skill(_)));
}
#[test]
fn resolve_target_finds_flow() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let target = resolve_definition(repo.path(), "code", None).expect("should find builtin flow");
assert!(matches!(target, Target::Flow(_)));
}
#[test]
fn resolve_target_errors_for_unknown() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let result = resolve_definition(repo.path(), "nonexistent", None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn categorized_listing_includes_known_skills() {
let builtins = builtin_skills();
for (_category, skills) in BUILTIN_SKILL_CATEGORIES {
for skill in *skills {
assert!(
builtins.contains(*skill),
"category includes unknown skill: {skill}"
);
}
}
}
#[test]
fn every_builtin_skill_is_categorized_and_discoverable() {
let tmp = TempDir::new().expect("tempdir");
let categorized: std::collections::HashMap<&str, &str> = BUILTIN_SKILL_CATEGORIES
.iter()
.flat_map(|(cat, names)| names.iter().map(move |n| (*n, *cat)))
.collect();
for name in builtin_skill_names() {
assert!(
categorized.contains_key(name),
"builtin skill {name} is missing from BUILTIN_SKILL_CATEGORIES",
);
let skill = discover_skill(tmp.path(), name)
.unwrap_or_else(|err| panic!("builtin skill {name} did not resolve: {err}"));
assert!(
skill
.content
.as_deref()
.map(|c| !c.is_empty())
.unwrap_or(false),
"builtin skill {name} loaded with empty content"
);
let desc = builtin_skill_description(name);
assert!(
!desc.is_empty(),
"builtin skill {name} has no description (add a `description:` frontmatter \
field or a leading prose line)"
);
}
let known: std::collections::HashSet<&'static str> =
builtin_skill_names().into_iter().collect();
for (_cat, names) in BUILTIN_SKILL_CATEGORIES {
for name in *names {
assert!(
known.contains(*name),
"category lists {name} but no matching builtin exists"
);
}
}
}
#[test]
fn every_builtin_flow_is_categorized_and_loadable() {
let tmp = TempDir::new().expect("tempdir");
let categorized: std::collections::HashMap<&str, &str> = BUILTIN_FLOW_CATEGORIES
.iter()
.flat_map(|(cat, names)| names.iter().map(move |n| (*n, *cat)))
.collect();
for name in builtin_flow_names() {
assert!(
categorized.contains_key(name),
"builtin flow {name} is missing from BUILTIN_FLOW_CATEGORIES",
);
load_flow(name, tmp.path())
.unwrap_or_else(|err| panic!("builtin flow {name} failed to load: {err}"));
}
let known: std::collections::HashSet<&'static str> = builtin_flow_names().into_iter().collect();
for (_cat, names) in BUILTIN_FLOW_CATEGORIES {
for name in *names {
assert!(
known.contains(*name),
"category lists flow {name} but no matching builtin exists"
);
}
}
}
#[test]
fn npx_skills_are_listed_from_cache_and_loopflow_skipped() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let skills_dir = repo.path().join(".agents/skills");
fs::create_dir_all(skills_dir.join("explain-code")).expect("create explain-code dir");
fs::create_dir_all(skills_dir.join("design")).expect("create design dir");
fs::write(
skills_dir.join("explain-code/SKILL.md"),
"---\nname: explain-code\ndescription: Explain code.\n---\nExplain code.",
)
.expect("write cached skill");
fs::write(
skills_dir.join("design/SKILL.md"),
"---\nname: design\ndescription: built-in\nloopflow: true\n---\nInjected",
)
.expect("write loopflow marker skill");
let (_user, _global, _builtin, external) = list_all_skills(Some(repo.path()));
assert!(external.contains(&("npx/explain-code".to_string(), "npx skills".to_string())));
assert!(
!external.iter().any(|(name, _)| name == "npx/design"),
"loopflow marker skills should be excluded from npx listing"
);
}
#[test]
fn npx_cache_miss_runs_add_and_loads_skill() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let npx_script = repo.path().join("fake-npx-add.sh");
let script = r#"#!/bin/sh
set -e
if [ "$1" = "--yes" ] && [ "$2" = "skills" ] && [ "$3" = "add" ] && [ "$4" = "explain-code" ]; then
mkdir -p ".agents/skills/explain-code"
cat > ".agents/skills/explain-code/SKILL.md" <<'EOF'
---
name: explain-code
description: Explain code
agent: codex
action_style: autonomous
---
Loaded from add
EOF
exit 0
fi
exit 1
"#;
write_executable(&npx_script, script);
let _npx_bin = EnvVarGuard::set("LF_NPX_BIN", npx_script.display().to_string());
let skill = discover_skill(repo.path(), "npx/explain-code").expect("load npx skill");
assert_eq!(skill.content.as_deref(), Some("Loaded from add\n"));
assert_eq!(skill.agent.as_deref(), Some("codex"));
assert_eq!(skill.action_style.as_deref(), Some("autonomous"));
fs::remove_file(npx_script).unwrap();
assert_eq!(
discover_skill(repo.path(), "npx/explain-code").unwrap(),
skill
);
let Target::Skill(inspected) =
resolve_local_definition(repo.path(), "npx/explain-code", None).unwrap()
else {
panic!("cached external definition must remain a skill");
};
assert_eq!(inspected, skill);
}
#[test]
fn missing_nested_definition_cannot_fall_back_to_a_cached_skill() {
let _home = HomeGuard::new();
let repo = TempDir::new().unwrap();
let flows = repo.path().join(".lf/flows/npx");
let cache = repo.path().join(".agents/skills/paired");
fs::create_dir_all(&flows).unwrap();
fs::create_dir_all(&cache).unwrap();
fs::write(flows.join("paired.yaml"), "- missing-child-23952\n").unwrap();
fs::write(cache.join("SKILL.md"), "Cached skill body").unwrap();
let error = resolve_local_definition(repo.path(), "npx/paired", None).unwrap_err();
assert!(error.to_string().contains("invalid flow"), "{error}");
assert!(error.to_string().contains("missing-child-23952"), "{error}");
assert_eq!(
discover_skill(repo.path(), "npx/paired")
.unwrap()
.content
.as_deref(),
Some("Cached skill body")
);
}
#[test]
fn malformed_cached_skill_reports_its_parse_error() {
let _home = HomeGuard::new();
let repo = TempDir::new().unwrap();
let cache = repo.path().join(".agents/skills/broken");
fs::create_dir_all(&cache).unwrap();
fs::write(cache.join("SKILL.md"), "---\nagent: [\n---\nBody").unwrap();
let _npx_bin = EnvVarGuard::set(
"LF_NPX_BIN",
repo.path().join("no-npx").display().to_string(),
);
for error in [
discover_skill(repo.path(), "npx/broken").unwrap_err(),
resolve_local_definition(repo.path(), "npx/broken", None).unwrap_err(),
] {
assert!(error.to_string().contains("invalid skill"), "{error}");
}
}
#[test]
fn npx_find_fallback_runs_when_add_fails() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let npx_script = repo.path().join("fake-npx-find.sh");
let script = r#"#!/bin/sh
set -e
if [ "$1" = "--yes" ] && [ "$2" = "skills" ] && [ "$3" = "add" ] && [ "$4" = "deep-research" ]; then
exit 1
fi
if [ "$1" = "--yes" ] && [ "$2" = "skills" ] && [ "$3" = "find" ] && [ "$4" = "deep-research" ]; then
echo "vercel-labs/deep-research"
exit 0
fi
if [ "$1" = "--yes" ] && [ "$2" = "skills" ] && [ "$3" = "add" ] && [ "$4" = "vercel-labs/deep-research" ]; then
mkdir -p ".agents/skills/deep-research"
cat > ".agents/skills/deep-research/SKILL.md" <<'EOF'
---
name: deep-research
description: Deep research
---
Loaded from find fallback
EOF
exit 0
fi
exit 1
"#;
write_executable(&npx_script, script);
let _npx_bin = EnvVarGuard::set("LF_NPX_BIN", npx_script.display().to_string());
let skill = discover_skill(repo.path(), "npx/deep-research").expect("load npx skill");
assert!(skill
.content
.as_deref()
.is_some_and(|content| content.contains("Loaded from find fallback")));
}
#[test]
fn npx_find_handles_qualified_skill_format() {
let _home = HomeGuard::new();
let repo = TempDir::new().expect("repo");
let npx_script = repo.path().join("fake-npx-qualified.sh");
let script = r#"#!/bin/sh
set -e
if [ "$1" = "--yes" ] && [ "$2" = "skills" ] && [ "$3" = "add" ] && [ "$4" = "skill-creator" ]; then
exit 1
fi
if [ "$1" = "--yes" ] && [ "$2" = "skills" ] && [ "$3" = "find" ] && [ "$4" = "skill-creator" ]; then
printf '\033[38;5;145manthropics/skills@skill-creator\033[0m \033[36m50.4K installs\033[0m\n'
exit 0
fi
if [ "$1" = "--yes" ] && [ "$2" = "skills" ] && [ "$3" = "add" ] && [ "$4" = "anthropics/skills@skill-creator" ]; then
mkdir -p ".agents/skills/skill-creator"
cat > ".agents/skills/skill-creator/SKILL.md" <<'EOF'
---
name: skill-creator
description: Create skills
---
Loaded via qualified format
EOF
exit 0
fi
exit 1
"#;
write_executable(&npx_script, script);
let _npx_bin = EnvVarGuard::set("LF_NPX_BIN", npx_script.display().to_string());
let skill = discover_skill(repo.path(), "npx/skill-creator").expect("load qualified npx skill");
assert!(skill
.content
.as_deref()
.is_some_and(|content| content.contains("Loaded via qualified format")));
}