Skip to content

Commit deb48a9

Browse files
committed
refactor: share prompt template arguments
1 parent 086df26 commit deb48a9

5 files changed

Lines changed: 102 additions & 177 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/** Parse an argument string using simple shell-style single and double quotes. */
2+
export function parseCommandArgs(argsString: string): string[] {
3+
const args: string[] = [];
4+
let current = "";
5+
let inQuote: string | null = null;
6+
7+
for (let i = 0; i < argsString.length; i++) {
8+
const char = argsString[i];
9+
if (inQuote) {
10+
if (char === inQuote) {
11+
inQuote = null;
12+
} else {
13+
current += char;
14+
}
15+
} else if (char === '"' || char === "'") {
16+
inQuote = char;
17+
} else if (/\s/.test(char)) {
18+
if (current) {
19+
args.push(current);
20+
current = "";
21+
}
22+
} else {
23+
current += char;
24+
}
25+
}
26+
if (current) {
27+
args.push(current);
28+
}
29+
return args;
30+
}
31+
32+
function parseSafeNonNegativeInteger(raw: string): number | undefined {
33+
const parsed = Number(raw);
34+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
35+
}
36+
37+
/** Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. */
38+
export function substituteArgs(content: string, args: string[]): string {
39+
let result = content;
40+
result = result.replace(/\$(\d+)/g, (_, num: string) => {
41+
const parsed = parseSafeNonNegativeInteger(num);
42+
if (parsed === undefined || parsed <= 0) {
43+
return "";
44+
}
45+
return args[parsed - 1] ?? "";
46+
});
47+
result = result.replace(
48+
/\$\{@:(\d+)(?::(\d+))?\}/g,
49+
(_, startStr: string, lengthStr?: string) => {
50+
const parsedStart = parseSafeNonNegativeInteger(startStr);
51+
if (parsedStart === undefined) {
52+
return "";
53+
}
54+
let start = parsedStart - 1;
55+
if (start < 0) {
56+
start = 0;
57+
}
58+
if (lengthStr) {
59+
const length = parseSafeNonNegativeInteger(lengthStr);
60+
if (length === undefined) {
61+
return "";
62+
}
63+
return args.slice(start, start + length).join(" ");
64+
}
65+
return args.slice(start).join(" ");
66+
},
67+
);
68+
const allArgs = args.join(" ");
69+
result = result.replace(/\$ARGUMENTS/g, allArgs);
70+
result = result.replace(/\$@/g, allArgs);
71+
return result;
72+
}

packages/agent-core/src/harness/prompt-templates.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { describe, expect, it } from "vitest";
2-
import { substituteArgs } from "./prompt-templates.js";
2+
import { parseCommandArgs, substituteArgs } from "./prompt-templates.js";
33

