diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx index 87f8c24..2655381 100644 --- a/apps/web/src/app/docs/page.tsx +++ b/apps/web/src/app/docs/page.tsx @@ -232,7 +232,8 @@ export default function DocsPage() { rules/, for skills{" "} .agents/skills then{" "} skills/ then a root{" "} - SKILL.md fallback. + SKILL.md fallback, then root{" "} + <name>/SKILL.md directories. diff --git a/packages/cli/README.md b/packages/cli/README.md index 5c8106c..14a6ae4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -72,7 +72,7 @@ Source path resolution is additive and priority-ordered: - Agents: `.agents/agents` -> `agents` - Commands: `.agents/commands` -> `commands` -> `prompts` -> provider fallbacks `.github/prompts` + `.gemini/commands` - Rules: `.agents/rules` -> `rules` -- Skills: `.agents/skills` -> `skills` -> root `SKILL.md` fallback +- Skills: `.agents/skills` -> `skills` -> root `SKILL.md` -> root `/SKILL.md` fallback - MCP: `.agents/mcp.json` -> `mcp.json` Aggregate `agentloom add ` can import command/skill/MCP-only repositories even when no `agents/` directory exists. diff --git a/packages/cli/package.json b/packages/cli/package.json index 30cf751..a5ce4cd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "agentloom", - "version": "0.1.10", + "version": "0.1.11", "description": "Unified agent and MCP sync CLI for multi-provider AI tooling", "type": "module", "bin": { diff --git a/packages/cli/src/core/copy.ts b/packages/cli/src/core/copy.ts index a5131f7..5480df3 100644 --- a/packages/cli/src/core/copy.ts +++ b/packages/cli/src/core/copy.ts @@ -86,7 +86,7 @@ Source discovery: agents: .agents/agents -> agents commands: .agents/commands -> commands -> prompts -> (.github/prompts + .gemini/commands fallback) rules: .agents/rules -> rules - skills: .agents/skills -> skills -> root SKILL.md + skills: .agents/skills -> skills -> root SKILL.md -> root /SKILL.md Usage: agentloom add [options] diff --git a/packages/cli/src/core/importer.ts b/packages/cli/src/core/importer.ts index f266365..50236e7 100644 --- a/packages/cli/src/core/importer.ts +++ b/packages/cli/src/core/importer.ts @@ -293,7 +293,7 @@ export async function importSource( sourceSkillsDirs.length === 0 ) { throw new Error( - `No source skills directory found under ${prepared.importRoot} (expected .agents/skills/, skills/, or root SKILL.md, including plugin sources declared in .claude-plugin/marketplace.json).`, + `No source skills directory found under ${prepared.importRoot} (expected .agents/skills/, skills/, root SKILL.md, or root /SKILL.md directories, including plugin sources declared in .claude-plugin/marketplace.json).`, ); } if ( @@ -313,7 +313,7 @@ export async function importSource( Object.keys(sourceMcp?.mcpServers ?? {}).length === 0 ) { throw new Error( - `No importable entities found in source "${sourceLocation}".\nExpected agents/, .agents/agents/, .github/agents/, commands/, .agents/commands/, prompts/, .gemini/commands/, .github/prompts/, mcp.json/.agents/mcp.json, rules/.agents/rules/, skills/, .agents/skills/, root SKILL.md, or plugin sources from .claude-plugin/marketplace.json.`, + `No importable entities found in source "${sourceLocation}".\nExpected agents/, .agents/agents/, .github/agents/, commands/, .agents/commands/, prompts/, .gemini/commands/, .github/prompts/, mcp.json/.agents/mcp.json, rules/.agents/rules/, skills/, .agents/skills/, root SKILL.md, root /SKILL.md directories, or plugin sources from .claude-plugin/marketplace.json.`, ); } diff --git a/packages/cli/src/core/sources.ts b/packages/cli/src/core/sources.ts index 8422477..ff69a83 100644 --- a/packages/cli/src/core/sources.ts +++ b/packages/cli/src/core/sources.ts @@ -313,6 +313,10 @@ function discoverSourceSkillsDirsForRoot(importRoot: string): string[] { return [importRoot]; } + if (hasImmediateRootSkillDirs(importRoot)) { + return [importRoot]; + } + return []; } @@ -334,6 +338,21 @@ function dedupePaths(paths: string[]): string[] { return [...new Set(paths)]; } +function hasImmediateRootSkillDirs(importRoot: string): boolean { + for (const entry of fs.readdirSync(importRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const skillFile = path.join(importRoot, entry.name, "SKILL.md"); + if (fs.existsSync(skillFile) && fs.statSync(skillFile).isFile()) { + return true; + } + } + + return false; +} + function isPathWithinRoot(rootPath: string, targetPath: string): boolean { const relative = path.relative(rootPath, targetPath); return ( diff --git a/packages/cli/tests/unit/copy.test.ts b/packages/cli/tests/unit/copy.test.ts index 0498658..ccf3382 100644 --- a/packages/cli/tests/unit/copy.test.ts +++ b/packages/cli/tests/unit/copy.test.ts @@ -70,6 +70,9 @@ describe("copy helpers", () => { it("includes targeted add import help", () => { const help = getAddHelpText(); + expect(help).toContain( + "skills: .agents/skills -> skills -> root SKILL.md -> root /SKILL.md", + ); expect(help).toContain("--agents "); expect(help).toContain("--rule "); expect(help).toContain("--rules "); diff --git a/packages/cli/tests/unit/importer-conflict.test.ts b/packages/cli/tests/unit/importer-conflict.test.ts index 4ae3a50..13ba3de 100644 --- a/packages/cli/tests/unit/importer-conflict.test.ts +++ b/packages/cli/tests/unit/importer-conflict.test.ts @@ -1153,6 +1153,47 @@ name: visual-explainer description: Explain visuals --- +Skill body. +`, + ); + + const paths = buildScopePaths(workspaceRoot, "local"); + const summary = await importSource({ + source: sourceRoot, + paths, + yes: true, + nonInteractive: true, + importAgents: false, + importCommands: false, + importMcp: false, + importSkills: true, + requireSkills: true, + skillSelectors: ["visual-explainer"], + }); + + expect(summary.importedSkills).toEqual(["visual-explainer"]); + expect( + fs.existsSync(path.join(paths.skillsDir, "visual-explainer", "SKILL.md")), + ).toBe(true); + }); + + it("recognizes root /SKILL.md directories and imports selected skills natively", async () => { + const sourceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-source-"), + ); + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + tempDirs.push(sourceRoot, workspaceRoot); + + ensureDir(path.join(sourceRoot, "visual-explainer")); + writeTextAtomic( + path.join(sourceRoot, "visual-explainer", "SKILL.md"), + `--- +name: visual-explainer +description: Explain visuals +--- + Skill body. `, ); diff --git a/packages/cli/tests/unit/sources.test.ts b/packages/cli/tests/unit/sources.test.ts index e6a410b..998fd85 100644 --- a/packages/cli/tests/unit/sources.test.ts +++ b/packages/cli/tests/unit/sources.test.ts @@ -148,6 +148,20 @@ describe("source parsing and revision", () => { expect(discoverSourceSkillsDir(root)).toBe(path.join(root, "skills")); }); + it("falls back to root /SKILL.md directories when canonical skill paths are absent", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-sources-")); + tempDirs.push(root); + + ensureDir(path.join(root, "reviewer")); + writeTextAtomic(path.join(root, "reviewer", "SKILL.md"), "# reviewer\n"); + + expect(discoverSourceSkillsDirs(root)).toEqual([root]); + expect(discoverSourceSkillsDir(root)).toBe(root); + + ensureDir(path.join(root, "skills")); + expect(discoverSourceSkillsDirs(root)).toEqual([path.join(root, "skills")]); + }); + it("discovers plugin roots from .claude-plugin marketplace metadata", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-sources-")); tempDirs.push(root);