diff --git a/README.md b/README.md index 1eca3e4..9314380 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,9 @@ Add skillflag to this project so the CLI can bundle and expose agent skills. 1. Install the skillflag library: npm install skillflag -2. Create a skill directory at skills//SKILL.md with a YAML - frontmatter (name, description) and markdown instructions for the agent. +2. Create a skill directory at skills//SKILL.md or + .agents/skills//SKILL.md with a YAML frontmatter + (name, description) and markdown instructions for the agent. 3. In the CLI entrypoint, intercept --skill and delegate to skillflag: @@ -130,7 +131,8 @@ In the wizard, select multiple entries with space, then confirm the matrix insta ## Add skillflag to your CLI 1. Add the library and ship your skill directory in the package. -2. Add a `skills//SKILL.md` in your repo. +2. Add a `skills//SKILL.md` or + `.agents/skills//SKILL.md` in your repo. 3. In your CLI entrypoint, intercept `--skill` and delegate to skillflag. ```bash diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 428ca4d..86fa683 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -13,6 +13,16 @@ skills/ scripts/ ``` +Portable repo-local agent skills are also supported: + +``` +.agents/ + skills/ + my-skill/ + SKILL.md + scripts/ +``` + Your `SKILL.md` must include frontmatter with `name` and `description` per the spec. Example: ```markdown @@ -28,11 +38,11 @@ Usage, scripts, references... ## 2) Make sure skills are bundled -Ensure the `skills/` directory is included in your published package. For npm: +Ensure the `skills/` or `.agents/skills/` directory is included in your published package. For npm: ```json { - "files": ["dist", "skills", "README.md", "LICENSE"] + "files": ["dist", "skills", ".agents/skills", "README.md", "LICENSE"] } ``` @@ -61,7 +71,7 @@ await maybeHandleSkillflag(process.argv, { }); ``` -`findSkillsRoot()` walks upward from the given file/dir until it finds a `skills/` directory, so you don't need to hardcode build offsets. If you prefer to be explicit, you can still pass a URL or path directly. +`findSkillsRoot()` walks upward from the given file/dir until it finds a `skills/` or `.agents/skills/` directory, so you don't need to hardcode build offsets. It prefers `skills/` when both exist. Use `findSkillsRoots()` if you intentionally ship both roots. If you prefer to be explicit, you can still pass a URL, path, or array of roots directly. ## 4) Try it locally diff --git a/skills/skillflag/SKILL.md b/skills/skillflag/SKILL.md index 4aa0076..6fea65c 100644 --- a/skills/skillflag/SKILL.md +++ b/skills/skillflag/SKILL.md @@ -75,7 +75,7 @@ skill-install --help - `--skill export ` streams a tar with a single top‑level `/` and `/SKILL.md`. - Export output MUST be deterministic: sorted entries, fixed `mtime = 0`, `uid/gid = 0`. - All errors go to stderr, exit code `1` on failure. -- Skills should be bundled under `skills//SKILL.md`. +- Skills should be bundled under `skills//SKILL.md` or `.agents/skills//SKILL.md`. ## Adding Skillflag to a Node CLI (library integration) @@ -121,9 +121,19 @@ skills/ templates/... ``` +Portable agent-skill layout is also supported: + +``` +.agents/ + skills/ + my-skill/ + SKILL.md + templates/... +``` + Package distribution: -- Ensure `skills/` is included in `package.json` `files` so it ships with the npm tarball. +- Ensure `skills/` or `.agents/skills/` is included in `package.json` `files` so it ships with the npm tarball. ## Installing without global install diff --git a/src/core/paths.ts b/src/core/paths.ts index 866b234..c9dc1ae 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -6,11 +6,15 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { SkillflagError } from "./errors.js"; +export type SkillsRootInput = URL | string; + export type SkillDir = { id: string; dir: string; }; +const PRODUCER_SKILLS_ROOTS = ["skills", path.join(".agents", "skills")]; + export function defaultSkillsRoot(): URL { const startDir = path.dirname(fileURLToPath(import.meta.url)); let current = startDir; @@ -27,17 +31,33 @@ export function defaultSkillsRoot(): URL { } } -export function resolveSkillsRoot(root: URL | string): string { +export function resolveSkillsRoot(root: SkillsRootInput): string { if (root instanceof URL) { - return fileURLToPath(root); + return path.resolve(fileURLToPath(root)); } if (root.startsWith("file:")) { - return fileURLToPath(new URL(root)); + return path.resolve(fileURLToPath(new URL(root))); } return path.resolve(root); } -function toPath(input: URL | string): string { +export function resolveSkillsRoots( + roots: SkillsRootInput | readonly SkillsRootInput[], +): string[] { + const inputs = Array.isArray(roots) ? roots : [roots]; + const seen = new Set(); + const resolved: string[] = []; + for (const input of inputs) { + const root = resolveSkillsRoot(input); + if (!seen.has(root)) { + seen.add(root); + resolved.push(root); + } + } + return resolved; +} + +function toPath(input: SkillsRootInput): string { if (input instanceof URL) { return fileURLToPath(input); } @@ -47,7 +67,18 @@ function toPath(input: URL | string): string { return input; } -export function findSkillsRoot(start: URL | string): URL { +function existingProducerRoots(dir: string): URL[] { + const roots: URL[] = []; + for (const rel of PRODUCER_SKILLS_ROOTS) { + const candidate = path.join(dir, rel); + if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + roots.push(pathToFileURL(candidate + path.sep)); + } + } + return roots; +} + +export function findSkillsRoots(start: SkillsRootInput): URL[] { let current = toPath(start); try { const stat = fs.statSync(current); @@ -59,20 +90,24 @@ export function findSkillsRoot(start: URL | string): URL { } while (true) { - const candidate = path.join(current, "skills"); - if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { - return pathToFileURL(candidate + path.sep); + const roots = existingProducerRoots(current); + if (roots.length > 0) { + return roots; } const parent = path.dirname(current); if (parent === current) { throw new SkillflagError( - "Could not find a skills/ directory. Pass skillsRoot explicitly.", + "Could not find a skills/ or .agents/skills/ directory. Pass skillsRoot explicitly.", ); } current = parent; } } +export function findSkillsRoot(start: SkillsRootInput): URL { + return findSkillsRoots(start)[0] as URL; +} + export function assertValidSkillId(id: string): void { if (!id || id === "." || id === "..") { throw new SkillflagError("Skill id is required."); diff --git a/src/index.ts b/src/index.ts index 82ff9a3..58e4bae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,4 +7,5 @@ export type { SkillflagDispatchOptions, SkillflagOptions, } from "./skillflag.js"; -export { findSkillsRoot } from "./core/paths.js"; +export { findSkillsRoot, findSkillsRoots } from "./core/paths.js"; +export type { SkillsRootInput } from "./core/paths.js"; diff --git a/src/skillflag.ts b/src/skillflag.ts index 13b52a3..1f80f0d 100644 --- a/src/skillflag.ts +++ b/src/skillflag.ts @@ -9,13 +9,15 @@ import { defaultSkillsRoot, resolveSkillDirFromRoots, resolveSkillsRoot, + resolveSkillsRoots, + type SkillsRootInput, } from "./core/paths.js"; import { showSkill } from "./core/show.js"; import { collectSkillEntries, createTarStream } from "./core/tar.js"; import { uniqueValues } from "./utils/collections.js"; export type SkillflagOptions = { - skillsRoot: URL | string; + skillsRoot: SkillsRootInput | readonly SkillsRootInput[]; stdin?: NodeJS.ReadableStream; stdout?: NodeJS.WritableStream; stderr?: NodeJS.WritableStream; @@ -271,13 +273,13 @@ export async function handleSkillflag( try { const action = parseSkillArgs(argv); - const skillsRoot = resolveSkillsRoot(opts.skillsRoot); const bundledRoot = resolveSkillsRoot(defaultSkillsRoot()); const includeBundled = opts.includeBundledSkill !== false; - const rootDirs = - includeBundled && bundledRoot !== skillsRoot - ? [skillsRoot, bundledRoot] - : [skillsRoot]; + const rootDirs = resolveSkillsRoots( + includeBundled + ? [...resolveSkillsRoots(opts.skillsRoot), bundledRoot] + : opts.skillsRoot, + ); if (action.kind === "install") { return await runInstallAction( diff --git a/test/integration/skillflag.test.ts b/test/integration/skillflag.test.ts index fc9be20..ded1b43 100644 --- a/test/integration/skillflag.test.ts +++ b/test/integration/skillflag.test.ts @@ -13,6 +13,7 @@ import { maybeHandleSkillflag, SKILLFLAG_HELP_TEXT, findSkillsRoot, + findSkillsRoots, } from "../../src/index.js"; import { collectSkillEntries, createTarStream } from "../../src/core/tar.js"; import { createCapture } from "../helpers/capture.js"; @@ -136,6 +137,91 @@ test("findSkillsRoot locates repo skills directory", () => { assert.ok(rootPath.endsWith(`${path.sep}skills${path.sep}`)); }); +test("findSkillsRoot locates portable .agents skills directory", async (t) => { + const repo = await makeTempDir("skillflag-agents-root-"); + t.after(async () => { + await repo.cleanup(); + }); + + await writeFile( + repo.dir, + ".agents/skills/portable-skill/SKILL.md", + "---\nname: portable-skill\ndescription: Portable skill\n---\n", + ); + await writeFile(repo.dir, "dist/cli.js", ""); + + const skillsRoot = findSkillsRoot(path.join(repo.dir, "dist/cli.js")); + assert.equal( + path.resolve(fileURLToPath(skillsRoot)), + path.join(repo.dir, ".agents/skills"), + ); +}); + +test("findSkillsRoots returns skills and portable .agents skills", async (t) => { + const repo = await makeTempDir("skillflag-multi-root-"); + t.after(async () => { + await repo.cleanup(); + }); + + await writeFile( + repo.dir, + "skills/tool-skill/SKILL.md", + "---\nname: tool-skill\ndescription: Tool skill\n---\n", + ); + await writeFile( + repo.dir, + ".agents/skills/portable-skill/SKILL.md", + "---\nname: portable-skill\ndescription: Portable skill\n---\n", + ); + await writeFile(repo.dir, "dist/cli.js", ""); + + const roots = findSkillsRoots(path.join(repo.dir, "dist/cli.js")).map((url) => + path.resolve(fileURLToPath(url)), + ); + assert.deepEqual(roots, [ + path.join(repo.dir, "skills"), + path.join(repo.dir, ".agents/skills"), + ]); +}); + +test("--skill list accepts multiple producer roots", async (t) => { + const first = await makeTempDir("skillflag-root-a-"); + const second = await makeTempDir("skillflag-root-b-"); + t.after(async () => { + await first.cleanup(); + await second.cleanup(); + }); + + await writeFile( + first.dir, + "alpha/SKILL.md", + "---\nname: alpha\ndescription: Alpha from first root\n---\n", + ); + await writeFile( + second.dir, + "gamma/SKILL.md", + "---\nname: gamma\ndescription: Gamma from second root\n---\n", + ); + + const stdout = createCapture(); + const stderr = createCapture(); + const exitCode = await handleSkillflag(["node", "cli", "--skill", "list"], { + skillsRoot: [first.dir, second.dir], + stdout: stdout.stream, + stderr: stderr.stream, + includeBundledSkill: false, + }); + + assert.equal(exitCode, 0); + assert.equal(stderr.text(), ""); + assert.equal( + stdout.text(), + ["alpha\tAlpha from first root", "gamma\tGamma from second root", ""].join( + "\n", + ), + ); +}); + test("bundled skill is discoverable and exportable", async () => { await fs.access(path.join(bundledSkillsRoot, "skillflag/SKILL.md"));