/** * skillsFromDir — load Skills that were AUTHORED AS FILES. * * The just-in-time half of skills already ships: `defineSkill` produces an * Injection whose `description` is all the model sees until it calls * `read_skill()`, at which point the body enters the context. What did not * ship was the other half — a way to keep those bodies where prose belongs, in * files, next to the code they describe, reviewable in a diff. * * This is that loader, and nothing more. It reads a directory of `SKILL.md` * files and hands each one to `defineSkill`. The progressive disclosure, the * activation tool, the slot routing — all unchanged, all upstream of here. * * skills/ * billing/SKILL.md * refunds/SKILL.md * * --- * name: billing * description: Use for refunds, charges and billing questions. * --- * When handling billing: confirm identity first, then … * * const skills = await skillsFromDir('./skills'); * const agent = Agent.create({ provider, model }).skills({ list: () => skills }).build(); * * The frontmatter is the disclosure stub (`name` + `description` — what the * model reads when deciding), and everything after the closing fence is the * body (what it reads after deciding). That is the same file convention Claude * Code made familiar, so a skill folder is portable between the two. * * ── What a SKILL.md can carry (9.36.0: the whole runbook) ──────────────────── * A runbook you already have on disk is three things — what to do (prose), * what to do it WITH (tools), and in what order (steps). This door used to * carry the first and drop the other two, so the one thing the library exists * to say ("extract the workflow you already have") arrived two-thirds empty. * All three now cross: * * --- * name: billing * description: Use for refunds, charges and billing questions. * tools: lookup_order, issue_refund * steps: * - lookup_order: look up the order before touching money * - issue_refund: refund only what the lookup found * onSkip: hold * --- * When handling billing: confirm identity first, then … * * const skills = await skillsFromDir('./skills', { * tools: [lookupOrder, issueRefund], // the real Tools, from YOUR code * }); * * The remaining limits, and why each one is a decision rather than an * oversight: * * - **the file names a tool; it never defines one.** A tool is code with an * `execute`, and a markdown file has none. `tools:` is a list of NAMES, * resolved against the `tools` registry the CALLER passes — so the file * can only ever pick from capabilities you already handed in, and reading a * directory can never introduce one. See "why this is not a code-execution * vector" below. * - **a name the registry does not carry is refused BY NAME at load** — * never a skill that quietly loads without the tool it asked for, because * that skill runs, sounds confident, and cannot do the job. * - **no per-step `produces` / `consumes`** — the artifact vocabularies * (9.25.0) are a build-time contract between skills, not runbook prose; * declare them with `defineSkill` where the rest of the agent is declared. * - **no `autoActivate`** — a directory does not decide the agent's tool * posture. Say it once on the agent with `.toolsFromActiveSkill()` * (9.36.0), which scopes every skill's tools to that skill's activation. * Without it, a loaded skill's tools are on the wire from iteration 1, the * same as any hand-written `defineSkill({ tools })`. * - **no per-file `surfaceMode`, `cache` or `refreshPolicy`** — `surfaceMode` * is settable for the WHOLE directory via `opts`, all of them or none; the * others take `defineSkill`'s defaults. * - unknown frontmatter keys are still IGNORED, not rejected, so a file * carrying another tool's metadata still loads here. `name`, * `description`, `tools`, `steps`, `onSkip` and `routes` are the KNOWN * keys — a file that used one of those six for something else is the one * case a release can change, and it changes it loudly. * * ── The routing, and the door that reads it (9.43.0) ───────────────────────── * A runbook says what to do, what to do it with, in what order — and where the * work goes NEXT. That last part is the graph, and until now it had to be * hand-wired in code even when the files said it plainly. `routes:` closes it: * * --- * name: billing * tools: lookup_order, issue_refund * routes: * - escalation: on issue_refund status=denied * - receipts: on issue_refund * --- * * const runbook = await runbookFromDir('./skills', { tools: [...] }); * const graph = skillGraph({ ...runbook, start: 'billing' }); * * TWO DOORS, one truth: `skillsFromDir` returns skills and REFUSES a file that * declares `routes:` (a door that dropped the routing would hand back a graph * you believed was declared on disk); {@link runbookFromDir} returns * `{ skills, steps }`. Same law as `tools:` — the file PICKS (a route names a * skill id this directory declares; an unknown id is refused at load, by name, * listing what is available), it never DEFINES. A guard is one of the two DATA * conditions a route already has (`on `, `status=`), and * nothing else: a `when` predicate is CODE, no file can carry code, and that * conditional stays in your source. See `skillsFromDirRoutes.ts` for the whole * grammar and every refusal. * * A worked example feeding this into a graph: * `examples/features/47-skills-from-dir-graph.ts`. * * ── Why authorship is decided here, at load time ───────────────────────────── * A Skill body is *instructions to a model*. Where it came from is therefore a * security property, not a convenience: content fetched at run time from * somewhere else is content someone else can change after you reviewed it. * This loader accepts a local directory and nothing else — a URL is refused BY * NAME rather than fetched — because "these files are mine" is a claim you can * only make about a path on your own disk at build time. Each file is read * ONCE, here; a later edit does not reach a run already in flight. * * ── Why carrying tools is not a code-execution vector ─────────────────────── * Nothing in a SKILL.md is evaluated, imported, required or resolved as a * path. `tools:` is a list of strings, and the only thing a string can do here * is MATCH — against `tool.schema.name` in a registry the caller constructed in * their own source, from their own imports. A hostile file can therefore ask * for a tool you already gave the agent (and it would have to guess the name); * it can never name a module, widen the agent's capabilities, or cause one byte * of new code to run. The set of things this directory can do is a SUBSET of * what the calling file already decided to do, which is the same property the * local-path rule above buys for bodies. * * Node-only. `node:fs/promises` and `node:path` are imported lazily inside the * call, the same gating `lib/tool-lint/cli.ts` uses: this module is reachable * from the `agentfootprint/context` barrel, and a TOP-LEVEL node:fs * import detonates a browser bundle at module-eval even when nothing calls it. */ import type { Injection } from './types.js'; import type { Tool } from '../../core/tools.js'; import { type SurfaceMode } from './factories/defineSkill.js'; import type { SkillGraphStep } from './skillGraph.js'; export interface SkillsFromDirOptions { /** * Where a loaded skill's body lands once activated. Defaults to * `defineSkill`'s own default, `'auto'`. See {@link SurfaceMode}. */ readonly surfaceMode?: SurfaceMode; /** * The tools a file may NAME (9.36.0) — the resolution registry for every * `tools:` declaration in the directory, matched by `tool.schema.name`. * * This is the half a markdown file cannot supply. The file picks; you decide * what there is to pick FROM, in your own source, from your own imports. A * declared name that is not in here is refused at load, naming the file, the * name, and what this registry does carry. * * Pass every tool the directory might use; a tool nothing names is simply * unused (no skill gets it, and no error). Omit the option entirely on a * prose-only directory — a file that declares `tools:` with no registry * passed is refused rather than loaded without them. */ readonly tools?: readonly Tool[]; } /** * A whole runbook directory, read as the two things a graph is made of * (9.43.0): the skills, and the edges between them. * * Spreads straight into the graph — the field names are the graph's own, so * nothing has to be translated at the call site: * * const runbook = await runbookFromDir('./skills', { tools }); * const graph = skillGraph({ ...runbook, start: 'triage' }); */ export interface DirRunbook { /** Every `SKILL.md` under the directory, as Skill Injections — byte-identical * to what `skillsFromDir` returns for the same directory. */ readonly skills: readonly Injection[]; /** * The declared edges, ready for `skillGraph({ steps })`. In skill-name order, * then file order, so a graph built from a directory is stable. * * NAME NOTE, because two different things are spelled `steps` in this * library: a file's own `steps:` key is that SKILL's tool sequence (which * rides on the skill, unchanged); these `steps` are the GRAPH's edges, read * from each file's `routes:` key. They never meet. */ readonly steps: readonly SkillGraphStep[]; } /** * Load every `SKILL.md` under `dir` as a Skill Injection. * * Two layouts are accepted, and they can be mixed: * - `dir//SKILL.md` — one folder per skill (the portable layout, * and the one to prefer: the folder can hold the skill's other assets). * - `dir/SKILL.md` — the directory IS one skill. * * The returned array is sorted by skill name, so a chart built from it is * stable regardless of the order the filesystem happened to hand back. * * @param dir - A local filesystem path. A URL (or any `scheme://` string, or a * UNC network path) is refused by name — see the module header for why. * @param opts - Applied uniformly to every loaded skill. * * @throws when `dir` is not a local path, does not exist, is not a directory, * or contains no `SKILL.md` at all; when a file's frontmatter is malformed * (the message names the file); when two files claim the same skill name * (the message names both); or when a file names a tool the `tools` registry * does not carry, or sequences a tool the file itself did not declare. */ export declare function skillsFromDir(dir: string, opts?: SkillsFromDirOptions): Promise; /** * Load a directory as a whole RUNBOOK (9.43.0) — the skills AND the edges * between them. * * `skillsFromDir` reads what one skill is; this reads what the skills are TO * EACH OTHER, from each file's `routes:` key, and hands back both halves ready * for `skillGraph({ ...runbook, start })`. Everything else is identical: same * layouts, same frontmatter, same tool registry, same refusals. * * The routing a file may carry is deliberately small, and the boundary is a * security property rather than a limitation — see `skillsFromDirRoutes.ts`: * a route NAMES a skill this directory declares (an unknown id is refused at * load, by name, listing what is available — never a half-graph), and a guard * is one of the two DATA conditions a route already has (`on `, * `status=`). A `when` predicate is code, so no file can express one; * that conditional stays in your source, where `skillGraph({ steps })` takes it. * * @param dir - A local filesystem path. Same rule as `skillsFromDir`. * @param opts - Applied uniformly to every loaded skill. * * @throws everything `skillsFromDir` throws, plus: a route to an id no * `SKILL.md` in the directory declares (the message lists what is * available), a guard the grammar cannot express (the message quotes the * whole grammar), a guard naming a tool the file itself does not declare, * and a file routing to the same skill twice. */ export declare function runbookFromDir(dir: string, opts?: SkillsFromDirOptions): Promise;