* Update create-agent-skills to match 2026 official docs, add /triage-prs command - Rewrite SKILL.md to document that commands and skills are now merged - Add new frontmatter fields: disable-model-invocation, user-invocable, context, agent - Add invocation control table and dynamic context injection docs - Fix skill-structure.md: was incorrectly recommending XML tags over markdown headings - Update official-spec.md with complete 2026 specification - Add local /triage-prs command for PR triage workflow - Add PR triage plan document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * [2.31.0] Reduce context token usage by 79%, include recent community contributions The plugin was consuming 316% of Claude Code's description character budget (~50,500 chars vs 16,000 limit), causing components to be silently excluded. Now at 65% (~10,400 chars) with all components visible. Changes: - Trim all 29 agent descriptions (move examples to body) - Add disable-model-invocation to 18 manual commands - Add disable-model-invocation to 6 manual skills - Include recent community contributions in changelog - Fix component counts (29 agents, 24 commands, 18 skills) Contributors: @trevin, @terryli, @robertomello, @zacwilliams, @aarnikoskela, @samxie, @davidalley Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix: keep disable-model-invocation off commands called by /lfg, rename xcode-test - Remove disable-model-invocation from test-browser, feature-video, resolve_todo_parallel — these are called programmatically by /lfg and /slfg - Rename xcode-test to test-xcode to match test-browser naming convention Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix: keep git-worktree skill auto-invocable (used by /workflows:work) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(converter): support disable-model-invocation frontmatter Parse disable-model-invocation from command and skill frontmatter. Commands/skills with this flag are excluded from OpenCode command maps and Codex prompt/skill generation, matching Claude Code behavior where these components are user-only invocable. Bump converter version to 0.3.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
253 lines
7.6 KiB
TypeScript
253 lines
7.6 KiB
TypeScript
import path from "path"
|
|
import { parseFrontmatter } from "../utils/frontmatter"
|
|
import { readJson, readText, pathExists, walkFiles } from "../utils/files"
|
|
import type {
|
|
ClaudeAgent,
|
|
ClaudeCommand,
|
|
ClaudeHooks,
|
|
ClaudeManifest,
|
|
ClaudeMcpServer,
|
|
ClaudePlugin,
|
|
ClaudeSkill,
|
|
} from "../types/claude"
|
|
|
|
const PLUGIN_MANIFEST = path.join(".claude-plugin", "plugin.json")
|
|
|
|
export async function loadClaudePlugin(inputPath: string): Promise<ClaudePlugin> {
|
|
const root = await resolveClaudeRoot(inputPath)
|
|
const manifestPath = path.join(root, PLUGIN_MANIFEST)
|
|
const manifest = await readJson<ClaudeManifest>(manifestPath)
|
|
|
|
const agents = await loadAgents(resolveComponentDirs(root, "agents", manifest.agents))
|
|
const commands = await loadCommands(resolveComponentDirs(root, "commands", manifest.commands))
|
|
const skills = await loadSkills(resolveComponentDirs(root, "skills", manifest.skills))
|
|
const hooks = await loadHooks(root, manifest.hooks)
|
|
|
|
const mcpServers = await loadMcpServers(root, manifest)
|
|
|
|
return {
|
|
root,
|
|
manifest,
|
|
agents,
|
|
commands,
|
|
skills,
|
|
hooks,
|
|
mcpServers,
|
|
}
|
|
}
|
|
|
|
async function resolveClaudeRoot(inputPath: string): Promise<string> {
|
|
const absolute = path.resolve(inputPath)
|
|
const manifestAtPath = path.join(absolute, PLUGIN_MANIFEST)
|
|
if (await pathExists(manifestAtPath)) {
|
|
return absolute
|
|
}
|
|
|
|
if (absolute.endsWith(PLUGIN_MANIFEST)) {
|
|
return path.dirname(path.dirname(absolute))
|
|
}
|
|
|
|
if (absolute.endsWith("plugin.json")) {
|
|
return path.dirname(path.dirname(absolute))
|
|
}
|
|
|
|
throw new Error(`Could not find ${PLUGIN_MANIFEST} under ${inputPath}`)
|
|
}
|
|
|
|
async function loadAgents(agentsDirs: string[]): Promise<ClaudeAgent[]> {
|
|
const files = await collectMarkdownFiles(agentsDirs)
|
|
|
|
const agents: ClaudeAgent[] = []
|
|
for (const file of files) {
|
|
const raw = await readText(file)
|
|
const { data, body } = parseFrontmatter(raw)
|
|
const name = (data.name as string) ?? path.basename(file, ".md")
|
|
agents.push({
|
|
name,
|
|
description: data.description as string | undefined,
|
|
capabilities: data.capabilities as string[] | undefined,
|
|
model: data.model as string | undefined,
|
|
body: body.trim(),
|
|
sourcePath: file,
|
|
})
|
|
}
|
|
return agents
|
|
}
|
|
|
|
async function loadCommands(commandsDirs: string[]): Promise<ClaudeCommand[]> {
|
|
const files = await collectMarkdownFiles(commandsDirs)
|
|
|
|
const commands: ClaudeCommand[] = []
|
|
for (const file of files) {
|
|
const raw = await readText(file)
|
|
const { data, body } = parseFrontmatter(raw)
|
|
const name = (data.name as string) ?? path.basename(file, ".md")
|
|
const allowedTools = parseAllowedTools(data["allowed-tools"])
|
|
const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined
|
|
commands.push({
|
|
name,
|
|
description: data.description as string | undefined,
|
|
argumentHint: data["argument-hint"] as string | undefined,
|
|
model: data.model as string | undefined,
|
|
allowedTools,
|
|
disableModelInvocation,
|
|
body: body.trim(),
|
|
sourcePath: file,
|
|
})
|
|
}
|
|
return commands
|
|
}
|
|
|
|
async function loadSkills(skillsDirs: string[]): Promise<ClaudeSkill[]> {
|
|
const entries = await collectFiles(skillsDirs)
|
|
const skillFiles = entries.filter((file) => path.basename(file) === "SKILL.md")
|
|
const skills: ClaudeSkill[] = []
|
|
for (const file of skillFiles) {
|
|
const raw = await readText(file)
|
|
const { data } = parseFrontmatter(raw)
|
|
const name = (data.name as string) ?? path.basename(path.dirname(file))
|
|
const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined
|
|
skills.push({
|
|
name,
|
|
description: data.description as string | undefined,
|
|
disableModelInvocation,
|
|
sourceDir: path.dirname(file),
|
|
skillPath: file,
|
|
})
|
|
}
|
|
return skills
|
|
}
|
|
|
|
async function loadHooks(root: string, hooksField?: ClaudeManifest["hooks"]): Promise<ClaudeHooks | undefined> {
|
|
const hookConfigs: ClaudeHooks[] = []
|
|
|
|
const defaultPath = path.join(root, "hooks", "hooks.json")
|
|
if (await pathExists(defaultPath)) {
|
|
hookConfigs.push(await readJson<ClaudeHooks>(defaultPath))
|
|
}
|
|
|
|
if (hooksField) {
|
|
if (typeof hooksField === "string" || Array.isArray(hooksField)) {
|
|
const hookPaths = toPathList(hooksField)
|
|
for (const hookPath of hookPaths) {
|
|
const resolved = resolveWithinRoot(root, hookPath, "hooks path")
|
|
if (await pathExists(resolved)) {
|
|
hookConfigs.push(await readJson<ClaudeHooks>(resolved))
|
|
}
|
|
}
|
|
} else {
|
|
hookConfigs.push(hooksField)
|
|
}
|
|
}
|
|
|
|
if (hookConfigs.length === 0) return undefined
|
|
return mergeHooks(hookConfigs)
|
|
}
|
|
|
|
async function loadMcpServers(
|
|
root: string,
|
|
manifest: ClaudeManifest,
|
|
): Promise<Record<string, ClaudeMcpServer> | undefined> {
|
|
const field = manifest.mcpServers
|
|
if (field) {
|
|
if (typeof field === "string" || Array.isArray(field)) {
|
|
return mergeMcpConfigs(await loadMcpPaths(root, field))
|
|
}
|
|
return field as Record<string, ClaudeMcpServer>
|
|
}
|
|
|
|
const mcpPath = path.join(root, ".mcp.json")
|
|
if (await pathExists(mcpPath)) {
|
|
return readJson<Record<string, ClaudeMcpServer>>(mcpPath)
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
function parseAllowedTools(value: unknown): string[] | undefined {
|
|
if (!value) return undefined
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => String(item))
|
|
}
|
|
if (typeof value === "string") {
|
|
return value
|
|
.split(/,/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function resolveComponentDirs(
|
|
root: string,
|
|
defaultDir: string,
|
|
custom?: string | string[],
|
|
): string[] {
|
|
const dirs = [path.join(root, defaultDir)]
|
|
for (const entry of toPathList(custom)) {
|
|
dirs.push(resolveWithinRoot(root, entry, `${defaultDir} path`))
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
function toPathList(value?: string | string[]): string[] {
|
|
if (!value) return []
|
|
if (Array.isArray(value)) return value
|
|
return [value]
|
|
}
|
|
|
|
async function collectMarkdownFiles(dirs: string[]): Promise<string[]> {
|
|
const entries = await collectFiles(dirs)
|
|
return entries.filter((file) => file.endsWith(".md"))
|
|
}
|
|
|
|
async function collectFiles(dirs: string[]): Promise<string[]> {
|
|
const files: string[] = []
|
|
for (const dir of dirs) {
|
|
if (!(await pathExists(dir))) continue
|
|
const entries = await walkFiles(dir)
|
|
files.push(...entries)
|
|
}
|
|
return files
|
|
}
|
|
|
|
function mergeHooks(hooksList: ClaudeHooks[]): ClaudeHooks {
|
|
const merged: ClaudeHooks = { hooks: {} }
|
|
for (const hooks of hooksList) {
|
|
for (const [event, matchers] of Object.entries(hooks.hooks)) {
|
|
if (!merged.hooks[event]) {
|
|
merged.hooks[event] = []
|
|
}
|
|
merged.hooks[event].push(...matchers)
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
async function loadMcpPaths(
|
|
root: string,
|
|
value: string | string[],
|
|
): Promise<Record<string, ClaudeMcpServer>[]> {
|
|
const configs: Record<string, ClaudeMcpServer>[] = []
|
|
for (const entry of toPathList(value)) {
|
|
const resolved = resolveWithinRoot(root, entry, "mcpServers path")
|
|
if (await pathExists(resolved)) {
|
|
configs.push(await readJson<Record<string, ClaudeMcpServer>>(resolved))
|
|
}
|
|
}
|
|
return configs
|
|
}
|
|
|
|
function mergeMcpConfigs(configs: Record<string, ClaudeMcpServer>[]): Record<string, ClaudeMcpServer> {
|
|
return configs.reduce((acc, config) => ({ ...acc, ...config }), {})
|
|
}
|
|
|
|
function resolveWithinRoot(root: string, entry: string, label: string): string {
|
|
const resolvedRoot = path.resolve(root)
|
|
const resolvedPath = path.resolve(root, entry)
|
|
if (resolvedPath === resolvedRoot || resolvedPath.startsWith(resolvedRoot + path.sep)) {
|
|
return resolvedPath
|
|
}
|
|
throw new Error(`Invalid ${label}: ${entry}. Paths must stay within the plugin root.`)
|
|
}
|