Discover Agent Skills under .agents/skills (project) and ~/.agents/skills (user), expose them as slash commands, list them in prompt.context, and optionally…

Claude Code mod that bridges the shared Agent Skills tree (.agents/skills) into Claude Code without native engine support and without symlinking into .claude/skills.
Draft for Claude Code Mods / Function Hooks. Not an Anthropic product.
.agents/skills (and ~/.agents/skills)..claude/skills (and plugins).AGENTS.md as project instructions — not .agents/skills..claude/skills (e.g. vercel-labs/add-skill). GitHub search found no existing mod that discovers .agents/skills via Mods.This mod is the Mods-native blank: discover → slash commands → optional Skill-tool intercept → optional prompt catalog.
| Path | Hook / API | Effect |
|---|---|---|
| Discover | session.start + $.fs | Scan project ancestors for .agents/skills/*/SKILL.md and ~/.agents/skills/*/SKILL.md |
| Slash commands | $.command.register + command.run | /name expands that skill’s body (with $ARGUMENTS / $0) |
| Catalog | prompt.context | Inject a short “Available skills (.agents/skills)” list |
| Skill tool | tool.call on Skill | If the engine misses the name, load our SKILL.md instead |
Project skills win over user skills on the same commandName. With preferClaudeSkills (default), a name already owned by .claude/skills is left alone.
<project>/
.agents/
skills/
my-skill/
SKILL.md # YAML frontmatter: name, description
~/.agents/
skills/
shared-skill/
SKILL.md
Frontmatter (minimal):
---
name: my-skill
description: One line on when to use this skill.
---
Body the model should follow…
Until this lives in a marketplace:
# from a checkout of this repo
claude plugins install ./ # or link as a local plugin per current Claude Code docs
Enable the mod / function hooks path for the plugin (same seating as agents-md / other mods). Exact CLI flag may change with Claude Code builds — check claude --help and the Mods docs for your version.
userConfig)| Option | Default | Meaning |
|---|---|---|
projectSkills | true | Scan ancestor .agents/skills |
userSkills | true | Scan ~/.agents/skills |
registerCommands | true | Register /name commands |
injectCatalog | true | Add catalog block on prompt.context |
interceptSkillTool | true | Bridge Skill tool for our names |
preferClaudeSkills | true | On clash, keep native .claude/skills |
.agents itself. Skills only appear through this mod’s hooks.command.run (prompt / handled), prompt.context (additionalContext), and Skill tool.call results must be checked against the installed mods/types/claude-code.d.ts before release. Adjust merge keys if the host rejects them.preferClaudeSkills is on; otherwise last register wins or the host errors..claude/skills outside the Mods path (other CLIs, CI, editors)..claude/skillsname + description)session.start)./tests/smoke.sh
Parses a fixture SKILL.md and prints the discovered commandName + description (no Claude Code runtime required).
mods/agents-md — AGENTS.md as instruction files (prompt.context)vercel-labs/add-skill, PaulRBerg/dot-agents, etc.hooks/register.ts 94 lines1import type { On, PluginOptions } from 'claude-code'
2
3import { catalogMarkdown } from './catalog/block.ts'
4import { registerViaEngine } from './commands/register-all.ts'
5import {
6 discoverAll,
7 expandSkillBody,
8 type DiscoveredSkill,
9} from './discover/index.ts'
10import { interceptSkillTool } from './skill-tool/intercept.ts'
11
12type Options = {
13 projectSkills?: boolean
14 userSkills?: boolean
15 registerCommands?: boolean
16 injectCatalog?: boolean
17 interceptSkillTool?: boolean
18 preferClaudeSkills?: boolean
19}
20
21function bool(v: unknown, fallback: boolean): boolean {
22 return typeof v === 'boolean' ? v : fallback
23}
24
25/**
26 * agents-skills mod entry — Mods-only bridge for `.agents/skills`.
27 *
28 * Does not teach the engine a native `.agents` walk. See README / DESIGN.
29 */
30export function register(on: On, options: PluginOptions): void {
31 const o = options as Options
32 const wantProject = bool(o.projectSkills, true)
33 const wantUser = bool(o.userSkills, true)
34 const doCommands = bool(o.registerCommands, true)
35 const doCatalog = bool(o.injectCatalog, true)
36 const doIntercept = bool(o.interceptSkillTool, true)
37 const preferClaude = bool(o.preferClaudeSkills, true)
38
39 let skills: DiscoveredSkill[] = []
40 let ready = false
41
42 // eslint-disable-next-line @typescript-eslint/no-explicit-any
43 async function ensure($: any): Promise<void> {
44 if (ready) return
45 skills = await discoverAll($, { project: wantProject, user: wantUser })
46 ready = true
47 }
48
49 on('session.start', async ($, e, next) => {
50 await ensure($)
51 if (doCommands) await registerViaEngine($, skills)
52 $.ui?.log?.(
53 skills.length === 0
54 ? 'agents-skills: no skills under .agents/skills'
55 : `agents-skills: ${skills.length} — ${skills.map(s => s.commandName).join(', ')}`,
56 )
57 return next(e)
58 })
59
60 if (doCommands) {
61 on('command.run', async ($, e, next) => {
62 await ensure($)
63 const skill = skills.find(s => s.commandName === e.name)
64 if (!skill) return next(e)
65 if (preferClaude && (e as { source?: string }).source === 'claude') {
66 return next(e)
67 }
68 const body = await expandSkillBody($, skill, e.args ?? '')
69 return next({ ...e, prompt: body, handled: true })
70 })
71 }
72
73 if (doCatalog) {
74 on('prompt.context', async ($, e, next) => {
75 await ensure($)
76 const block = catalogMarkdown(skills)
77 if (!block) return next(e)
78 const prev =
79 typeof (e as { additionalContext?: string }).additionalContext ===
80 'string'
81 ? (e as { additionalContext: string }).additionalContext
82 : ''
83 return next({
84 ...e,
85 additionalContext: prev ? `${prev}\n\n${block}` : block,
86 })
87 })
88 }
89
90 if (doIntercept) {
91 interceptSkillTool(on, () => skills, preferClaude)
92 }
93}
94hooks/catalog/block.ts 27 lines1import type { DiscoveredSkill } from '../discover/types.ts'
2
3/**
4 * Short catalog block for prompt.context (engine treats unknown fields
5 * per Mods docs — we attach as an extra instruction-style string via
6 * the hook return shape the host accepts for `context` appendages).
7 *
8 * Draft note: exact ContextSkill / instructionFiles merge API may differ
9 * by Claude Code build; prefer appending a plain text block the model sees.
10 */
11export function catalogMarkdown(skills: readonly DiscoveredSkill[]): string {
12 if (skills.length === 0) return ''
13 const lines = [
14 '## Available skills (`.agents/skills`)',
15 '',
16 'These skills are bridged by the `agents-skills` mod (not under `.claude/skills`).',
17 'Invoke with `/<name>` or ask to use the skill by name.',
18 '',
19 ]
20 for (const s of skills) {
21 const desc = s.description || '(no description)'
22 lines.push(`- \`/${s.commandName}\` (${s.scope}): ${desc}`)
23 }
24 lines.push('')
25 return lines.join('\n')
26}
27hooks/commands/register-all.ts 40 lines1import type { EngineInterface } from 'claude-code'
2
3import type { DiscoveredSkill } from '../discover/types.ts'
4
5/**
6 * Register slash commands via EngineInterface during session.start.
7 * Host may reject duplicate names — we log and skip.
8 */
9export async function registerViaEngine(
10 $: EngineInterface,
11 skills: readonly DiscoveredSkill[],
12): Promise<string[]> {
13 const registered: string[] = []
14 const register = $.command?.register
15 if (typeof register !== 'function') {
16 $.ui?.log?.(
17 'agents-skills: $.command.register unavailable — slash commands skipped',
18 )
19 return registered
20 }
21
22 for (const skill of skills) {
23 try {
24 await register({
25 name: skill.commandName,
26 description:
27 skill.description ||
28 `Agent skill from .agents/skills (${skill.scope})`,
29 argumentHint: '[args]',
30 })
31 registered.push(skill.commandName)
32 } catch (err) {
33 $.ui?.log?.(
34 `agents-skills: skip /${skill.commandName} (${String(err)})`,
35 )
36 }
37 }
38 return registered
39}
40hooks/discover/index.ts 5 lines1export { discoverAll, discoverProjectSkills, discoverUserSkills } from './scan.ts'
2export { expandSkillBody } from './expand.ts'
3export { parseFrontmatter } from './parse-frontmatter.ts'
4export type { DiscoveredSkill } from './types.ts'
5hooks/skill-tool/intercept.ts 45 lines1import type { On } from 'claude-code'
2
3import { expandSkillBody, type DiscoveredSkill } from '../discover/index.ts'
4
5/**
6 * Intercept Skill tool for names we discovered under .agents/skills.
7 */
8export function interceptSkillTool(
9 on: On,
10 getSkills: () => readonly DiscoveredSkill[],
11 preferClaude: boolean,
12): void {
13 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
14 const input = (e.input ?? (e as { args?: unknown }).args ?? {}) as {
15 skill?: string
16 name?: string
17 args?: string
18 arguments?: string
19 }
20 const name = (input.skill || input.name || '').toLowerCase()
21 const skill = getSkills().find(s => s.commandName === name)
22 if (!skill) return next(e)
23
24 if (preferClaude) {
25 const result = await next(e)
26 const failed =
27 result &&
28 typeof result === 'object' &&
29 ('error' in (result as object) ||
30 (result as { isError?: boolean }).isError === true ||
31 /not found|unknown skill/i.test(
32 String((result as { content?: string }).content ?? ''),
33 ))
34 if (!failed) return result
35 }
36
37 const body = await expandSkillBody(
38 $,
39 skill,
40 input.args ?? input.arguments ?? '',
41 )
42 return { result: { content: body, isError: false }, handled: true }
43 })
44}
45hooks/discover/types.ts 18 lines1/**
2 * One Agent Skill found under an `.agents/skills` tree.
3 */
4export type DiscoveredSkill = {
5 /** Directory basename; also the default slash / Skill name. */
6 name: string
7 /** Absolute path to the skill folder (contains SKILL.md). */
8 dir: string
9 /** Absolute path to SKILL.md. */
10 skillFile: string
11 /** Frontmatter description, or empty. */
12 description: string
13 /** Frontmatter name when present and valid; else basename. */
14 commandName: string
15 /** Where it was found. */
16 scope: 'project' | 'user'
17}
18hooks/discover/scan.ts 108 lines1import type { EngineInterface } from 'claude-code'
2
3import { parseFrontmatter } from './parse-frontmatter.ts'
4import type { DiscoveredSkill } from './types.ts'
5
6const SKILL_MD = 'SKILL.md'
7
8async function listSkillDirs(
9 $: EngineInterface,
10 skillsRoot: string,
11): Promise<string[]> {
12 const listing = await $.fs
13 .readdir(skillsRoot)
14 .catch(() => [] as string[])
15 const dirs: string[] = []
16 for (const name of listing) {
17 if (name.startsWith('.')) continue
18 const dir = `${skillsRoot.replace(/\/$/, '')}/${name}`
19 const st = await $.fs.stat(dir).catch(() => undefined)
20 if (st?.isDirectory) dirs.push(dir)
21 }
22 return dirs
23}
24
25async function readSkill(
26 $: EngineInterface,
27 dir: string,
28 scope: DiscoveredSkill['scope'],
29): Promise<DiscoveredSkill | undefined> {
30 const skillFile = `${dir}/${SKILL_MD}`
31 const text = await $.fs.read(skillFile).catch(() => undefined)
32 if (typeof text !== 'string' || text.length === 0) return undefined
33 const { name, description } = parseFrontmatter(text)
34 const basename = dir.split('/').filter(Boolean).at(-1) ?? 'skill'
35 const commandName = (name && /^[a-z0-9][a-z0-9-]{0,63}$/.test(name)
36 ? name
37 : basename
38 ).toLowerCase()
39 return {
40 name: basename,
41 dir,
42 skillFile,
43 description: description ?? '',
44 commandName,
45 scope,
46 }
47}
48
49/**
50 * Walk cwd → ancestors for `<dir>/.agents/skills`, stop at git root when known.
51 */
52export async function discoverProjectSkills(
53 $: EngineInterface,
54): Promise<DiscoveredSkill[]> {
55 const cwd = await $.session.cwd()
56 const root = await $.session.root().catch(() => undefined)
57 const out: DiscoveredSkill[] = []
58 const seen = new Set<string>()
59
60 let dir = cwd
61 for (;;) {
62 const skillsRoot = `${dir}/.agents/skills`
63 for (const skillDir of await listSkillDirs($, skillsRoot)) {
64 const skill = await readSkill($, skillDir, 'project')
65 if (!skill || seen.has(skill.commandName)) continue
66 seen.add(skill.commandName)
67 out.push(skill)
68 }
69 if (root && dir === root) break
70 const parent = dir.replace(/\/[^/]+\/?$/, '') || '/'
71 if (parent === dir) break
72 dir = parent
73 if (!root && dir === '/') break
74 }
75 return out
76}
77
78/**
79 * Scan `~/.agents/skills/*/SKILL.md`.
80 */
81export async function discoverUserSkills(
82 $: EngineInterface,
83): Promise<DiscoveredSkill[]> {
84 const home =
85 (await $.env.get('HOME').catch(() => undefined)) ||
86 (await $.env.get('USERPROFILE').catch(() => undefined))
87 if (!home) return []
88 const out: DiscoveredSkill[] = []
89 for (const skillDir of await listSkillDirs($, `${home}/.agents/skills`)) {
90 const skill = await readSkill($, skillDir, 'user')
91 if (skill) out.push(skill)
92 }
93 return out
94}
95
96/**
97 * Project skills win over user skills on the same commandName.
98 */
99export async function discoverAll(
100 $: EngineInterface,
101 opts: { project: boolean; user: boolean },
102): Promise<DiscoveredSkill[]> {
103 const project = opts.project ? await discoverProjectSkills($) : []
104 const names = new Set(project.map(s => s.commandName))
105 const user = opts.user ? await discoverUserSkills($) : []
106 return [...project, ...user.filter(s => !names.has(s.commandName))]
107}
108hooks/discover/expand.ts 35 lines1import type { EngineInterface } from 'claude-code'
2
3import { parseFrontmatter } from './parse-frontmatter.ts'
4import type { DiscoveredSkill } from './types.ts'
5
6/**
7 * Load SKILL.md body and substitute $ARGUMENTS / $0.. style placeholders.
8 */
9export async function expandSkillBody(
10 $: EngineInterface,
11 skill: DiscoveredSkill,
12 args: string,
13): Promise<string> {
14 const text = await $.fs.read(skill.skillFile)
15 const { body } = parseFrontmatter(text)
16 const parts = shellSplit(args)
17 let out = body
18 out = out.replaceAll('$ARGUMENTS', args)
19 out = out.replace(/\$ARGUMENTS\[(\d+)\]/g, (_, i) => parts[Number(i)] ?? '')
20 out = out.replace(/\$(\d+)\b/g, (_, i) => parts[Number(i)] ?? '')
21 out = out.replaceAll('${CLAUDE_SKILL_DIR}', skill.dir)
22 out = out.replaceAll('$CLAUDE_SKILL_DIR', skill.dir)
23 return out
24}
25
26function shellSplit(args: string): string[] {
27 const re = /"([^"]*)"|'([^']*)'|(\S+)/g
28 const out: string[] = []
29 let m: RegExpExecArray | null
30 while ((m = re.exec(args))) {
31 out.push(m[1] ?? m[2] ?? m[3] ?? '')
32 }
33 return out
34}
35hooks/discover/parse-frontmatter.ts 37 lines1/**
2 * Minimal YAML frontmatter reader for SKILL.md (name + description only).
3 * Not a full YAML parser — enough for Agent Skills frontmatter.
4 */
5export function parseFrontmatter(text: string): {
6 name?: string
7 description?: string
8 body: string
9} {
10 if (!text.startsWith('---')) {
11 return { body: text }
12 }
13 const end = text.indexOf('\n---', 3)
14 if (end === -1) {
15 return { body: text }
16 }
17 const raw = text.slice(4, end).trim()
18 const body = text.slice(end + 4).replace(/^\n/, '')
19 let name: string | undefined
20 let description: string | undefined
21 for (const line of raw.split('\n')) {
22 const m = /^(name|description)\s*:\s*(.*)$/.exec(line)
23 if (!m) continue
24 const key = m[1]
25 let val = m[2].trim()
26 if (
27 (val.startsWith('"') && val.endsWith('"')) ||
28 (val.startsWith("'") && val.endsWith("'"))
29 ) {
30 val = val.slice(1, -1)
31 }
32 if (key === 'name') name = val
33 if (key === 'description') description = val
34 }
35 return { name, description, body }
36}
37