Skip to content

Commit 07b49ae

Browse files
committed
feat: add DefineCommandContext and defineCommand().requires() for typed interceptor context
Commands defined with defineCommand() now get optional logger, tracing, and progress context by default via DefineCommandContext (overridable via module augmentation). Use defineCommand().requires<T>().define(fn) to declare additional context requirements with compile-time validation when registering via .command().
1 parent e8bc2df commit 07b49ae

6 files changed

Lines changed: 173 additions & 17 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'padrone': minor
3+
---
4+
5+
Add `DefineCommandContext` interface and `defineCommand().requires()` for typed interceptor context in modular commands. Commands defined with `defineCommand()` now have optional `logger`, `tracing`, and `progress` context by default. Use `defineCommand().requires<T>().define(fn)` for additional context requirements with compile-time validation at `.command()` registration.

packages/padrone/src/core/create.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import type {
1414
AnyPadroneCommand,
1515
AnyPadroneProgram,
1616
CommandTypesBase,
17+
DefineCommandBuilder,
18+
DefineCommandContext,
1719
InterceptorFactory,
1820
InterceptorMeta,
1921
PadroneBuilder,
@@ -158,7 +160,7 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
158160
const handler = createWrapHandler(config, existingCommand.argsSchema as any, existingCommand.meta?.positional);
159161
return createPadroneBuilder({ ...existingCommand, action: handler }) as any;
160162
},
161-
command(nameOrNames, builderFn) {
163+
command(nameOrNames: string | readonly string[], builderFn?: (builder: any) => any) {
162164
const name = Array.isArray(nameOrNames) ? nameOrNames[0] : nameOrNames;
163165
const aliases = Array.isArray(nameOrNames) && nameOrNames.length > 1 ? (nameOrNames.slice(1) as string[]) : undefined;
164166

@@ -274,28 +276,33 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
274276
* Use this when defining commands in separate files — the parent program retains exact type information
275277
* about the subcommand's args, result, and nested commands.
276278
*
277-
* @example
279+
* The builder's context includes `DefineCommandContext` by default (optional `logger`, `tracing`, `progress`).
280+
* Override globally via module augmentation on `DefineCommandContext`, or per-command via `.requires()`.
281+
*
282+
* @example Direct form (most common)
278283
* ```ts
279-
* // my-command.ts
280284
* export const myCommand = defineCommand((c) =>
281285
* c.arguments(z.object({ name: z.string() }))
282286
* .action((args) => console.log(args.name))
283287
* );
284-
*
285-
* // cli.ts
286-
* createPadrone('test').command('my-command', myCommand)
287288
* ```
288289
*
289-
* @example With context
290+
* @example With required interceptor context
290291
* ```ts
291-
* export const myCommand = defineCommand<{ db: Database }>((c) =>
292-
* c.arguments(z.object({ id: z.string() }))
293-
* .action((args, ctx) => ctx.context.db.find(args.id))
294-
* );
292+
* export const adminCommand = defineCommand()
293+
* .requires<{ adminDb: AdminDB }>()
294+
* .define((c) => c.action((_args, ctx) => ctx.context.adminDb.query(...)));
295295
* ```
296296
*/
297297
export function defineCommand<TContext = unknown, TOut extends CommandTypesBase = CommandTypesBase>(
298-
fn: (builder: PadroneBuilder<string, string, string, PadroneSchema<void>, void, [], any, false, TContext>) => TOut,
299-
): typeof fn {
300-
return fn;
298+
fn: (builder: PadroneBuilder<string, string, string, PadroneSchema<void>, void, [], any, false, TContext, DefineCommandContext>) => TOut,
299+
): typeof fn;
300+
export function defineCommand(): DefineCommandBuilder;
301+
export function defineCommand(fn?: any): any {
302+
if (fn) return fn;
303+
const builder: DefineCommandBuilder = {
304+
requires: () => builder as any,
305+
define: (f: any) => f,
306+
};
307+
return builder;
301308
}

packages/padrone/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ export type {
9191
AsyncPadroneSchema,
9292
CommandTypesBase,
9393
DefineCommand,
94+
DefineCommandBuilder,
95+
DefineCommandContext,
9496
ExtractInterceptorContext,
9597
ExtractInterceptorRequires,
9698
GetArgsMeta,

packages/padrone/src/types/builder.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { StandardSchemaV1 } from '@standard-schema/spec';
22
import type { Tool } from 'ai';
3-
import type { PadroneRuntime } from '../core/runtime.ts';
3+
import type { PadroneProgressIndicator, PadroneRuntime } from '../core/runtime.ts';
4+
import type { PadroneLogger } from '../extension/logger.ts';
5+
import type { PadroneTracer } from '../extension/tracing.ts';
46
import type { PadroneMcpPreferences } from '../feature/mcp.ts';
57
import type { PadroneServePreferences } from '../feature/serve.ts';
68
import type { WrapConfig, WrapResult } from '../feature/wrap.ts';
@@ -385,6 +387,33 @@ export type PadroneBuilderMethods<
385387
TContext,
386388
TContextProvided
387389
>;
390+
// Overload for defineCommand.requires() branded callbacks — validates context requirements
391+
<TNameNested extends string, TAliases extends string[] = [], TBuilder extends CommandTypesBase = CommandTypesBase, TReq = unknown>(
392+
name: TNameNested | readonly [TNameNested, ...TAliases],
393+
builderFn: ((builder: any) => TBuilder) & { '~contextRequires': (ctx: TReq) => void },
394+
): TContext & TContextProvided extends TReq
395+
? BuilderOrProgram<
396+
TReturn,
397+
TProgramName,
398+
TName,
399+
TParentName,
400+
TArgs,
401+
TRes,
402+
TCommands extends []
403+
? [WithAliases<TBuilder['~types']['command'], TAliases>]
404+
: AnyPadroneCommand[] extends TCommands
405+
? [WithAliases<TBuilder['~types']['command'], TAliases>]
406+
: ReplaceOrAppendCommand<
407+
TCommands,
408+
TNameNested,
409+
WithAliases<TBuilder['~types']['command'], ResolvedAliases<TCommands, TNameNested, TAliases>>
410+
>,
411+
TParentArgs,
412+
TAsync,
413+
TContext,
414+
TContextProvided
415+
>
416+
: DefineCommandRequiresError;
388417
// Fallback overload: accepts DefineCommand-typed callbacks where the builder type is not structurally compatible
389418
// (e.g., DefineCommand with unknown context used in a parent with specific context)
390419
<TNameNested extends string, TAliases extends string[] = [], TBuilder extends CommandTypesBase = CommandTypesBase>(
@@ -723,6 +752,30 @@ export type AnyPadroneProgram = PadroneProgram<string, string, string, any, any,
723752
*/
724753
export type PadroneExtension<TIn extends CommandTypesBase = CommandTypesBase, TOut extends CommandTypesBase = TIn> = (builder: TIn) => TOut;
725754

755+
/**
756+
* Default context type for commands defined with `defineCommand()`.
757+
* Includes optional context properties provided by common extensions (logger, tracing, progress).
758+
*
759+
* Override globally via module augmentation to add your application's context:
760+
* ```ts
761+
* declare module 'padrone' {
762+
* interface DefineCommandContext {
763+
* db: Database;
764+
* }
765+
* }
766+
* ```
767+
*/
768+
export interface DefineCommandContext {
769+
logger?: PadroneLogger;
770+
tracing?: PadroneTracer;
771+
progress?: PadroneProgressIndicator;
772+
}
773+
774+
/** Error brand returned by `.command()` when a `defineCommand.requires()` context requirement is not satisfied. */
775+
export type DefineCommandRequiresError = {
776+
readonly '~error': 'Required context not satisfied. Ensure required interceptors are registered on the program.';
777+
};
778+
726779
/**
727780
* Type for a command builder callback used with `.command()`.
728781
* Use this when defining commands in separate files where full return type inference isn't needed.
@@ -741,7 +794,27 @@ export type PadroneExtension<TIn extends CommandTypesBase = CommandTypesBase, TO
741794
* ```
742795
*/
743796
export type DefineCommand<TContext = unknown, TParentArgs extends PadroneSchema = PadroneSchema> = (
744-
builder: PadroneBuilder<string, string, string, PadroneSchema<void>, void, [], TParentArgs, false, TContext>,
797+
builder: PadroneBuilder<string, string, string, PadroneSchema<void>, void, [], TParentArgs, false, TContext, DefineCommandContext>,
745798
) => CommandTypesBase;
746799

800+
/**
801+
* Builder returned by `defineCommand()` (no-arg form).
802+
* Call `.requires<T>()` to declare context dependencies, then `.command()` to provide the builder callback.
803+
*
804+
* @example
805+
* ```ts
806+
* const adminCommand = defineCommand()
807+
* .requires<{ adminDb: AdminDB }>()
808+
* .define((c) => c.action((_args, ctx) => ctx.context.adminDb.query(...)));
809+
* ```
810+
*/
811+
export type DefineCommandBuilder<TContextProvided = DefineCommandContext, TBrand = unknown> = {
812+
/** Declare context types this command requires. Purely type-level — no runtime effect. */
813+
requires: <TRequires>() => DefineCommandBuilder<DefineCommandContext & TRequires, { '~contextRequires': (ctx: TRequires) => void }>;
814+
/** Provide the command builder callback. */
815+
define: <TContext = unknown, TOut extends CommandTypesBase = CommandTypesBase>(
816+
fn: (builder: PadroneBuilder<string, string, string, PadroneSchema<void>, void, [], any, false, TContext, TContextProvided>) => TOut,
817+
) => typeof fn & TBrand;
818+
};
819+
747820
type DefaultArgs = Record<string, unknown> | void;

packages/padrone/src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export type {
33
AnyPadroneBuilder,
44
AnyPadroneProgram,
55
DefineCommand,
6+
DefineCommandBuilder,
7+
DefineCommandContext,
68
PadroneBuilder,
79
PadroneBuilderMethods,
810
PadroneExtension,

packages/padrone/tests/type.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
// biome-ignore-all lint/correctness/noUnusedVariables: This file is for testing TypeScript types, so unused variables are intentional.
22

33
import { expectTypeOf, test } from 'bun:test';
4-
import type { DefineCommand, PadroneBuilder, PadroneProgram, PadroneProgressIndicator } from 'padrone';
4+
import type {
5+
DefineCommand,
6+
DefineCommandContext,
7+
PadroneBuilder,
8+
PadroneLogger,
9+
PadroneProgram,
10+
PadroneProgressIndicator,
11+
PadroneTracer,
12+
} from 'padrone';
513
import { asyncSchema, createPadrone, defineCommand, defineInterceptor, padroneProgress } from 'padrone';
614
import * as z from 'zod/v4';
715
import { createTasksProgram } from './common.ts';
@@ -465,3 +473,62 @@ test.skip('Types - DefineCommand with extensions', () => {
465473
const result = program.eval('load --id abc');
466474
expectTypeOf(result.result).toEqualTypeOf<string | undefined>();
467475
});
476+
477+
/** This test verifies DefineCommandContext provides optional logger, tracing, progress by default */
478+
test.skip('Types - DefineCommandContext default context', () => {
479+
// DefineCommandContext interface has optional logger, tracing, progress
480+
expectTypeOf<DefineCommandContext>().toHaveProperty('logger');
481+
expectTypeOf<DefineCommandContext['logger']>().toEqualTypeOf<PadroneLogger | undefined>();
482+
expectTypeOf<DefineCommandContext['tracing']>().toEqualTypeOf<PadroneTracer | undefined>();
483+
expectTypeOf<DefineCommandContext['progress']>().toEqualTypeOf<PadroneProgressIndicator | undefined>();
484+
485+
// defineCommand action handler has access to optional logger, tracing, progress
486+
defineCommand((c) =>
487+
c.action((_args, ctx) => {
488+
expectTypeOf(ctx.context.logger).toEqualTypeOf<PadroneLogger | undefined>();
489+
expectTypeOf(ctx.context.tracing).toEqualTypeOf<PadroneTracer | undefined>();
490+
expectTypeOf(ctx.context.progress).toEqualTypeOf<PadroneProgressIndicator | undefined>();
491+
}),
492+
);
493+
494+
// DefineCommand type alias also gets DefineCommandContext
495+
const cmd: DefineCommand = (c) =>
496+
c.action((_args, ctx) => {
497+
expectTypeOf(ctx.context.logger).toEqualTypeOf<PadroneLogger | undefined>();
498+
});
499+
});
500+
501+
/** This test verifies defineCommand().requires().define() brands callbacks and validates at .command() */
502+
test.skip('Types - defineCommand().requires()', () => {
503+
type AdminDB = { query: (sql: string) => string[] };
504+
505+
// .requires() adds the required type to context and brands the callback
506+
const adminCommand = defineCommand()
507+
.requires<{ adminDb: AdminDB }>()
508+
.define((c) =>
509+
c.action((_args, ctx) => {
510+
// adminDb is required (not optional)
511+
expectTypeOf(ctx.context.adminDb).toEqualTypeOf<AdminDB>();
512+
// DefineCommandContext optionals are still available
513+
expectTypeOf(ctx.context.logger).toEqualTypeOf<PadroneLogger | undefined>();
514+
return { test: 5 };
515+
}),
516+
);
517+
518+
// Branded callback has '~contextRequires' phantom property
519+
expectTypeOf(adminCommand).toHaveProperty('~contextRequires');
520+
521+
// Registering on a program that provides the context: no error
522+
const adminInterceptor = defineInterceptor({ name: 'admin' }, () => ({
523+
execute(_ctx, next) {
524+
return next({ context: { adminDb: { query: (sql: string) => [sql] } } });
525+
},
526+
})).provides<{ adminDb: AdminDB }>();
527+
528+
const program = createPadrone('test').intercept(adminInterceptor).command('admin', adminCommand);
529+
expectTypeOf(program.eval).toBeFunction();
530+
531+
// Registering on a program WITHOUT the context: returns DefineCommandRequiresError
532+
const badProgram = createPadrone('test').command('admin', adminCommand);
533+
expectTypeOf(badProgram).toHaveProperty('~error');
534+
});

0 commit comments

Comments
 (0)