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
13 changes: 13 additions & 0 deletions .changeset/skill-agent-targeting.md
Original file line number Diff line number Diff line change
@@ -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 <id>` flag; `skills list` shows the filter and includes `agents`/`excludeAgents` in `--json` output.
3 changes: 2 additions & 1 deletion docs/cli-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`
Expand All @@ -82,6 +82,7 @@
- Flags:
- `--description <text>`: sets the skill description (defaults to a placeholder if omitted).
- `--scope <id>`: repeatable; emits mirrors to that scope's mirror locations instead of root. Use `*` for all scopes.
- `--agents <id>`: 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.
Expand Down
20 changes: 20 additions & 0 deletions docs/skills-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 16 additions & 7 deletions packages/cli/src/commands/skills/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { buildAll, formatContextError } from "@timothycrooker/ai-context-core";
interface CreateOptions {
description: string;
scope: string[];
agents: string[];
withReferences: boolean;
withScripts: boolean;
}
Expand All @@ -16,28 +17,32 @@ 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);
}

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);
}

Expand All @@ -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.
Expand All @@ -59,7 +68,7 @@ export function runSkillsCreate(name: string, opts: CreateOptions): void {
const skillMd = `---
name: ${name}
description: "${descriptionEscaped}"
${scopeYaml}---
${scopeYaml}${agentsYaml}---

# ${name}

Expand All @@ -73,15 +82,15 @@ 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) {
fs.mkdirSync(path.join(sourceDir, "scripts"), { recursive: true });
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);
}
Expand Down
37 changes: 28 additions & 9 deletions packages/cli/src/commands/skills/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>")
Expand All @@ -19,16 +21,33 @@ export function registerSkillsCommand(program: Command): void {
"--scope <id>",
"Limit emission to this scope (repeatable); omit for root-only",
(value: string, prev: string[]) => [...prev, value],
[] as string[]
[] as string[],
)
.option(
"--agents <id>",
"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),
}),
);
}
19 changes: 15 additions & 4 deletions packages/cli/src/commands/skills/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
};
Expand All @@ -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) {
Expand Down
Loading
Loading