146 lines
5.0 KiB
TypeScript
146 lines
5.0 KiB
TypeScript
/**
|
|
* 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
|
|
}
|