From 046a3ec524ebc163344dbb9648de43cffbf1cf93 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Mon, 13 Jul 2026 20:10:45 -0400 Subject: [PATCH] feat(skills): per-skill agent targeting via agents/excludeAgents frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills can now whitelist (agents:) or blacklist (excludeAgents:) the agent mirror directories they emit to. Agent IDs derive from each manifest mirror path's first segment (.claude/skills → claude, .agents/skills → agents), so a claude-only skill is agents: [claude]. Unknown IDs fail with the new AICTX_SKILL_AGENT_UNKNOWN error. Orphan detection is plan-aware so build --remove-orphans deletes mirrors excluded by a newly-added filter. skills create gains --agents; skills list surfaces the filter. Touched files were run through prettier (repo-declared style), which accounts for the formatting-only hunks. Co-Authored-By: Claude Fable 5 --- .changeset/skill-agent-targeting.md | 13 ++ docs/cli-contract.md | 3 +- docs/skills-guide.md | 20 +++ packages/cli/src/commands/skills/create.ts | 23 ++- packages/cli/src/commands/skills/index.ts | 37 +++- packages/cli/src/commands/skills/list.ts | 19 +- packages/core/src/engine.ts | 150 ++++++++++++---- packages/core/src/errors.ts | 9 +- packages/core/src/skills.ts | 170 +++++++++++++++--- packages/core/src/types.ts | 4 + packages/core/test/engine-skills.test.ts | 54 +++++- packages/core/test/skills-frontmatter.test.ts | 74 ++++++-- packages/core/test/skills-orphans.test.ts | 39 +++- packages/core/test/skills-plan.test.ts | 71 +++++++- .../references/authoring-skills.md | 39 ++-- 15 files changed, 586 insertions(+), 139 deletions(-) create mode 100644 .changeset/skill-agent-targeting.md diff --git a/.changeset/skill-agent-targeting.md b/.changeset/skill-agent-targeting.md new file mode 100644 index 0000000..6d48801 --- /dev/null +++ b/.changeset/skill-agent-targeting.md @@ -0,0 +1,13 @@ +--- +"@timothycrooker/ai-context-core": minor +"@timothycrooker/ai-context-cli": minor +"@timothycrooker/ai-context-templates": patch +--- + +Per-skill agent targeting — restrict a skill to specific agents' mirror directories. + +Skills can now declare `agents:` (whitelist) or `excludeAgents:` (blacklist, mutually exclusive) in SKILL.md frontmatter. Agent IDs derive from each `manifest.skills.mirrors` path's first segment minus the leading dot (`.claude/skills` → `claude`, `.agents/skills` → `agents`), so a claude-only skill is simply `agents: [claude]` — it emits to `.claude/skills/` but stays invisible to Codex and other consumers of `.agents/skills/`. + +- Unknown agent IDs fail the build with the new `AICTX_SKILL_AGENT_UNKNOWN` error. +- Orphan detection is now plan-aware: adding a filter to an existing skill and running `ai-context build --remove-orphans` deletes the now-excluded mirrors; `doctor` flags them. +- `ai-context skills create` gains a repeatable `--agents ` flag; `skills list` shows the filter and includes `agents`/`excludeAgents` in `--json` output. diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 394c868..01322ed 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -72,7 +72,7 @@ - Purpose: list discovered skills with their mirror status. - Exit codes: `0` on success, `1` on error. - Default output: human-readable text — one skill per block with `name`, `description`, `scope` tag, `source` path, and mirror states. -- `--json` flag: emits valid JSON with shape `{skills: [{name, description, scope, source, mirrors: [{path, state}]}]}`. `state` is one of `symlink | copy | missing`. +- `--json` flag: emits valid JSON with shape `{skills: [{name, description, scope, agents, excludeAgents, source, mirrors: [{path, state}]}]}`. `state` is one of `symlink | copy | missing`. `agents`/`excludeAgents` are `null` when the skill has no agent filter. - When the manifest has no `skills` block: prints `No skills configured (manifest.skills absent).` (or `{skills: []}` with `--json`). ### `ai-context skills create ` @@ -82,6 +82,7 @@ - Flags: - `--description `: sets the skill description (defaults to a placeholder if omitted). - `--scope `: repeatable; emits mirrors to that scope's mirror locations instead of root. Use `*` for all scopes. + - `--agents `: repeatable; whitelists agent mirror IDs (e.g. `claude`) so the skill emits only to those agents' mirror directories. - `--with-references`: scaffolds `references/example.md` alongside `SKILL.md`. - `--with-scripts`: scaffolds `scripts/example.sh` with exec bit set. - Exit 1 on: invalid name, missing manifest, manifest without `skills` block, or skill source already exists. diff --git a/docs/skills-guide.md b/docs/skills-guide.md index eb4bb01..35d1cb2 100644 --- a/docs/skills-guide.md +++ b/docs/skills-guide.md @@ -46,6 +46,26 @@ scope: [api] The skill emits to `apps/api/.agents/skills/api-conventions` and `apps/api/.claude/skills/api-conventions` instead of repo root. Use `scope: ["*"]` to emit at root AND every scope. +## Agent targeting: claude-only (or codex-only) skills + +By default a skill mirrors to every location in `manifest.skills.mirrors`. To restrict a skill to specific agents, add `agents:` (whitelist) or `excludeAgents:` (blacklist) to its frontmatter — the two are mutually exclusive: + +```yaml +--- +name: dispatching-subagents +description: How to delegate work to subagent executors +agents: [claude] # only .claude/skills/ — hidden from codex & friends +--- +``` + +```yaml +excludeAgents: [claude] # everywhere EXCEPT .claude/skills/ +``` + +Agent IDs derive from each mirror path's first segment with the leading dot stripped: `.claude/skills` → `claude`, `.agents/skills` → `agents`, a future `.cursor/skills` → `cursor`. Referencing an ID that no manifest mirror produces fails the build with `AICTX_SKILL_AGENT_UNKNOWN`. + +The filter composes with `scope:` — it restricts which mirror directories are used at every emission root. When you add a filter to an existing skill, run `ai-context build --remove-orphans` to delete the now-excluded mirror symlinks; plain `build` leaves them and `doctor` reports them as orphans. + ## Windows users Symlinks need Developer Mode enabled (Settings → Update & Security → For developers) and `git config core.symlinks true`. Without them, the kit falls back to copying skill content with a `_generated:` banner. `ai-context doctor` reports the fallback. diff --git a/packages/cli/src/commands/skills/create.ts b/packages/cli/src/commands/skills/create.ts index 55ccb41..091f617 100644 --- a/packages/cli/src/commands/skills/create.ts +++ b/packages/cli/src/commands/skills/create.ts @@ -6,6 +6,7 @@ import { buildAll, formatContextError } from "@timothycrooker/ai-context-core"; interface CreateOptions { description: string; scope: string[]; + agents: string[]; withReferences: boolean; withScripts: boolean; } @@ -16,7 +17,7 @@ export function runSkillsCreate(name: string, opts: CreateOptions): void { try { if (!SKILL_NAME_PATTERN.test(name) || name.length > 64) { console.error( - `error: invalid skill name '${name}'. Use lowercase letters, digits, and hyphens only (max 64 chars, no leading/trailing/consecutive hyphens).` + `error: invalid skill name '${name}'. Use lowercase letters, digits, and hyphens only (max 64 chars, no leading/trailing/consecutive hyphens).`, ); process.exit(1); } @@ -24,20 +25,24 @@ export function runSkillsCreate(name: string, opts: CreateOptions): void { const cwd = process.cwd(); const manifestPath = path.join(cwd, ".ai/context/manifest.json"); if (!fs.existsSync(manifestPath)) { - console.error("error: .ai/context/manifest.json not found. Run `ai-context init` first."); + console.error( + "error: .ai/context/manifest.json not found. Run `ai-context init` first.", + ); process.exit(1); } const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); if (!manifest.skills) { console.error( - "error: manifest.skills is not configured. Run `ai-context init --upgrade` to enable the skills subsystem." + "error: manifest.skills is not configured. Run `ai-context init --upgrade` to enable the skills subsystem.", ); process.exit(1); } const sourceDir = path.join(cwd, manifest.skills.source, name); if (fs.existsSync(sourceDir)) { - console.error(`error: skill source already exists at ${path.relative(cwd, sourceDir)}`); + console.error( + `error: skill source already exists at ${path.relative(cwd, sourceDir)}`, + ); process.exit(1); } @@ -48,6 +53,10 @@ export function runSkillsCreate(name: string, opts: CreateOptions): void { opts.scope.length === 0 ? "" : `scope: [${opts.scope.map((s) => `"${s.replace(/"/g, '\\"')}"`).join(", ")}]\n`; + const agentsYaml = + opts.agents.length === 0 + ? "" + : `agents: [${opts.agents.map((a) => `"${a.replace(/"/g, '\\"')}"`).join(", ")}]\n`; const rawDescription = opts.description.length > 0 ? opts.description : `Describe ${name}`; // Quote description as a YAML double-quoted string so colons, newlines, brackets are safe. @@ -59,7 +68,7 @@ export function runSkillsCreate(name: string, opts: CreateOptions): void { const skillMd = `--- name: ${name} description: "${descriptionEscaped}" -${scopeYaml}--- +${scopeYaml}${agentsYaml}--- # ${name} @@ -73,7 +82,7 @@ Replace this section with the skill's instructions. Keep it concise — under ~5 fs.writeFileSync( path.join(sourceDir, "references/example.md"), `# Reference: example\n\nLong-form supporting content for ${name}. Reference this file from SKILL.md.\n`, - "utf8" + "utf8", ); } if (opts.withScripts) { @@ -81,7 +90,7 @@ Replace this section with the skill's instructions. Keep it concise — under ~5 fs.writeFileSync( path.join(sourceDir, "scripts/example.sh"), `#!/usr/bin/env bash\nset -euo pipefail\n\necho "Skill ${name}: example script"\n`, - "utf8" + "utf8", ); fs.chmodSync(path.join(sourceDir, "scripts/example.sh"), 0o755); } diff --git a/packages/cli/src/commands/skills/index.ts b/packages/cli/src/commands/skills/index.ts index 95231f8..a8d25ce 100644 --- a/packages/cli/src/commands/skills/index.ts +++ b/packages/cli/src/commands/skills/index.ts @@ -9,7 +9,9 @@ export function registerSkillsCommand(program: Command): void { .command("list") .description("List discovered skills with mirror status") .option("--json", "Emit JSON output", false) - .action((opts: { json: boolean }) => runSkillsList({ json: Boolean(opts.json) })); + .action((opts: { json: boolean }) => + runSkillsList({ json: Boolean(opts.json) }), + ); skills .command("create ") @@ -19,16 +21,33 @@ export function registerSkillsCommand(program: Command): void { "--scope ", "Limit emission to this scope (repeatable); omit for root-only", (value: string, prev: string[]) => [...prev, value], - [] as string[] + [] as string[], + ) + .option( + "--agents ", + "Emit only to this agent's mirror, e.g. claude (repeatable); omit for all agents", + (value: string, prev: string[]) => [...prev, value], + [] as string[], ) .option("--with-references", "Scaffold a references/ directory", false) .option("--with-scripts", "Scaffold a scripts/ directory", false) - .action((name: string, opts: { description: string; scope: string[]; withReferences: boolean; withScripts: boolean }) => - runSkillsCreate(name, { - description: opts.description, - scope: opts.scope, - withReferences: Boolean(opts.withReferences), - withScripts: Boolean(opts.withScripts), - }) + .action( + ( + name: string, + opts: { + description: string; + scope: string[]; + agents: string[]; + withReferences: boolean; + withScripts: boolean; + }, + ) => + runSkillsCreate(name, { + description: opts.description, + scope: opts.scope, + agents: opts.agents, + withReferences: Boolean(opts.withReferences), + withScripts: Boolean(opts.withScripts), + }), ); } diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts index a14f073..4fcbc8f 100644 --- a/packages/cli/src/commands/skills/list.ts +++ b/packages/cli/src/commands/skills/list.ts @@ -32,12 +32,15 @@ export function runSkillsList(opts: ListOptions): void { const skillPlans = plans.filter((p) => p.source === s.dir); const mirrorStatus = skillPlans.map((p) => { const rel = path.relative(cwd, p.mirror); - if (!fs.existsSync(p.mirror)) return { path: rel, state: "missing" as const }; + if (!fs.existsSync(p.mirror)) + return { path: rel, state: "missing" as const }; try { const stat = fs.lstatSync(p.mirror); return { path: rel, - state: stat.isSymbolicLink() ? ("symlink" as const) : ("copy" as const), + state: stat.isSymbolicLink() + ? ("symlink" as const) + : ("copy" as const), }; } catch { // Could not read the entry; surface as conflict so docs match impl. @@ -48,6 +51,8 @@ export function runSkillsList(opts: ListOptions): void { name: s.name, description: s.frontmatter.description, scope: s.frontmatter.scope ?? [], + agents: s.frontmatter.agents ?? null, + excludeAgents: s.frontmatter.excludeAgents ?? null, source: path.relative(cwd, s.dir), mirrors: mirrorStatus, }; @@ -59,8 +64,14 @@ export function runSkillsList(opts: ListOptions): void { } for (const skill of skillRows) { - const scopeTag = skill.scope.length === 0 ? "[root]" : `[${skill.scope.join(",")}]`; - console.log(`${skill.name} ${scopeTag}`); + const scopeTag = + skill.scope.length === 0 ? "[root]" : `[${skill.scope.join(",")}]`; + const agentTag = skill.agents + ? ` [agents: ${skill.agents.join(",")}]` + : skill.excludeAgents + ? ` [agents: not ${skill.excludeAgents.join(",")}]` + : ""; + console.log(`${skill.name} ${scopeTag}${agentTag}`); console.log(` ${skill.description}`); console.log(` source: ${skill.source}`); for (const m of skill.mirrors) { diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index d86bd1b..e4dec60 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -5,12 +5,20 @@ import { loadModules, loadScopeManifest, resolveManifestPath, - validateScopeWiring + validateScopeWiring, } from "./config.js"; import { computeDocChainBudget } from "./budget.js"; import { collectGeneratedOutputs } from "./render.js"; import { lintContent } from "./content-lint.js"; -import { exists, isSymlink, readSymlink, readUtf8, removeFile, walkFiles, writeUtf8 } from "./io.js"; +import { + exists, + isSymlink, + readSymlink, + readUtf8, + removeFile, + walkFiles, + writeUtf8, +} from "./io.js"; import { rel, toPosix } from "./path-utils.js"; import { ContextError, formatContextError } from "./errors.js"; import { @@ -29,7 +37,7 @@ import type { InitOptions, Template, VerifyOptions, - VerifyResult + VerifyResult, } from "./types.js"; const GENERATED_MARKER = "Source: .ai/context/scopes.json"; @@ -42,7 +50,7 @@ const SKIP_DIRS = new Set([ ".next", ".expo", ".idea", - ".vscode" + ".vscode", ]); function gatherGeneratedFiles(cwd: string): string[] { @@ -55,14 +63,14 @@ function gatherGeneratedFiles(cwd: string): string[] { function writeOutputs( cwd: string, outputs: ReturnType, - options: BuildOptions + options: BuildOptions, ): BuildResult { const result: BuildResult = { written: [], unchanged: [], removed: [], warnings: [], - upToDate: true + upToDate: true, }; for (const output of outputs) { @@ -105,7 +113,12 @@ function buildInternal(cwd: string, options: BuildOptions): BuildResult { const scopeManifest = loadScopeManifest(cwd, manifest); const modules = loadModules(cwd, manifest); const wiring = validateScopeWiring(cwd, manifest, scopeManifest); - const outputs = collectGeneratedOutputs(cwd, manifest, scopeManifest, modules); + const outputs = collectGeneratedOutputs( + cwd, + manifest, + scopeManifest, + modules, + ); const result = writeOutputs(cwd, outputs, options); result.warnings.push(...wiring.warnings); @@ -122,14 +135,19 @@ function buildInternal(cwd: string, options: BuildOptions): BuildResult { for (const plan of apply.fallbackToCopy) { result.written.push(path.relative(cwd, plan.mirror)); result.warnings.push( - `Skill mirror at ${path.relative(cwd, plan.mirror)} used copy-fallback (no symlink support)` + `Skill mirror at ${path.relative(cwd, plan.mirror)} used copy-fallback (no symlink support)`, ); } for (const fail of apply.failed) { result.warnings.push(`Skill mirror failed: ${fail.reason}`); } if (options.removeOrphans) { - const orphans = findOrphanedSkillMirrors(cwd, manifest, activeNames); + const orphans = findOrphanedSkillMirrors( + cwd, + manifest, + activeNames, + plans.map((p) => p.mirror), + ); for (const orphan of orphans) { fs.unlinkSync(orphan); result.removed.push(path.relative(cwd, orphan)); @@ -155,7 +173,12 @@ function buildInternal(cwd: string, options: BuildOptions): BuildResult { } } if (options.removeOrphans) { - for (const orphan of findOrphanedSkillMirrors(cwd, manifest, activeNames)) { + for (const orphan of findOrphanedSkillMirrors( + cwd, + manifest, + activeNames, + plans.map((p) => p.mirror), + )) { result.removed.push(path.relative(cwd, orphan)); } } @@ -168,7 +191,10 @@ function buildInternal(cwd: string, options: BuildOptions): BuildResult { const mcpOutputs = planMcpOutputs(cwd, reg, manifest.mcp.clients); // Never pass removeOrphans here: writeOutputs' orphan scan would treat the // context .md files as orphans against the MCP-only output set and delete them. - const mcpResult = writeOutputs(cwd, mcpOutputs, { ...options, removeOrphans: false }); + const mcpResult = writeOutputs(cwd, mcpOutputs, { + ...options, + removeOrphans: false, + }); result.written.push(...mcpResult.written); result.unchanged.push(...mcpResult.unchanged); if (!mcpResult.upToDate) result.upToDate = false; @@ -182,12 +208,20 @@ export function buildAll(cwd: string, options: BuildOptions = {}): BuildResult { return buildInternal(cwd, options); } -export function diffGenerated(cwd: string, options: BuildOptions = {}): DiffReport { +export function diffGenerated( + cwd: string, + options: BuildOptions = {}, +): DiffReport { const manifest = loadManifest(cwd, options.manifestPath); const scopeManifest = loadScopeManifest(cwd, manifest); const modules = loadModules(cwd, manifest); validateScopeWiring(cwd, manifest, scopeManifest); - const outputs = collectGeneratedOutputs(cwd, manifest, scopeManifest, modules); + const outputs = collectGeneratedOutputs( + cwd, + manifest, + scopeManifest, + modules, + ); const items: DiffItem[] = []; for (const output of outputs) { @@ -239,7 +273,12 @@ export function diffGenerated(cwd: string, options: BuildOptions = {}): DiffRepo } } const activeNames = skills.map((s) => s.name); - for (const orphan of findOrphanedSkillMirrors(cwd, manifest, activeNames)) { + for (const orphan of findOrphanedSkillMirrors( + cwd, + manifest, + activeNames, + plans.map((p) => p.mirror), + )) { items.push({ path: path.relative(cwd, orphan), type: "delete" }); } } @@ -248,7 +287,10 @@ export function diffGenerated(cwd: string, options: BuildOptions = {}): DiffRepo return { items }; } -export function verifyAll(cwd: string, options: VerifyOptions = {}): VerifyResult { +export function verifyAll( + cwd: string, + options: VerifyOptions = {}, +): VerifyResult { const errors: string[] = []; const warnings: string[] = []; let budgetReport: ReturnType = null; @@ -258,7 +300,7 @@ export function verifyAll(cwd: string, options: VerifyOptions = {}): VerifyResul manifestPath: options.manifestPath, check: true, dryRun: true, - removeOrphans: false + removeOrphans: false, }); warnings.push(...buildResult.warnings); @@ -284,7 +326,7 @@ export function verifyAll(cwd: string, options: VerifyOptions = {}): VerifyResul const expected = computeSymlinkTarget(plan.mirror, plan.source); if (target !== expected) { errors.push( - `Skill mirror points at wrong target: ${relMirror} → ${target} (expected ${expected})` + `Skill mirror points at wrong target: ${relMirror} → ${target} (expected ${expected})`, ); } } @@ -305,9 +347,13 @@ export function verifyAll(cwd: string, options: VerifyOptions = {}): VerifyResul const abs = path.join(cwd, out.path); if (!exists(abs)) continue; const stripped = readUtf8(abs).replace(/\$\{[A-Z0-9_]+\}/g, ""); - if (/(sk-|xox[baprs]-|ghp_|AKIA|-----BEGIN|[A-Za-z0-9_-]{40,})/.test(stripped)) { + if ( + /(sk-|xox[baprs]-|ghp_|AKIA|-----BEGIN|[A-Za-z0-9_-]{40,})/.test( + stripped, + ) + ) { errors.push( - `[AICTX_MCP_SECRET_LEAK] Possible secret literal in managed file ${out.path}; use a \${VAR} reference` + `[AICTX_MCP_SECRET_LEAK] Possible secret literal in managed file ${out.path}; use a \${VAR} reference`, ); } } @@ -323,13 +369,14 @@ export function verifyAll(cwd: string, options: VerifyOptions = {}): VerifyResul errors.push( `Unmanaged generated files detected: ${orphanDeletes .map((item) => item.path) - .join(", ")}` + .join(", ")}`, ); } budgetReport = computeDocChainBudget(cwd); if (!budgetReport) { - const msg = "No .codex/config.toml project_doc_max_bytes detected; skipping budget checks"; + const msg = + "No .codex/config.toml project_doc_max_bytes detected; skipping budget checks"; if (options.strictCodexConfig) { errors.push(msg); } else { @@ -340,8 +387,10 @@ export function verifyAll(cwd: string, options: VerifyOptions = {}): VerifyResul if (entry.violations.length > 0) { errors.push( `${entry.docName} budget exceeded (${entry.maxBytes}): ${entry.violations - .map((violation) => `${violation.directory}=${violation.totalBytes}`) - .join(", ")}` + .map( + (violation) => `${violation.directory}=${violation.totalBytes}`, + ) + .join(", ")}`, ); } } @@ -354,11 +403,14 @@ export function verifyAll(cwd: string, options: VerifyOptions = {}): VerifyResul ok: errors.length === 0, errors, warnings, - budgetReport: budgetReport ?? undefined + budgetReport: budgetReport ?? undefined, }; } -export function lintConfig(cwd: string, manifestPath?: string): { ok: boolean; errors: string[] } { +export function lintConfig( + cwd: string, + manifestPath?: string, +): { ok: boolean; errors: string[] } { const errors: string[] = []; try { const manifest = loadManifest(cwd, manifestPath); @@ -375,13 +427,18 @@ export function lintConfig(cwd: string, manifestPath?: string): { ok: boolean; e return { ok: errors.length === 0, errors }; } -export function doctor(cwd: string, manifestPath?: string): { issues: string[]; suggestions: string[] } { +export function doctor( + cwd: string, + manifestPath?: string, +): { issues: string[]; suggestions: string[] } { const issues: string[] = []; const suggestions: string[] = []; const manifestResolved = resolveManifestPath(cwd, manifestPath); if (!exists(manifestResolved)) { - issues.push(`Missing manifest at ${toPosix(path.relative(cwd, manifestResolved))}`); + issues.push( + `Missing manifest at ${toPosix(path.relative(cwd, manifestResolved))}`, + ); suggestions.push("Run: ai-context init"); return { issues, suggestions }; } @@ -409,7 +466,8 @@ export function doctor(cwd: string, manifestPath?: string): { issues: string[]; const manifest = loadManifest(cwd, manifestPath); if (manifest.skills) { const skills = discoverSkills(cwd, manifest.skills.source); - const activeNames = new Set(skills.map((s) => s.name)); + const plans = planSkillMirrors(cwd, manifest, skills); + const plannedMirrors = new Set(plans.map((p) => p.mirror)); // Build list of mirror base directories to scan const mirrorBases: string[] = []; @@ -434,24 +492,26 @@ export function doctor(cwd: string, manifestPath?: string): { issues: string[]; const resolved = path.resolve(path.dirname(full), target); if (!fs.existsSync(resolved)) { issues.push( - `Skill mirror broken (target missing): ${path.relative(cwd, full)}` + `Skill mirror broken (target missing): ${path.relative(cwd, full)}`, ); suggestions.push(`Run: ai-context build --remove-orphans`); } } - if (!activeNames.has(entry.name)) { + if (!plannedMirrors.has(full)) { issues.push( - `Orphan skill mirror: ${path.relative(cwd, full)} (no source at ${manifest.skills.source}/${entry.name}/)` + `Orphan skill mirror: ${path.relative(cwd, full)} (source deleted, or excluded by scope/agents filter)`, ); + suggestions.push(`Run: ai-context build --remove-orphans`); } } } // Per-plan checks for active skills - const plans = planSkillMirrors(cwd, manifest, skills); for (const plan of plans) { if (!fs.existsSync(plan.mirror)) { - issues.push(`Skill mirror missing: ${path.relative(cwd, plan.mirror)}`); + issues.push( + `Skill mirror missing: ${path.relative(cwd, plan.mirror)}`, + ); suggestions.push(`Run: ai-context build`); continue; } @@ -460,7 +520,7 @@ export function doctor(cwd: string, manifestPath?: string): { issues: string[]; const expected = computeSymlinkTarget(plan.mirror, plan.source); if (target !== expected) { issues.push( - `Skill mirror points to wrong target: ${path.relative(cwd, plan.mirror)} (got ${target}, expected ${expected})` + `Skill mirror points to wrong target: ${path.relative(cwd, plan.mirror)} (got ${target}, expected ${expected})`, ); suggestions.push(`Run: ai-context build`); } @@ -472,13 +532,19 @@ export function doctor(cwd: string, manifestPath?: string): { issues: string[]; } if (issues.length === 0 && suggestions.length === 0) { - suggestions.push("System looks healthy. Run ai-context diff before major scope changes."); + suggestions.push( + "System looks healthy. Run ai-context diff before major scope changes.", + ); } return { issues, suggestions }; } -export function initProject(cwd: string, template: Template, options: InitOptions = {}): string[] { +export function initProject( + cwd: string, + template: Template, + options: InitOptions = {}, +): string[] { const written: string[] = []; for (const file of template.files) { @@ -489,18 +555,24 @@ export function initProject(cwd: string, template: Template, options: InitOption if (fileExists && !options.force) { if (options.upgrade) { if (isMetaSkillFile && options.refreshMetaSkill) { - writeUtf8(abs, file.content.endsWith("\n") ? file.content : `${file.content}\n`); + writeUtf8( + abs, + file.content.endsWith("\n") ? file.content : `${file.content}\n`, + ); written.push(file.path); } continue; // skip existing files in upgrade mode } throw new ContextError( "AICTX_INIT_FAILED", - `Refusing to overwrite existing file without --force: ${toPosix(path.relative(cwd, abs))}` + `Refusing to overwrite existing file without --force: ${toPosix(path.relative(cwd, abs))}`, ); } - writeUtf8(abs, file.content.endsWith("\n") ? file.content : `${file.content}\n`); + writeUtf8( + abs, + file.content.endsWith("\n") ? file.content : `${file.content}\n`, + ); written.push(file.path); } diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 34950c6..29662a0 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -9,6 +9,7 @@ export type ContextErrorCode = | "AICTX_SKILL_NAME_INVALID" | "AICTX_SKILL_MISSING_FILE" | "AICTX_SKILL_SCOPE_UNKNOWN" + | "AICTX_SKILL_AGENT_UNKNOWN" | "AICTX_SKILL_MIRROR_CONFLICT" | "AICTX_SKILL_MIRROR_BROKEN" | "AICTX_MIGRATE_PLAN_EXISTS" @@ -38,11 +39,15 @@ export class ContextError extends Error { readonly details?: Record; constructor(message: string); - constructor(code: ContextErrorCode, message: string, details?: Record); + constructor( + code: ContextErrorCode, + message: string, + details?: Record, + ); constructor( codeOrMessage: string, message?: string, - details?: Record + details?: Record, ) { if (message === undefined) { super(codeOrMessage); diff --git a/packages/core/src/skills.ts b/packages/core/src/skills.ts index 5d6b53a..6d14d19 100644 --- a/packages/core/src/skills.ts +++ b/packages/core/src/skills.ts @@ -2,10 +2,24 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { ContextError } from "./errors.js"; -import { readUtf8, createSymlink, isSymlink, readSymlink, removeSymlink, copyDirRecursive, restoreExecBits, writeUtf8 } from "./io.js"; +import { + readUtf8, + createSymlink, + isSymlink, + readSymlink, + removeSymlink, + copyDirRecursive, + restoreExecBits, + writeUtf8, +} from "./io.js"; import { parseFrontMatter } from "./front-matter.js"; import { toPosix } from "./path-utils.js"; -import type { SkillFrontmatter, SkillSource, Manifest, SkillMirrorPlan } from "./types.js"; +import type { + SkillFrontmatter, + SkillSource, + Manifest, + SkillMirrorPlan, +} from "./types.js"; // Lowercase alphanumerics + single hyphens; no leading/trailing hyphen, no consecutive hyphens. const SKILL_NAME_PATTERN = /^[a-z0-9](?:-?[a-z0-9]+)*$/; @@ -15,7 +29,7 @@ const MAX_DESCRIPTION_LENGTH = 1024; export function parseSkillFrontmatter( raw: string, expectedName: string, - sourcePath: string + sourcePath: string, ): SkillFrontmatter { const { meta } = parseFrontMatter(raw, sourcePath); @@ -23,25 +37,25 @@ export function parseSkillFrontmatter( if (typeof name !== "string" || name.length === 0) { throw new ContextError( "AICTX_SKILL_FRONTMATTER_INVALID", - `Skill name is required in ${sourcePath}` + `Skill name is required in ${sourcePath}`, ); } if (name.length > MAX_NAME_LENGTH) { throw new ContextError( "AICTX_SKILL_NAME_INVALID", - `Skill name '${name}' exceeds ${MAX_NAME_LENGTH} chars (${sourcePath})` + `Skill name '${name}' exceeds ${MAX_NAME_LENGTH} chars (${sourcePath})`, ); } if (!SKILL_NAME_PATTERN.test(name)) { throw new ContextError( "AICTX_SKILL_NAME_INVALID", - `Skill '${name}' has invalid name pattern in ${sourcePath} (must be [a-z0-9-], no leading/trailing/consecutive hyphens)` + `Skill '${name}' has invalid name pattern in ${sourcePath} (must be [a-z0-9-], no leading/trailing/consecutive hyphens)`, ); } if (name !== expectedName) { throw new ContextError( "AICTX_SKILL_NAME_INVALID", - `Skill name '${name}' does not match directory '${expectedName}' in ${sourcePath}` + `Skill name '${name}' does not match directory '${expectedName}' in ${sourcePath}`, ); } @@ -49,13 +63,13 @@ export function parseSkillFrontmatter( if (typeof description !== "string" || description.length === 0) { throw new ContextError( "AICTX_SKILL_FRONTMATTER_INVALID", - `Skill description is required in ${sourcePath}` + `Skill description is required in ${sourcePath}`, ); } if (description.length > MAX_DESCRIPTION_LENGTH) { throw new ContextError( "AICTX_SKILL_FRONTMATTER_INVALID", - `Skill description exceeds ${MAX_DESCRIPTION_LENGTH} chars in ${sourcePath}` + `Skill description exceeds ${MAX_DESCRIPTION_LENGTH} chars in ${sourcePath}`, ); } @@ -64,30 +78,86 @@ export function parseSkillFrontmatter( if (!Array.isArray(meta.scope)) { throw new ContextError( "AICTX_SKILL_FRONTMATTER_INVALID", - `Skill scope must be an array in ${sourcePath}` + `Skill scope must be an array in ${sourcePath}`, ); } for (const entry of meta.scope) { if (typeof entry !== "string") { throw new ContextError( "AICTX_SKILL_FRONTMATTER_INVALID", - `Skill scope entries must be strings in ${sourcePath}` + `Skill scope entries must be strings in ${sourcePath}`, ); } } scope = meta.scope as string[]; } + const agents = parseAgentList(meta.agents, "agents", sourcePath); + const excludeAgents = parseAgentList( + meta.excludeAgents, + "excludeAgents", + sourcePath, + ); + if (agents !== undefined && excludeAgents !== undefined) { + throw new ContextError( + "AICTX_SKILL_FRONTMATTER_INVALID", + `Skill declares both agents and excludeAgents in ${sourcePath}; use one or the other`, + ); + } + if (agents !== undefined && agents.length === 0) { + throw new ContextError( + "AICTX_SKILL_FRONTMATTER_INVALID", + `Skill agents whitelist is empty in ${sourcePath}; it would emit nowhere. Remove the field to emit to all agents.`, + ); + } + return { ...(meta as Record), name, description, scope, + agents, + excludeAgents, } as SkillFrontmatter; } +function parseAgentList( + value: unknown, + field: "agents" | "excludeAgents", + sourcePath: string, +): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + throw new ContextError( + "AICTX_SKILL_FRONTMATTER_INVALID", + `Skill ${field} must be an array in ${sourcePath}`, + ); + } + for (const entry of value) { + if (typeof entry !== "string" || entry.length === 0) { + throw new ContextError( + "AICTX_SKILL_FRONTMATTER_INVALID", + `Skill ${field} entries must be non-empty strings in ${sourcePath}`, + ); + } + } + return value as string[]; +} + +/** + * Agent ID for a mirror path: first path segment minus its leading dot. + * ".claude/skills" → "claude", ".agents/skills" → "agents", ".cursor/skills" → "cursor". + */ +export function mirrorAgentId(mirrorPath: string): string { + const first = + toPosix(mirrorPath).replace(/^\.\//, "").split("/")[0] ?? mirrorPath; + return first.replace(/^\./, ""); +} + export function discoverSkills(cwd: string, sourceDir: string): SkillSource[] { - const absSourceDir = path.isAbsolute(sourceDir) ? sourceDir : path.join(cwd, sourceDir); + const absSourceDir = path.isAbsolute(sourceDir) + ? sourceDir + : path.join(cwd, sourceDir); if (!fs.existsSync(absSourceDir)) { return []; } @@ -104,7 +174,7 @@ export function discoverSkills(cwd: string, sourceDir: string): SkillSource[] { if (!fs.existsSync(skillMdPath)) { throw new ContextError( "AICTX_SKILL_MISSING_FILE", - `SKILL.md not found in ${dir}` + `SKILL.md not found in ${dir}`, ); } @@ -118,12 +188,18 @@ export function discoverSkills(cwd: string, sourceDir: string): SkillSource[] { return results; } -export function computeSymlinkTarget(mirrorPath: string, sourcePath: string): string { +export function computeSymlinkTarget( + mirrorPath: string, + sourcePath: string, +): string { const relative = path.relative(path.dirname(mirrorPath), sourcePath); return toPosix(relative); } -export function createMirrorSymlink(sourceDir: string, mirrorPath: string): void { +export function createMirrorSymlink( + sourceDir: string, + mirrorPath: string, +): void { const expectedTarget = computeSymlinkTarget(mirrorPath, sourceDir); if (fs.existsSync(mirrorPath) || isSymlink(mirrorPath)) { @@ -136,7 +212,7 @@ export function createMirrorSymlink(sourceDir: string, mirrorPath: string): void } else { throw new ContextError( "AICTX_SKILL_MIRROR_CONFLICT", - `Cannot create skill mirror at ${mirrorPath}: a real file or directory exists there. Either delete it or move skill source elsewhere.` + `Cannot create skill mirror at ${mirrorPath}: a real file or directory exists there. Either delete it or move skill source elsewhere.`, ); } } @@ -146,7 +222,11 @@ export function createMirrorSymlink(sourceDir: string, mirrorPath: string): void const COPY_BANNER_PREFIX = "