Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-id>/SKILL.md with a YAML
frontmatter (name, description) and markdown instructions for the agent.
2. Create a skill directory at skills/<skill-id>/SKILL.md or
.agents/skills/<skill-id>/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:

Expand Down Expand Up @@ -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-id>/SKILL.md` in your repo.
2. Add a `skills/<skill-id>/SKILL.md` or
`.agents/skills/<skill-id>/SKILL.md` in your repo.
3. In your CLI entrypoint, intercept `--skill` and delegate to skillflag.

```bash
Expand Down
16 changes: 13 additions & 3 deletions docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
}
```

Expand Down Expand Up @@ -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

Expand Down
14 changes: 12 additions & 2 deletions skills/skillflag/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ skill-install --help
- `--skill export <id>` streams a tar with a single top‑level `<id>/` and `<id>/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/<id>/SKILL.md`.
- Skills should be bundled under `skills/<id>/SKILL.md` or `.agents/skills/<id>/SKILL.md`.

## Adding Skillflag to a Node CLI (library integration)

Expand Down Expand Up @@ -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

Expand Down
53 changes: 44 additions & 9 deletions src/core/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string>();
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);
}
Expand All @@ -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);
Expand All @@ -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.");
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
14 changes: 8 additions & 6 deletions src/skillflag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
86 changes: 86 additions & 0 deletions test/integration/skillflag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"));

Expand Down
Loading