diff --git a/src/commands/skills/mod.rs b/src/commands/skills/mod.rs index 5395a0c..fc08018 100644 --- a/src/commands/skills/mod.rs +++ b/src/commands/skills/mod.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; use std::time::Duration; @@ -564,8 +564,25 @@ fn agent_paths_for_skill(name: &str) -> Vec { let Some(home) = dirs::home_dir() else { return Vec::new(); }; - - let paths = [ + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| home.join(".config")); + agent_paths_for_skill_with( + name, + &home, + &config_home, + std::env::current_dir().ok().as_deref(), + ) +} + +fn agent_paths_for_skill_with( + name: &str, + home: &Path, + config_home: &Path, + cwd: Option<&Path>, +) -> Vec { + let mut paths = vec![ ( "Claude Code", home.join(".claude") @@ -579,13 +596,6 @@ fn agent_paths_for_skill(name: &str) -> Vec { .join("rules") .join(format!("{name}.mdc")), ), - ( - "OpenCode", - home.join(".config") - .join("opencode") - .join("agents") - .join(format!("{name}.md")), - ), ( "Codex/Pi", home.join(".agents") @@ -595,12 +605,36 @@ fn agent_paths_for_skill(name: &str) -> Vec { ), ]; + let mut opencode_sources = vec![config_home.join("opencode").join("skills")]; + if let Some(cwd) = cwd { + // Stop at the nearest Git worktree root (including .git files). Without + // a repository, only the active directory is a project source. + let root = cwd + .ancestors() + .find(|dir| dir.join(".git").exists()) + .unwrap_or(cwd); + for dir in cwd.ancestors() { + for source in [".opencode", ".agents", ".claude"] { + opencode_sources.push(dir.join(source).join("skills")); + } + if dir == root { + break; + } + } + } + // OpenCode supports both directory skills and root-level Markdown skills. + // Agent definitions under opencode/agents are not installed skills. + for source in opencode_sources { + paths.push(("OpenCode", source.join(name).join("SKILL.md"))); + paths.push(("OpenCode", source.join(format!("{name}.md")))); + } + paths .into_iter() .map(|(agent, path)| AgentPath { agent, skill: name.to_string(), - installed: path.exists(), + installed: path.is_file(), path, }) .collect() diff --git a/tests/skills_discovery.rs b/tests/skills_discovery.rs new file mode 100644 index 0000000..d8a213f --- /dev/null +++ b/tests/skills_discovery.rs @@ -0,0 +1,144 @@ +//! Exercise real skill diagnostics with isolated HOME/XDG/project state. +use std::path::Path; +use std::process::Command; + +use serde_json::Value; + +fn run(home: &Path, cwd: &Path, args: &[&str]) -> Value { + let result = Command::new(env!("CARGO_BIN_EXE_steel")) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("xdg")) + .env("STEEL_CONFIG_DIR", home.join("steel")) + .env("STEEL_TELEMETRY_DISABLED", "1") + .env("STEEL_API_KEY", "synthetic-offline-key") + .env_remove("STEEL_SKILLS_MANIFEST_URL") + .current_dir(cwd) + .args(["--no-update-check", "--json", "skills"]) + .args(args) + .output() + .unwrap(); + serde_json::from_slice(&result.stdout).unwrap_or_else(|_| { + panic!( + "Expected diagnostic JSON: {}", + String::from_utf8_lossy(&result.stderr) + ) + }) +} + +fn install(path: &Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + "---\nname: steel-browser\ndescription: Synthetic skill\n---\n", + ) + .unwrap(); +} + +fn installed_paths(home: &Path, cwd: &Path) -> Vec { + run(home, cwd, &["paths"])["data"] + .as_array() + .unwrap() + .iter() + .filter(|row| { + row["agent"] == "OpenCode" + && row["skill"] == "steel-browser" + && row["installed"] == true + }) + .map(|row| { + Path::new(row["path"].as_str().unwrap()) + .canonicalize() + .unwrap() + .to_str() + .unwrap() + .to_string() + }) + .collect() +} + +#[test] +fn project_skill_is_visible_in_paths_list_and_doctor() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let skill = project + .path() + .join(".opencode/skills/steel-browser/SKILL.md"); + install(&skill); + assert_eq!( + installed_paths(home.path(), project.path()), + [skill.canonicalize().unwrap().to_str().unwrap()] + ); + let listed = run(home.path(), project.path(), &["list", "--offline"]); + assert!( + listed["data"]["skills"] + .as_array() + .unwrap() + .iter() + .any(|row| row["name"] == "steel-browser" && row["installed"] == true) + ); + let doctor = run(home.path(), project.path(), &["doctor", "--offline"]); + assert!( + doctor["data"]["checks"]["installed_paths"] + .as_u64() + .unwrap() + > 0 + ); +} + +#[test] +fn global_skills_use_xdg_and_do_not_count_agent_definitions() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + install(&home.path().join(".config/opencode/agents/steel-browser.md")); + install( + &home + .path() + .join(".config/opencode/skills/steel-browser/SKILL.md"), + ); + assert!( + installed_paths(home.path(), project.path()).is_empty(), + "XDG override must replace the default config root" + ); + let skill = home.path().join("xdg/opencode/skills/steel-browser.md"); + install(&skill); + assert_eq!( + installed_paths(home.path(), project.path()), + [skill.canonicalize().unwrap().to_str().unwrap()] + ); +} + +#[test] +fn nested_project_discovers_ancestor_skills_but_not_above_worktree_root() { + let home = tempfile::tempdir().unwrap(); + let outer = tempfile::tempdir().unwrap(); + install(&outer.path().join(".opencode/skills/steel-browser/SKILL.md")); + let project = outer.path().join("project"); + std::fs::create_dir_all(project.join("nested/package")).unwrap(); + std::fs::write(project.join(".git"), "gitdir: synthetic-worktree\n").unwrap(); + let skill = project.join(".agents/skills/steel-browser/SKILL.md"); + install(&skill); + assert_eq!( + installed_paths(home.path(), &project.join("nested/package")), + [skill.canonicalize().unwrap().to_str().unwrap()] + ); +} + +#[cfg(unix)] +#[test] +fn symlinked_project_skill_is_detected() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let target = home.path().join("fixture/steel-browser/SKILL.md"); + install(&target); + let link = project.path().join(".opencode/skills/steel-browser"); + std::fs::create_dir_all(link.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(target.parent().unwrap(), &link).unwrap(); + assert_eq!( + installed_paths(home.path(), project.path()), + [link + .join("SKILL.md") + .canonicalize() + .unwrap() + .to_str() + .unwrap()] + ); +}