From 46b2e500301c58c1a4ef7f80f58c1ecc3da34ad9 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 10 Sep 2026 20:39:13 +0000 Subject: [PATCH 1/7] fix(router): allow required flags --- src/handlers/harness/get/index.tsx | 7 +------ src/handlers/runtime/logs/logs.test.tsx | 2 +- src/router/flags.tsx | 3 --- src/router/router.test.ts | 5 ++++- src/router/router.tsx | 14 +++++++++++--- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/handlers/harness/get/index.tsx b/src/handlers/harness/get/index.tsx index 85a4fc4b5..ced2c4f4f 100644 --- a/src/handlers/harness/get/index.tsx +++ b/src/handlers/harness/get/index.tsx @@ -3,18 +3,13 @@ import { createHandler, flag } from "../../../router"; import type { Core } from "../../types.tsx"; import { coreOptsFromCtx } from "../../utils.tsx"; import { JsonRendererKey } from "../../../tui"; -import { InputValidationError } from "../../../errors"; export const createGetHarnessHandler = (core: Core) => createHandler({ name: "get", description: "get a harness", - flags: [flag("id", "the ID of the harness", z.string().max(48).optional())], + flags: [flag("id", "the ID of the harness", z.string().min(1).max(48))], handle: async (ctx, flags) => { - if (!flags["id"]) { - throw new InputValidationError("required option '--id ' not specified"); - } - const harness = await core.harness.getHarness(flags["id"], coreOptsFromCtx(ctx)); ctx.require(JsonRendererKey).renderJson(harness); }, diff --git a/src/handlers/runtime/logs/logs.test.tsx b/src/handlers/runtime/logs/logs.test.tsx index 51769edb8..4e714faab 100644 --- a/src/handlers/runtime/logs/logs.test.tsx +++ b/src/handlers/runtime/logs/logs.test.tsx @@ -107,7 +107,7 @@ describe("runtime logs", () => { process.chdir(root); try { await expect(route(["runtime", "logs", "--since", `${SINCE_MS}`])).rejects.toThrow( - "required option '--id ' not specified", + "Invalid value for option '--id': Invalid input: expected string, received undefined", ); } finally { process.chdir(previousCwd); diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 1f59770a1..3797ceb14 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -29,9 +29,6 @@ export function toOption(flag: Flag): Option { } else if (info.boolean) { option.default(false); } - if (info.required && !info.boolean) { - option.makeOptionMandatory(true); - } if (flag.group) { option.helpGroup(flag.group); } diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 9808ed240..8d3b81981 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -367,6 +367,7 @@ test("a required (non-optional) flag is mandatory", async () => { const root = new Router("app"); root.handler(get); + root.supportedTuiCommands(); const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); @@ -502,6 +503,7 @@ test("validates, coerces, and passes typed positional arguments to handle", asyn const root = new Router("app"); root.handler(serve); + root.supportedTuiCommands(); await root.route(["node", "app", "serve", "api", "8080", "true"]); @@ -528,7 +530,7 @@ test("optional arguments resolve to undefined when omitted", async () => { expect(seen).toEqual({ key: undefined }); }); -test("arguments with schema defaults use the default when omitted", async () => { +test("arguments with schema defaults use the default value when omitted", async () => { let seen: { env: string } | undefined; const deploy = createHandler({ @@ -542,6 +544,7 @@ test("arguments with schema defaults use the default when omitted", async () => const root = new Router("app"); root.handler(deploy); + root.supportedTuiCommands(); await root.route(["node", "app", "deploy"]); diff --git a/src/router/router.tsx b/src/router/router.tsx index df938de28..59cc48202 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -118,7 +118,7 @@ function attachAction( // of where they appear on the command line. c.action(async (...actionArgs: unknown[]) => { const command = actionArgs[actionArgs.length - 1] as Command; - const merged = command.optsWithGlobals(); + const allOptions = command.optsWithGlobals(); recordCommandPath(ctx); @@ -133,10 +133,18 @@ function attachAction( // Inherited group/global flags -> context (typed, read via ctx.value(key)). let leafCtx = ctx.withValue(CommandKey, command); - leafCtx = applyGlobalFlags(globals, merged, leafCtx); + leafCtx = applyGlobalFlags(globals, allOptions, leafCtx); + if ( + node.doesSupportTui() && + Object.keys(command.opts()).length === 0 && + node.arguments().length == 0 + ) { + await wrapped.handle(leafCtx, {}, {}); + return; + } // Own flags -> the statically-typed object passed to handle. - const parsedFlags = parseFlags(ownFlags, merged); + const parsedFlags = parseFlags(ownFlags, allOptions); const parsedArguments = parseArguments(node.arguments(), command); await wrapped.handle(leafCtx, parsedFlags, parsedArguments); From 7db23cffaa8d45ed2f4eaf2c697caa2ad63a954b Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 13:47:35 +0000 Subject: [PATCH 2/7] fix(router): move parsing into middleware to allow required flags --- src/middleware/withTuiOnEmptyFlagsAndArgs.tsx | 44 ++++------------ src/router/flags.tsx | 5 +- src/router/router.test.ts | 5 +- src/router/router.tsx | 51 ++++++++++++------- 4 files changed, 50 insertions(+), 55 deletions(-) diff --git a/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx b/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx index bd89ca433..b7ec0bec0 100644 --- a/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx +++ b/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx @@ -1,33 +1,11 @@ -import { Option, type Command } from "commander"; import { renderTui } from "../tui"; -import { JsonKey } from "../handlers/keys"; import type { AppIO } from "../io"; import type { Core } from "../handlers/types"; -import { CommandKey, type Handler, type Middleware } from "../router"; - -// countPassedValues counts how many entries of an object hold a defined value. -const countPassedValues = (obj: object) => - Object.entries(obj).reduce((acc, [_key, val]) => { - if (val !== undefined) { - acc += 1; - } - - return acc; - }, 0); - -// countPassedFlags counts the leaf's own flags the user actually supplied on -// the command line. The parsed flags object can't be used for this: schema -// (and Commander boolean) defaults arrive there as defined values, which would -// make a leaf with defaulted flags look non-empty on a bare invocation. -const countPassedFlags = (h: Handler, command: Command) => - h.flags().filter((f) => { - const attribute = new Option(`--${f.name}`).attributeName(); - return command.getOptionValueSource(attribute) === "cli"; - }).length; +import { type Middleware } from "../router"; +import { CommandKey } from "../router/router"; +import { attributeName } from "../router/flags"; +import { JsonKey } from "../handlers/keys"; -// withTuiOnEmptyFlagsAndArgs opens the interactive TUI when a leaf command is -// invoked with no flags or arguments (and not in JSON mode); otherwise it -// delegates to the wrapped handler. export function withTuiOnEmptyFlagsAndArgs(core: Core, io: AppIO): Middleware { const boundRenderTui = renderTui(core, io); @@ -40,17 +18,15 @@ export function withTuiOnEmptyFlagsAndArgs(core: Core, io: AppIO): Middleware { children: () => h.children(), handle: async (ctx, flags, args) => { const command = ctx.require(CommandKey); - if ( - h.doesSupportTui() && - !ctx.require(JsonKey) && - countPassedFlags(h, command) === 0 && - countPassedValues(args) === 0 - ) { + const noFlagsPassed = h + .flags() + .every((f) => command.getOptionValueSource(attributeName(f.name)) !== "cli"); + + if (h.doesSupportTui() && !ctx.value(JsonKey) && noFlagsPassed && command.args.length === 0) { await boundRenderTui(ctx, flags, args); return; - } else { - await h.handle(ctx, flags, args); } + await h.handle(ctx, flags, args); }, }); } diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 3797ceb14..2203e5502 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -8,8 +8,7 @@ import { coerce, formatZodError, inspect } from "./schema"; // to true is exposed as `--no-`: the behavior is already on, so the only useful // action is turning it off, which Commander stores under the positive name (e.g. // `--no-traces` sets `traces=false`). A boolean that defaults off stays `--`. -// Everything else takes a value (`` / variadic ``); a required -// non-boolean flag is made mandatory; defaults are forwarded. +// Everything else takes a value (`` / variadic ``); defaults are forwarded. export function toOption(flag: Flag): Option { const info = inspect(flag.schema); const long = `--${flag.name}`; @@ -55,7 +54,7 @@ export function formatParameterDetails(flags: Flag[]): string | undefined { // attributeName mirrors how Commander camelCases an option name into the key it // stores on the parsed options object (e.g. "harness-id" -> "harnessId"). -function attributeName(name: string): string { +export function attributeName(name: string): string { return new Option(`--${name}`).attributeName(); } diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 8d3b81981..3ebc87a4f 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -289,6 +289,7 @@ test("boolean flags default to false when omitted", async () => { }); const root = new Router("app"); + root.supportedTuiCommands(); root.handler(run); await root.route(["node", "app", "run"]); @@ -309,6 +310,7 @@ test("a boolean flag defaulting to true is declared as its --no- negation", asyn }); const root = new Router("app"); + root.supportedTuiCommands(); root.handler(run); await root.route(["node", "app", "run"]); @@ -331,6 +333,7 @@ test("applies a schema default for an omitted flag", async () => { const root = new Router("app"); root.handler(opt); + root.supportedTuiCommands(); await root.route(["node", "app", "opt"]); @@ -567,7 +570,6 @@ test("variadic argument collects multiple values into an array", async () => { root.handler(lint); await root.route(["node", "app", "lint", "a.ts", "b.ts", "c.ts"]); - expect(seen).toEqual({ files: ["a.ts", "b.ts", "c.ts"] }); }); @@ -599,6 +601,7 @@ test("rejects an argument that fails schema validation", async () => { const root = new Router("app"); root.handler(config); + root.supportedTuiCommands(); const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); diff --git a/src/router/router.tsx b/src/router/router.tsx index 59cc48202..a38df0fa6 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -1,9 +1,14 @@ import type { Argument, Flag, GlobalFlag, Handler } from "./handler"; import { type Middleware, type MiddlewareProvider, isMiddlewareProvider } from "./middleware"; import { type Context, type ContextKey, ValueContext, contextKey } from "./context"; -import { applyGlobalFlags, formatParameterDetails, parseFlags, toOption } from "./flags"; +import { + applyGlobalFlags, + attributeName, + formatParameterDetails, + parseFlags, + toOption, +} from "./flags"; import { parseArguments, toCommanderArgument } from "./args"; - import { Command, CommanderError, Option } from "commander"; import { InputValidationError } from "../errors"; import type { Logger } from "../logging"; @@ -99,6 +104,23 @@ function declareArguments(c: Command, args: Argument[]): void { } } +function withValidation(ownFlags: Flag[]): Middleware { + return (node: Handler) => ({ + name: () => node.name(), + description: () => node.description(), + flags: () => node.flags(), + arguments: () => node.arguments(), + doesSupportTui: () => node.doesSupportTui(), + children: () => node.children(), + handle: async (ctx) => { + const command = ctx.require(CommandKey); + const flags = parseFlags(ownFlags, command.optsWithGlobals()); + const args = parseArguments(node.arguments(), command); + await node.handle(ctx, flags, args); + }, + }); +} + // attachAction wires `node` as the executing handler for command `c`. The // accumulated middleware `stack` wraps the node (ancestor-first, via reduceRight), // `globals` are validated and injected into the context under their keys, and the @@ -112,7 +134,7 @@ function attachAction( globals: GlobalFlag[], ownFlags: Flag[], ): void { - const wrapped = stack.reduceRight((h, mw) => mw(h), node); + const wrapped = stack.reduceRight((h, mw) => mw(h), withValidation(ownFlags)(node)); // `optsWithGlobals()` merges this command's options with all ancestors', so // group-level flags declared higher in the tree are visible here regardless // of where they appear on the command line. @@ -134,20 +156,15 @@ function attachAction( // Inherited group/global flags -> context (typed, read via ctx.value(key)). let leafCtx = ctx.withValue(CommandKey, command); leafCtx = applyGlobalFlags(globals, allOptions, leafCtx); - if ( - node.doesSupportTui() && - Object.keys(command.opts()).length === 0 && - node.arguments().length == 0 - ) { - await wrapped.handle(leafCtx, {}, {}); - return; - } - // Own flags -> the statically-typed object passed to handle. - const parsedFlags = parseFlags(ownFlags, allOptions); - const parsedArguments = parseArguments(node.arguments(), command); + const named = Object.fromEntries( + ownFlags.map((f) => [f.name, allOptions[attributeName(f.name)]]), + ); + const namedArgs = Object.fromEntries( + node.arguments().map((a, i) => [a.name, command.processedArgs[i]]), + ); - await wrapped.handle(leafCtx, parsedFlags, parsedArguments); + await wrapped.handle(leafCtx, named, namedArgs); }); } @@ -272,7 +289,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid constructor( private readonly cmdName: string, private readonly cmdDescription: string = "", - ) {} + ) { } // --- Router authoring API --- @@ -347,7 +364,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid } // A group/branch never executes directly; it just hosts subcommands. - async handle(_ctx: Context, _flags: any, _args: any): Promise {} + async handle(_ctx: Context, _flags: any, _args: any): Promise { } children(): Handler[] { return this.handlers; From 20aa718527fdef4bd8ee909a69f604c1e78f4f79 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 15:00:22 +0000 Subject: [PATCH 3/7] fix(handler): handle edge case where we need middleware in the leaf node only --- src/handlers/project/index.ts | 44 +++++++++++++++++++++++++---------- src/router/router.tsx | 8 +++---- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 39a26c39d..b1baf45c4 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,4 +1,4 @@ -import { Router } from "../../router"; +import { Router, type Handler, type MiddlewareProvider } from "../../router"; import { checkPort, openBrowser, startHttpServer, watchFile, type AppIO } from "../../io"; import { CodeZipDevRunner } from "../../core/dev/codezip"; import { ContainerDevRunner } from "../../core/dev/container"; @@ -46,9 +46,21 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router // A bare `agentcore project create` in an interactive session opens the TUI // create wizard; any user-supplied flag, --json, or a non-TTY invocation keeps - // the headless handler (see withTuiWhenInteractive). - const tuiWhenInteractive = withTuiWhenInteractive(core, io); - project.handler(tuiWhenInteractive(createCreateProjectHandler({ projectManager, io }))); + // the headless handler. The TUI is exposed via middlewares() (not hand-wrapped) + // so the router runs it before flag validation, letting the wizard supply a + // required flag the headless path would otherwise reject. + const createProject = createCreateProjectHandler({ projectManager, io }); + const createProjectWithTui: Handler & MiddlewareProvider = { + name: () => createProject.name(), + description: () => createProject.description(), + flags: () => createProject.flags(), + arguments: () => createProject.arguments(), + doesSupportTui: () => createProject.doesSupportTui(), + children: () => createProject.children(), + handle: (ctx, flags, args) => createProject.handle(ctx, flags, args), + middlewares: () => [withTuiWhenInteractive(core, io)], + }; + project.handler(createProjectWithTui); project.handler(createAddProjectResourceHandler(config, core)); project.handler(createExportProjectResourceHandler({ projectManager, core, io })); project.handler( @@ -84,14 +96,22 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router project.handler(createProjectInvokeHandler(core, io)); // A bare `agentcore project status` in an interactive session opens the TUI // linked-resources screen; any user-supplied flag, --json, or a non-TTY - // invocation keeps the headless JSON report. withProject stays outermost so - // the not-found guidance outside a project is the CLI's own, and the resolved - // project seeds the screen via ProjectKey. - project.handler( - withProject({ projectManager: config.projectManager })( - tuiWhenInteractive(createStatusProjectHandler({ projectManager: config.projectManager })), - ), - ); + // invocation keeps the headless JSON report. withProject runs before the TUI + // so the not-found guidance is the CLI's own and the resolved project seeds + // the screen via ProjectKey. + const statusProject = createStatusProjectHandler({ projectManager: config.projectManager }); + const withStatusProject = withProject({ projectManager: config.projectManager }); + const statusProjectWithTui: Handler & MiddlewareProvider = { + name: () => statusProject.name(), + description: () => statusProject.description(), + flags: () => statusProject.flags(), + arguments: () => statusProject.arguments(), + doesSupportTui: () => statusProject.doesSupportTui(), + children: () => statusProject.children(), + handle: (ctx, flags, args) => statusProject.handle(ctx, flags, args), + middlewares: () => [withStatusProject, withTuiWhenInteractive(core, io)], + }; + project.handler(statusProjectWithTui); // withProject wraps only the commands that require an existing project, so // `create` (which refuses to nest inside one) stays unaffected. project.handler( diff --git a/src/router/router.tsx b/src/router/router.tsx index a38df0fa6..c7249619d 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -157,14 +157,14 @@ function attachAction( let leafCtx = ctx.withValue(CommandKey, command); leafCtx = applyGlobalFlags(globals, allOptions, leafCtx); - const named = Object.fromEntries( + const namedFlags = Object.fromEntries( ownFlags.map((f) => [f.name, allOptions[attributeName(f.name)]]), ); const namedArgs = Object.fromEntries( node.arguments().map((a, i) => [a.name, command.processedArgs[i]]), ); - await wrapped.handle(leafCtx, named, namedArgs); + await wrapped.handle(leafCtx, namedFlags, namedArgs); }); } @@ -289,7 +289,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid constructor( private readonly cmdName: string, private readonly cmdDescription: string = "", - ) { } + ) {} // --- Router authoring API --- @@ -364,7 +364,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid } // A group/branch never executes directly; it just hosts subcommands. - async handle(_ctx: Context, _flags: any, _args: any): Promise { } + async handle(_ctx: Context, _flags: any, _args: any): Promise {} children(): Handler[] { return this.handlers; From cadf60f7eb08976abcad29d34ea45f71fe4a9830 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 15:50:58 +0000 Subject: [PATCH 4/7] feat(middleware): allow handlers to define their own middleware to simplify wrapping handlers --- src/handlers/project/create/index.ts | 4 ++- src/handlers/project/index.ts | 44 ++++++++++------------------ src/handlers/project/status/index.ts | 4 ++- src/router/handler.tsx | 8 +++++ src/router/middleware.tsx | 4 +-- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index f0fd26c62..b5b5b9d7d 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import z from "zod"; -import { createHandler, flag, PlatformKey } from "../../../router"; +import { createHandler, flag, PlatformKey, type Middleware } from "../../../router"; import { assertProjectPathFits } from "./pathLimit"; import { SourceResolver, type AppIO } from "../../../io"; import { runWithProgress } from "../../../tui/progress"; @@ -31,6 +31,7 @@ import { projectReference, type ProjectMutationResult } from "../output"; type CreateProjectHandlerConfig = { projectManager: ProjectManager; io: AppIO; + middlewares?: Middleware[]; }; const ModelProviderFlagSchema = z.enum([...HarnessModelProviderSchema.options, "anthropic"]); @@ -47,6 +48,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = createHandler({ name: "create", description: "create a new AgentCore project", + middlewares: config.middlewares, flags: [ // Optional at the flag layer (and enforced in handle) so a bare // interactive `project create` reaches the TUI wizard middleware instead diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index b1baf45c4..a0d38c4b2 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,4 +1,4 @@ -import { Router, type Handler, type MiddlewareProvider } from "../../router"; +import { Router } from "../../router"; import { checkPort, openBrowser, startHttpServer, watchFile, type AppIO } from "../../io"; import { CodeZipDevRunner } from "../../core/dev/codezip"; import { ContainerDevRunner } from "../../core/dev/container"; @@ -46,21 +46,15 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router // A bare `agentcore project create` in an interactive session opens the TUI // create wizard; any user-supplied flag, --json, or a non-TTY invocation keeps - // the headless handler. The TUI is exposed via middlewares() (not hand-wrapped) - // so the router runs it before flag validation, letting the wizard supply a - // required flag the headless path would otherwise reject. - const createProject = createCreateProjectHandler({ projectManager, io }); - const createProjectWithTui: Handler & MiddlewareProvider = { - name: () => createProject.name(), - description: () => createProject.description(), - flags: () => createProject.flags(), - arguments: () => createProject.arguments(), - doesSupportTui: () => createProject.doesSupportTui(), - children: () => createProject.children(), - handle: (ctx, flags, args) => createProject.handle(ctx, flags, args), - middlewares: () => [withTuiWhenInteractive(core, io)], - }; - project.handler(createProjectWithTui); + // the headless handler. The TUI is exposed as middleware so the router runs it + // before flag validation, letting the wizard supply a required flag. + project.handler( + createCreateProjectHandler({ + projectManager, + io, + middlewares: [withTuiWhenInteractive(core, io)], + }), + ); project.handler(createAddProjectResourceHandler(config, core)); project.handler(createExportProjectResourceHandler({ projectManager, core, io })); project.handler( @@ -99,19 +93,13 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router // invocation keeps the headless JSON report. withProject runs before the TUI // so the not-found guidance is the CLI's own and the resolved project seeds // the screen via ProjectKey. - const statusProject = createStatusProjectHandler({ projectManager: config.projectManager }); const withStatusProject = withProject({ projectManager: config.projectManager }); - const statusProjectWithTui: Handler & MiddlewareProvider = { - name: () => statusProject.name(), - description: () => statusProject.description(), - flags: () => statusProject.flags(), - arguments: () => statusProject.arguments(), - doesSupportTui: () => statusProject.doesSupportTui(), - children: () => statusProject.children(), - handle: (ctx, flags, args) => statusProject.handle(ctx, flags, args), - middlewares: () => [withStatusProject, withTuiWhenInteractive(core, io)], - }; - project.handler(statusProjectWithTui); + project.handler( + createStatusProjectHandler({ + projectManager: config.projectManager, + middlewares: [withStatusProject, withTuiWhenInteractive(core, io)], + }), + ); // withProject wraps only the commands that require an existing project, so // `create` (which refuses to nest inside one) stays unaffected. project.handler( diff --git a/src/handlers/project/status/index.ts b/src/handlers/project/status/index.ts index 8ad24d1eb..27978a150 100644 --- a/src/handlers/project/status/index.ts +++ b/src/handlers/project/status/index.ts @@ -1,6 +1,6 @@ import z from "zod"; import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; -import { createHandler, flag, ProjectKey } from "../../../router"; +import { createHandler, flag, ProjectKey, type Middleware } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { ProjectManager, ResolvedProjectResource } from "../types"; import { RegionKey } from "../../keys"; @@ -8,6 +8,7 @@ import { ProjectStateError } from "../../../errors"; type StatusProjectHandlerConfig = { projectManager: ProjectManager; + middlewares?: Middleware[]; }; type ProjectStatus = { @@ -21,6 +22,7 @@ export const createStatusProjectHandler = (config: StatusProjectHandlerConfig) = createHandler({ name: "status", description: "show the status of the project's deployed resources", + middlewares: config.middlewares, flags: [ flag( "target", diff --git a/src/router/handler.tsx b/src/router/handler.tsx index abb24834b..5fd343ad7 100644 --- a/src/router/handler.tsx +++ b/src/router/handler.tsx @@ -1,5 +1,6 @@ import type z from "zod"; import type { Context, ContextKey } from "./context"; +import type { Middleware } from "./middleware"; // Flag is generic over its literal name `N` and its inferred value type `T`, so a // tuple of flags can be mapped to a typed object at the authoring boundary (see @@ -110,6 +111,7 @@ type CreateHandlerInput< arguments?: A; handle?: HandleFn; children?: Handler[]; + middlewares?: Middleware[]; }; const noOpHandler = async (_ctx: Context, _flags: any, _args: any): Promise => {}; @@ -121,6 +123,7 @@ class BaseHandler implements Handler { _arguments: Argument[]; _handle: HandleFn; _children: Handler[]; + _middlewares: Middleware[]; constructor( input: CreateHandlerInput[], readonly Argument[]>, @@ -131,6 +134,7 @@ class BaseHandler implements Handler { this._arguments = (input.arguments ?? []) as Argument[]; this._handle = (input.handle ?? noOpHandler) as HandleFn; this._children = input.children ?? []; + this._middlewares = input.middlewares ?? []; } name(): string { @@ -160,6 +164,10 @@ class BaseHandler implements Handler { children(): Handler[] { return this._children; } + + middlewares(): Middleware[] { + return this._middlewares; + } } // createHandler infers the flags tuple from `flags` (the `const` type parameter diff --git a/src/router/middleware.tsx b/src/router/middleware.tsx index b6cb1e0b6..0bb307127 100644 --- a/src/router/middleware.tsx +++ b/src/router/middleware.tsx @@ -2,8 +2,8 @@ import type { Handler } from "./handler"; export type Middleware = (handler: Handler) => Handler; -// A node carries its own middleware when it can contribute to its subtree. -// Routers implement this; plain leaf handlers don't need to. +// A node may carry its own middleware, applied to it and its subtree. Routers +// implement this via `use()`; leaf handlers via createHandler's `middlewares`. export interface MiddlewareProvider { middlewares(): Middleware[]; } From b67427913fd2121a62abb0f63ea6b550fb30e6e5 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 17:04:34 +0000 Subject: [PATCH 5/7] docs(router): remove stale comment --- src/router/router.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 3ebc87a4f..c2a273e93 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -374,7 +374,6 @@ test("a required (non-optional) flag is mandatory", async () => { const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); - // Omitting the mandatory option makes Commander reject before the handler runs. await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow(); }); From 766605091e699c7545eba2f5d6cf802cd92083dc Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 19:02:22 +0000 Subject: [PATCH 6/7] fix(help): mark required fields as required in the help text --- src/router/flags.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 2203e5502..a4f8c7e83 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -22,7 +22,9 @@ export function toOption(flag: Flag): Option { token = `${long} <${flag.name}>`; } - const option = new Option(token, flag.description); + const description = + info.required && !info.boolean ? `${flag.description} (required)` : flag.description; + const option = new Option(token, description); if (info.hasDefault) { option.default(info.defaultValue); } else if (info.boolean) { From d11a5cd38cc9ddbdb06ba18a5a6f253e43633cb1 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 20:34:14 +0000 Subject: [PATCH 7/7] refactor(error): clean up error message --- src/handlers/runtime/logs/logs.test.tsx | 2 +- src/router/args.tsx | 3 +++ src/router/flags.tsx | 8 +++++++- src/router/router.test.ts | 8 ++++++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/handlers/runtime/logs/logs.test.tsx b/src/handlers/runtime/logs/logs.test.tsx index 4e714faab..51769edb8 100644 --- a/src/handlers/runtime/logs/logs.test.tsx +++ b/src/handlers/runtime/logs/logs.test.tsx @@ -107,7 +107,7 @@ describe("runtime logs", () => { process.chdir(root); try { await expect(route(["runtime", "logs", "--since", `${SINCE_MS}`])).rejects.toThrow( - "Invalid value for option '--id': Invalid input: expected string, received undefined", + "required option '--id ' not specified", ); } finally { process.chdir(previousCwd); diff --git a/src/router/args.tsx b/src/router/args.tsx index b6bd9c40d..fdde3af80 100644 --- a/src/router/args.tsx +++ b/src/router/args.tsx @@ -21,6 +21,9 @@ export function toCommanderArgument(arg: Argument): CommanderArgument { function validateArgument(argument: Argument, input: unknown | undefined): unknown { const result = argument.schema.safeParse(coerce(argument.schema, input)); if (!result.success) { + if (input === undefined) { + throw new InputValidationError(`missing required argument '${argument.name}'`); + } throw new InputValidationError( `Invalid value for argument '${argument.name}': ${formatZodError(result.error)}`, { cause: result.error }, diff --git a/src/router/flags.tsx b/src/router/flags.tsx index a4f8c7e83..fb3775591 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -65,8 +65,14 @@ export function attributeName(name: string): string { // `command.error`, which prints a message and exits (or, with exitOverride, // throws) — so this returns only on success. function validateFlag(flag: Flag, opts: Record): unknown { - const result = flag.schema.safeParse(coerce(flag.schema, opts[attributeName(flag.name)])); + const raw = opts[attributeName(flag.name)]; + const result = flag.schema.safeParse(coerce(flag.schema, raw)); if (!result.success) { + if (raw === undefined) { + throw new InputValidationError( + `required option '--${flag.name} <${flag.name}>' not specified`, + ); + } throw new InputValidationError( `Invalid value for option '--${flag.name}': ${formatZodError(result.error)}`, { cause: result.error }, diff --git a/src/router/router.test.ts b/src/router/router.test.ts index c2a273e93..fecf3328f 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -374,7 +374,9 @@ test("a required (non-optional) flag is mandatory", async () => { const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); - await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow(); + await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow( + "required option '--harness-id ' not specified", + ); }); // --- flag inheritance (group-level / global flags) ------------------------- @@ -585,7 +587,9 @@ test("a required positional argument is mandatory", async () => { const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); - await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow(); + await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow( + "missing required argument 'id'", + ); }); test("rejects an argument that fails schema validation", async () => {