fix: discover global agents skills (#848)
This commit is contained in:
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(", "))
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user