Skip to content

Commit 8b87697

Browse files
AgentEnderclaude
andcommitted
feat(cli-forge): add sdk() method for programmatic CLI invocation
Add a new `sdk()` method to CLI that returns a typed, callable interface for invoking CLI commands programmatically without argv parsing. Features: - Object-style args with TypeScript type safety (skips validation) - String array args for CLI-style invocation (full validation pipeline) - Nested subcommand access via property syntax (sdk.build.watch()) - Middleware execution for both invocation styles - $args attached to object results for accessing parsed args - Proper default value handling from option configs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 010b396 commit 8b87697

File tree

3 files changed

+493
-0
lines changed

3 files changed

+493
-0
lines changed

packages/cli-forge/src/lib/internal-cli.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
CLIHandlerContext,
1818
Command,
1919
ErrorHandler,
20+
SDKCommand,
2021
} from './public-api';
2122
import { readOptionGroupsForCLI } from './cli-option-groups';
2223
import { formatHelp } from './format-help';
@@ -523,6 +524,119 @@ export class InternalCLI<
523524
) as THandlerReturn;
524525
}
525526

527+
sdk(): SDKCommand<TArgs, THandlerReturn, TChildren> {
528+
return this.buildSDKProxy(this) as SDKCommand<TArgs, THandlerReturn, TChildren>;
529+
}
530+
531+
private buildSDKProxy(
532+
targetCmd: InternalCLI<any, any, any, any>
533+
): unknown {
534+
// eslint-disable-next-line @typescript-eslint/no-this-alias
535+
const self = this;
536+
537+
const invoke = async (argsOrArgv?: Record<string, unknown> | string[]) => {
538+
// Clone the target command to avoid mutating the original
539+
const cmd = targetCmd.clone();
540+
541+
const handler = cmd._configuration?.handler;
542+
if (!handler) {
543+
throw new Error(`Command '${cmd.name}' has no handler`);
544+
}
545+
546+
let parsedArgs: any;
547+
548+
if (Array.isArray(argsOrArgv)) {
549+
// String array: full pipeline (parse → validate → middleware)
550+
// Run the builder first if present
551+
if (cmd._configuration?.builder) {
552+
cmd._configuration.builder(cmd as any);
553+
}
554+
parsedArgs = cmd.parser.parse(argsOrArgv);
555+
} else {
556+
// Object args: skip validation, apply defaults, run middleware
557+
// Run the builder first to register options and get defaults
558+
if (cmd._configuration?.builder) {
559+
cmd._configuration.builder(cmd as any);
560+
}
561+
// Build defaults from configured options
562+
const defaults: Record<string, unknown> = {};
563+
for (const [key, config] of Object.entries(cmd.parser.configuredOptions)) {
564+
if (config.default !== undefined) {
565+
defaults[key] = config.default;
566+
}
567+
}
568+
parsedArgs = {
569+
...defaults,
570+
...argsOrArgv,
571+
unmatched: [],
572+
};
573+
}
574+
575+
// Collect and run middleware from the command chain
576+
const middlewares = self.collectMiddlewareChain(targetCmd);
577+
for (const mw of middlewares) {
578+
const middlewareResult = await mw(parsedArgs);
579+
if (
580+
middlewareResult !== void 0 &&
581+
typeof middlewareResult === 'object'
582+
) {
583+
parsedArgs = middlewareResult;
584+
}
585+
}
586+
587+
// Execute handler
588+
const context: CLIHandlerContext<any, any> = {
589+
command: cmd as unknown as CLI<any, any, any, any>,
590+
};
591+
const result = await handler(parsedArgs, context);
592+
593+
// Try to attach $args to the result (fails silently for primitives)
594+
if (result !== null && typeof result === 'object') {
595+
try {
596+
(result as any).$args = parsedArgs;
597+
} catch {
598+
// Cannot attach to frozen objects or primitives, return as-is
599+
}
600+
}
601+
602+
return result;
603+
};
604+
605+
// Ensure builder has run to register all subcommands
606+
if (targetCmd._configuration?.builder) {
607+
targetCmd._configuration.builder(targetCmd as any);
608+
}
609+
610+
// Create proxy that is both callable and has child properties
611+
return new Proxy(invoke, {
612+
get(_, prop: string) {
613+
// Handle special properties
614+
if (prop === 'then' || prop === 'catch' || prop === 'finally') {
615+
// Don't intercept Promise methods - this prevents issues with await
616+
return undefined;
617+
}
618+
619+
const child = targetCmd.registeredCommands[prop];
620+
if (child) {
621+
return self.buildSDKProxy(child);
622+
}
623+
return undefined;
624+
},
625+
});
626+
}
627+
628+
private collectMiddlewareChain(
629+
cmd: InternalCLI<any, any, any, any>
630+
): Array<(args: any) => unknown | Promise<unknown>> {
631+
const chain: InternalCLI<any, any, any, any>[] = [];
632+
let current: InternalCLI<any, any, any, any> | undefined = cmd;
633+
while (current) {
634+
chain.unshift(current);
635+
current = current._parent;
636+
}
637+
return chain.flatMap((c) => c.registeredMiddleware);
638+
}
639+
526640
enableInteractiveShell(): CLI<TArgs, THandlerReturn, TChildren, TParent> {
527641
if (this.requiresCommand === 'EXPLICIT') {
528642
throw new Error(

packages/cli-forge/src/lib/public-api.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,38 @@ export interface CLI<
778778
*/
779779
getParent(): TParent;
780780

781+
/**
782+
* Returns a programmatic SDK for invoking this CLI and its subcommands.
783+
* The SDK provides typed function calls instead of argv parsing.
784+
*
785+
* @example
786+
* ```ts
787+
* const myCLI = cli('my-app')
788+
* .option('verbose', { type: 'boolean' })
789+
* .command('build', {
790+
* builder: (cmd) => cmd.option('watch', { type: 'boolean' }),
791+
* handler: (args) => ({ success: true, files: ['a.js'] })
792+
* });
793+
*
794+
* const sdk = myCLI.sdk();
795+
*
796+
* // Invoke root command (if it has a handler)
797+
* await sdk({ verbose: true });
798+
*
799+
* // Invoke subcommand with typed args
800+
* const result = await sdk.build({ watch: true });
801+
* console.log(result.files); // ['a.js']
802+
* console.log(result.$args.watch); // true
803+
*
804+
* // Use CLI-style args for -- support
805+
* await sdk.build(['--watch', '--', 'extra-arg']);
806+
* ```
807+
*
808+
* @returns An SDK object that is callable (if this command has a handler)
809+
* and has properties for each subcommand.
810+
*/
811+
sdk(): SDKCommand<TArgs, THandlerReturn, TChildren>;
812+
781813
getBuilder<T extends ParsedArgs = ParsedArgs>(
782814
initialCli?: CLI<T, any, any>
783815
):
@@ -910,6 +942,65 @@ export type MiddlewareFunction<TArgs extends ParsedArgs, TArgs2> = (
910942
args: TArgs
911943
) => TArgs2 | Promise<TArgs2>;
912944

945+
// ============================================================================
946+
// SDK Types
947+
// ============================================================================
948+
949+
/**
950+
* Result type that conditionally includes $args.
951+
* Only attaches $args when result is an object type.
952+
*/
953+
export type SDKResult<TArgs, THandlerReturn> = THandlerReturn extends object
954+
? THandlerReturn & { $args: TArgs }
955+
: THandlerReturn;
956+
957+
/**
958+
* The callable signature for a command with a handler.
959+
* Supports both object-style args (typed, skips validation) and
960+
* string array args (CLI-style, full validation pipeline).
961+
*/
962+
export type SDKInvokable<TArgs, THandlerReturn> = {
963+
/**
964+
* Invoke the command with typed object args.
965+
* Skips validation (TypeScript handles it), applies defaults, runs middleware.
966+
*/
967+
(args?: Partial<Omit<TArgs, 'unmatched' | '--'>>): Promise<
968+
SDKResult<TArgs, THandlerReturn>
969+
>;
970+
/**
971+
* Invoke the command with CLI-style string args.
972+
* Runs full pipeline: parse → validate → middleware → handler.
973+
* Use this when you need to pass `--` extra args.
974+
*/
975+
(args: string[]): Promise<SDKResult<TArgs, THandlerReturn>>;
976+
};
977+
978+
/**
979+
* Recursively builds SDK type from TChildren.
980+
* Each child command becomes a property on the SDK object.
981+
*/
982+
export type SDKChildren<TChildren> = {
983+
[K in keyof TChildren]: TChildren[K] extends CLI<
984+
infer A,
985+
infer R,
986+
infer C,
987+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
988+
infer _P
989+
>
990+
? SDKCommand<A, R, C>
991+
: never;
992+
};
993+
994+
/**
995+
* A single SDK command - callable if it has a handler, with nested children as properties.
996+
* Container commands (no handler) are not callable but still provide access to children.
997+
*/
998+
export type SDKCommand<TArgs, THandlerReturn, TChildren> =
999+
// eslint-disable-next-line @typescript-eslint/ban-types
1000+
THandlerReturn extends void | undefined
1001+
? SDKChildren<TChildren> // No handler = just children (not callable)
1002+
: SDKInvokable<TArgs, THandlerReturn> & SDKChildren<TChildren>;
1003+
9131004
/**
9141005
* Constructs a CLI instance. See {@link CLI} for more information.
9151006
* @param name Name for the top level CLI

0 commit comments

Comments
 (0)