From 343c1919bebf2476c38b8aba3bd8f83052ef0258 Mon Sep 17 00:00:00 2001 From: nguyenngothuong Date: Fri, 20 Feb 2026 18:56:27 +0700 Subject: [PATCH 1/2] fix: support domain-grouped skills and global MCP config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #621 – Skills in domain subfolders (skills///SKILL.md) were silently skipped because gather_skills() (Rust) and listSkillsInDir() (TypeScript) only scanned one level deep. Both functions now treat a top-level directory that has no SKILL.md as a domain/category folder and scan its immediate children, keeping full backwards-compatibility with the flat layout. Fixes #622 – MCP servers defined in the global opencode config (~/.config/opencode/opencode.json) were completely ignored by listMcp(). The function now reads global config as well, merging it with the workspace-level config. Project entries take priority over global ones; each item carries the correct source field (config.global vs config.project). --- .../desktop/src-tauri/src/commands/skills.rs | 43 +++++++--- packages/server/src/mcp.ts | 49 ++++++++++-- packages/server/src/skills.ts | 79 +++++++++++++------ 3 files changed, 130 insertions(+), 41 deletions(-) diff --git a/packages/desktop/src-tauri/src/commands/skills.rs b/packages/desktop/src-tauri/src/commands/skills.rs index 7986c9e1f..e3d9dd76a 100644 --- a/packages/desktop/src-tauri/src/commands/skills.rs +++ b/packages/desktop/src-tauri/src/commands/skills.rs @@ -142,16 +142,39 @@ fn gather_skills( } let path = entry.path(); - if !path.join("SKILL.md").is_file() { - continue; - } - - let Some(name) = path.file_name().and_then(|s| s.to_str()) else { - continue; - }; - - if seen.insert(name.to_string()) { - out.push(path); + if path.join("SKILL.md").is_file() { + // Direct skill: //SKILL.md + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + if seen.insert(name.to_string()) { + out.push(path); + } + } else { + // Domain/category folder: ///SKILL.md – scan one level deeper. + // This supports the convention where global skills are organised as + // skills///SKILL.md + // in addition to the flat skills//SKILL.md layout. + if let Ok(sub_entries) = fs::read_dir(&path) { + for sub_entry in sub_entries.flatten() { + let Ok(sub_ft) = sub_entry.file_type() else { + continue; + }; + if !sub_ft.is_dir() { + continue; + } + let sub_path = sub_entry.path(); + if !sub_path.join("SKILL.md").is_file() { + continue; + } + let Some(name) = sub_path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + if seen.insert(name.to_string()) { + out.push(sub_path); + } + } + } } } diff --git a/packages/server/src/mcp.ts b/packages/server/src/mcp.ts index 145a69325..835bd035a 100644 --- a/packages/server/src/mcp.ts +++ b/packages/server/src/mcp.ts @@ -1,9 +1,21 @@ import { minimatch } from "minimatch"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; import type { McpItem } from "./types.js"; import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js"; import { opencodeConfigPath } from "./workspace-files.js"; import { validateMcpConfig, validateMcpName } from "./validators.js"; +function globalOpenCodeConfigPath(): string { + const base = join(homedir(), ".config", "opencode"); + const jsonc = join(base, "opencode.jsonc"); + const json = join(base, "opencode.json"); + if (existsSync(jsonc)) return jsonc; + if (existsSync(json)) return json; + return jsonc; // fall back to jsonc (readJsoncFile handles missing files gracefully) +} + function getMcpConfig(config: Record): Record> { const mcp = config.mcp; if (!mcp || typeof mcp !== "object") return {}; @@ -27,13 +39,36 @@ function isMcpDisabledByTools(config: Record, name: string): bo export async function listMcp(workspaceRoot: string): Promise { const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); - const mcpMap = getMcpConfig(config); - return Object.entries(mcpMap).map(([name, entry]) => ({ - name, - config: entry, - source: "config.project", - disabledByTools: isMcpDisabledByTools(config, name) || undefined, - })); + const { data: globalConfig } = await readJsoncFile(globalOpenCodeConfigPath(), {} as Record); + + const projectMcpMap = getMcpConfig(config); + const globalMcpMap = getMcpConfig(globalConfig); + + const items: McpItem[] = []; + + // Global MCPs first; project-level entries override global ones with the same name. + for (const [name, entry] of Object.entries(globalMcpMap)) { + if (Object.prototype.hasOwnProperty.call(projectMcpMap, name)) continue; + items.push({ + name, + config: entry, + source: "config.global", + disabledByTools: + (isMcpDisabledByTools(globalConfig, name) || isMcpDisabledByTools(config, name)) || undefined, + }); + } + + // Project MCPs (highest priority). + for (const [name, entry] of Object.entries(projectMcpMap)) { + items.push({ + name, + config: entry, + source: "config.project", + disabledByTools: isMcpDisabledByTools(config, name) || undefined, + }); + } + + return items; } export async function addMcp( diff --git a/packages/server/src/skills.ts b/packages/server/src/skills.ts index 937e5cd6a..6747952bb 100644 --- a/packages/server/src/skills.ts +++ b/packages/server/src/skills.ts @@ -1,4 +1,5 @@ import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import type { Dirent } from "node:fs"; import { join, resolve } from "node:path"; import { homedir } from "node:os"; import type { SkillItem } from "./types.js"; @@ -49,6 +50,37 @@ const extractTriggerFromBody = (body: string) => { return ""; }; +async function parseSkillEntry( + skillPath: string, + entryName: string, + scope: "project" | "global", +): Promise { + const content = await readFile(skillPath, "utf8"); + const { data, body } = parseFrontmatter(content); + const name = typeof data.name === "string" ? data.name : entryName; + const description = typeof data.description === "string" ? data.description : ""; + const trigger = + typeof data.trigger === "string" + ? data.trigger + : typeof data.when === "string" + ? data.when + : extractTriggerFromBody(body); + try { + validateSkillName(name); + validateDescription(description); + } catch { + return null; + } + if (name !== entryName) return null; + return { + name, + description, + path: skillPath, + scope, + trigger: trigger.trim() || undefined, + }; +} + async function listSkillsInDir(dir: string, scope: "project" | "global"): Promise { if (!(await exists(dir))) return []; const entries = await readdir(dir, { withFileTypes: true }); @@ -56,31 +88,30 @@ async function listSkillsInDir(dir: string, scope: "project" | "global"): Promis for (const entry of entries) { if (!entry.isDirectory()) continue; const skillPath = join(dir, entry.name, "SKILL.md"); - if (!(await exists(skillPath))) continue; - const content = await readFile(skillPath, "utf8"); - const { data, body } = parseFrontmatter(content); - const name = typeof data.name === "string" ? data.name : entry.name; - const description = typeof data.description === "string" ? data.description : ""; - const trigger = - typeof data.trigger === "string" - ? data.trigger - : typeof data.when === "string" - ? data.when - : extractTriggerFromBody(body); - try { - validateSkillName(name); - validateDescription(description); - } catch { - continue; + if (await exists(skillPath)) { + // Direct skill: //SKILL.md + const item = await parseSkillEntry(skillPath, entry.name, scope); + if (item) items.push(item); + } else { + // Domain/category folder: ///SKILL.md – scan one level deeper. + // This supports the convention where global skills are organised as + // skills///SKILL.md + // in addition to the flat skills//SKILL.md layout. + const domainDir = join(dir, entry.name); + let subEntries: Dirent[]; + try { + subEntries = await readdir(domainDir, { withFileTypes: true }); + } catch { + continue; + } + for (const subEntry of subEntries) { + if (!subEntry.isDirectory()) continue; + const subSkillPath = join(domainDir, subEntry.name, "SKILL.md"); + if (!(await exists(subSkillPath))) continue; + const item = await parseSkillEntry(subSkillPath, subEntry.name, scope); + if (item) items.push(item); + } } - if (name !== entry.name) continue; - items.push({ - name, - description, - path: skillPath, - scope, - trigger: trigger.trim() || undefined, - }); } return items; } From e9469942f842a4e3ace79a18462f9ec09d5a85a3 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 20 Feb 2026 10:23:27 -0800 Subject: [PATCH 2/2] fix: support nested skill paths for local skill actions --- .../desktop/src-tauri/src/commands/skills.rs | 68 +++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/desktop/src-tauri/src/commands/skills.rs b/packages/desktop/src-tauri/src/commands/skills.rs index e3d9dd76a..313a8a26a 100644 --- a/packages/desktop/src-tauri/src/commands/skills.rs +++ b/packages/desktop/src-tauri/src/commands/skills.rs @@ -181,6 +181,55 @@ fn gather_skills( Ok(()) } +fn find_skill_file_in_root(root: &Path, name: &str) -> Option { + let direct = root.join(name).join("SKILL.md"); + if direct.is_file() { + return Some(direct); + } + + let entries = fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let candidate = entry.path().join(name).join("SKILL.md"); + if candidate.is_file() { + return Some(candidate); + } + } + + None +} + +fn collect_skill_dirs_by_name(root: &Path, name: &str) -> Vec { + let mut out = Vec::new(); + + let direct = root.join(name); + if direct.join("SKILL.md").is_file() { + out.push(direct); + } + + if let Ok(entries) = fs::read_dir(root) { + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let candidate = entry.path().join(name); + if candidate.join("SKILL.md").is_file() { + out.push(candidate); + } + } + } + + out +} + #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct LocalSkillCard { @@ -364,10 +413,9 @@ pub fn read_local_skill(project_dir: String, name: String) -> Result = None; for root in roots { - let path = root.join(&name).join("SKILL.md"); - if path.is_file() { + if let Some(path) = find_skill_file_in_root(&root, &name) { target = Some(path); break; } @@ -484,14 +531,11 @@ pub fn uninstall_skill(project_dir: String, name: String) -> Result