File size: 5,079 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | /**
* 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
}
|