-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathcommand-builder.ts
More file actions
378 lines (333 loc) · 11.3 KB
/
Copy pathcommand-builder.ts
File metadata and controls
378 lines (333 loc) · 11.3 KB
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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
/**
* CommandBuilder - Pure functions for constructing command specifications
*
* This module separates command construction (pure) from execution (side effects).
* All functions here are deterministic and testable without mocking process spawning.
*
* IMPORTANT: This is the single source of truth for building command arguments.
* Both execution and dry-run paths MUST use these functions to ensure consistency.
*/
import type { AgentFrontmatter } from "./types";
import type { GlobalConfig } from "./config";
import { concatFrontmatter } from "./config";
/**
* Specification for a command to be executed
* Contains all information needed to spawn a process
*/
export interface CommandSpec {
/** The executable to run (e.g., "claude", "gemini") */
executable: string;
/** Subcommands to prepend (e.g., ["exec"] for codex exec) */
subcommands: string[];
/** Command line arguments (flags and values) */
args: string[];
/** Positional arguments (body and extra CLI args) */
positionals: string[];
/** Environment variables to set (merged with process.env at execution time) */
env: Record<string, string>;
/** Working directory for the command */
cwd: string;
}
/**
* Get the complete argument array for spawning
* Combines subcommands, args, and positionals in the correct order
*/
export function getSpawnArgs(spec: CommandSpec): string[] {
return [...spec.subcommands, ...spec.args, ...spec.positionals];
}
/**
* Keys handled by the system, not passed to the command
* - _inputs: consumed for template variable mapping
* - _env: sets process.env, not passed as flag
* - $N patterns: positional mapping, handled specially
* - pre/before: lifecycle hooks
* - post/after: lifecycle hooks
* - context_window: token limit override
* Note: All underscore-prefixed keys are also skipped in buildArgsFromFrontmatter
*/
const SYSTEM_KEYS = new Set([
"_inputs",
"_env",
"pre",
"before",
"post",
"after",
"context_window",
]);
/**
* Check if a key is a positional mapping ($1, $2, etc.)
*/
function isPositionalKey(key: string): boolean {
return /^\$\d+$/.test(key);
}
/**
* Variadic flags that consume all following positional arguments.
* These must use --flag=value syntax to avoid eating the prompt.
*/
const VARIADIC_FLAGS = new Set([
"allowed-tools",
"allowedTools",
"disallowed-tools",
"disallowedTools",
"tools",
"add-dir",
"betas",
"mcp-config",
"plugin-dir",
]);
/**
* Convert frontmatter key to CLI flag
* e.g., "model" -> "--model"
* e.g., "p" -> "-p"
*/
function toFlag(key: string): string {
if (key.startsWith("-")) return key;
if (key.length === 1) return `-${key}`;
return `--${key}`;
}
/**
* Build CLI args from frontmatter
* Each key becomes a flag, values become arguments
*
* @param frontmatter - The parsed frontmatter from the markdown file
* @param templateVars - Set of variable names used in templates (to skip)
* @returns Array of CLI arguments
*/
export function buildArgsFromFrontmatter(
frontmatter: AgentFrontmatter,
templateVars: Set<string>
): string[] {
const args: string[] = [];
for (const [key, value] of Object.entries(frontmatter)) {
// Skip system keys
if (SYSTEM_KEYS.has(key)) continue;
// Skip positional mappings ($1, $2, etc.) - handled separately
if (isPositionalKey(key)) continue;
// Skip named template variable fields ($varname) - consumed for template substitution
if (key.startsWith("$")) continue;
// Skip internal md keys (_interactive, _subcommand, _cwd, etc.)
if (key.startsWith("_")) continue;
// Skip template variables (used for substitution, not passed to command)
if (templateVars.has(key)) continue;
// Skip objects (except arrays) - they don't map cleanly to CLI flags
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
continue;
}
// Skip undefined/null/false
if (value === undefined || value === null || value === false) continue;
// Boolean true -> just the flag
if (value === true) {
args.push(toFlag(key));
continue;
}
// Array -> repeat flag for each value
if (Array.isArray(value)) {
for (const v of value) {
// Variadic flags need --flag=value syntax to not eat following args
if (VARIADIC_FLAGS.has(key)) {
args.push(`${toFlag(key)}=${String(v)}`);
} else {
args.push(toFlag(key), String(v));
}
}
continue;
}
// String/number -> flag with value
// Variadic flags need --flag=value syntax to not eat following args
if (VARIADIC_FLAGS.has(key)) {
const strValue = String(value);
// Split comma-separated values for variadic flags
// Handle both "Read,Edit" and "Bash(git commit:*), Bash(git add:*)"
const parts = strValue.includes(', ')
? strValue.split(', ') // Split on ", " (comma + space)
: strValue.includes(',')
? strValue.split(',') // Split on just ","
: [strValue]; // No commas, single value
for (const part of parts) {
args.push(`${toFlag(key)}=${part.trim()}`);
}
} else {
args.push(toFlag(key), String(value));
}
}
return args;
}
/**
* Extract positional mappings from frontmatter ($1, $2, etc.)
* Returns a map of position number to flag name
*/
export function extractPositionalMappings(frontmatter: AgentFrontmatter): Map<number, string> {
const mappings = new Map<number, string>();
for (const [key, value] of Object.entries(frontmatter)) {
if (isPositionalKey(key) && typeof value === "string") {
const pos = parseInt(key.slice(1), 10);
mappings.set(pos, value);
}
}
return mappings;
}
/**
* Extract environment variables to set (from _env object)
*/
export function extractEnvVars(frontmatter: AgentFrontmatter): Record<string, string> {
const env = frontmatter._env;
if (typeof env === "object" && env !== null && !Array.isArray(env)) {
return env as Record<string, string>;
}
return {};
}
/**
* Apply positional arguments to args array based on mappings
*
* @param baseArgs - The base args built from frontmatter
* @param positionals - Positional arguments (body is $1, additional CLI args are $2+)
* @param mappings - Map of position number to flag name
* @returns Final args array with positionals applied
*/
export function applyPositionalArgs(
baseArgs: string[],
positionals: string[],
mappings: Map<number, string>
): string[] {
const finalArgs = [...baseArgs];
for (let i = 0; i < positionals.length; i++) {
const pos = i + 1; // $1 is first positional
const value = positionals[i];
if (value === undefined) continue;
if (mappings.has(pos)) {
// Map to flag: $1: prompt -> --prompt <value>
const flagName = mappings.get(pos)!;
finalArgs.push(toFlag(flagName), value);
} else {
// Pass as positional argument
finalArgs.push(value);
}
}
return finalArgs;
}
/**
* Get command defaults from global config
*/
function getCommandDefaultsFromConfig(
command: string,
config: GlobalConfig
): Record<string, unknown> {
return config.commands?.[command] ?? {};
}
/**
* Extract subcommands from frontmatter (_subcommand key)
* Returns an array of subcommand strings
*/
export function extractSubcommands(frontmatter: AgentFrontmatter): string[] {
const subcommand = frontmatter._subcommand;
if (!subcommand) return [];
if (Array.isArray(subcommand)) {
return subcommand.map(String);
}
return [String(subcommand)];
}
/**
* Build a complete CommandSpec from frontmatter, body, and configuration
*
* This is a pure function that returns a specification object.
* No side effects - doesn't spawn processes or modify environment.
*
* @param command - The command to execute (e.g., "claude")
* @param frontmatter - Parsed frontmatter from the markdown file
* @param body - The prompt body text
* @param positionalArgs - Additional positional arguments from CLI
* @param templateVars - Set of template variable names (to exclude from args)
* @param config - Global configuration with command defaults
* @param cwd - Working directory for command execution
* @returns CommandSpec ready for execution
*/
export function buildCommand(
command: string,
frontmatter: AgentFrontmatter,
body: string,
positionalArgs: string[],
templateVars: Set<string>,
config: GlobalConfig,
cwd: string = process.cwd()
): CommandSpec {
// Apply command defaults from config (defaults ⊕ frontmatter; frontmatter wins)
const defaults = getCommandDefaultsFromConfig(command, config);
const mergedFrontmatter = concatFrontmatter(defaults as AgentFrontmatter, frontmatter);
// Build base args from frontmatter
const baseArgs = buildArgsFromFrontmatter(mergedFrontmatter, templateVars);
// Extract positional mappings
const positionalMappings = extractPositionalMappings(mergedFrontmatter);
// Build positionals array: body is $1, additional args are $2+
const rawPositionals = [body, ...positionalArgs];
// Apply positional arguments (transforms based on mappings)
const processedPositionals = applyPositionalArgs([], rawPositionals, positionalMappings);
// Extract subcommands
const subcommands = extractSubcommands(mergedFrontmatter);
// Extract environment variables
const env = extractEnvVars(mergedFrontmatter);
return {
executable: command,
subcommands,
args: baseArgs,
positionals: processedPositionals,
env,
cwd,
};
}
/**
* Build a CommandSpec without positional argument processing
* Useful when you want to handle positionals separately
*
* @param command - The command to execute
* @param frontmatter - Parsed frontmatter
* @param templateVars - Set of template variable names
* @param config - Global configuration
* @param cwd - Working directory
* @returns Partial CommandSpec (positionals array is empty)
*/
export function buildCommandBase(
command: string,
frontmatter: AgentFrontmatter,
templateVars: Set<string>,
config: GlobalConfig,
cwd: string = process.cwd()
): CommandSpec {
// Apply command defaults from config (defaults ⊕ frontmatter; frontmatter wins)
const defaults = getCommandDefaultsFromConfig(command, config);
const mergedFrontmatter = concatFrontmatter(defaults as AgentFrontmatter, frontmatter);
// Build base args from frontmatter
const args = buildArgsFromFrontmatter(mergedFrontmatter, templateVars);
// Extract subcommands
const subcommands = extractSubcommands(mergedFrontmatter);
// Extract environment variables
const env = extractEnvVars(mergedFrontmatter);
return {
executable: command,
subcommands,
args,
positionals: [],
env,
cwd,
};
}
/**
* Format a CommandSpec for display (dry-run output)
* Escapes values properly for shell display
*
* @param spec - The command specification
* @returns A string representation of the command
*/
export function formatCommandForDisplay(spec: CommandSpec): string {
const allArgs = getSpawnArgs(spec);
// Escape each argument for display
const escapedArgs = allArgs.map(arg => {
// If arg contains spaces, newlines, or special chars, quote it
if (/[\s"'\\$`!]/.test(arg) || arg.includes("\n")) {
// Escape backslashes and double quotes, then wrap in double quotes
const escaped = arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `"${escaped}"`;
}
return arg;
});
return `${spec.executable} ${escapedArgs.join(" ")}`;
}