fix: discover global agents skills (#848)

This commit is contained in:
Hunter Bown
2026-05-06 05:21:02 -05:00
committed by GitHub
parent 67eddd6344
commit a1a96d1afc
8 changed files with 124 additions and 24 deletions
+2 -2
View File
@@ -327,10 +327,10 @@ Legacy aliases `deepseek-chat` / `deepseek-reasoner` map to `deepseek-v4-flash`.
## Publishing Your Own Skill
DeepSeek TUI discovers skills from workspace directories (`.agents/skills``skills``.opencode/skills``.claude/skills`) and the global `~/.deepseek/skills`. Each skill is a directory with a `SKILL.md` file:
DeepSeek TUI discovers skills from workspace directories (`.agents/skills``skills``.opencode/skills``.claude/skills``.cursor/skills`) and global directories (`~/.agents/skills` `~/.deepseek/skills`). Each skill is a directory with a `SKILL.md` file:
```text
~/.deepseek/skills/my-skill/
~/.agents/skills/my-skill/
└── SKILL.md
```
+62 -9
View File
@@ -1763,18 +1763,25 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
let global_skills_dir = config.skills_dir();
let agents_skills_dir = workspace.join(".agents").join("skills");
let local_skills_dir = workspace.join("skills");
let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
// #432: cross-tool skill discovery dirs. Presence is reported here
// even though they sit lower in the precedence chain so users can
// see at a glance whether a `.opencode/skills/` or `.claude/skills/`
// directory is contributing to the merged catalogue.
// see at a glance whether a `.opencode/skills/`, `.claude/skills/`,
// `.cursor/skills/`, or global agentskills.io directory is contributing
// to the merged catalogue.
let opencode_skills_dir = workspace.join(".opencode").join("skills");
let claude_skills_dir = workspace.join(".claude").join("skills");
let selected_skills_dir = if agents_skills_dir.exists() {
&agents_skills_dir
agents_skills_dir.clone()
} else if local_skills_dir.exists() {
&local_skills_dir
local_skills_dir.clone()
} else if config.skills_dir.is_none()
&& let Some(global_agents) = agents_global_skills_dir.as_ref()
&& global_agents.exists()
{
global_agents.clone()
} else {
&global_skills_dir
global_skills_dir.clone()
};
let describe_dir = |dir: &Path| -> usize {
@@ -1813,6 +1820,23 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
);
}
if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
if agents_global_skills_dir.exists() {
println!(
" {} global .agents skills dir found at {} ({} items)",
"".truecolor(aqua_r, aqua_g, aqua_b),
crate::utils::display_path(agents_global_skills_dir),
describe_dir(agents_global_skills_dir)
);
} else {
println!(
" {} global .agents skills dir not found at {}",
"·".dimmed(),
crate::utils::display_path(agents_global_skills_dir)
);
}
}
if global_skills_dir.exists() {
println!(
" {} global skills dir found at {} ({} items)",
@@ -1851,9 +1875,15 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(
" {} selected skills dir: {}",
"·".dimmed(),
crate::utils::display_path(selected_skills_dir)
crate::utils::display_path(&selected_skills_dir)
);
if !agents_skills_dir.exists() && !local_skills_dir.exists() && !global_skills_dir.exists() {
if !agents_skills_dir.exists()
&& !local_skills_dir.exists()
&& !agents_global_skills_dir
.as_ref()
.is_some_and(|dir| dir.exists())
&& !global_skills_dir.exists()
{
println!(" Run `deepseek setup --skills` (or add --local for ./skills).");
}
@@ -2037,19 +2067,41 @@ fn run_doctor_json(
let global_skills_dir = config.skills_dir();
let agents_skills_dir = workspace.join(".agents").join("skills");
let local_skills_dir = workspace.join("skills");
let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
// #432: cross-tool skill discovery dirs surface in the JSON
// report so external dashboards can see whether any
// `.opencode/skills/` or `.claude/skills/` content is contributing
// to the merged catalogue.
// `.opencode/skills/`, `.claude/skills/`, `.cursor/skills/`, or
// global agentskills.io content is contributing to the merged catalogue.
let opencode_skills_dir = workspace.join(".opencode").join("skills");
let claude_skills_dir = workspace.join(".claude").join("skills");
let selected_skills_dir = if agents_skills_dir.exists() {
agents_skills_dir.clone()
} else if local_skills_dir.exists() {
local_skills_dir.clone()
} else if config.skills_dir.is_none()
&& let Some(global_agents) = agents_global_skills_dir.as_ref()
&& global_agents.exists()
{
global_agents.clone()
} else {
global_skills_dir.clone()
};
let agents_global_summary = agents_global_skills_dir
.as_ref()
.map(|path| {
json!({
"path": path.display().to_string(),
"present": path.exists(),
"count": skills_count_for(path),
})
})
.unwrap_or_else(|| {
json!({
"path": null,
"present": false,
"count": 0,
})
});
let tools_dir = default_tools_dir();
let plugins_dir = default_plugins_dir();
@@ -2105,6 +2157,7 @@ fn run_doctor_json(
"present": agents_skills_dir.exists(),
"count": skills_count_for(&agents_skills_dir),
},
"agents_global": agents_global_summary,
"local": {
"path": local_skills_dir.display().to_string(),
"present": local_skills_dir.exists(),
+4 -3
View File
@@ -382,9 +382,10 @@ 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`, `.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
// `.opencode/skills`, `.claude/skills`, `.cursor/skills`) plus global
// `~/.agents/skills` / `~/.deepseek/skills` 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
// registry when available.
+43 -4
View File
@@ -36,6 +36,12 @@ pub fn default_skills_dir() -> PathBuf {
)
}
/// Global agentskills.io-compatible skills directory (`~/.agents/skills`).
#[must_use]
pub fn agents_global_skills_dir() -> Option<PathBuf> {
dirs::home_dir().map(|p| p.join(".agents").join("skills"))
}
// === Types ===
/// Parsed representation of a SKILL.md definition.
@@ -265,7 +271,8 @@ impl SkillRegistry {
/// The full `SKILL.md` body is intentionally not included here. This mirrors
/// Resolve the active skills directory given a workspace, mirroring the
/// hierarchy `App::new` walks: `<workspace>/.agents/skills` →
/// `<workspace>/skills` → [`default_skills_dir`] (`~/.deepseek/skills`).
/// `<workspace>/skills` → [`agents_global_skills_dir`] (`~/.agents/skills`,
/// when present) → [`default_skills_dir`] (`~/.deepseek/skills`).
/// Returns the first directory that exists, or the global default
/// (which itself falls back to `/tmp/deepseek/skills` if the user
/// has no home directory).
@@ -285,6 +292,11 @@ pub fn resolve_skills_dir(workspace: &Path) -> PathBuf {
if local.exists() {
return local;
}
if let Some(global_agents) = agents_global_skills_dir()
&& global_agents.exists()
{
return global_agents;
}
default_skills_dir()
}
@@ -301,21 +313,29 @@ pub fn resolve_skills_dir(workspace: &Path) -> PathBuf {
/// 3. `<workspace>/.opencode/skills` — OpenCode interop.
/// 4. `<workspace>/.claude/skills` — Claude Code interop.
/// 5. `<workspace>/.cursor/skills` — Cursor interop.
/// 6. [`default_skills_dir`] — global, user-installed.
/// 6. [`agents_global_skills_dir`] — agentskills.io global.
/// 7. [`default_skills_dir`] — DeepSeek 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
/// installed (the system-prompt skills block is then suppressed).
#[must_use]
pub fn skills_directories(workspace: &Path) -> Vec<PathBuf> {
let candidates = [
let mut candidates = vec![
workspace.join(".agents").join("skills"),
workspace.join("skills"),
workspace.join(".opencode").join("skills"),
workspace.join(".claude").join("skills"),
workspace.join(".cursor").join("skills"),
default_skills_dir(),
];
if let Some(global_agents) = agents_global_skills_dir() {
candidates.push(global_agents);
}
candidates.push(default_skills_dir());
existing_skill_dirs(candidates)
}
fn existing_skill_dirs(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
let mut out = Vec::new();
for path in candidates {
if path.is_dir() && !out.iter().any(|p: &PathBuf| p == &path) {
@@ -702,6 +722,25 @@ mod tests {
);
}
#[test]
fn existing_skill_dirs_keeps_agents_global_before_deepseek_global() {
let tmpdir = TempDir::new().unwrap();
let agents_global = tmpdir.path().join(".agents").join("skills");
let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
let missing = tmpdir.path().join("missing").join("skills");
std::fs::create_dir_all(&agents_global).unwrap();
std::fs::create_dir_all(&deepseek_global).unwrap();
let dirs = super::existing_skill_dirs(vec![
missing,
agents_global.clone(),
deepseek_global.clone(),
agents_global.clone(),
]);
assert_eq!(dirs, vec![agents_global, deepseek_global]);
}
#[test]
fn discover_in_workspace_merges_with_first_wins_precedence() {
let tmpdir = TempDir::new().unwrap();
+3 -3
View File
@@ -86,8 +86,8 @@ impl ToolSpec for LoadSkillTool {
// #432: walk every candidate skill directory (workspace
// .agents/skills, skills, .opencode/skills, .claude/skills,
// .cursor/skills, global default), merging with first-wins
// precedence. The
// .cursor/skills, ~/.agents/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.
@@ -100,7 +100,7 @@ impl ToolSpec for LoadSkillTool {
.map(|p| p.display().to_string())
.collect();
if dirs.is_empty() {
"no skills directories found; install skills under `<workspace>/.agents/skills/<name>/SKILL.md` or `~/.deepseek/skills/<name>/SKILL.md`"
"no skills directories found; install skills under `<workspace>/.agents/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md`, or `~/.deepseek/skills/<name>/SKILL.md`"
.to_string()
} else {
format!("no skills installed. Searched: {}", dirs.join(", "))
+6
View File
@@ -1153,10 +1153,16 @@ impl App {
let agents_skills_dir = workspace.join(".agents").join("skills");
let local_skills_dir = workspace.join("skills");
let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
let skills_dir = if agents_skills_dir.exists() {
agents_skills_dir
} else if local_skills_dir.exists() {
local_skills_dir
} else if config.skills_dir.is_none()
&& let Some(global_agents) = agents_global_skills_dir
&& global_agents.exists()
{
global_agents
} else {
global_skills_dir
};
+1 -1
View File
@@ -326,7 +326,7 @@ If you are upgrading from older releases:
keys such as `worker`, `explorer`, `general`, `explore`, `plan`, and
`review`. Values must normalize to a supported DeepSeek model id before an
agent is spawned.
- `skills_dir` (string, optional): defaults to `~/.deepseek/skills` (each skill is a directory containing `SKILL.md`). Workspace-local `.agents/skills` or `./skills` are preferred when present.
- `skills_dir` (string, optional): defaults to `~/.deepseek/skills` (each skill is a directory containing `SKILL.md`). Workspace-local `.agents/skills` or `./skills` are preferred when present; the runtime also discovers global agentskills.io-compatible `~/.agents/skills`.
- `mcp_config_path` (string, optional): defaults to `~/.deepseek/mcp.json`.
It is visible in `/config` and can be changed from the TUI. The new path is
used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool
+3 -2
View File
@@ -68,8 +68,9 @@ deepseek doctor --json
| `mcp.present` | bool | Whether MCP config exists |
| `mcp.servers` | array | Per-server health: `{name, enabled, status, detail}` |
| `skills.selected` | string | Resolved skills directory |
| `skills.global.path` / `.present` / `.count` | — | Global skills dir |
| `skills.agents.path` / `.present` / `.count` | — | `.agents/skills/` dir |
| `skills.global.path` / `.present` / `.count` | — | DeepSeek global skills dir (`~/.deepseek/skills`) |
| `skills.agents.path` / `.present` / `.count` | — | Workspace `.agents/skills/` dir |
| `skills.agents_global.path` / `.present` / `.count` | — | agentskills.io global skills dir (`~/.agents/skills`) |
| `skills.local.path` / `.present` / `.count` | — | `skills/` dir |
| `skills.opencode.path` / `.present` / `.count` | — | `.opencode/skills/` dir |
| `skills.claude.path` / `.present` / `.count` | — | `.claude/skills/` dir |