From a1a96d1afceaeba1dda56892dd32e07cd3d1b3af Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 6 May 2026 05:21:02 -0500 Subject: [PATCH] fix: discover global agents skills (#848) --- README.md | 4 +- crates/tui/src/main.rs | 71 ++++++++++++++++++++++++++++++----- crates/tui/src/prompts.rs | 7 ++-- crates/tui/src/skills/mod.rs | 47 +++++++++++++++++++++-- crates/tui/src/tools/skill.rs | 6 +-- crates/tui/src/tui/app.rs | 6 +++ docs/CONFIGURATION.md | 2 +- docs/RUNTIME_API.md | 5 ++- 8 files changed, 124 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index c903a5ff..cf7d31a8 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 84093d60..3579c2db 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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(), diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index f10a2927..8f8659ca 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -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. diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index 6363d8f4..ae8fa1fe 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -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 { + 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: `/.agents/skills` → -/// `/skills` → [`default_skills_dir`] (`~/.deepseek/skills`). +/// `/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. `/.opencode/skills` — OpenCode interop. /// 4. `/.claude/skills` — Claude Code interop. /// 5. `/.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 { - 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) -> Vec { 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(); diff --git a/crates/tui/src/tools/skill.rs b/crates/tui/src/tools/skill.rs index 6633e4a0..d956279f 100644 --- a/crates/tui/src/tools/skill.rs +++ b/crates/tui/src/tools/skill.rs @@ -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 `/.agents/skills//SKILL.md` or `~/.deepseek/skills//SKILL.md`" + "no skills directories found; install skills under `/.agents/skills//SKILL.md`, `~/.agents/skills//SKILL.md`, or `~/.deepseek/skills//SKILL.md`" .to_string() } else { format!("no skills installed. Searched: {}", dirs.join(", ")) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 13208d2a..5018c5fd 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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 }; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f0b80136..adae42a3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index eab38911..9b6f638c 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -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 |