/ utils / argumentSubstitution.ts
argumentSubstitution.ts
  1  /**
  2   * Utility for substituting $ARGUMENTS placeholders in skill/command prompts.
  3   *
  4   * Supports:
  5   * - $ARGUMENTS - replaced with the full arguments string
  6   * - $ARGUMENTS[0], $ARGUMENTS[1], etc. - replaced with individual indexed arguments
  7   * - $0, $1, etc. - shorthand for $ARGUMENTS[0], $ARGUMENTS[1]
  8   * - Named arguments (e.g., $foo, $bar) - when argument names are defined in frontmatter
  9   *
 10   * Arguments are parsed using shell-quote for proper shell argument handling.
 11   */
 12  
 13  import { tryParseShellCommand } from './bash/shellQuote.js'
 14  
 15  /**
 16   * Parse an arguments string into an array of individual arguments.
 17   * Uses shell-quote for proper shell argument parsing including quoted strings.
 18   *
 19   * Examples:
 20   * - "foo bar baz" => ["foo", "bar", "baz"]
 21   * - 'foo "hello world" baz' => ["foo", "hello world", "baz"]
 22   * - "foo 'hello world' baz" => ["foo", "hello world", "baz"]
 23   */
 24  export function parseArguments(args: string): string[] {
 25    if (!args || !args.trim()) {
 26      return []
 27    }
 28  
 29    // Return $KEY to preserve variable syntax literally (don't expand variables)
 30    const result = tryParseShellCommand(args, key => `$${key}`)
 31    if (!result.success) {
 32      // Fall back to simple whitespace split if parsing fails
 33      return args.split(/\s+/).filter(Boolean)
 34    }
 35  
 36    // Filter to only string tokens (ignore shell operators, etc.)
 37    return result.tokens.filter(
 38      (token): token is string => typeof token === 'string',
 39    )
 40  }
 41  
 42  /**
 43   * Parse argument names from the frontmatter 'arguments' field.
 44   * Accepts either a space-separated string or an array of strings.
 45   *
 46   * Examples:
 47   * - "foo bar baz" => ["foo", "bar", "baz"]
 48   * - ["foo", "bar", "baz"] => ["foo", "bar", "baz"]
 49   */
 50  export function parseArgumentNames(
 51    argumentNames: string | string[] | undefined,
 52  ): string[] {
 53    if (!argumentNames) {
 54      return []
 55    }
 56  
 57    // Filter out empty strings and numeric-only names (which conflict with $0, $1 shorthand)
 58    const isValidName = (name: string): boolean =>
 59      typeof name === 'string' && name.trim() !== '' && !/^\d+$/.test(name)
 60  
 61    if (Array.isArray(argumentNames)) {
 62      return argumentNames.filter(isValidName)
 63    }
 64    if (typeof argumentNames === 'string') {
 65      return argumentNames.split(/\s+/).filter(isValidName)
 66    }
 67    return []
 68  }
 69  
 70  /**
 71   * Generate argument hint showing remaining unfilled args.
 72   * @param argNames - Array of argument names from frontmatter
 73   * @param typedArgs - Arguments the user has typed so far
 74   * @returns Hint string like "[arg2] [arg3]" or undefined if all filled
 75   */
 76  export function generateProgressiveArgumentHint(
 77    argNames: string[],
 78    typedArgs: string[],
 79  ): string | undefined {
 80    const remaining = argNames.slice(typedArgs.length)
 81    if (remaining.length === 0) return undefined
 82    return remaining.map(name => `[${name}]`).join(' ')
 83  }
 84  
 85  /**
 86   * Substitute $ARGUMENTS placeholders in content with actual argument values.
 87   *
 88   * @param content - The content containing placeholders
 89   * @param args - The raw arguments string (may be undefined/null)
 90   * @param appendIfNoPlaceholder - If true and no placeholders are found, appends "ARGUMENTS: {args}" to content
 91   * @param argumentNames - Optional array of named arguments (e.g., ["foo", "bar"]) that map to indexed positions
 92   * @returns The content with placeholders substituted
 93   */
 94  export function substituteArguments(
 95    content: string,
 96    args: string | undefined,
 97    appendIfNoPlaceholder = true,
 98    argumentNames: string[] = [],
 99  ): string {
100    // undefined/null means no args provided - return content unchanged
101    // empty string is a valid input that should replace placeholders with empty
102    if (args === undefined || args === null) {
103      return content
104    }
105  
106    const parsedArgs = parseArguments(args)
107    const originalContent = content
108  
109    // Replace named arguments (e.g., $foo, $bar) with their values
110    // Named arguments map to positions: argumentNames[0] -> parsedArgs[0], etc.
111    for (let i = 0; i < argumentNames.length; i++) {
112      const name = argumentNames[i]
113      if (!name) continue
114  
115      // Match $name but not $name[...] or $nameXxx (word chars)
116      // Also ensure we match word boundaries to avoid partial matches
117      content = content.replace(
118        new RegExp(`\\$${name}(?![\\[\\w])`, 'g'),
119        parsedArgs[i] ?? '',
120      )
121    }
122  
123    // Replace indexed arguments ($ARGUMENTS[0], $ARGUMENTS[1], etc.)
124    content = content.replace(/\$ARGUMENTS\[(\d+)\]/g, (_, indexStr: string) => {
125      const index = parseInt(indexStr, 10)
126      return parsedArgs[index] ?? ''
127    })
128  
129    // Replace shorthand indexed arguments ($0, $1, etc.)
130    content = content.replace(/\$(\d+)(?!\w)/g, (_, indexStr: string) => {
131      const index = parseInt(indexStr, 10)
132      return parsedArgs[index] ?? ''
133    })
134  
135    // Replace $ARGUMENTS with the full arguments string
136    content = content.replaceAll('$ARGUMENTS', args)
137  
138    // If no placeholders were found and appendIfNoPlaceholder is true, append
139    // But only if args is non-empty (empty string means command invoked with no args)
140    if (content === originalContent && appendIfNoPlaceholder && args) {
141      content = content + `\n\nARGUMENTS: ${args}`
142    }
143  
144    return content
145  }