From f869cc4ae53ba45d951daeb9f5993d42e36b402b Mon Sep 17 00:00:00 2001 From: Zeno Jiricek Date: Fri, 16 Jan 2026 20:56:44 +1030 Subject: [PATCH 1/2] feat: add bash-style array slicing for $@ in prompt templates Implements support for ${@:N} and ${@:N:L} syntax to slice argument arrays in prompt templates, following bash conventions. Syntax: - ${@:N} - All arguments from Nth position onwards (1-indexed) - ${@:N:L} - L arguments starting from Nth position Features: - Bash-style slicing familiar to shell users - 1-indexed for consistency with $1, $2, etc. - Processes before simple $@ to avoid conflicts - No recursive substitution of patterns in arguments - Comprehensive edge case handling Examples: - ${@:2} with ["a", "b", "c"] -> "b c" - ${@:2:1} with ["a", "b", "c"] -> "b" - ${@:99} with ["a", "b"] -> "" (empty, out of range) Test coverage: 24 new tests, all passing (73 total) Closes #769 --- .../coding-agent/src/core/prompt-templates.ts | 20 +++- .../test/prompt-templates.test.ts | 105 ++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index aeff8af9..e5732189 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -52,7 +52,11 @@ export function parseCommandArgs(argsString: string): string[] { /** * Substitute argument placeholders in template content - * Supports $1, $2, ... for positional args, $@ and $ARGUMENTS for all args + * Supports: + * - $1, $2, ... for positional args + * - $@ and $ARGUMENTS for all args + * - ${@:N} for args from Nth onwards (bash-style slicing) + * - ${@:N:L} for L args starting from Nth * * Note: Replacement happens on the template string only. Argument values * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. @@ -67,6 +71,20 @@ export function substituteArgs(content: string, args: string[]): string { return args[index] ?? ""; }); + // Replace ${@:start} or ${@:start:length} with sliced args (bash-style) + // Process BEFORE simple $@ to avoid conflicts + result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr, lengthStr) => { + let start = parseInt(startStr, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) + // Treat 0 as 1 (bash convention: args start at 1) + if (start < 0) start = 0; + + if (lengthStr) { + const length = parseInt(lengthStr, 10); + return args.slice(start, start + length).join(" "); + } + return args.slice(start).join(" "); + }); + // Pre-compute all args joined (optimization) const allArgs = args.join(" "); diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index b0d40b2e..7b9413ce 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -181,6 +181,111 @@ describe("substituteArgs", () => { }); }); +// ============================================================================ +// substituteArgs - Array Slicing (Bash-Style) +// ============================================================================ + +// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing literal ${@:N} syntax +describe("substituteArgs - array slicing", () => { + test("should slice from index (${@:N})", () => { + expect(substituteArgs("${@:2}", ["a", "b", "c", "d"])).toBe("b c d"); + expect(substituteArgs("${@:1}", ["a", "b", "c"])).toBe("a b c"); + expect(substituteArgs("${@:3}", ["a", "b", "c", "d"])).toBe("c d"); + }); + + test("should slice with length (${@:N:L})", () => { + expect(substituteArgs("${@:2:2}", ["a", "b", "c", "d"])).toBe("b c"); + expect(substituteArgs("${@:1:1}", ["a", "b", "c"])).toBe("a"); + expect(substituteArgs("${@:3:1}", ["a", "b", "c", "d"])).toBe("c"); + expect(substituteArgs("${@:2:3}", ["a", "b", "c", "d", "e"])).toBe("b c d"); + }); + + test("should handle out of range slices", () => { + expect(substituteArgs("${@:99}", ["a", "b"])).toBe(""); + expect(substituteArgs("${@:5}", ["a", "b"])).toBe(""); + expect(substituteArgs("${@:10:5}", ["a", "b"])).toBe(""); + }); + + test("should handle zero-length slices", () => { + expect(substituteArgs("${@:2:0}", ["a", "b", "c"])).toBe(""); + expect(substituteArgs("${@:1:0}", ["a", "b"])).toBe(""); + }); + + test("should handle length exceeding array", () => { + expect(substituteArgs("${@:2:99}", ["a", "b", "c"])).toBe("b c"); + expect(substituteArgs("${@:1:10}", ["a", "b"])).toBe("a b"); + }); + + test("should process slice before simple $@", () => { + expect(substituteArgs("${@:2} vs $@", ["a", "b", "c"])).toBe("b c vs a b c"); + expect(substituteArgs("First: ${@:1:1}, All: $@", ["x", "y", "z"])).toBe("First: x, All: x y z"); + }); + + test("should not recursively substitute slice patterns in args", () => { + expect(substituteArgs("${@:1}", ["${@:2}", "test"])).toBe("${@:2} test"); + expect(substituteArgs("${@:2}", ["a", "${@:3}", "c"])).toBe("${@:3} c"); + }); + + test("should handle mixed usage with positional args", () => { + expect(substituteArgs("$1: ${@:2}", ["cmd", "arg1", "arg2"])).toBe("cmd: arg1 arg2"); + expect(substituteArgs("$1 $2 ${@:3}", ["a", "b", "c", "d"])).toBe("a b c d"); + }); + + test("should treat ${@:0} as all args", () => { + expect(substituteArgs("${@:0}", ["a", "b", "c"])).toBe("a b c"); + }); + + test("should handle empty args array", () => { + expect(substituteArgs("${@:2}", [])).toBe(""); + expect(substituteArgs("${@:1}", [])).toBe(""); + }); + + test("should handle single arg array", () => { + expect(substituteArgs("${@:1}", ["only"])).toBe("only"); + expect(substituteArgs("${@:2}", ["only"])).toBe(""); + }); + + test("should handle slice in middle of text", () => { + expect(substituteArgs("Process ${@:2} with $1", ["tool", "file1", "file2"])).toBe( + "Process file1 file2 with tool", + ); + }); + + test("should handle multiple slices in one template", () => { + expect(substituteArgs("${@:1:1} and ${@:2}", ["a", "b", "c"])).toBe("a and b c"); + expect(substituteArgs("${@:1:2} vs ${@:3:2}", ["a", "b", "c", "d", "e"])).toBe("a b vs c d"); + }); + + test("should handle quoted arguments in slices", () => { + expect(substituteArgs("${@:2}", ["cmd", "first arg", "second arg"])).toBe("first arg second arg"); + }); + + test("should handle special characters in sliced args", () => { + expect(substituteArgs("${@:2}", ["cmd", "$100", "@user", "#tag"])).toBe("$100 @user #tag"); + }); + + test("should handle unicode in sliced args", () => { + expect(substituteArgs("${@:1}", ["日本語", "🎉", "café"])).toBe("日本語 🎉 café"); + }); + + test("should combine positional, slice, and wildcard placeholders", () => { + const template = "Run $1 on ${@:2:2}, then process $@"; + const args = ["eslint", "file1.ts", "file2.ts", "file3.ts"]; + expect(substituteArgs(template, args)).toBe( + "Run eslint on file1.ts file2.ts, then process eslint file1.ts file2.ts file3.ts", + ); + }); + + test("should handle slice with no spacing", () => { + expect(substituteArgs("prefix${@:2}suffix", ["a", "b", "c"])).toBe("prefixb csuffix"); + }); + + test("should handle large slice lengths gracefully", () => { + const args = Array.from({ length: 10 }, (_, i) => `arg${i + 1}`); + expect(substituteArgs("${@:5:100}", args)).toBe("arg5 arg6 arg7 arg8 arg9 arg10"); + }); +}); + // ============================================================================ // parseCommandArgs // ============================================================================ From 43c4a80e2e621fb8bd557d7015e26fc5a7efff59 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 16 Jan 2026 12:06:21 +0100 Subject: [PATCH 2/2] docs: document prompt template slicing --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 2 + .../test/prompt-templates.test.ts | 75 +++++++++---------- 3 files changed, 43 insertions(+), 38 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c0931c52..bfaad940 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added bash-style argument slicing for prompt templates ([#770](https://github.com/badlogic/pi-mono/pull/770) by [@airtonix](https://github.com/airtonix)) + ## [0.47.0] - 2026-01-16 ### Breaking Changes diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index bf1e66c7..76c625ba 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -847,6 +847,8 @@ Create a React component named $1 with features: $@ Usage: `/component Button "onClick handler" "disabled support"` - `$1` = `Button` - `$@` or `$ARGUMENTS` = all arguments joined (`Button onClick handler disabled support`) +- `${@:N}` = arguments from the Nth position onwards (1-indexed) +- `${@:N:L}` = `L` arguments starting from the Nth position **Namespacing:** Subdirectories create prefixes. `.pi/prompts/frontend/component.md` → `/component (project:frontend)` diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index 7b9413ce..168f7407 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -185,91 +185,90 @@ describe("substituteArgs", () => { // substituteArgs - Array Slicing (Bash-Style) // ============================================================================ -// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing literal ${@:N} syntax describe("substituteArgs - array slicing", () => { - test("should slice from index (${@:N})", () => { - expect(substituteArgs("${@:2}", ["a", "b", "c", "d"])).toBe("b c d"); - expect(substituteArgs("${@:1}", ["a", "b", "c"])).toBe("a b c"); - expect(substituteArgs("${@:3}", ["a", "b", "c", "d"])).toBe("c d"); + test(`should slice from index (\${@:N})`, () => { + expect(substituteArgs(`\${@:2}`, ["a", "b", "c", "d"])).toBe("b c d"); + expect(substituteArgs(`\${@:1}`, ["a", "b", "c"])).toBe("a b c"); + expect(substituteArgs(`\${@:3}`, ["a", "b", "c", "d"])).toBe("c d"); }); - test("should slice with length (${@:N:L})", () => { - expect(substituteArgs("${@:2:2}", ["a", "b", "c", "d"])).toBe("b c"); - expect(substituteArgs("${@:1:1}", ["a", "b", "c"])).toBe("a"); - expect(substituteArgs("${@:3:1}", ["a", "b", "c", "d"])).toBe("c"); - expect(substituteArgs("${@:2:3}", ["a", "b", "c", "d", "e"])).toBe("b c d"); + test(`should slice with length (\${@:N:L})`, () => { + expect(substituteArgs(`\${@:2:2}`, ["a", "b", "c", "d"])).toBe("b c"); + expect(substituteArgs(`\${@:1:1}`, ["a", "b", "c"])).toBe("a"); + expect(substituteArgs(`\${@:3:1}`, ["a", "b", "c", "d"])).toBe("c"); + expect(substituteArgs(`\${@:2:3}`, ["a", "b", "c", "d", "e"])).toBe("b c d"); }); test("should handle out of range slices", () => { - expect(substituteArgs("${@:99}", ["a", "b"])).toBe(""); - expect(substituteArgs("${@:5}", ["a", "b"])).toBe(""); - expect(substituteArgs("${@:10:5}", ["a", "b"])).toBe(""); + expect(substituteArgs(`\${@:99}`, ["a", "b"])).toBe(""); + expect(substituteArgs(`\${@:5}`, ["a", "b"])).toBe(""); + expect(substituteArgs(`\${@:10:5}`, ["a", "b"])).toBe(""); }); test("should handle zero-length slices", () => { - expect(substituteArgs("${@:2:0}", ["a", "b", "c"])).toBe(""); - expect(substituteArgs("${@:1:0}", ["a", "b"])).toBe(""); + expect(substituteArgs(`\${@:2:0}`, ["a", "b", "c"])).toBe(""); + expect(substituteArgs(`\${@:1:0}`, ["a", "b"])).toBe(""); }); test("should handle length exceeding array", () => { - expect(substituteArgs("${@:2:99}", ["a", "b", "c"])).toBe("b c"); - expect(substituteArgs("${@:1:10}", ["a", "b"])).toBe("a b"); + expect(substituteArgs(`\${@:2:99}`, ["a", "b", "c"])).toBe("b c"); + expect(substituteArgs(`\${@:1:10}`, ["a", "b"])).toBe("a b"); }); test("should process slice before simple $@", () => { - expect(substituteArgs("${@:2} vs $@", ["a", "b", "c"])).toBe("b c vs a b c"); - expect(substituteArgs("First: ${@:1:1}, All: $@", ["x", "y", "z"])).toBe("First: x, All: x y z"); + expect(substituteArgs(`\${@:2} vs $@`, ["a", "b", "c"])).toBe("b c vs a b c"); + expect(substituteArgs(`First: \${@:1:1}, All: $@`, ["x", "y", "z"])).toBe("First: x, All: x y z"); }); test("should not recursively substitute slice patterns in args", () => { - expect(substituteArgs("${@:1}", ["${@:2}", "test"])).toBe("${@:2} test"); - expect(substituteArgs("${@:2}", ["a", "${@:3}", "c"])).toBe("${@:3} c"); + expect(substituteArgs(`\${@:1}`, [`\${@:2}`, "test"])).toBe(`\${@:2} test`); + expect(substituteArgs(`\${@:2}`, ["a", `\${@:3}`, "c"])).toBe(`\${@:3} c`); }); test("should handle mixed usage with positional args", () => { - expect(substituteArgs("$1: ${@:2}", ["cmd", "arg1", "arg2"])).toBe("cmd: arg1 arg2"); - expect(substituteArgs("$1 $2 ${@:3}", ["a", "b", "c", "d"])).toBe("a b c d"); + expect(substituteArgs(`$1: \${@:2}`, ["cmd", "arg1", "arg2"])).toBe("cmd: arg1 arg2"); + expect(substituteArgs(`$1 $2 \${@:3}`, ["a", "b", "c", "d"])).toBe("a b c d"); }); - test("should treat ${@:0} as all args", () => { - expect(substituteArgs("${@:0}", ["a", "b", "c"])).toBe("a b c"); + test(`should treat \${@:0} as all args`, () => { + expect(substituteArgs(`\${@:0}`, ["a", "b", "c"])).toBe("a b c"); }); test("should handle empty args array", () => { - expect(substituteArgs("${@:2}", [])).toBe(""); - expect(substituteArgs("${@:1}", [])).toBe(""); + expect(substituteArgs(`\${@:2}`, [])).toBe(""); + expect(substituteArgs(`\${@:1}`, [])).toBe(""); }); test("should handle single arg array", () => { - expect(substituteArgs("${@:1}", ["only"])).toBe("only"); - expect(substituteArgs("${@:2}", ["only"])).toBe(""); + expect(substituteArgs(`\${@:1}`, ["only"])).toBe("only"); + expect(substituteArgs(`\${@:2}`, ["only"])).toBe(""); }); test("should handle slice in middle of text", () => { - expect(substituteArgs("Process ${@:2} with $1", ["tool", "file1", "file2"])).toBe( + expect(substituteArgs(`Process \${@:2} with $1`, ["tool", "file1", "file2"])).toBe( "Process file1 file2 with tool", ); }); test("should handle multiple slices in one template", () => { - expect(substituteArgs("${@:1:1} and ${@:2}", ["a", "b", "c"])).toBe("a and b c"); - expect(substituteArgs("${@:1:2} vs ${@:3:2}", ["a", "b", "c", "d", "e"])).toBe("a b vs c d"); + expect(substituteArgs(`\${@:1:1} and \${@:2}`, ["a", "b", "c"])).toBe("a and b c"); + expect(substituteArgs(`\${@:1:2} vs \${@:3:2}`, ["a", "b", "c", "d", "e"])).toBe("a b vs c d"); }); test("should handle quoted arguments in slices", () => { - expect(substituteArgs("${@:2}", ["cmd", "first arg", "second arg"])).toBe("first arg second arg"); + expect(substituteArgs(`\${@:2}`, ["cmd", "first arg", "second arg"])).toBe("first arg second arg"); }); test("should handle special characters in sliced args", () => { - expect(substituteArgs("${@:2}", ["cmd", "$100", "@user", "#tag"])).toBe("$100 @user #tag"); + expect(substituteArgs(`\${@:2}`, ["cmd", "$100", "@user", "#tag"])).toBe("$100 @user #tag"); }); test("should handle unicode in sliced args", () => { - expect(substituteArgs("${@:1}", ["日本語", "🎉", "café"])).toBe("日本語 🎉 café"); + expect(substituteArgs(`\${@:1}`, ["日本語", "🎉", "café"])).toBe("日本語 🎉 café"); }); test("should combine positional, slice, and wildcard placeholders", () => { - const template = "Run $1 on ${@:2:2}, then process $@"; + const template = `Run $1 on \${@:2:2}, then process $@`; const args = ["eslint", "file1.ts", "file2.ts", "file3.ts"]; expect(substituteArgs(template, args)).toBe( "Run eslint on file1.ts file2.ts, then process eslint file1.ts file2.ts file3.ts", @@ -277,12 +276,12 @@ describe("substituteArgs - array slicing", () => { }); test("should handle slice with no spacing", () => { - expect(substituteArgs("prefix${@:2}suffix", ["a", "b", "c"])).toBe("prefixb csuffix"); + expect(substituteArgs(`prefix\${@:2}suffix`, ["a", "b", "c"])).toBe("prefixb csuffix"); }); test("should handle large slice lengths gracefully", () => { const args = Array.from({ length: 10 }, (_, i) => `arg${i + 1}`); - expect(substituteArgs("${@:5:100}", args)).toBe("arg5 arg6 arg7 arg8 arg9 arg10"); + expect(substituteArgs(`\${@:5:100}`, args)).toBe("arg5 arg6 arg7 arg8 arg9 arg10"); }); });