init claude-code
This commit is contained in:
@@ -0,0 +1,946 @@
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { getInlinePlugins, getSessionId } from '../../bootstrap/state.js'
|
||||
import type { Command } from '../../types/command.js'
|
||||
import { getPluginErrorMessage } from '../../types/plugin.js'
|
||||
import {
|
||||
parseArgumentNames,
|
||||
substituteArguments,
|
||||
} from '../argumentSubstitution.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { EFFORT_LEVELS, parseEffortValue } from '../effort.js'
|
||||
import { isBareMode } from '../envUtils.js'
|
||||
import { isENOENT } from '../errors.js'
|
||||
import {
|
||||
coerceDescriptionToString,
|
||||
type FrontmatterData,
|
||||
parseBooleanFrontmatter,
|
||||
parseFrontmatter,
|
||||
parseShellFrontmatter,
|
||||
} from '../frontmatterParser.js'
|
||||
import { getFsImplementation, isDuplicatePath } from '../fsOperations.js'
|
||||
import {
|
||||
extractDescriptionFromMarkdown,
|
||||
parseSlashCommandToolsFromFrontmatter,
|
||||
} from '../markdownConfigLoader.js'
|
||||
import { parseUserSpecifiedModel } from '../model/model.js'
|
||||
import { executeShellCommandsInPrompt } from '../promptShellExecution.js'
|
||||
import { loadAllPluginsCacheOnly } from './pluginLoader.js'
|
||||
import {
|
||||
loadPluginOptions,
|
||||
substitutePluginVariables,
|
||||
substituteUserConfigInContent,
|
||||
} from './pluginOptionsStorage.js'
|
||||
import type { CommandMetadata, PluginManifest } from './schemas.js'
|
||||
import { walkPluginMarkdown } from './walkPluginMarkdown.js'
|
||||
|
||||
// Similar to MarkdownFile but for plugin sources
|
||||
type PluginMarkdownFile = {
|
||||
filePath: string
|
||||
baseDir: string
|
||||
frontmatter: FrontmatterData
|
||||
content: string
|
||||
}
|
||||
|
||||
// Configuration for loading commands or skills
|
||||
type LoadConfig = {
|
||||
isSkillMode: boolean // true when loading from skills/ directory
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is a skill file (SKILL.md)
|
||||
*/
|
||||
function isSkillFile(filePath: string): boolean {
|
||||
return /^skill\.md$/i.test(basename(filePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get command name from file path, handling both regular files and skills
|
||||
*/
|
||||
function getCommandNameFromFile(
|
||||
filePath: string,
|
||||
baseDir: string,
|
||||
pluginName: string,
|
||||
): string {
|
||||
const isSkill = isSkillFile(filePath)
|
||||
|
||||
if (isSkill) {
|
||||
// For skills, use the parent directory name
|
||||
const skillDirectory = dirname(filePath)
|
||||
const parentOfSkillDir = dirname(skillDirectory)
|
||||
const commandBaseName = basename(skillDirectory)
|
||||
|
||||
// Build namespace from parent of skill directory
|
||||
const relativePath = parentOfSkillDir.startsWith(baseDir)
|
||||
? parentOfSkillDir.slice(baseDir.length).replace(/^\//, '')
|
||||
: ''
|
||||
const namespace = relativePath ? relativePath.split('/').join(':') : ''
|
||||
|
||||
return namespace
|
||||
? `${pluginName}:${namespace}:${commandBaseName}`
|
||||
: `${pluginName}:${commandBaseName}`
|
||||
} else {
|
||||
// For regular files, use filename without .md
|
||||
const fileDirectory = dirname(filePath)
|
||||
const commandBaseName = basename(filePath).replace(/\.md$/, '')
|
||||
|
||||
// Build namespace from file directory
|
||||
const relativePath = fileDirectory.startsWith(baseDir)
|
||||
? fileDirectory.slice(baseDir.length).replace(/^\//, '')
|
||||
: ''
|
||||
const namespace = relativePath ? relativePath.split('/').join(':') : ''
|
||||
|
||||
return namespace
|
||||
? `${pluginName}:${namespace}:${commandBaseName}`
|
||||
: `${pluginName}:${commandBaseName}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects all markdown files from a directory
|
||||
*/
|
||||
async function collectMarkdownFiles(
|
||||
dirPath: string,
|
||||
baseDir: string,
|
||||
loadedPaths: Set<string>,
|
||||
): Promise<PluginMarkdownFile[]> {
|
||||
const files: PluginMarkdownFile[] = []
|
||||
const fs = getFsImplementation()
|
||||
|
||||
await walkPluginMarkdown(
|
||||
dirPath,
|
||||
async fullPath => {
|
||||
if (isDuplicatePath(fs, fullPath, loadedPaths)) return
|
||||
const content = await fs.readFile(fullPath, { encoding: 'utf-8' })
|
||||
const { frontmatter, content: markdownContent } = parseFrontmatter(
|
||||
content,
|
||||
fullPath,
|
||||
)
|
||||
files.push({
|
||||
filePath: fullPath,
|
||||
baseDir,
|
||||
frontmatter,
|
||||
content: markdownContent,
|
||||
})
|
||||
},
|
||||
{ stopAtSkillDir: true, logLabel: 'commands' },
|
||||
)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms plugin markdown files to handle skill directories
|
||||
*/
|
||||
function transformPluginSkillFiles(
|
||||
files: PluginMarkdownFile[],
|
||||
): PluginMarkdownFile[] {
|
||||
const filesByDir = new Map<string, PluginMarkdownFile[]>()
|
||||
|
||||
for (const file of files) {
|
||||
const dir = dirname(file.filePath)
|
||||
const dirFiles = filesByDir.get(dir) ?? []
|
||||
dirFiles.push(file)
|
||||
filesByDir.set(dir, dirFiles)
|
||||
}
|
||||
|
||||
const result: PluginMarkdownFile[] = []
|
||||
|
||||
for (const [dir, dirFiles] of filesByDir) {
|
||||
const skillFiles = dirFiles.filter(f => isSkillFile(f.filePath))
|
||||
if (skillFiles.length > 0) {
|
||||
// Use the first skill file if multiple exist
|
||||
const skillFile = skillFiles[0]!
|
||||
if (skillFiles.length > 1) {
|
||||
logForDebugging(
|
||||
`Multiple skill files found in ${dir}, using ${basename(skillFile.filePath)}`,
|
||||
)
|
||||
}
|
||||
// Directory has a skill - only include the skill file
|
||||
result.push(skillFile)
|
||||
} else {
|
||||
result.push(...dirFiles)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function loadCommandsFromDirectory(
|
||||
commandsPath: string,
|
||||
pluginName: string,
|
||||
sourceName: string,
|
||||
pluginManifest: PluginManifest,
|
||||
pluginPath: string,
|
||||
config: LoadConfig = { isSkillMode: false },
|
||||
loadedPaths: Set<string> = new Set(),
|
||||
): Promise<Command[]> {
|
||||
// Collect all markdown files
|
||||
const markdownFiles = await collectMarkdownFiles(
|
||||
commandsPath,
|
||||
commandsPath,
|
||||
loadedPaths,
|
||||
)
|
||||
|
||||
// Apply skill transformation
|
||||
const processedFiles = transformPluginSkillFiles(markdownFiles)
|
||||
|
||||
// Convert to commands
|
||||
const commands: Command[] = []
|
||||
for (const file of processedFiles) {
|
||||
const commandName = getCommandNameFromFile(
|
||||
file.filePath,
|
||||
file.baseDir,
|
||||
pluginName,
|
||||
)
|
||||
|
||||
const command = createPluginCommand(
|
||||
commandName,
|
||||
file,
|
||||
sourceName,
|
||||
pluginManifest,
|
||||
pluginPath,
|
||||
isSkillFile(file.filePath),
|
||||
config,
|
||||
)
|
||||
|
||||
if (command) {
|
||||
commands.push(command)
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Command from a plugin markdown file
|
||||
*/
|
||||
function createPluginCommand(
|
||||
commandName: string,
|
||||
file: PluginMarkdownFile,
|
||||
sourceName: string,
|
||||
pluginManifest: PluginManifest,
|
||||
pluginPath: string,
|
||||
isSkill: boolean,
|
||||
config: LoadConfig = { isSkillMode: false },
|
||||
): Command | null {
|
||||
try {
|
||||
const { frontmatter, content } = file
|
||||
|
||||
const validatedDescription = coerceDescriptionToString(
|
||||
frontmatter.description,
|
||||
commandName,
|
||||
)
|
||||
const description =
|
||||
validatedDescription ??
|
||||
extractDescriptionFromMarkdown(
|
||||
content,
|
||||
isSkill ? 'Plugin skill' : 'Plugin command',
|
||||
)
|
||||
|
||||
// Substitute ${CLAUDE_PLUGIN_ROOT} in allowed-tools before parsing
|
||||
const rawAllowedTools = frontmatter['allowed-tools']
|
||||
const substitutedAllowedTools =
|
||||
typeof rawAllowedTools === 'string'
|
||||
? substitutePluginVariables(rawAllowedTools, {
|
||||
path: pluginPath,
|
||||
source: sourceName,
|
||||
})
|
||||
: Array.isArray(rawAllowedTools)
|
||||
? rawAllowedTools.map(tool =>
|
||||
typeof tool === 'string'
|
||||
? substitutePluginVariables(tool, {
|
||||
path: pluginPath,
|
||||
source: sourceName,
|
||||
})
|
||||
: tool,
|
||||
)
|
||||
: rawAllowedTools
|
||||
const allowedTools = parseSlashCommandToolsFromFrontmatter(
|
||||
substitutedAllowedTools,
|
||||
)
|
||||
|
||||
const argumentHint = frontmatter['argument-hint'] as string | undefined
|
||||
const argumentNames = parseArgumentNames(
|
||||
frontmatter.arguments as string | string[] | undefined,
|
||||
)
|
||||
const whenToUse = frontmatter.when_to_use as string | undefined
|
||||
const version = frontmatter.version as string | undefined
|
||||
const displayName = frontmatter.name as string | undefined
|
||||
|
||||
// Handle model configuration, resolving aliases like 'haiku', 'sonnet', 'opus'
|
||||
const model =
|
||||
frontmatter.model === 'inherit'
|
||||
? undefined
|
||||
: frontmatter.model
|
||||
? parseUserSpecifiedModel(frontmatter.model as string)
|
||||
: undefined
|
||||
|
||||
const effortRaw = frontmatter['effort']
|
||||
const effort =
|
||||
effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
|
||||
if (effortRaw !== undefined && effort === undefined) {
|
||||
logForDebugging(
|
||||
`Plugin command ${commandName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
|
||||
)
|
||||
}
|
||||
|
||||
const disableModelInvocation = parseBooleanFrontmatter(
|
||||
frontmatter['disable-model-invocation'],
|
||||
)
|
||||
|
||||
const userInvocableValue = frontmatter['user-invocable']
|
||||
const userInvocable =
|
||||
userInvocableValue === undefined
|
||||
? true
|
||||
: parseBooleanFrontmatter(userInvocableValue)
|
||||
|
||||
const shell = parseShellFrontmatter(frontmatter.shell, commandName)
|
||||
|
||||
return {
|
||||
type: 'prompt',
|
||||
name: commandName,
|
||||
description,
|
||||
hasUserSpecifiedDescription: validatedDescription !== null,
|
||||
allowedTools,
|
||||
argumentHint,
|
||||
argNames: argumentNames.length > 0 ? argumentNames : undefined,
|
||||
whenToUse,
|
||||
version,
|
||||
model,
|
||||
effort,
|
||||
disableModelInvocation,
|
||||
userInvocable,
|
||||
contentLength: content.length,
|
||||
source: 'plugin' as const,
|
||||
loadedFrom: isSkill || config.isSkillMode ? 'plugin' : undefined,
|
||||
pluginInfo: {
|
||||
pluginManifest,
|
||||
repository: sourceName,
|
||||
},
|
||||
isHidden: !userInvocable,
|
||||
progressMessage: isSkill || config.isSkillMode ? 'loading' : 'running',
|
||||
userFacingName(): string {
|
||||
return displayName || commandName
|
||||
},
|
||||
async getPromptForCommand(args, context) {
|
||||
// For skills from skills/ directory, include base directory
|
||||
let finalContent = config.isSkillMode
|
||||
? `Base directory for this skill: ${dirname(file.filePath)}\n\n${content}`
|
||||
: content
|
||||
|
||||
finalContent = substituteArguments(
|
||||
finalContent,
|
||||
args,
|
||||
true,
|
||||
argumentNames,
|
||||
)
|
||||
|
||||
// Replace ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA} with their paths
|
||||
finalContent = substitutePluginVariables(finalContent, {
|
||||
path: pluginPath,
|
||||
source: sourceName,
|
||||
})
|
||||
|
||||
// Replace ${user_config.X} with saved option values. Sensitive keys
|
||||
// resolve to a descriptive placeholder instead — skill content goes to
|
||||
// the model prompt and we don't put secrets there.
|
||||
if (pluginManifest.userConfig) {
|
||||
finalContent = substituteUserConfigInContent(
|
||||
finalContent,
|
||||
loadPluginOptions(sourceName),
|
||||
pluginManifest.userConfig,
|
||||
)
|
||||
}
|
||||
|
||||
// Replace ${CLAUDE_SKILL_DIR} with this specific skill's directory.
|
||||
// Distinct from ${CLAUDE_PLUGIN_ROOT}: a plugin can contain multiple
|
||||
// skills, so CLAUDE_PLUGIN_ROOT points to the plugin root while
|
||||
// CLAUDE_SKILL_DIR points to the individual skill's subdirectory.
|
||||
if (config.isSkillMode) {
|
||||
const rawSkillDir = dirname(file.filePath)
|
||||
const skillDir =
|
||||
process.platform === 'win32'
|
||||
? rawSkillDir.replace(/\\/g, '/')
|
||||
: rawSkillDir
|
||||
finalContent = finalContent.replace(
|
||||
/\$\{CLAUDE_SKILL_DIR\}/g,
|
||||
skillDir,
|
||||
)
|
||||
}
|
||||
|
||||
// Replace ${CLAUDE_SESSION_ID} with the current session ID
|
||||
finalContent = finalContent.replace(
|
||||
/\$\{CLAUDE_SESSION_ID\}/g,
|
||||
getSessionId(),
|
||||
)
|
||||
|
||||
finalContent = await executeShellCommandsInPrompt(
|
||||
finalContent,
|
||||
{
|
||||
...context,
|
||||
getAppState() {
|
||||
const appState = context.getAppState()
|
||||
return {
|
||||
...appState,
|
||||
toolPermissionContext: {
|
||||
...appState.toolPermissionContext,
|
||||
alwaysAllowRules: {
|
||||
...appState.toolPermissionContext.alwaysAllowRules,
|
||||
command: allowedTools,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
`/${commandName}`,
|
||||
shell,
|
||||
)
|
||||
|
||||
return [{ type: 'text', text: finalContent }]
|
||||
},
|
||||
} satisfies Command
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`Failed to create command from ${file.filePath}: ${error}`,
|
||||
{
|
||||
level: 'error',
|
||||
},
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const getPluginCommands = memoize(async (): Promise<Command[]> => {
|
||||
// --bare: skip marketplace plugin auto-load. Explicit --plugin-dir still
|
||||
// works — getInlinePlugins() is set by main.tsx from --plugin-dir.
|
||||
// loadAllPluginsCacheOnly already short-circuits to inline-only when
|
||||
// inlinePlugins.length > 0.
|
||||
if (isBareMode() && getInlinePlugins().length === 0) {
|
||||
return []
|
||||
}
|
||||
// Only load commands from enabled plugins
|
||||
const { enabled, errors } = await loadAllPluginsCacheOnly()
|
||||
|
||||
if (errors.length > 0) {
|
||||
logForDebugging(
|
||||
`Plugin loading errors: ${errors.map(e => getPluginErrorMessage(e)).join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Process plugins in parallel; each plugin has its own loadedPaths scope
|
||||
const perPluginCommands = await Promise.all(
|
||||
enabled.map(async (plugin): Promise<Command[]> => {
|
||||
// Track loaded file paths to prevent duplicates within this plugin
|
||||
const loadedPaths = new Set<string>()
|
||||
const pluginCommands: Command[] = []
|
||||
|
||||
// Load commands from default commands directory
|
||||
if (plugin.commandsPath) {
|
||||
try {
|
||||
const commands = await loadCommandsFromDirectory(
|
||||
plugin.commandsPath,
|
||||
plugin.name,
|
||||
plugin.source,
|
||||
plugin.manifest,
|
||||
plugin.path,
|
||||
{ isSkillMode: false },
|
||||
loadedPaths,
|
||||
)
|
||||
pluginCommands.push(...commands)
|
||||
|
||||
if (commands.length > 0) {
|
||||
logForDebugging(
|
||||
`Loaded ${commands.length} commands from plugin ${plugin.name} default directory`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`Failed to load commands from plugin ${plugin.name} default directory: ${error}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Load commands from additional paths specified in manifest
|
||||
if (plugin.commandsPaths) {
|
||||
logForDebugging(
|
||||
`Plugin ${plugin.name} has commandsPaths: ${plugin.commandsPaths.join(', ')}`,
|
||||
)
|
||||
// Process all commandsPaths in parallel. isDuplicatePath is synchronous
|
||||
// (check-and-add), so concurrent access to loadedPaths is safe.
|
||||
const pathResults = await Promise.all(
|
||||
plugin.commandsPaths.map(async (commandPath): Promise<Command[]> => {
|
||||
try {
|
||||
const fs = getFsImplementation()
|
||||
const stats = await fs.stat(commandPath)
|
||||
logForDebugging(
|
||||
`Checking commandPath ${commandPath} - isDirectory: ${stats.isDirectory()}, isFile: ${stats.isFile()}`,
|
||||
)
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Load all .md files and skill directories from directory
|
||||
const commands = await loadCommandsFromDirectory(
|
||||
commandPath,
|
||||
plugin.name,
|
||||
plugin.source,
|
||||
plugin.manifest,
|
||||
plugin.path,
|
||||
{ isSkillMode: false },
|
||||
loadedPaths,
|
||||
)
|
||||
|
||||
if (commands.length > 0) {
|
||||
logForDebugging(
|
||||
`Loaded ${commands.length} commands from plugin ${plugin.name} custom path: ${commandPath}`,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`Warning: No commands found in plugin ${plugin.name} custom directory: ${commandPath}. Expected .md files or SKILL.md in subdirectories.`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
return commands
|
||||
} else if (stats.isFile() && commandPath.endsWith('.md')) {
|
||||
if (isDuplicatePath(fs, commandPath, loadedPaths)) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Load single command file
|
||||
const content = await fs.readFile(commandPath, {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
const { frontmatter, content: markdownContent } =
|
||||
parseFrontmatter(content, commandPath)
|
||||
|
||||
// Check if there's metadata for this command (object-mapping format)
|
||||
let commandName: string | undefined
|
||||
let metadataOverride: CommandMetadata | undefined
|
||||
|
||||
if (plugin.commandsMetadata) {
|
||||
// Find metadata by matching the command's absolute path to the metadata source
|
||||
// Convert metadata.source (relative to plugin root) to absolute path for comparison
|
||||
for (const [name, metadata] of Object.entries(
|
||||
plugin.commandsMetadata,
|
||||
)) {
|
||||
if (metadata.source) {
|
||||
const fullMetadataPath = join(
|
||||
plugin.path,
|
||||
metadata.source,
|
||||
)
|
||||
if (commandPath === fullMetadataPath) {
|
||||
commandName = `${plugin.name}:${name}`
|
||||
metadataOverride = metadata
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to filename-based naming if no metadata
|
||||
if (!commandName) {
|
||||
commandName = `${plugin.name}:${basename(commandPath).replace(/\.md$/, '')}`
|
||||
}
|
||||
|
||||
// Apply metadata overrides to frontmatter
|
||||
const finalFrontmatter = metadataOverride
|
||||
? {
|
||||
...frontmatter,
|
||||
...(metadataOverride.description && {
|
||||
description: metadataOverride.description,
|
||||
}),
|
||||
...(metadataOverride.argumentHint && {
|
||||
'argument-hint': metadataOverride.argumentHint,
|
||||
}),
|
||||
...(metadataOverride.model && {
|
||||
model: metadataOverride.model,
|
||||
}),
|
||||
...(metadataOverride.allowedTools && {
|
||||
'allowed-tools':
|
||||
metadataOverride.allowedTools.join(','),
|
||||
}),
|
||||
}
|
||||
: frontmatter
|
||||
|
||||
const file: PluginMarkdownFile = {
|
||||
filePath: commandPath,
|
||||
baseDir: dirname(commandPath),
|
||||
frontmatter: finalFrontmatter,
|
||||
content: markdownContent,
|
||||
}
|
||||
|
||||
const command = createPluginCommand(
|
||||
commandName,
|
||||
file,
|
||||
plugin.source,
|
||||
plugin.manifest,
|
||||
plugin.path,
|
||||
false,
|
||||
)
|
||||
|
||||
if (command) {
|
||||
logForDebugging(
|
||||
`Loaded command from plugin ${plugin.name} custom file: ${commandPath}${metadataOverride ? ' (with metadata override)' : ''}`,
|
||||
)
|
||||
return [command]
|
||||
}
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`Failed to load commands from plugin ${plugin.name} custom path ${commandPath}: ${error}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
return []
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const commands of pathResults) {
|
||||
pluginCommands.push(...commands)
|
||||
}
|
||||
}
|
||||
|
||||
// Load commands with inline content (no source file)
|
||||
// Note: Commands with source files were already loaded in the previous loop
|
||||
// when iterating through commandsPaths. This loop handles metadata entries
|
||||
// that specify inline content instead of file references.
|
||||
if (plugin.commandsMetadata) {
|
||||
for (const [name, metadata] of Object.entries(
|
||||
plugin.commandsMetadata,
|
||||
)) {
|
||||
// Only process entries with inline content (no source)
|
||||
if (metadata.content && !metadata.source) {
|
||||
try {
|
||||
// Parse inline content for frontmatter
|
||||
const { frontmatter, content: markdownContent } =
|
||||
parseFrontmatter(
|
||||
metadata.content,
|
||||
`<inline:${plugin.name}:${name}>`,
|
||||
)
|
||||
|
||||
// Apply metadata overrides to frontmatter
|
||||
const finalFrontmatter: FrontmatterData = {
|
||||
...frontmatter,
|
||||
...(metadata.description && {
|
||||
description: metadata.description,
|
||||
}),
|
||||
...(metadata.argumentHint && {
|
||||
'argument-hint': metadata.argumentHint,
|
||||
}),
|
||||
...(metadata.model && {
|
||||
model: metadata.model,
|
||||
}),
|
||||
...(metadata.allowedTools && {
|
||||
'allowed-tools': metadata.allowedTools.join(','),
|
||||
}),
|
||||
}
|
||||
|
||||
const commandName = `${plugin.name}:${name}`
|
||||
const file: PluginMarkdownFile = {
|
||||
filePath: `<inline:${commandName}>`, // Virtual path for inline content
|
||||
baseDir: plugin.path, // Use plugin root as base directory
|
||||
frontmatter: finalFrontmatter,
|
||||
content: markdownContent,
|
||||
}
|
||||
|
||||
const command = createPluginCommand(
|
||||
commandName,
|
||||
file,
|
||||
plugin.source,
|
||||
plugin.manifest,
|
||||
plugin.path,
|
||||
false,
|
||||
)
|
||||
|
||||
if (command) {
|
||||
pluginCommands.push(command)
|
||||
logForDebugging(
|
||||
`Loaded inline content command from plugin ${plugin.name}: ${commandName}`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`Failed to load inline content command ${name} from plugin ${plugin.name}: ${error}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return pluginCommands
|
||||
}),
|
||||
)
|
||||
|
||||
const allCommands = perPluginCommands.flat()
|
||||
logForDebugging(`Total plugin commands loaded: ${allCommands.length}`)
|
||||
return allCommands
|
||||
})
|
||||
|
||||
export function clearPluginCommandCache(): void {
|
||||
getPluginCommands.cache?.clear?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads skills from plugin skills directories
|
||||
* Skills are directories containing SKILL.md files
|
||||
*/
|
||||
async function loadSkillsFromDirectory(
|
||||
skillsPath: string,
|
||||
pluginName: string,
|
||||
sourceName: string,
|
||||
pluginManifest: PluginManifest,
|
||||
pluginPath: string,
|
||||
loadedPaths: Set<string>,
|
||||
): Promise<Command[]> {
|
||||
const fs = getFsImplementation()
|
||||
const skills: Command[] = []
|
||||
|
||||
// First, check if skillsPath itself contains SKILL.md (direct skill directory)
|
||||
const directSkillPath = join(skillsPath, 'SKILL.md')
|
||||
let directSkillContent: string | null = null
|
||||
try {
|
||||
directSkillContent = await fs.readFile(directSkillPath, {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
if (!isENOENT(e)) {
|
||||
logForDebugging(`Failed to load skill from ${directSkillPath}: ${e}`, {
|
||||
level: 'error',
|
||||
})
|
||||
return skills
|
||||
}
|
||||
// ENOENT: no direct SKILL.md, fall through to scan subdirectories
|
||||
}
|
||||
|
||||
if (directSkillContent !== null) {
|
||||
// This is a direct skill directory, load the skill from here
|
||||
if (isDuplicatePath(fs, directSkillPath, loadedPaths)) {
|
||||
return skills
|
||||
}
|
||||
try {
|
||||
const { frontmatter, content: markdownContent } = parseFrontmatter(
|
||||
directSkillContent,
|
||||
directSkillPath,
|
||||
)
|
||||
|
||||
const skillName = `${pluginName}:${basename(skillsPath)}`
|
||||
|
||||
const file: PluginMarkdownFile = {
|
||||
filePath: directSkillPath,
|
||||
baseDir: dirname(directSkillPath),
|
||||
frontmatter,
|
||||
content: markdownContent,
|
||||
}
|
||||
|
||||
const skill = createPluginCommand(
|
||||
skillName,
|
||||
file,
|
||||
sourceName,
|
||||
pluginManifest,
|
||||
pluginPath,
|
||||
true, // isSkill
|
||||
{ isSkillMode: true }, // config
|
||||
)
|
||||
|
||||
if (skill) {
|
||||
skills.push(skill)
|
||||
}
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`Failed to load skill from ${directSkillPath}: ${error}`,
|
||||
{
|
||||
level: 'error',
|
||||
},
|
||||
)
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
// Otherwise, scan for subdirectories containing SKILL.md files
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(skillsPath)
|
||||
} catch (e: unknown) {
|
||||
if (!isENOENT(e)) {
|
||||
logForDebugging(
|
||||
`Failed to load skills from directory ${skillsPath}: ${e}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async entry => {
|
||||
// Accept both directories and symlinks (symlinks may point to skill directories)
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
return
|
||||
}
|
||||
|
||||
const skillDirPath = join(skillsPath, entry.name)
|
||||
const skillFilePath = join(skillDirPath, 'SKILL.md')
|
||||
|
||||
// Try to read SKILL.md directly; skip if it doesn't exist
|
||||
let content: string
|
||||
try {
|
||||
content = await fs.readFile(skillFilePath, { encoding: 'utf-8' })
|
||||
} catch (e: unknown) {
|
||||
if (!isENOENT(e)) {
|
||||
logForDebugging(`Failed to load skill from ${skillFilePath}: ${e}`, {
|
||||
level: 'error',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isDuplicatePath(fs, skillFilePath, loadedPaths)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { frontmatter, content: markdownContent } = parseFrontmatter(
|
||||
content,
|
||||
skillFilePath,
|
||||
)
|
||||
|
||||
const skillName = `${pluginName}:${entry.name}`
|
||||
|
||||
const file: PluginMarkdownFile = {
|
||||
filePath: skillFilePath,
|
||||
baseDir: dirname(skillFilePath),
|
||||
frontmatter,
|
||||
content: markdownContent,
|
||||
}
|
||||
|
||||
const skill = createPluginCommand(
|
||||
skillName,
|
||||
file,
|
||||
sourceName,
|
||||
pluginManifest,
|
||||
pluginPath,
|
||||
true, // isSkill
|
||||
{ isSkillMode: true }, // config
|
||||
)
|
||||
|
||||
if (skill) {
|
||||
skills.push(skill)
|
||||
}
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`Failed to load skill from ${skillFilePath}: ${error}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
export const getPluginSkills = memoize(async (): Promise<Command[]> => {
|
||||
// --bare: same gate as getPluginCommands above — honor explicit
|
||||
// --plugin-dir, skip marketplace auto-load.
|
||||
if (isBareMode() && getInlinePlugins().length === 0) {
|
||||
return []
|
||||
}
|
||||
// Only load skills from enabled plugins
|
||||
const { enabled, errors } = await loadAllPluginsCacheOnly()
|
||||
|
||||
if (errors.length > 0) {
|
||||
logForDebugging(
|
||||
`Plugin loading errors: ${errors.map(e => getPluginErrorMessage(e)).join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`getPluginSkills: Processing ${enabled.length} enabled plugins`,
|
||||
)
|
||||
|
||||
// Process plugins in parallel; each plugin has its own loadedPaths scope
|
||||
const perPluginSkills = await Promise.all(
|
||||
enabled.map(async (plugin): Promise<Command[]> => {
|
||||
// Track loaded file paths to prevent duplicates within this plugin
|
||||
const loadedPaths = new Set<string>()
|
||||
const pluginSkills: Command[] = []
|
||||
|
||||
logForDebugging(
|
||||
`Checking plugin ${plugin.name}: skillsPath=${plugin.skillsPath ? 'exists' : 'none'}, skillsPaths=${plugin.skillsPaths ? plugin.skillsPaths.length : 0} paths`,
|
||||
)
|
||||
// Load skills from default skills directory
|
||||
if (plugin.skillsPath) {
|
||||
logForDebugging(
|
||||
`Attempting to load skills from plugin ${plugin.name} default skillsPath: ${plugin.skillsPath}`,
|
||||
)
|
||||
try {
|
||||
const skills = await loadSkillsFromDirectory(
|
||||
plugin.skillsPath,
|
||||
plugin.name,
|
||||
plugin.source,
|
||||
plugin.manifest,
|
||||
plugin.path,
|
||||
loadedPaths,
|
||||
)
|
||||
pluginSkills.push(...skills)
|
||||
|
||||
logForDebugging(
|
||||
`Loaded ${skills.length} skills from plugin ${plugin.name} default directory`,
|
||||
)
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`Failed to load skills from plugin ${plugin.name} default directory: ${error}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Load skills from additional paths specified in manifest
|
||||
if (plugin.skillsPaths) {
|
||||
logForDebugging(
|
||||
`Attempting to load skills from plugin ${plugin.name} skillsPaths: ${plugin.skillsPaths.join(', ')}`,
|
||||
)
|
||||
// Process all skillsPaths in parallel. isDuplicatePath is synchronous
|
||||
// (check-and-add), so concurrent access to loadedPaths is safe.
|
||||
const pathResults = await Promise.all(
|
||||
plugin.skillsPaths.map(async (skillPath): Promise<Command[]> => {
|
||||
try {
|
||||
logForDebugging(
|
||||
`Loading from skillPath: ${skillPath} for plugin ${plugin.name}`,
|
||||
)
|
||||
const skills = await loadSkillsFromDirectory(
|
||||
skillPath,
|
||||
plugin.name,
|
||||
plugin.source,
|
||||
plugin.manifest,
|
||||
plugin.path,
|
||||
loadedPaths,
|
||||
)
|
||||
|
||||
logForDebugging(
|
||||
`Loaded ${skills.length} skills from plugin ${plugin.name} custom path: ${skillPath}`,
|
||||
)
|
||||
return skills
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`Failed to load skills from plugin ${plugin.name} custom path ${skillPath}: ${error}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
return []
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const skills of pathResults) {
|
||||
pluginSkills.push(...skills)
|
||||
}
|
||||
}
|
||||
return pluginSkills
|
||||
}),
|
||||
)
|
||||
|
||||
const allSkills = perPluginSkills.flat()
|
||||
logForDebugging(`Total plugin skills loaded: ${allSkills.length}`)
|
||||
return allSkills
|
||||
})
|
||||
|
||||
export function clearPluginSkillsCache(): void {
|
||||
getPluginSkills.cache?.clear?.()
|
||||
}
|
||||
Reference in New Issue
Block a user