feat(skills): add .cursor/skills to skill discovery paths (#817)
* feat(skills): add .cursor/skills to skill discovery paths
Adds Cursor IDE interop by scanning .cursor/skills alongside the
existing .opencode/skills and .claude/skills directories (#432).
Precedence: .agents/skills > skills > .opencode/skills >
.claude/skills > .cursor/skills > ~/.deepseek/skills.
* test(skills): cover cursor skills discovery
---------
Co-authored-by: ornn.ban <ornn.ban@biuya.com>
Co-authored-by: Hunter Bown <hmbown@gmail.com>
This commit is contained in:
@@ -382,8 +382,8 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
|
||||
|
||||
// 3. Skills block. #432: walks every candidate workspace
|
||||
// skills directory (`.agents/skills`, `skills`,
|
||||
// `.opencode/skills`, `.claude/skills`) plus the global
|
||||
// default so skills installed for any AI-tool convention show
|
||||
// `.opencode/skills`, `.claude/skills`, `.cursor/skills`) plus the
|
||||
// global default so skills installed for any AI-tool convention show
|
||||
// up in the catalogue. The legacy single-`skills_dir` path is
|
||||
// honoured as a fallback for callers that don't supply a
|
||||
// workspace-aware view; it falls through to the same merged
|
||||
|
||||
@@ -300,7 +300,8 @@ pub fn resolve_skills_dir(workspace: &Path) -> PathBuf {
|
||||
/// 2. `<workspace>/skills` — flat, project-local.
|
||||
/// 3. `<workspace>/.opencode/skills` — OpenCode interop.
|
||||
/// 4. `<workspace>/.claude/skills` — Claude Code interop.
|
||||
/// 5. [`default_skills_dir`] — global, user-installed.
|
||||
/// 5. `<workspace>/.cursor/skills` — Cursor interop.
|
||||
/// 6. [`default_skills_dir`] — global, user-installed.
|
||||
///
|
||||
/// Only directories that exist on disk are returned — callers don't
|
||||
/// need to filter further. Returns an empty vec when nothing is
|
||||
@@ -312,6 +313,7 @@ pub fn skills_directories(workspace: &Path) -> Vec<PathBuf> {
|
||||
workspace.join("skills"),
|
||||
workspace.join(".opencode").join("skills"),
|
||||
workspace.join(".claude").join("skills"),
|
||||
workspace.join(".cursor").join("skills"),
|
||||
default_skills_dir(),
|
||||
];
|
||||
let mut out = Vec::new();
|
||||
@@ -665,10 +667,11 @@ mod tests {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let workspace = tmpdir.path();
|
||||
|
||||
// Create three of the four candidate dirs (skip `.opencode`).
|
||||
// Create four of the five workspace candidate dirs (skip `.opencode`).
|
||||
std::fs::create_dir_all(workspace.join(".agents").join("skills")).unwrap();
|
||||
std::fs::create_dir_all(workspace.join("skills")).unwrap();
|
||||
std::fs::create_dir_all(workspace.join(".claude").join("skills")).unwrap();
|
||||
std::fs::create_dir_all(workspace.join(".cursor").join("skills")).unwrap();
|
||||
|
||||
let dirs = super::skills_directories(workspace);
|
||||
// We don't assert on the global default position because it's
|
||||
@@ -677,6 +680,7 @@ mod tests {
|
||||
let agents = workspace.join(".agents").join("skills");
|
||||
let local = workspace.join("skills");
|
||||
let claude = workspace.join(".claude").join("skills");
|
||||
let cursor = workspace.join(".cursor").join("skills");
|
||||
|
||||
assert_eq!(dirs.get(idx), Some(&agents), "agents must come first");
|
||||
idx += 1;
|
||||
@@ -690,6 +694,12 @@ mod tests {
|
||||
"missing dir must be omitted, got: {dirs:?}"
|
||||
);
|
||||
assert_eq!(dirs.get(idx), Some(&claude), "claude must come after local");
|
||||
idx += 1;
|
||||
assert_eq!(
|
||||
dirs.get(idx),
|
||||
Some(&cursor),
|
||||
"cursor must come after claude"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -757,6 +767,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_in_workspace_pulls_skills_from_cursor_dir() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let workspace = tmpdir.path();
|
||||
write_skill(
|
||||
&workspace.join(".cursor").join("skills"),
|
||||
"cursor-only",
|
||||
"for cursor interop",
|
||||
"body",
|
||||
);
|
||||
|
||||
let registry = super::discover_in_workspace(workspace);
|
||||
assert!(
|
||||
registry.get("cursor-only").is_some(),
|
||||
".cursor/skills must be scanned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_available_skills_context_for_workspace_picks_up_cross_tool_dirs() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
|
||||
@@ -86,7 +86,8 @@ impl ToolSpec for LoadSkillTool {
|
||||
|
||||
// #432: walk every candidate skill directory (workspace
|
||||
// .agents/skills, skills, .opencode/skills, .claude/skills,
|
||||
// global default), merging with first-wins precedence. The
|
||||
// .cursor/skills, global default), merging with first-wins
|
||||
// precedence. The
|
||||
// tool's lookup mirrors what the system-prompt skills block
|
||||
// already lists, so the model never asks for a name it
|
||||
// can't find.
|
||||
|
||||
Reference in New Issue
Block a user