From 8917a1f8539ed91ae7c17f1daaf6d022dd2e5609 Mon Sep 17 00:00:00 2001 From: Evgeniy Skuridin Date: Sat, 3 Jan 2026 14:08:39 +0100 Subject: [PATCH] feat(coding-agent): add $ARGUMENTS syntax for slash commands (#418) * feat(coding-agent): add $ARGUMENTS syntax for slash commands * test(coding-agent): add tests for slash command argument substitution --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 2 +- .../coding-agent/src/core/slash-commands.ts | 20 +- .../coding-agent/test/slash-commands.test.ts | 277 ++++++++++++++++++ 4 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/test/slash-commands.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fb1e9c8d..d6287b0c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `$ARGUMENTS` syntax for custom slash commands as alternative to `$@` for all arguments joined. Aligns with patterns used by Claude, Codex, and OpenCode. Both syntaxes remain fully supported. + ## [0.32.1] - 2026-01-03 ### Added diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 277131b6..b4c31b61 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -647,7 +647,7 @@ Create a React component named $1 with features: $@ Usage: `/component Button "onClick handler" "disabled support"` - `$1` = `Button` -- `$@` = all arguments joined +- `$@` or `$ARGUMENTS` = all arguments joined (`Button onClick handler disabled support`) **Namespacing:** Subdirectories create prefixes. `.pi/commands/frontend/component.md` → `/component (project:frontend)` diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 25f48063..1aec734f 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -81,20 +81,30 @@ export function parseCommandArgs(argsString: string): string[] { /** * Substitute argument placeholders in command content - * Supports $1, $2, ... for positional args and $@ for all args + * Supports $1, $2, ... for positional args, $@ and $ARGUMENTS for all args + * + * Note: Replacement happens on the template string only. Argument values + * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. */ export function substituteArgs(content: string, args: string[]): string { let result = content; - // Replace $@ with all args joined - result = result.replace(/\$@/g, args.join(" ")); - - // Replace $1, $2, etc. with positional args + // Replace $1, $2, etc. with positional args FIRST (before wildcards) + // This prevents wildcard replacement values containing $ patterns from being re-substituted result = result.replace(/\$(\d+)/g, (_, num) => { const index = parseInt(num, 10) - 1; return args[index] ?? ""; }); + // Pre-compute all args joined (optimization) + const allArgs = args.join(" "); + + // Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode) + result = result.replace(/\$ARGUMENTS/g, allArgs); + + // Replace $@ with all args joined (existing syntax) + result = result.replace(/\$@/g, allArgs); + return result; } diff --git a/packages/coding-agent/test/slash-commands.test.ts b/packages/coding-agent/test/slash-commands.test.ts new file mode 100644 index 00000000..7aa7778a --- /dev/null +++ b/packages/coding-agent/test/slash-commands.test.ts @@ -0,0 +1,277 @@ +/** + * Tests for slash command argument parsing and substitution. + * + * Tests verify: + * - Argument parsing with quotes and special characters + * - Placeholder substitution ($1, $2, $@, $ARGUMENTS) + * - No recursive substitution of patterns in argument values + * - Edge cases and integration between parsing and substitution + */ + +import { describe, expect, test } from "vitest"; +import { parseCommandArgs, substituteArgs } from "../src/core/slash-commands.js"; + +// ============================================================================ +// substituteArgs +// ============================================================================ + +describe("substituteArgs", () => { + test("should replace $ARGUMENTS with all args joined", () => { + expect(substituteArgs("Test: $ARGUMENTS", ["a", "b", "c"])).toBe("Test: a b c"); + }); + + test("should replace $@ with all args joined", () => { + expect(substituteArgs("Test: $@", ["a", "b", "c"])).toBe("Test: a b c"); + }); + + test("should replace $@ and $ARGUMENTS identically", () => { + const args = ["foo", "bar", "baz"]; + expect(substituteArgs("Test: $@", args)).toBe(substituteArgs("Test: $ARGUMENTS", args)); + }); + + // CRITICAL: argument values containing patterns should remain literal + test("should NOT recursively substitute patterns in argument values", () => { + expect(substituteArgs("$ARGUMENTS", ["$1", "$ARGUMENTS"])).toBe("$1 $ARGUMENTS"); + expect(substituteArgs("$@", ["$100", "$1"])).toBe("$100 $1"); + expect(substituteArgs("$ARGUMENTS", ["$100", "$1"])).toBe("$100 $1"); + }); + + test("should support mixed $1, $2, and $ARGUMENTS", () => { + expect(substituteArgs("$1: $ARGUMENTS", ["prefix", "a", "b"])).toBe("prefix: prefix a b"); + }); + + test("should support mixed $1, $2, and $@", () => { + expect(substituteArgs("$1: $@", ["prefix", "a", "b"])).toBe("prefix: prefix a b"); + }); + + test("should handle empty arguments array with $ARGUMENTS", () => { + expect(substituteArgs("Test: $ARGUMENTS", [])).toBe("Test: "); + }); + + test("should handle empty arguments array with $@", () => { + expect(substituteArgs("Test: $@", [])).toBe("Test: "); + }); + + test("should handle empty arguments array with $1", () => { + expect(substituteArgs("Test: $1", [])).toBe("Test: "); + }); + + test("should handle multiple occurrences of $ARGUMENTS", () => { + expect(substituteArgs("$ARGUMENTS and $ARGUMENTS", ["a", "b"])).toBe("a b and a b"); + }); + + test("should handle multiple occurrences of $@", () => { + expect(substituteArgs("$@ and $@", ["a", "b"])).toBe("a b and a b"); + }); + + test("should handle mixed occurrences of $@ and $ARGUMENTS", () => { + expect(substituteArgs("$@ and $ARGUMENTS", ["a", "b"])).toBe("a b and a b"); + }); + + test("should handle special characters in arguments", () => { + // Note: $100 in argument doesn't get partially matched - full strings are substituted + expect(substituteArgs("$1 $2: $ARGUMENTS", ["arg100", "@user"])).toBe("arg100 @user: arg100 @user"); + }); + + test("should handle out-of-range numbered placeholders", () => { + // Note: Out-of-range placeholders become empty strings (preserving spaces from template) + expect(substituteArgs("$1 $2 $3 $4 $5", ["a", "b"])).toBe("a b "); + }); + + test("should handle unicode characters", () => { + expect(substituteArgs("$ARGUMENTS", ["日本語", "🎉", "café"])).toBe("日本語 🎉 café"); + }); + + test("should preserve newlines and tabs in argument values", () => { + expect(substituteArgs("$1 $2", ["line1\nline2", "tab\tthere"])).toBe("line1\nline2 tab\tthere"); + }); + + test("should handle consecutive dollar patterns", () => { + expect(substituteArgs("$1$2", ["a", "b"])).toBe("ab"); + }); + + test("should handle quoted arguments with spaces", () => { + expect(substituteArgs("$ARGUMENTS", ["first arg", "second arg"])).toBe("first arg second arg"); + }); + + test("should handle single argument with $ARGUMENTS", () => { + expect(substituteArgs("Test: $ARGUMENTS", ["only"])).toBe("Test: only"); + }); + + test("should handle single argument with $@", () => { + expect(substituteArgs("Test: $@", ["only"])).toBe("Test: only"); + }); + + test("should handle $0 (zero index)", () => { + expect(substituteArgs("$0", ["a", "b"])).toBe(""); + }); + + test("should handle decimal number in pattern (only integer part matches)", () => { + expect(substituteArgs("$1.5", ["a"])).toBe("a.5"); + }); + + test("should handle $ARGUMENTS as part of word", () => { + expect(substituteArgs("pre$ARGUMENTS", ["a", "b"])).toBe("prea b"); + }); + + test("should handle $@ as part of word", () => { + expect(substituteArgs("pre$@", ["a", "b"])).toBe("prea b"); + }); + + test("should handle empty arguments in middle of list", () => { + expect(substituteArgs("$ARGUMENTS", ["a", "", "c"])).toBe("a c"); + }); + + test("should handle trailing and leading spaces in arguments", () => { + expect(substituteArgs("$ARGUMENTS", [" leading ", "trailing "])).toBe(" leading trailing "); + }); + + test("should handle argument containing pattern partially", () => { + expect(substituteArgs("Prefix $ARGUMENTS suffix", ["ARGUMENTS"])).toBe("Prefix ARGUMENTS suffix"); + }); + + test("should handle non-matching patterns", () => { + expect(substituteArgs("$A $$ $ $ARGS", ["a"])).toBe("$A $$ $ $ARGS"); + }); + + test("should handle case variations (case-sensitive)", () => { + expect(substituteArgs("$arguments $Arguments $ARGUMENTS", ["a", "b"])).toBe("$arguments $Arguments a b"); + }); + + test("should handle both syntaxes in same command with same result", () => { + const args = ["x", "y", "z"]; + const result1 = substituteArgs("$@ and $ARGUMENTS", args); + const result2 = substituteArgs("$ARGUMENTS and $@", args); + expect(result1).toBe(result2); + expect(result1).toBe("x y z and x y z"); + }); + + test("should handle very long argument lists", () => { + const args = Array.from({ length: 100 }, (_, i) => `arg${i}`); + const result = substituteArgs("$ARGUMENTS", args); + expect(result).toBe(args.join(" ")); + }); + + test("should handle numbered placeholders with single digit", () => { + expect(substituteArgs("$1 $2 $3", ["a", "b", "c"])).toBe("a b c"); + }); + + test("should handle numbered placeholders with multiple digits", () => { + const args = Array.from({ length: 15 }, (_, i) => `val${i}`); + expect(substituteArgs("$10 $12 $15", args)).toBe("val9 val11 val14"); + }); + + test("should handle escaped dollar signs (literal backslash preserved)", () => { + // Note: No escape mechanism exists - backslash is treated literally + expect(substituteArgs("Price: \\$100", [])).toBe("Price: \\"); + }); + + test("should handle mixed numbered and wildcard placeholders", () => { + expect(substituteArgs("$1: $@ ($ARGUMENTS)", ["first", "second", "third"])).toBe( + "first: first second third (first second third)", + ); + }); + + test("should handle command with no placeholders", () => { + expect(substituteArgs("Just plain text", ["a", "b"])).toBe("Just plain text"); + }); + + test("should handle command with only placeholders", () => { + expect(substituteArgs("$1 $2 $@", ["a", "b", "c"])).toBe("a b a b c"); + }); +}); + +// ============================================================================ +// parseCommandArgs +// ============================================================================ + +describe("parseCommandArgs", () => { + test("should parse simple space-separated arguments", () => { + expect(parseCommandArgs("a b c")).toEqual(["a", "b", "c"]); + }); + + test("should parse quoted arguments with spaces", () => { + expect(parseCommandArgs('"first arg" second')).toEqual(["first arg", "second"]); + }); + + test("should parse single-quoted arguments", () => { + expect(parseCommandArgs("'first arg' second")).toEqual(["first arg", "second"]); + }); + + test("should parse mixed quote styles", () => { + expect(parseCommandArgs('"double" \'single\' "double again"')).toEqual(["double", "single", "double again"]); + }); + + test("should handle empty string", () => { + expect(parseCommandArgs("")).toEqual([]); + }); + + test("should handle extra spaces", () => { + expect(parseCommandArgs("a b c")).toEqual(["a", "b", "c"]); + }); + + test("should handle tabs as separators", () => { + expect(parseCommandArgs("a\tb\tc")).toEqual(["a", "b", "c"]); + }); + + test("should handle quoted empty string", () => { + // Note: Empty quotes are skipped by current implementation + expect(parseCommandArgs('"" " "')).toEqual([" "]); + }); + + test("should handle arguments with special characters", () => { + expect(parseCommandArgs("$100 @user #tag")).toEqual(["$100", "@user", "#tag"]); + }); + + test("should handle unicode characters", () => { + expect(parseCommandArgs("日本語 🎉 café")).toEqual(["日本語", "🎉", "café"]); + }); + + test("should handle newlines in arguments", () => { + expect(parseCommandArgs('"line1\nline2" second')).toEqual(["line1\nline2", "second"]); + }); + + test("should handle escaped quotes inside quoted strings", () => { + // Note: This implementation doesn't handle escaped quotes - backslash is literal + expect(parseCommandArgs('"quoted \\"text\\""')).toEqual(["quoted \\text\\"]); + }); + + test("should handle trailing spaces", () => { + expect(parseCommandArgs("a b c ")).toEqual(["a", "b", "c"]); + }); + + test("should handle leading spaces", () => { + expect(parseCommandArgs(" a b c")).toEqual(["a", "b", "c"]); + }); +}); + +// ============================================================================ +// Integration +// ============================================================================ + +describe("parseCommandArgs + substituteArgs integration", () => { + test("should parse and substitute together correctly", () => { + const input = 'Button "onClick handler" "disabled support"'; + const args = parseCommandArgs(input); + const template = "Create component $1 with features: $ARGUMENTS"; + const result = substituteArgs(template, args); + expect(result).toBe("Create component Button with features: Button onClick handler disabled support"); + }); + + test("should handle the example from README", () => { + const input = 'Button "onClick handler" "disabled support"'; + const args = parseCommandArgs(input); + const template = "Create a React component named $1 with features: $ARGUMENTS"; + const result = substituteArgs(template, args); + expect(result).toBe( + "Create a React component named Button with features: Button onClick handler disabled support", + ); + }); + + test("should produce same result with $@ and $ARGUMENTS", () => { + const args = parseCommandArgs("feature1 feature2 feature3"); + const template1 = "Implement: $@"; + const template2 = "Implement: $ARGUMENTS"; + expect(substituteArgs(template1, args)).toBe(substituteArgs(template2, args)); + }); +});