/**
* Claude Skills Extension
*
* Bridges Claude Code's skills into pi. On resources_discover it hands pi the
* existing `.claude/skills` directories (user then project) as skill paths, so
* pi discovers Claude Code skills the same way it loads its own `.pi/skills`.
* pi implements the Agent Skills standard, so `SKILL.md` directories work
* unchanged and register as `/skill:name`.
*
* Claude documents that a command file and a skill "work the same way", so a
* SKILL.md body carries the dynamic features command bodies do: `` !`cmd` ``
* spans, `@file` references, `$ARGUMENTS`/positional substitution and
* `${CLAUDE_*}` variables. pi's loader delivers the raw text, so this extension
* intercepts `/skill:name` input for the skills it contributed, expands the body
* through the shared command pipeline, and hands pi the already-expanded content
* in pi's own skill-block format (pi emits the input event before its own
* expansion, and skips text that no longer starts with `/`).
*
* Docs: https://code.claude.com/docs/en/skills.md, https://agentskills.io
*/
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
import { expandCommand, shellExecutionDisabled } from './commands.js'
import { runAgent } from './internal/agent-run.js'
import { parseCommandFile } from './internal/command-file.js'
import { claudeConfigDir } from './internal/config-dir.js'
import { parseClaudeFrontmatter } from './internal/frontmatter.js'
import { managedSettingsFile } from './internal/managed-settings.js'
import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
import { isProjectApprovedSilently } from './internal/project-approval.js'
import { ancestorDirs } from './internal/project-root.js'
import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
import { SKILL_HOOKS_CHANNEL } from './internal/skill-hooks.js'
import { errorMessage, isDirectory, isRecord } from './internal/values.js'
/** The extra skill directories a manifest declares, as a list. A string is one entry,
* a list is itself, anything else declares none. */
function declaredSkillDirs(declared: unknown): string[] {
if (Array.isArray(declared)) return declared.map(String)
return typeof declared === 'string' ? [declared] : []
}
/** Existing `.claude/skills` directories, user first then project. The project
* directory is included only for approved projects: pi's loader surfaces every skill's
* name and description to the model, so an untrusted repository would otherwise get
* text into the prompt without the user ever agreeing to load its config. */
export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
// Claude's precedence: enterprise (the skills directory beside the managed
// settings file) overrides personal, and personal overrides project; discovery
// here is first-match, so higher precedence goes first.
// Claude: "Set to 1 to skip loading skills from the system-wide managed skills
// directory. Useful for container or CI sessions that should not load
// operator-provisioned skills." The enterprise dir beside managed-settings.json is
// that directory here; personal and project skills are unaffected.
const enterprise = process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS === '1' ? [] : [path.join(path.dirname(managedSettingsFile()), '.claude', 'skills')]
const candidates = [...enterprise, path.join(claudeConfigDir(home), 'skills')]
// Enabled plugins contribute their skills directories. pi's loader names a
// skill by its directory, so a plugin skill registers without Claude's
// /plugin: prefix; a rename-free approximation, disclosed in the README.
for (const plugin of installedPlugins(home)) {
// Claude: "Adds to the default: `skills`. The default `skills/` directory is always
// scanned, and directories listed in `skills` are loaded alongside it." Treating the
// declaration as a replacement silently dropped every skill in the conventional
// location. (The reference's one exception, a marketplace entry whose source resolves
// to the marketplace root, is a marketplace shape pi-code does not model.)
const extra = declaredSkillDirs(plugin.manifest.skills)
const dirs = [...extra, 'skills']
candidates.push(...dirs.map((dir) => pluginComponentPath(plugin, String(dir))).filter((dir): dir is string => dir !== undefined))
// NOT SUPPORTED: Claude's single-skill layout, "a plugin that ships exactly one skill
// can place SKILL.md directly at the plugin root". skillPaths is handed to pi's own
// loader, which owns the layout and looks for
//SKILL.md; adding the plugin
// root here does not surface root/SKILL.md and does start scanning every sibling
// directory (hooks/, agents/, commands/) for skills. Supporting it needs a loader that
// accepts a directory that IS the skill, which is pi's call, not this extension's.
}
// Claude loads skills from every .claude/skills between cwd and the repository
// root; the list goes nearest-first so findClaudeSkill's first match is the
// closest definition (pi's loader receives the same order).
if (trusted) candidates.push(...ancestorDirs(cwd, path.join('.claude', 'skills')))
const dirs: string[] = []
for (const dir of candidates) {
if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
}
return dirs
}
interface FoundSkill {
filePath: string
baseDir: string
}
/** The skill a directory entry holds, named as pi's loader names it (frontmatter
* `name`, else the directory name); undefined without a readable SKILL.md. */
function skillAt(root: string, dirName: string): { name: string; filePath: string } | undefined {
const filePath = path.join(root, dirName, 'SKILL.md')
let content: string
try {
content = fs.readFileSync(filePath, 'utf-8')
} catch {
return undefined
}
let name = dirName
try {
const declared = parseClaudeFrontmatter>(content).frontmatter.name
if (typeof declared === 'string' && declared.trim()) name = declared.trim()
} catch {
// Malformed frontmatter: pi's loader falls back to the directory name too.
}
return { name, filePath }
}
/** A Claude-contributed skill by the name pi's loader gives it. One directory
* level, the standard layout. */
function findClaudeSkill(name: string, roots: string[]): FoundSkill | undefined {
for (const root of roots) {
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(root, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
// A link counts: pi's loader follows it, and skillAt already turns a link that
// leads to no SKILL.md (dangling, or to a plain file) into undefined.
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const skill = skillAt(root, entry.name)
if (skill?.name === name) return { filePath: skill.filePath, baseDir: path.dirname(skill.filePath) }
}
}
return undefined
}
export default function skillsExtension(pi: ExtensionAPI) {
pi.on('resources_discover', async (_event, ctx) => {
// resources_discover fires after session_start, so the approval is already
// resolved; reading it silently keeps a second trust dialog off the screen.
const skillPaths = skillDirs(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
return skillPaths.length > 0 ? { skillPaths } : undefined
})
// The dynamic-content shim: only for skills this extension contributed; pi's own
// `.pi/skills` (or an unknown name) pass through to pi's plain expansion.
pi.on('input', async (event, ctx) => {
if (event.source === 'extension') return
return expandSkillInvocation(pi, event.text, ctx)
})
}
/** Claude's `skillOverrides` value for one skill from the settings chain, later
* files winning: "off" hides the skill entirely, "name-only" trims its listing
* (a pi-loader surface, noted in docs). */
function skillOverrideFor(name: string, cwd: string, trusted: boolean): string | undefined {
let value: string | undefined
for (const settings of readSettingsChain(claudeSettingsChain(cwd, os.homedir(), trusted))) {
const overrides = settings.skillOverrides
if (isRecord(overrides) && typeof overrides[name] === 'string') value = overrides[name]
}
return value
}
/** Claude's skillOverrides "off": the skill is hidden and does not run; the
* invocation is swallowed with a notice. Undefined lets the invocation proceed. */
function refusedByOverride(name: string, ctx: ExtensionContext, trusted: boolean): { action: 'handled' } | undefined {
if (skillOverrideFor(name, ctx.cwd, trusted) !== 'off') return undefined
if (ctx.hasUI) ctx.ui.notify(`Skill "${name}" is turned off by skillOverrides in settings.`, 'info')
return { action: 'handled' }
}
/** Forked skills still running in the background, by name. */
const runningForks = new Set()
/** Whether a context: fork skill holds the invoking turn for its result. Claude: "The
* forked subagent runs in the background: you keep working while it runs, and its result
* arrives in your conversation when it completes. Set background: false in the frontmatter
* to instead wait for the result in the turn that invoked the skill." It also waits in
* non-interactive mode, when CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is 1, and "when you
* invoke a forked skill while an earlier invocation of the same skill is still running". */
function forkWaits(name: string, frontmatter: Record, ctx: ExtensionContext): boolean {
if (String(frontmatter.background).trim().toLowerCase() === 'false') return true
return !ctx.hasUI || process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS === '1' || runningForks.has(name)
}
/** Start a forked skill in the background and hand its result to the conversation when it
* completes. The promise has no awaiter, and sendMessage on a pi whose session was
* replaced meanwhile throws: the catch keeps that from exiting pi, at the cost of the
* delivery, as a background subagent run's completion notice is lost the same way. */
function startForkedSkill(pi: ExtensionAPI, ctx: ExtensionContext, fork: { name: string; filePath: string; expanded: string; agentName: string | undefined }): { action: 'handled' } {
runningForks.add(fork.name)
ctx.ui.notify(`Skill ${fork.name} is running in a forked subagent in the background; its result arrives here when it completes.`, 'info')
void runForkedSkill(fork.name, fork.filePath, fork.expanded, fork.agentName)
.then((result) => pi.sendMessage({ customType: 'skill-fork', content: result.text, display: true }, { triggerTurn: true }))
.catch(() => {})
.finally(() => runningForks.delete(fork.name))
return { action: 'handled' }
}
/** Claude's context: fork run: the expanded skill content becomes the prompt that
* drives a subagent, without the conversation history. Never rejects: a failed run
* is reported in the returned block. */
async function runForkedSkill(name: string, filePath: string, expanded: string, agentName: string | undefined): Promise<{ action: 'transform'; text: string }> {
try {
const output = await runAgent({ prompt: expanded, fullTools: true, ...(agentName ? { agent: agentName } : {}) })
return { action: 'transform', text: `\nThe skill ran in a forked subagent (no conversation history shared). Its result:\n\n${output}\n` }
} catch (error) {
return { action: 'transform', text: `\nThe forked subagent run failed: ${errorMessage(error)}\n` }
}
}
/** A `/skill:name args` invocation into its expanded skill block, or undefined to
* pass the input through to pi untouched. The expanded body is wrapped in pi's
* skill-block format so downstream behavior (the baseDir note for relative
* references) matches an untouched invocation. */
async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: ExtensionContext): Promise<{ action: 'transform'; text: string } | { action: 'handled' } | undefined> {
const text = rawText.trimStart()
if (!text.startsWith('/skill:')) return
const space = text.indexOf(' ')
const name = (space === -1 ? text.slice(7) : text.slice(7, space)).trim()
const args = space === -1 ? '' : text.slice(space + 1).trim()
if (!name) return
const trusted = isProjectApprovedSilently(ctx)
const found = findClaudeSkill(name, skillDirs(ctx.cwd, os.homedir(), trusted))
if (!found) return
const refused = refusedByOverride(name, ctx, trusted)
if (refused) return refused
let parsed: ReturnType
let content: string
try {
content = fs.readFileSync(found.filePath, 'utf-8')
parsed = parseCommandFile(content)
} catch {
// Unreadable, or malformed frontmatter: pass through to pi's plain expansion
// (the loader registered the skill and delivers the raw body), rather than
// failing the invocation over the dynamic features it cannot have.
return
}
// Claude registers hooks a skill's frontmatter declares when the skill is
// invoked, for the rest of the session; the hooks extension owns running them,
// so the declaration is announced over the shared bus.
const frontmatter = parseClaudeFrontmatter>(content).frontmatter
const declaredHooks = frontmatter.hooks
if (declaredHooks !== null && typeof declaredHooks === 'object' && !Array.isArray(declaredHooks)) {
pi.events?.emit(SKILL_HOOKS_CHANNEL, { skillName: name, hooks: declaredHooks })
}
const expanded = await expandCommand(pi, parsed, args, { cwd: ctx.cwd }, found.filePath, undefined, { allowShell: !shellExecutionDisabled(ctx.cwd, os.homedir(), trusted) })
if (typeof frontmatter.context === 'string' && frontmatter.context.trim().toLowerCase() === 'fork') {
const agentName = typeof frontmatter.agent === 'string' ? frontmatter.agent.trim() : undefined
if (forkWaits(name, frontmatter, ctx)) return runForkedSkill(name, found.filePath, expanded, agentName)
return startForkedSkill(pi, ctx, { name, filePath: found.filePath, expanded, agentName })
}
return { action: 'transform', text: `\nReferences are relative to ${found.baseDir}.\n\n${expanded}\n` }
}