diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 4c4bad2643..18c99e7689 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -28,15 +28,16 @@ npx skills add https://github.com/anthropics/skills --skill frontend-design ## Where skills live -Mux discovers skills from four roots: +Mux discovers skills from five roots: - **Workspace-local**: `.mux/skills//SKILL.md` (in the workspace working directory) - **Global (Mux-specific)**: `~/.mux/skills//SKILL.md` +- **Workspace universal**: `.agent/skills//SKILL.md` (in the workspace working directory) - **Universal (cross-tool)**: `~/.agents/skills//SKILL.md` - **Built-in**: shipped with Mux If a skill exists in multiple locations, the precedence order is: -**workspace-local > global (`~/.mux/skills`) > universal (`~/.agents/skills`) > built-in**. +**workspace-local > global (`~/.mux/skills`) > workspace universal (`.agent/skills`) > universal (`~/.agents/skills`) > built-in**. Mux reads skills using the active workspace runtime. For SSH workspaces, skills are read from the diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 2de1d3a6c4..72ac61ebf7 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -697,7 +697,7 @@ export const TOOL_DEFINITIONS = { agent_skill_read: { description: "Load an Agent Skill's SKILL.md (YAML frontmatter + markdown body) by name. " + - "Skills are discovered from /.mux/skills//SKILL.md, ~/.mux/skills//SKILL.md, and ~/.agents/skills//SKILL.md.", + "Skills are discovered from /.mux/skills//SKILL.md, /.agent/skills//SKILL.md, ~/.mux/skills//SKILL.md, and ~/.agents/skills//SKILL.md.", schema: z .object({ name: SkillNameSchema.describe("Skill name (directory name under the skills root)"), diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts index 7970aab156..bf60b3f071 100644 --- a/src/node/services/agentSkills/agentSkillsService.test.ts +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -9,6 +9,7 @@ import { DisposableTempDir } from "@/node/services/tempDir"; import { discoverAgentSkills, discoverAgentSkillsDiagnostics, + getDefaultAgentSkillsRoots, readAgentSkill, } from "./agentSkillsService"; @@ -55,22 +56,66 @@ describe("agentSkillsService", () => { expect(bar!.scope).toBe("global"); }); - test("scans universal root after mux global root", async () => { + test("falls back to lower-precedence root when higher-precedence skill is invalid", async () => { + using project = new DisposableTempDir("agent-skills-project-invalid"); + using global = new DisposableTempDir("agent-skills-global-valid"); + + const projectSkillsRoot = path.join(project.path, ".mux", "skills"); + const globalSkillsRoot = global.path; + + const invalidProjectSkillDir = path.join(projectSkillsRoot, "foo"); + await fs.mkdir(invalidProjectSkillDir, { recursive: true }); + await fs.writeFile( + path.join(invalidProjectSkillDir, "SKILL.md"), + "---\nname: foo\n---\n", + "utf-8" + ); + + await writeSkill(globalSkillsRoot, "foo", "from global"); + + const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot }; + const runtime = new LocalRuntime(project.path); + + const skills = await discoverAgentSkills(runtime, project.path, { roots }); + const foo = skills.find((s) => s.name === "foo"); + + expect(foo).toBeDefined(); + expect(foo!.scope).toBe("global"); + expect(foo!.description).toBe("from global"); + }); + + test("default roots include workspace and home universal paths", () => { + using project = new DisposableTempDir("agent-skills-default-roots"); + const runtime = new LocalRuntime(project.path); + + const roots = getDefaultAgentSkillsRoots(runtime, project.path); + + expect(roots.projectRoot).toBe(path.join(project.path, ".mux", "skills")); + expect(roots.workspaceUniversalRoot).toBe(path.join(project.path, ".agent", "skills")); + expect(roots.globalRoot).toBe("~/.mux/skills"); + expect(roots.universalRoot).toBe("~/.agents/skills"); + }); + + test("scans workspace/home universal roots after mux global root", async () => { using project = new DisposableTempDir("agent-skills-project"); using global = new DisposableTempDir("agent-skills-global"); using universal = new DisposableTempDir("agent-skills-universal"); const projectSkillsRoot = path.join(project.path, ".mux", "skills"); + const workspaceUniversalSkillsRoot = path.join(project.path, ".agent", "skills"); const globalSkillsRoot = global.path; const universalSkillsRoot = universal.path; await writeSkill(globalSkillsRoot, "shared", "from global"); - await writeSkill(universalSkillsRoot, "shared", "from universal"); - await writeSkill(universalSkillsRoot, "universal-only", "from universal only"); + await writeSkill(workspaceUniversalSkillsRoot, "shared", "from workspace universal"); + await writeSkill(universalSkillsRoot, "shared", "from home universal"); + await writeSkill(workspaceUniversalSkillsRoot, "workspace-universal-only", "workspace only"); + await writeSkill(universalSkillsRoot, "home-universal-only", "home only"); const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot, + workspaceUniversalRoot: workspaceUniversalSkillsRoot, universalRoot: universalSkillsRoot, }; const runtime = new LocalRuntime(project.path); @@ -82,15 +127,34 @@ describe("agentSkillsService", () => { expect(shared!.scope).toBe("global"); expect(shared!.description).toBe("from global"); - const universalOnly = skills.find((s) => s.name === "universal-only"); - expect(universalOnly).toBeDefined(); - expect(universalOnly!.scope).toBe("global"); - expect(universalOnly!.description).toBe("from universal only"); - - const universalOnlyName = SkillNameSchema.parse("universal-only"); - const resolved = await readAgentSkill(runtime, project.path, universalOnlyName, { roots }); - expect(resolved.package.scope).toBe("global"); - expect(resolved.package.frontmatter.description).toBe("from universal only"); + const workspaceUniversalOnly = skills.find((s) => s.name === "workspace-universal-only"); + expect(workspaceUniversalOnly).toBeDefined(); + expect(workspaceUniversalOnly!.scope).toBe("global"); + expect(workspaceUniversalOnly!.description).toBe("workspace only"); + + const homeUniversalOnly = skills.find((s) => s.name === "home-universal-only"); + expect(homeUniversalOnly).toBeDefined(); + expect(homeUniversalOnly!.scope).toBe("global"); + expect(homeUniversalOnly!.description).toBe("home only"); + + const workspaceUniversalOnlyName = SkillNameSchema.parse("workspace-universal-only"); + const resolvedWorkspace = await readAgentSkill( + runtime, + project.path, + workspaceUniversalOnlyName, + { + roots, + } + ); + expect(resolvedWorkspace.package.scope).toBe("global"); + expect(resolvedWorkspace.package.frontmatter.description).toBe("workspace only"); + + const homeUniversalOnlyName = SkillNameSchema.parse("home-universal-only"); + const resolvedHome = await readAgentSkill(runtime, project.path, homeUniversalOnlyName, { + roots, + }); + expect(resolvedHome.package.scope).toBe("global"); + expect(resolvedHome.package.frontmatter.description).toBe("home only"); }); test("readAgentSkill resolves project before global", async () => { diff --git a/src/node/services/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts index 7302f3b369..14d816650a 100644 --- a/src/node/services/agentSkills/agentSkillsService.ts +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -24,11 +24,13 @@ import { AgentSkillParseError, parseSkillMarkdown } from "./parseSkillMarkdown"; import { getBuiltInSkillByName, getBuiltInSkillDescriptors } from "./builtInSkillDefinitions"; const GLOBAL_SKILLS_ROOT = "~/.mux/skills"; +const WORKSPACE_UNIVERSAL_SKILLS_SUBPATH = ".agent/skills"; const UNIVERSAL_SKILLS_ROOT = "~/.agents/skills"; export interface AgentSkillsRoots { projectRoot: string; globalRoot: string; + workspaceUniversalRoot?: string; universalRoot?: string; } @@ -43,18 +45,46 @@ export function getDefaultAgentSkillsRoots( return { projectRoot: runtime.normalizePath(".mux/skills", workspacePath), globalRoot: GLOBAL_SKILLS_ROOT, + workspaceUniversalRoot: runtime.normalizePath( + WORKSPACE_UNIVERSAL_SKILLS_SUBPATH, + workspacePath + ), universalRoot: UNIVERSAL_SKILLS_ROOT, }; } function getGlobalSkillRoots(roots: AgentSkillsRoots): string[] { - const orderedRoots = [roots.globalRoot, roots.universalRoot].filter( + const orderedRoots = [roots.globalRoot, roots.workspaceUniversalRoot, roots.universalRoot].filter( (root): root is string => root != null && root.length > 0 ); return Array.from(new Set(orderedRoots)); } +interface SkillRootScan { + scope: AgentSkillScope; + root: string; +} + +interface ListedSkillRoot { + scope: AgentSkillScope; + resolvedRoot: string; + directoryNames: string[]; +} + +interface SkillDescriptorCandidate { + scope: AgentSkillScope; + directoryName: SkillName; + skillDir: string; +} + +function buildSkillRootScans(roots: AgentSkillsRoots): SkillRootScan[] { + return [ + { scope: "project", root: roots.projectRoot }, + ...getGlobalSkillRoots(roots).map((root) => ({ scope: "global" as const, root })), + ]; +} + function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -99,6 +129,102 @@ async function listSkillDirectoriesFromRuntime( .filter(Boolean); } +async function listSkillRoots( + runtime: Runtime, + workspacePath: string, + scans: SkillRootScan[] +): Promise { + const listed = await Promise.all( + scans.map(async (scan): Promise => { + let resolvedRoot: string; + try { + resolvedRoot = await runtime.resolvePath(scan.root); + } catch (err) { + log.warn(`Failed to resolve skills root ${scan.root}: ${formatError(err)}`); + return null; + } + + const directoryNames = + runtime instanceof SSHRuntime + ? await listSkillDirectoriesFromRuntime(runtime, resolvedRoot, { cwd: workspacePath }) + : await listSkillDirectoriesFromLocalFs(resolvedRoot); + + return { + scope: scan.scope, + resolvedRoot, + directoryNames, + }; + }) + ); + + return listed.filter((entry): entry is ListedSkillRoot => entry != null); +} + +function collectSkillDescriptorCandidates( + runtime: Runtime, + listedRoots: ListedSkillRoot[], + options?: { invalidSkills?: AgentSkillIssue[] } +): Map { + const candidatesByName = new Map(); + + for (const listedRoot of listedRoots) { + for (const directoryNameRaw of listedRoot.directoryNames) { + const nameParsed = SkillNameSchema.safeParse(directoryNameRaw); + if (!nameParsed.success) { + log.warn( + `Skipping invalid skill directory name '${directoryNameRaw}' in ${listedRoot.resolvedRoot}` + ); + options?.invalidSkills?.push({ + directoryName: directoryNameRaw, + scope: listedRoot.scope, + displayPath: runtime.normalizePath(directoryNameRaw, listedRoot.resolvedRoot), + message: `Invalid skill directory name '${directoryNameRaw}'.`, + hint: "Rename the directory to kebab-case (lowercase letters/numbers/hyphens).", + }); + continue; + } + + const directoryName = nameParsed.data; + const candidate: SkillDescriptorCandidate = { + scope: listedRoot.scope, + directoryName, + skillDir: runtime.normalizePath(directoryName, listedRoot.resolvedRoot), + }; + + const existing = candidatesByName.get(directoryName); + if (existing) { + existing.push(candidate); + } else { + candidatesByName.set(directoryName, [candidate]); + } + } + } + + return candidatesByName; +} + +async function resolveBestSkillDescriptorCandidate( + runtime: Runtime, + candidates: SkillDescriptorCandidate[], + options?: { invalidSkills?: AgentSkillIssue[] } +): Promise { + for (const candidate of candidates) { + const descriptor = await readSkillDescriptorFromDir( + runtime, + candidate.skillDir, + candidate.directoryName, + candidate.scope, + options + ); + + if (descriptor) { + return descriptor; + } + } + + return null; +} + async function readSkillDescriptorFromDir( runtime: Runtime, skillDir: string, @@ -218,54 +344,25 @@ export async function discoverAgentSkills( const byName = new Map(); - // Project skills take precedence over global roots. - const scans: Array<{ scope: AgentSkillScope; root: string }> = [ - { scope: "project", root: roots.projectRoot }, - ...getGlobalSkillRoots(roots).map((root) => ({ scope: "global" as const, root })), - ]; + const scans = buildSkillRootScans(roots); + const listedRoots = await listSkillRoots(runtime, workspacePath, scans); + const candidatesByName = collectSkillDescriptorCandidates(runtime, listedRoots); - for (const scan of scans) { - let resolvedRoot: string; - try { - resolvedRoot = await runtime.resolvePath(scan.root); - } catch (err) { - log.warn(`Failed to resolve skills root ${scan.root}: ${formatError(err)}`); + const resolvedDescriptors = await Promise.all( + Array.from(candidatesByName.values()).map((candidates) => + resolveBestSkillDescriptorCandidate(runtime, candidates) + ) + ); + + for (const descriptor of resolvedDescriptors) { + if (!descriptor) { continue; } - const directoryNames = - runtime instanceof SSHRuntime - ? await listSkillDirectoriesFromRuntime(runtime, resolvedRoot, { cwd: workspacePath }) - : await listSkillDirectoriesFromLocalFs(resolvedRoot); - - for (const directoryNameRaw of directoryNames) { - const nameParsed = SkillNameSchema.safeParse(directoryNameRaw); - if (!nameParsed.success) { - log.warn(`Skipping invalid skill directory name '${directoryNameRaw}' in ${resolvedRoot}`); - continue; - } - - const directoryName = nameParsed.data; - - if (scan.scope === "global" && byName.has(directoryName)) { - continue; - } - - const skillDir = runtime.normalizePath(directoryName, resolvedRoot); - const descriptor = await readSkillDescriptorFromDir( - runtime, - skillDir, - directoryName, - scan.scope - ); - if (!descriptor) continue; - - // Precedence: project overwrites global. - byName.set(descriptor.name, descriptor); - } + byName.set(descriptor.name, descriptor); } - // Add built-in skills (lowest precedence - only if not overridden by project/global) + // Add built-in skills (lowest precedence - only if not overridden by project/global/universal) for (const builtIn of getBuiltInSkillDescriptors()) { if (!byName.has(builtIn.name)) { byName.set(builtIn.name, builtIn); @@ -294,64 +391,29 @@ export async function discoverAgentSkillsDiagnostics( const byName = new Map(); const invalidSkills: AgentSkillIssue[] = []; - // Project skills take precedence over global roots. - const scans: Array<{ scope: AgentSkillScope; root: string }> = [ - { scope: "project", root: roots.projectRoot }, - ...getGlobalSkillRoots(roots).map((root) => ({ scope: "global" as const, root })), - ]; + const scans = buildSkillRootScans(roots); + const listedRoots = await listSkillRoots(runtime, workspacePath, scans); + const candidatesByName = collectSkillDescriptorCandidates(runtime, listedRoots, { + invalidSkills, + }); - for (const scan of scans) { - let resolvedRoot: string; - try { - resolvedRoot = await runtime.resolvePath(scan.root); - } catch (err) { - log.warn(`Failed to resolve skills root ${scan.root}: ${formatError(err)}`); + const resolvedDescriptors = await Promise.all( + Array.from(candidatesByName.values()).map((candidates) => + resolveBestSkillDescriptorCandidate(runtime, candidates, { + invalidSkills, + }) + ) + ); + + for (const descriptor of resolvedDescriptors) { + if (!descriptor) { continue; } - const directoryNames = - runtime instanceof SSHRuntime - ? await listSkillDirectoriesFromRuntime(runtime, resolvedRoot, { cwd: workspacePath }) - : await listSkillDirectoriesFromLocalFs(resolvedRoot); - - for (const directoryNameRaw of directoryNames) { - const nameParsed = SkillNameSchema.safeParse(directoryNameRaw); - if (!nameParsed.success) { - log.warn(`Skipping invalid skill directory name '${directoryNameRaw}' in ${resolvedRoot}`); - invalidSkills.push({ - directoryName: directoryNameRaw, - scope: scan.scope, - displayPath: runtime.normalizePath(directoryNameRaw, resolvedRoot), - message: `Invalid skill directory name '${directoryNameRaw}'.`, - hint: "Rename the directory to kebab-case (lowercase letters/numbers/hyphens).", - }); - continue; - } - - const directoryName = nameParsed.data; - - if (scan.scope === "global" && byName.has(directoryName)) { - continue; - } - - const skillDir = runtime.normalizePath(directoryName, resolvedRoot); - const descriptor = await readSkillDescriptorFromDir( - runtime, - skillDir, - directoryName, - scan.scope, - { - invalidSkills, - } - ); - if (!descriptor) continue; - - // Precedence: project overwrites global. - byName.set(descriptor.name, descriptor); - } + byName.set(descriptor.name, descriptor); } - // Add built-in skills (lowest precedence - only if not overridden by project/global) + // Add built-in skills (lowest precedence - only if not overridden by project/global/universal) for (const builtIn of getBuiltInSkillDescriptors()) { if (!byName.has(builtIn.name)) { byName.set(builtIn.name, builtIn); @@ -440,30 +502,36 @@ export async function readAgentSkill( const roots = options?.roots ?? getDefaultAgentSkillsRoots(runtime, workspacePath); - // Project overrides all global roots. - const candidates: Array<{ scope: AgentSkillScope; root: string }> = [ - { scope: "project", root: roots.projectRoot }, - ...getGlobalSkillRoots(roots).map((root) => ({ scope: "global" as const, root })), - ]; + // Project overrides all global/universal roots. + const candidates = buildSkillRootScans(roots); - for (const candidate of candidates) { - let resolvedRoot: string; - try { - resolvedRoot = await runtime.resolvePath(candidate.root); - } catch { - continue; - } + const resolvedCandidates = await Promise.all( + candidates.map(async (candidate): Promise => { + let resolvedRoot: string; + try { + resolvedRoot = await runtime.resolvePath(candidate.root); + } catch { + return null; + } - const skillDir = runtime.normalizePath(name, resolvedRoot); + const skillDir = runtime.normalizePath(name, resolvedRoot); - try { - const stat = await runtime.stat(skillDir); - if (!stat.isDirectory) continue; + try { + const stat = await runtime.stat(skillDir); + if (!stat.isDirectory) { + return null; + } - return await readAgentSkillFromDir(runtime, skillDir, name, candidate.scope); - } catch { - continue; - } + return await readAgentSkillFromDir(runtime, skillDir, name, candidate.scope); + } catch { + return null; + } + }) + ); + + const resolved = resolvedCandidates.find((candidate) => candidate != null); + if (resolved) { + return resolved; } // Check built-in skills as fallback diff --git a/src/node/services/tools/agent_skill_read.ts b/src/node/services/tools/agent_skill_read.ts index 909babc3a5..b65e331587 100644 --- a/src/node/services/tools/agent_skill_read.ts +++ b/src/node/services/tools/agent_skill_read.ts @@ -46,7 +46,7 @@ function buildSkillReadDescription(config: ToolConfiguration): string { /** * Agent Skill read tool factory. - * Reads and validates a skill's SKILL.md from project-local or global skills roots. + * Reads and validates a skill's SKILL.md from project-local, global, or universal skills roots. */ export const createAgentSkillReadTool: ToolFactory = (config: ToolConfiguration) => { return tool({