44
describe("prompt template argument substitution", () => {
5+
it("parses quoted and multiline arguments", () => {
6+
expect(parseCommandArgs(`alpha "beta gamma"\ndelta 'echo one two'`)).toEqual([
7+
"alpha",
8+
"beta gamma",
9+
"delta",
10+
"echo one two",
11+
]);
12+
});
13+
514
it("rejects unsafe positional placeholders", () => {
615
expect(substituteArgs("$9007199254740992", ["first", "second"])).toBe("");
716
});

packages/agent-core/src/harness/prompt-templates.ts

Lines changed: 2 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
parseFrontmatter,
44
resolveFileInfoKind as resolveKind,
55
} from "./file-loader-utils.js";
6+
export { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js";
7+
import { substituteArgs } from "./prompt-template-arguments.js";
68
import { type ExecutionEnv, type PromptTemplate, type Result } from "./types.js";
79

810
export type PromptTemplateDiagnosticCode =
@@ -188,79 +190,6 @@ async function loadTemplateFromFile(
188190
};
189191
}
190192

191-
/** Parse an argument string using simple shell-style single and double quotes. */
192-
export function parseCommandArgs(argsString: string): string[] {
193-
const args: string[] = [];
194-
let current = "";
195-
let inQuote: string | null = null;
196-
197-
for (let i = 0; i < argsString.length; i++) {
198-
const char = argsString[i];
199-
if (inQuote) {
200-
if (char === inQuote) {
201-
inQuote = null;
202-
} else {
203-
current += char;
204-
}
205-
} else if (char === '"' || char === "'") {
206-
inQuote = char;
207-
} else if (char === " " || char === "\t") {
208-
if (current) {
209-
args.push(current);
210-
current = "";
211-
}
212-
} else {
213-
current += char;
214-
}
215-
}
216-
if (current) {
217-
args.push(current);
218-
}
219-
return args;
220-
}
221-
222-
function parseSafeNonNegativeInteger(raw: string): number | undefined {
223-
const parsed = Number(raw);
224-
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
225-
}
226-
227-
/** Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. */
228-
export function substituteArgs(content: string, args: string[]): string {
229-
let result = content;
230-
result = result.replace(/\$(\d+)/g, (_, num: string) => {
231-
const parsed = parseSafeNonNegativeInteger(num);
232-
if (parsed === undefined || parsed <= 0) {
233-
return "";
234-
}
235-
return args[parsed - 1] ?? "";
236-
});
237-
result = result.replace(
238-
/\$\{@:(\d+)(?::(\d+))?\}/g,
239-
(_, startStr: string, lengthStr?: string) => {
240-
const parsedStart = parseSafeNonNegativeInteger(startStr);
241-
if (parsedStart === undefined) {
242-
return "";
243-
}
244-
let start = parsedStart - 1;
245-
if (start < 0) {
246-
start = 0;
247-
}
248-
if (lengthStr) {
249-
const length = parseSafeNonNegativeInteger(lengthStr);
250-
if (length === undefined) {
251-
return "";
252-
}
253-
return args.slice(start, start + length).join(" ");
254-
}
255-
return args.slice(start).join(" ");
256-
},
257-
);
258-
const allArgs = args.join(" ");
259-
result = result.replace(/\$ARGUMENTS/g, allArgs);
260-
result = result.replace(/\$@/g, allArgs);
261-
return result;
262-
}
263-
264193
/** Format a prompt template invocation with positional arguments. */
265194
export function formatPromptTemplateInvocation(
266195
template: PromptTemplate,

src/agents/sessions/prompt-templates.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { describe, expect, it } from "vitest";
2-
import { substituteArgs } from "./prompt-templates.js";
2+
import { parseCommandArgs, substituteArgs } from "./prompt-templates.js";
33

44
describe("prompt template argument substitution", () => {
5+
it("parses quoted and multiline arguments", () => {
6+
expect(parseCommandArgs(`alpha "beta gamma"\ndelta 'echo one two'`)).toEqual([
7+
"alpha",
8+
"beta gamma",
9+
"delta",
10+
"echo one two",
11+
]);
12+
});
13+
514
it("rejects unsafe positional placeholders", () => {
615
expect(substituteArgs("$9007199254740992", ["first", "second"])).toBe("");
716
});

src/agents/sessions/prompt-templates.ts

Lines changed: 8 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
22
import { homedir } from "node:os";
33
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
4+
export {
5+
parseCommandArgs,
6+
substituteArgs,
7+
} from "../../../packages/agent-core/src/harness/prompt-template-arguments.js";
8+
import {
9+
parseCommandArgs,
10+
substituteArgs,
11+
} from "../../../packages/agent-core/src/harness/prompt-template-arguments.js";
412
import { CONFIG_DIR_NAME } from "../config.js";
513
import { parseFrontmatter } from "../utils/frontmatter.js";
614
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
@@ -17,108 +25,6 @@ export interface PromptTemplate {
1725
filePath: string; // Absolute path to the template file
1826
}
1927

20-
/**
21-
* Parse command arguments respecting quoted strings (bash-style)
22-
* Returns array of arguments
23-
*/
24-
export function parseCommandArgs(argsString: string): string[] {
25-
const args: string[] = [];
26-
let current = "";
27-
let inQuote: string | null = null;
28-
29-
for (let i = 0; i < argsString.length; i++) {
30-
const char = argsString[i];
31-
32-
if (inQuote) {
33-
if (char === inQuote) {
34-
inQuote = null;
35-
} else {
36-
current += char;
37-
}
38-
} else if (char === '"' || char === "'") {
39-
inQuote = char;
40-
} else if (/\s/.test(char)) {
41-
if (current) {
42-
args.push(current);
43-
current = "";
44-
}
45-
} else {
46-
current += char;
47-
}
48-
}
49-
50-
if (current) {
51-
args.push(current);
52-
}
53-
54-
return args;
55-
}
56-
57-
function parseSafeNonNegativeInteger(raw: string): number | undefined {
58-
const parsed = Number(raw);
59-
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
60-
}
61-
62-
/**
63-
* Substitute argument placeholders in template content
64-
* Supports:
65-
* - $1, $2, ... for positional args
66-
* - $@ and $ARGUMENTS for all args
67-
* - ${@:N} for args from Nth onwards (bash-style slicing)
68-
* - ${@:N:L} for L args starting from Nth
69-
*
70-
* Note: Replacement happens on the template string only. Argument values
71-
* containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted.
72-
*/
73-
export function substituteArgs(content: string, args: string[]): string {
74-
let result = content;
75-
76-
// Replace $1, $2, etc. with positional args FIRST (before wildcards)
77-
// This prevents wildcard replacement values containing $<digit> patterns from being re-substituted
78-
result = result.replace(/\$(\d+)/g, (_, num) => {
79-
const parsed = parseSafeNonNegativeInteger(num);
80-
if (parsed === undefined || parsed <= 0) {
81-
return "";
82-
}
83-
const index = parsed - 1;
84-
return args[index] ?? "";
85-
});
86-
87-
// Replace ${@:start} or ${@:start:length} with sliced args (bash-style)
88-
// Process BEFORE simple $@ to avoid conflicts
89-
result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr, lengthStr) => {
90-
const parsedStart = parseSafeNonNegativeInteger(startStr);
91-
if (parsedStart === undefined) {
92-
return "";
93-
}
94-
let start = parsedStart - 1; // Convert to 0-indexed (user provides 1-indexed)
95-
// Treat 0 as 1 (bash convention: args start at 1)
96-
if (start < 0) {
97-
start = 0;
98-
}
99-
100-
if (lengthStr) {
101-
const length = parseSafeNonNegativeInteger(lengthStr);
102-
if (length === undefined) {
103-
return "";
104-
}
105-
return args.slice(start, start + length).join(" ");
106-
}
107-
return args.slice(start).join(" ");
108-
});
109-
110-
// Pre-compute all args joined (optimization)
111-
const allArgs = args.join(" ");
112-
113-
// Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode)
114-
result = result.replace(/\$ARGUMENTS/g, allArgs);
115-
116-
// Replace $@ with all args joined (existing syntax)
117-
result = result.replace(/\$@/g, allArgs);
118-
119-
return result;
120-
}
121-
12228
function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null {
12329
try {
12430
const rawContent = readFileSync(filePath, "utf-8");

0 commit comments

Comments
 (0)