/** * Utility for substituting $ARGUMENTS placeholders in skill/command prompts. * * Supports: * - $ARGUMENTS - replaced with the full arguments string * - $ARGUMENTS[0], $ARGUMENTS[1], etc. - replaced with individual indexed arguments * - $0, $1, etc. - shorthand for $ARGUMENTS[0], $ARGUMENTS[1] * - Named arguments (e.g., $foo, $bar) - when argument names are defined in frontmatter * * Arguments are parsed using shell-quote for proper shell argument handling. */ import { tryParseShellCommand } from './bash/shellQuote.js' /** * Parse an arguments string into an array of individual arguments. * Uses shell-quote for proper shell argument parsing including quoted strings. * * Examples: * - "foo bar baz" => ["foo", "bar", "baz"] * - 'foo "hello world" baz' => ["foo", "hello world", "baz"] * - "foo 'hello world' baz" => ["foo", "hello world", "baz"] */ export function parseArguments(args: string): string[] { if (!args || !args.trim()) { return [] } // Return $KEY to preserve variable syntax literally (don't expand variables) const result = tryParseShellCommand(args, key => `$${key}`) if (!result.success) { // Fall back to simple whitespace split if parsing fails return args.split(/\s+/).filter(Boolean) } // Filter to only string tokens (ignore shell operators, etc.) return result.tokens.filter( (token): token is string => typeof token === 'string', ) } /** * Parse argument names from the frontmatter 'arguments' field. * Accepts either a space-separated string or an array of strings. * * Examples: * - "foo bar baz" => ["foo", "bar", "baz"] * - ["foo", "bar", "baz"] => ["foo", "bar", "baz"] */ export function parseArgumentNames( argumentNames: string | string[] | undefined, ): string[] { if (!argumentNames) { return [] } // Filter out empty strings and numeric-only names (which conflict with $0, $1 shorthand) const isValidName = (name: string): boolean => typeof name === 'string' && name.trim() !== '' && !/^\d+$/.test(name) if (Array.isArray(argumentNames)) { return argumentNames.filter(isValidName) } if (typeof argumentNames === 'string') { return argumentNames.split(/\s+/).filter(isValidName) } return [] } /** * Generate argument hint showing remaining unfilled args. * @param argNames - Array of argument names from frontmatter * @param typedArgs - Arguments the user has typed so far * @returns Hint string like "[arg2] [arg3]" or undefined if all filled */ export function generateProgressiveArgumentHint( argNames: string[], typedArgs: string[], ): string | undefined { const remaining = argNames.slice(typedArgs.length) if (remaining.length === 0) return undefined return remaining.map(name => `[${name}]`).join(' ') } /** * Substitute $ARGUMENTS placeholders in content with actual argument values. * * @param content - The content containing placeholders * @param args - The raw arguments string (may be undefined/null) * @param appendIfNoPlaceholder - If true and no placeholders are found, appends "ARGUMENTS: {args}" to content * @param argumentNames - Optional array of named arguments (e.g., ["foo", "bar"]) that map to indexed positions * @returns The content with placeholders substituted */ export function substituteArguments( content: string, args: string | undefined, appendIfNoPlaceholder = true, argumentNames: string[] = [], ): string { // undefined/null means no args provided - return content unchanged // empty string is a valid input that should replace placeholders with empty if (args === undefined || args === null) { return content } const parsedArgs = parseArguments(args) const originalContent = content // Replace named arguments (e.g., $foo, $bar) with their values // Named arguments map to positions: argumentNames[0] -> parsedArgs[0], etc. for (let i = 0; i < argumentNames.length; i++) { const name = argumentNames[i] if (!name) continue // Match $name but not $name[...] or $nameXxx (word chars) // Also ensure we match word boundaries to avoid partial matches content = content.replace( new RegExp(`\\$${name}(?![\\[\\w])`, 'g'), parsedArgs[i] ?? '', ) } // Replace indexed arguments ($ARGUMENTS[0], $ARGUMENTS[1], etc.) content = content.replace(/\$ARGUMENTS\[(\d+)\]/g, (_, indexStr: string) => { const index = parseInt(indexStr, 10) return parsedArgs[index] ?? '' }) // Replace shorthand indexed arguments ($0, $1, etc.) content = content.replace(/\$(\d+)(?!\w)/g, (_, indexStr: string) => { const index = parseInt(indexStr, 10) return parsedArgs[index] ?? '' }) // Replace $ARGUMENTS with the full arguments string content = content.replaceAll('$ARGUMENTS', args) // If no placeholders were found and appendIfNoPlaceholder is true, append // But only if args is non-empty (empty string means command invoked with no args) if (content === originalContent && appendIfNoPlaceholder && args) { content = content + `\n\nARGUMENTS: ${args}` } return content }