SLOPSHOPPER

agents-skills

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

new
★ 1v0.1.0no licenseupdated 2026-09-27Leechael/claude-code-mod-agent-skills
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (89 | for (const skillDir of await listSkillDirs($, `${home}/.agents/skills`)) { ^ error: Expected ";" but found "$"
README

agents-skills

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.

Why

  • Codex and Pi load skills from .agents/skills (and ~/.agents/skills).
  • Claude Code natively loads .claude/skills (and plugins).
  • PR #95409 adds AGENTS.md as project instructions — not .agents/skills.
  • Community interoperability today is mostly symlink / install into .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.

What it does

PathHook / APIEffect
Discoversession.start + $.fsScan 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)
Catalogprompt.contextInject a short “Available skills (.agents/skills)” list
Skill tooltool.call on SkillIf 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.

Layout expected

<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…

Install (draft)

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.

Options (userConfig)

OptionDefaultMeaning
projectSkillstrueScan ancestor .agents/skills
userSkillstrueScan ~/.agents/skills
registerCommandstrueRegister /name commands
injectCatalogtrueAdd catalog block on prompt.context
interceptSkillTooltrueBridge Skill tool for our names
preferClaudeSkillstrueOn clash, keep native .claude/skills

Limits (read before shipping)

  1. Not native discovery. Claude Code still does not walk .agents itself. Skills only appear through this mod’s hooks.
  2. API surface is draft-shaped. Field names on 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.
  3. No progress / permission UI parity with first-party Skill loading unless the host exposes those nouns — this draft returns body text only.
  4. Name clashes. Prefer Claude’s skills when preferClaudeSkills is on; otherwise last register wins or the host errors.
  5. Does not replace symlinks for tools that only scan .claude/skills outside the Mods path (other CLIs, CI, editors).

Out of scope (v0)

  • Writing skills into .claude/skills
  • Marketplace packaging / auto-update
  • Full YAML frontmatter (only name + description)
  • Watching the filesystem for hot reload (re-discover on next session.start)

Smoke

./tests/smoke.sh

Parses a fixture SKILL.md and prints the discovered commandName + description (no Claude Code runtime required).

Related

  • Anthropic mods/agents-md — AGENTS.md as instruction files (prompt.context)
  • Issue #91870 — Function Hooks / Mods discussion
  • Community symlink approach — vercel-labs/add-skill, PaulRBerg/dot-agents, etc.
Source 9 files
hooks/register.ts 94 lines
1import 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}
94
hooks/catalog/block.ts 27 lines
1import 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}
27
hooks/commands/register-all.ts 40 lines
1import 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}
40
hooks/discover/index.ts 5 lines
1export { discoverAll, discoverProjectSkills, discoverUserSkills } from './scan.ts'
2export { expandSkillBody } from './expand.ts'
3export { parseFrontmatter } from './parse-frontmatter.ts'
4export type { DiscoveredSkill } from './types.ts'
5
hooks/skill-tool/intercept.ts 45 lines
1import 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}
45
hooks/discover/types.ts 18 lines
1/**
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}
18
hooks/discover/scan.ts 108 lines
1import 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}
108
hooks/discover/expand.ts 35 lines
1import 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}
35
hooks/discover/parse-frontmatter.ts 37 lines
1/**
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