padrone 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/create.ts ADDED
@@ -0,0 +1,559 @@
1
+ import type { Schema } from 'ai';
2
+ import { generateHelp } from './help';
3
+ import { extractSchemaMetadata, parsePositionalConfig, preprocessOptions } from './options';
4
+ import { parseCliInputToParts } from './parse';
5
+ import type { AnyPadroneCommand, AnyPadroneProgram, PadroneAPI, PadroneCommand, PadroneCommandBuilder, PadroneProgram } from './types';
6
+ import { findConfigFile, getVersion, loadConfigFile } from './utils';
7
+
8
+ const commandSymbol = Symbol('padrone_command');
9
+
10
+ const noop = <TRes>() => undefined as TRes;
11
+
12
+ export function createPadroneCommandBuilder<TBuilder extends PadroneProgram = PadroneProgram>(
13
+ existingCommand: AnyPadroneCommand,
14
+ ): TBuilder & { [commandSymbol]: AnyPadroneCommand } {
15
+ function findCommandByName(name: string, commands?: AnyPadroneCommand[]): AnyPadroneCommand | undefined {
16
+ if (!commands) return undefined;
17
+
18
+ const foundByName = commands.find((cmd) => cmd.name === name);
19
+ if (foundByName) return foundByName;
20
+
21
+ for (const cmd of commands) {
22
+ if (cmd.commands && name.startsWith(`${cmd.name} `)) {
23
+ const subCommandName = name.slice(cmd.name.length + 1);
24
+ const subCommand = findCommandByName(subCommandName, cmd.commands);
25
+ if (subCommand) return subCommand;
26
+ }
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ const find: AnyPadroneProgram['find'] = (command) => {
32
+ return findCommandByName(command, existingCommand.commands) as ReturnType<AnyPadroneProgram['find']>;
33
+ };
34
+
35
+ /**
36
+ * Parses CLI input to find the command and extract raw options without validation.
37
+ */
38
+ const parseCommand = (input: string | undefined) => {
39
+ input ??= typeof process !== 'undefined' ? (process.argv.slice(2).join(' ') as any) : undefined;
40
+ if (!input) return { command: existingCommand, rawOptions: {} as Record<string, unknown>, args: [] as string[] };
41
+
42
+ const parts = parseCliInputToParts(input);
43
+
44
+ const terms = parts.filter((p) => p.type === 'term').map((p) => p.value);
45
+ const args = parts.filter((p) => p.type === 'arg').map((p) => p.value);
46
+
47
+ let curCommand: AnyPadroneCommand | undefined = existingCommand;
48
+
49
+ // If the first term is the program name, skip it
50
+ if (terms[0] === existingCommand.name) terms.shift();
51
+
52
+ for (let i = 0; i < terms.length; i++) {
53
+ const term = terms[i] || '';
54
+ const found = findCommandByName(term, curCommand.commands);
55
+
56
+ if (found) {
57
+ curCommand = found;
58
+ } else {
59
+ args.unshift(...terms.slice(i));
60
+ break;
61
+ }
62
+ }
63
+
64
+ if (!curCommand) return { command: existingCommand, rawOptions: {} as Record<string, unknown>, args };
65
+
66
+ // Extract option metadata from the nested options object in meta
67
+ const optionsMeta = curCommand.meta?.options;
68
+ const schemaMetadata = curCommand.options
69
+ ? extractSchemaMetadata(curCommand.options, optionsMeta)
70
+ : { aliases: {}, envBindings: {}, configKeys: {} };
71
+ const { aliases } = schemaMetadata;
72
+
73
+ // Get array options from schema (arrays are always variadic)
74
+ const arrayOptions = new Set<string>();
75
+ if (curCommand.options) {
76
+ try {
77
+ const jsonSchema = curCommand.options['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) as Record<string, any>;
78
+ if (jsonSchema.type === 'object' && jsonSchema.properties) {
79
+ for (const [key, prop] of Object.entries(jsonSchema.properties as Record<string, any>)) {
80
+ if (prop?.type === 'array') arrayOptions.add(key);
81
+ }
82
+ }
83
+ } catch {
84
+ // Ignore schema parsing errors
85
+ }
86
+ }
87
+
88
+ const opts = parts.filter((p) => p.type === 'option' || p.type === 'alias');
89
+ const rawOptions: Record<string, unknown> = {};
90
+
91
+ for (const opt of opts) {
92
+ const key = opt.type === 'alias' ? aliases[opt.key] || opt.key : opt.key;
93
+
94
+ // Handle negated boolean options (--no-verbose)
95
+ if (opt.type === 'option' && opt.negated) {
96
+ rawOptions[key] = false;
97
+ continue;
98
+ }
99
+
100
+ const value = opt.value ?? true;
101
+
102
+ // Handle array options - accumulate values into arrays (arrays are always variadic)
103
+ if (arrayOptions.has(key)) {
104
+ if (key in rawOptions) {
105
+ const existing = rawOptions[key];
106
+ if (Array.isArray(existing)) {
107
+ if (Array.isArray(value)) {
108
+ existing.push(...value);
109
+ } else {
110
+ existing.push(value);
111
+ }
112
+ } else {
113
+ if (Array.isArray(value)) {
114
+ rawOptions[key] = [existing, ...value];
115
+ } else {
116
+ rawOptions[key] = [existing, value];
117
+ }
118
+ }
119
+ } else {
120
+ rawOptions[key] = Array.isArray(value) ? value : [value];
121
+ }
122
+ } else {
123
+ rawOptions[key] = value;
124
+ }
125
+ }
126
+
127
+ return { command: curCommand, rawOptions, args };
128
+ };
129
+
130
+ /**
131
+ * Validates raw options against the command's schema and applies preprocessing.
132
+ */
133
+ const validateOptions = (
134
+ command: AnyPadroneCommand,
135
+ rawOptions: Record<string, unknown>,
136
+ args: string[],
137
+ parseOptions?: { env?: Record<string, string | undefined>; configData?: Record<string, unknown> },
138
+ ) => {
139
+ // Extract option metadata for preprocessing
140
+ const optionsMeta = command.meta?.options;
141
+ const schemaMetadata = command.options
142
+ ? extractSchemaMetadata(command.options, optionsMeta)
143
+ : { aliases: {}, envBindings: {}, configKeys: {} };
144
+ const { envBindings, configKeys } = schemaMetadata;
145
+
146
+ // Apply preprocessing (env and config bindings)
147
+ const preprocessedOptions = preprocessOptions(rawOptions, {
148
+ aliases: {}, // Already resolved aliases in parseCommand
149
+ envBindings,
150
+ configKeys,
151
+ configData: parseOptions?.configData,
152
+ env: parseOptions?.env,
153
+ });
154
+
155
+ // Parse positional configuration
156
+ const positionalConfig = command.meta?.positional ? parsePositionalConfig(command.meta.positional) : [];
157
+
158
+ // Map positional arguments to their named options
159
+ if (positionalConfig.length > 0) {
160
+ let argIndex = 0;
161
+ for (const { name, variadic } of positionalConfig) {
162
+ if (argIndex >= args.length) break;
163
+
164
+ if (variadic) {
165
+ // Collect remaining args (but leave room for non-variadic args after)
166
+ const remainingPositionals = positionalConfig.slice(positionalConfig.indexOf({ name, variadic }) + 1);
167
+ const nonVariadicAfter = remainingPositionals.filter((p) => !p.variadic).length;
168
+ const variadicEnd = args.length - nonVariadicAfter;
169
+ preprocessedOptions[name] = args.slice(argIndex, variadicEnd);
170
+ argIndex = variadicEnd;
171
+ } else {
172
+ preprocessedOptions[name] = args[argIndex];
173
+ argIndex++;
174
+ }
175
+ }
176
+ }
177
+
178
+ const optionsParsed = command.options ? command.options['~standard'].validate(preprocessedOptions) : { value: preprocessedOptions };
179
+
180
+ if (optionsParsed instanceof Promise) {
181
+ throw new Error('Async validation is not supported. Schema validate() must return a synchronous result.');
182
+ }
183
+
184
+ // Return undefined for options when there's no schema and no meaningful options
185
+ const hasOptions = command.options || Object.keys(preprocessedOptions).length > 0;
186
+
187
+ return {
188
+ options: optionsParsed.issues ? undefined : hasOptions ? (optionsParsed.value as any) : undefined,
189
+ optionsResult: optionsParsed as any,
190
+ };
191
+ };
192
+
193
+ const parse: AnyPadroneProgram['parse'] = (input, parseOptions) => {
194
+ const { command, rawOptions, args } = parseCommand(input);
195
+ const { options, optionsResult } = validateOptions(command, rawOptions, args, parseOptions);
196
+
197
+ return {
198
+ command: command as any,
199
+ options,
200
+ optionsResult,
201
+ };
202
+ };
203
+
204
+ const stringify: AnyPadroneProgram['stringify'] = (command = '' as any, options) => {
205
+ const commandObj = typeof command === 'string' ? findCommandByName(command, existingCommand.commands) : (command as AnyPadroneCommand);
206
+ if (!commandObj) throw new Error(`Command "${command ?? ''}" not found`);
207
+
208
+ const parts: string[] = [];
209
+
210
+ if (commandObj.path) parts.push(commandObj.path);
211
+
212
+ // Get positional config to determine which options are positional
213
+ const positionalConfig = commandObj.meta?.positional ? parsePositionalConfig(commandObj.meta.positional) : [];
214
+ const positionalNames = new Set(positionalConfig.map((p) => p.name));
215
+
216
+ // Output positional arguments first in order
217
+ if (options && typeof options === 'object') {
218
+ for (const { name, variadic } of positionalConfig) {
219
+ const value = (options as Record<string, unknown>)[name];
220
+ if (value === undefined) continue;
221
+
222
+ if (variadic && Array.isArray(value)) {
223
+ for (const v of value) {
224
+ const vStr = String(v);
225
+ if (vStr.includes(' ')) parts.push(`"${vStr}"`);
226
+ else parts.push(vStr);
227
+ }
228
+ } else {
229
+ const argStr = String(value);
230
+ if (argStr.includes(' ')) parts.push(`"${argStr}"`);
231
+ else parts.push(argStr);
232
+ }
233
+ }
234
+
235
+ // Output remaining options (non-positional)
236
+ for (const [key, value] of Object.entries(options)) {
237
+ if (value === undefined || positionalNames.has(key)) continue;
238
+
239
+ if (typeof value === 'boolean') {
240
+ if (value) parts.push(`--${key}`);
241
+ else parts.push(`--no-${key}`);
242
+ } else if (Array.isArray(value)) {
243
+ // Handle variadic options - output each value separately
244
+ for (const v of value) {
245
+ const vStr = String(v);
246
+ if (vStr.includes(' ')) parts.push(`--${key}="${vStr}"`);
247
+ else parts.push(`--${key}=${vStr}`);
248
+ }
249
+ } else if (typeof value === 'string') {
250
+ if (value.includes(' ')) parts.push(`--${key}="${value}"`);
251
+ else parts.push(`--${key}=${value}`);
252
+ } else {
253
+ parts.push(`--${key}=${value}`);
254
+ }
255
+ }
256
+ }
257
+
258
+ return parts.join(' ');
259
+ };
260
+
261
+ type DetailLevel = 'minimal' | 'standard' | 'full';
262
+ type FormatLevel = 'text' | 'ansi' | 'console' | 'markdown' | 'html' | 'json' | 'auto';
263
+
264
+ /**
265
+ * Check if help or version flags/commands are present in the input.
266
+ * Returns the appropriate action to take, or null if normal execution should proceed.
267
+ */
268
+ const checkBuiltinCommands = (
269
+ input: string | undefined,
270
+ ): { type: 'help'; command?: AnyPadroneCommand; detail?: DetailLevel; format?: FormatLevel } | { type: 'version' } | null => {
271
+ if (!input) return null;
272
+
273
+ const parts = parseCliInputToParts(input);
274
+ const terms = parts.filter((p) => p.type === 'term').map((p) => p.value);
275
+ const opts = parts.filter((p) => p.type === 'option' || p.type === 'alias');
276
+
277
+ // Check for --help, -h flags (these take precedence over commands)
278
+ const hasHelpFlag = opts.some((p) => (p.type === 'option' && p.key === 'help') || (p.type === 'alias' && p.key === 'h'));
279
+
280
+ // Extract detail level from --detail=<level> or -d <level>
281
+ const getDetailLevel = (): DetailLevel | undefined => {
282
+ for (const opt of opts) {
283
+ if (opt.type === 'option' && opt.key === 'detail' && typeof opt.value === 'string') {
284
+ if (opt.value === 'minimal' || opt.value === 'standard' || opt.value === 'full') {
285
+ return opt.value;
286
+ }
287
+ }
288
+ if (opt.type === 'alias' && opt.key === 'd' && typeof opt.value === 'string') {
289
+ if (opt.value === 'minimal' || opt.value === 'standard' || opt.value === 'full') {
290
+ return opt.value;
291
+ }
292
+ }
293
+ }
294
+ return undefined;
295
+ };
296
+ const detail = getDetailLevel();
297
+
298
+ // Extract format from --format=<value> or -f <value>
299
+ const getFormat = (): FormatLevel | undefined => {
300
+ const validFormats: FormatLevel[] = ['text', 'ansi', 'console', 'markdown', 'html', 'json', 'auto'];
301
+ for (const opt of opts) {
302
+ if (opt.type === 'option' && opt.key === 'format' && typeof opt.value === 'string') {
303
+ if (validFormats.includes(opt.value as FormatLevel)) {
304
+ return opt.value as FormatLevel;
305
+ }
306
+ }
307
+ if (opt.type === 'alias' && opt.key === 'f' && typeof opt.value === 'string') {
308
+ if (validFormats.includes(opt.value as FormatLevel)) {
309
+ return opt.value as FormatLevel;
310
+ }
311
+ }
312
+ }
313
+ return undefined;
314
+ };
315
+ const format = getFormat();
316
+
317
+ // Check for --version, -v, -V flags
318
+ const hasVersionFlag = opts.some(
319
+ (p) => (p.type === 'option' && p.key === 'version') || (p.type === 'alias' && (p.key === 'v' || p.key === 'V')),
320
+ );
321
+
322
+ // If the first term is the program name, skip it
323
+ const normalizedTerms = [...terms];
324
+ if (normalizedTerms[0] === existingCommand.name) normalizedTerms.shift();
325
+
326
+ // Check if user has defined 'help' or 'version' commands (they take precedence)
327
+ const userHelpCommand = findCommandByName('help', existingCommand.commands);
328
+ const userVersionCommand = findCommandByName('version', existingCommand.commands);
329
+
330
+ // Check for 'help' command (only if user hasn't defined one)
331
+ if (!userHelpCommand && normalizedTerms[0] === 'help') {
332
+ // help <command> - get help for specific command
333
+ const commandName = normalizedTerms.slice(1).join(' ');
334
+ const targetCommand = commandName ? findCommandByName(commandName, existingCommand.commands) : undefined;
335
+ return { type: 'help', command: targetCommand, detail, format };
336
+ }
337
+
338
+ // Check for 'version' command (only if user hasn't defined one)
339
+ if (!userVersionCommand && normalizedTerms[0] === 'version') {
340
+ return { type: 'version' };
341
+ }
342
+
343
+ // Handle help flag - find the command being requested
344
+ if (hasHelpFlag) {
345
+ // Filter out help-related terms and flags to find the target command
346
+ const commandTerms = normalizedTerms.filter((t) => t !== 'help');
347
+ const commandName = commandTerms.join(' ');
348
+ const targetCommand = commandName ? findCommandByName(commandName, existingCommand.commands) : undefined;
349
+ return { type: 'help', command: targetCommand, detail, format };
350
+ }
351
+
352
+ // Handle version flag (only for root command, i.e., no subcommand terms)
353
+ if (hasVersionFlag && normalizedTerms.length === 0) {
354
+ return { type: 'version' };
355
+ }
356
+
357
+ return null;
358
+ };
359
+
360
+ /**
361
+ * Extract the config file path from --config=<path> or -c <path> flags.
362
+ */
363
+ const extractConfigPath = (input: string | undefined): string | undefined => {
364
+ if (!input) return undefined;
365
+
366
+ const parts = parseCliInputToParts(input);
367
+ const opts = parts.filter((p) => p.type === 'option' || p.type === 'alias');
368
+
369
+ for (const opt of opts) {
370
+ if (opt.type === 'option' && opt.key === 'config' && typeof opt.value === 'string') {
371
+ return opt.value;
372
+ }
373
+ if (opt.type === 'alias' && opt.key === 'c' && typeof opt.value === 'string') {
374
+ return opt.value;
375
+ }
376
+ }
377
+ return undefined;
378
+ };
379
+
380
+ const cli: AnyPadroneProgram['cli'] = (input, cliOptions) => {
381
+ // Resolve input from process.argv if not provided
382
+ const resolvedInput = input ?? (typeof process !== 'undefined' ? (process.argv.slice(2).join(' ') as any) : undefined);
383
+
384
+ // Check for built-in help/version commands and flags
385
+ const builtin = checkBuiltinCommands(resolvedInput);
386
+
387
+ if (builtin) {
388
+ if (builtin.type === 'help') {
389
+ const helpText = generateHelp(existingCommand, builtin.command ?? existingCommand, {
390
+ detail: builtin.detail,
391
+ format: builtin.format,
392
+ });
393
+ console.log(helpText);
394
+ return {
395
+ command: existingCommand,
396
+ args: undefined,
397
+ options: undefined,
398
+ result: helpText,
399
+ } as any;
400
+ }
401
+
402
+ if (builtin.type === 'version') {
403
+ const version = getVersion(existingCommand.version);
404
+ console.log(version);
405
+ return {
406
+ command: existingCommand,
407
+ options: undefined,
408
+ result: version,
409
+ } as any;
410
+ }
411
+ }
412
+
413
+ // Parse the command first (without validating options)
414
+ const { command, rawOptions, args } = parseCommand(resolvedInput);
415
+
416
+ // Extract config file path from --config or -c flag
417
+ const configPath = extractConfigPath(resolvedInput);
418
+
419
+ // Resolve config files: command's own configFiles > inherited from parent/root
420
+ // undefined = inherit, empty array = no config files (explicit opt-out)
421
+ const resolveConfigFiles = (cmd: AnyPadroneCommand): string[] | undefined => {
422
+ if (cmd.configFiles !== undefined) return cmd.configFiles;
423
+ if (cmd.parent) return resolveConfigFiles(cmd.parent);
424
+ return undefined;
425
+ };
426
+ const effectiveConfigFiles = resolveConfigFiles(command);
427
+
428
+ // Determine config data: explicit --config flag > auto-discovered config > provided configData
429
+ let configData = cliOptions?.configData;
430
+ if (configPath) {
431
+ // Explicit config path takes precedence
432
+ configData = loadConfigFile(configPath);
433
+ } else if (effectiveConfigFiles?.length) {
434
+ // Search for config files if configFiles is configured (inherited or own)
435
+ const foundConfigPath = findConfigFile(effectiveConfigFiles);
436
+ if (foundConfigPath) {
437
+ configData = loadConfigFile(foundConfigPath) ?? configData;
438
+ }
439
+ }
440
+
441
+ // Validate options with config data
442
+ const { options, optionsResult } = validateOptions(command, rawOptions, args, {
443
+ ...cliOptions,
444
+ configData,
445
+ });
446
+
447
+ const res = run(command, options) as any;
448
+ return {
449
+ ...res,
450
+ optionsResult,
451
+ };
452
+ };
453
+
454
+ const run: AnyPadroneProgram['run'] = (command, options) => {
455
+ const commandObj = typeof command === 'string' ? findCommandByName(command, existingCommand.commands) : (command as AnyPadroneCommand);
456
+ if (!commandObj) throw new Error(`Command "${command ?? ''}" not found`);
457
+ if (!commandObj.handler) throw new Error(`Command "${commandObj.path}" has no handler`);
458
+
459
+ const result = commandObj.handler(options as any);
460
+
461
+ return {
462
+ command: commandObj as any,
463
+ options: options as any,
464
+ result,
465
+ };
466
+ };
467
+
468
+ const tool: AnyPadroneProgram['tool'] = () => {
469
+ return {
470
+ type: 'function',
471
+ name: existingCommand.name,
472
+ description: generateHelp(existingCommand, undefined, { format: 'text', detail: 'full' }),
473
+ strict: true,
474
+ inputExamples: [{ input: { command: '<command> [args...] [options...]' } }],
475
+ inputSchema: {
476
+ [Symbol.for('vercel.ai.schema') as keyof Schema & symbol]: true,
477
+ jsonSchema: {
478
+ type: 'object',
479
+ properties: { command: { type: 'string' } },
480
+ additionalProperties: false,
481
+ },
482
+ _type: undefined as unknown as { command: string },
483
+ validate: (value) => {
484
+ const command = (value as any)?.command;
485
+ if (typeof command === 'string') return { success: true, value: { command } };
486
+ return { success: false, error: new Error('Expected an object with command property as string.') };
487
+ },
488
+ } satisfies Schema<{ command: string }> as Schema<{ command: string }>,
489
+ title: existingCommand.description,
490
+ needsApproval: (input) => {
491
+ const { command, options } = parse(input.command);
492
+ if (typeof command.needsApproval === 'function') return command.needsApproval(options);
493
+ return !!command.needsApproval;
494
+ },
495
+ execute: (input) => {
496
+ return cli(input.command).result;
497
+ },
498
+ };
499
+ };
500
+
501
+ return {
502
+ configure(config) {
503
+ return createPadroneCommandBuilder({ ...existingCommand, ...config }) as any;
504
+ },
505
+ options(options, meta) {
506
+ return createPadroneCommandBuilder({ ...existingCommand, options, meta }) as any;
507
+ },
508
+ action(handler = noop) {
509
+ return createPadroneCommandBuilder({ ...existingCommand, handler }) as any;
510
+ },
511
+ command: <TName extends string, TCommand extends PadroneCommand<TName, string, any, any, any>>(
512
+ name: TName,
513
+ builderFn?: (builder: PadroneCommandBuilder<TName>) => PadroneCommandBuilder,
514
+ ) => {
515
+ const initialCommand = {
516
+ name,
517
+ path: existingCommand.path ? `${existingCommand.path} ${name}` : name,
518
+ parent: existingCommand,
519
+ '~types': {} as any,
520
+ } satisfies PadroneCommand<TName, any>;
521
+ const builder = createPadroneCommandBuilder(initialCommand);
522
+
523
+ const commandObj = ((builderFn?.(builder as any) as typeof builder)?.[commandSymbol] as TCommand) ?? initialCommand;
524
+ return createPadroneCommandBuilder({ ...existingCommand, commands: [...(existingCommand.commands || []), commandObj] }) as any;
525
+ },
526
+
527
+ run,
528
+ find,
529
+ parse,
530
+ stringify,
531
+ cli,
532
+ tool,
533
+
534
+ api() {
535
+ function buildApi(command: AnyPadroneCommand) {
536
+ const runCommand = ((options) => run(command, options).result) as PadroneAPI<AnyPadroneCommand>;
537
+ if (!command.commands) return runCommand;
538
+ for (const cmd of command.commands) runCommand[cmd.name] = buildApi(cmd);
539
+ return runCommand;
540
+ }
541
+
542
+ return buildApi(existingCommand);
543
+ },
544
+
545
+ help(command, options) {
546
+ const commandObj = !command
547
+ ? existingCommand
548
+ : typeof command === 'string'
549
+ ? findCommandByName(command, existingCommand.commands)
550
+ : (command as AnyPadroneCommand);
551
+ if (!commandObj) throw new Error(`Command "${command ?? ''}" not found`);
552
+ return generateHelp(existingCommand, commandObj, options);
553
+ },
554
+
555
+ '~types': {} as any,
556
+
557
+ [commandSymbol]: existingCommand,
558
+ } satisfies AnyPadroneProgram & { [commandSymbol]: AnyPadroneCommand } as unknown as TBuilder & { [commandSymbol]: AnyPadroneCommand };
559
+ }