Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions src/handlers/harness/get/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>' not specified");
}

const harness = await core.harness.getHarness(flags["id"], coreOptsFromCtx(ctx));
ctx.require(JsonRendererKey).renderJson(harness);
},
Expand Down
4 changes: 3 additions & 1 deletion src/handlers/project/create/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"]);
Expand All @@ -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
Expand Down
26 changes: 17 additions & 9 deletions src/handlers/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +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 (see withTuiWhenInteractive).
const tuiWhenInteractive = withTuiWhenInteractive(core, io);
project.handler(tuiWhenInteractive(createCreateProjectHandler({ projectManager, io })));
// 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(
Expand Down Expand Up @@ -84,13 +90,15 @@ 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.
// 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 withStatusProject = withProject({ projectManager: config.projectManager });
project.handler(
withProject({ projectManager: config.projectManager })(
tuiWhenInteractive(createStatusProjectHandler({ projectManager: config.projectManager })),
),
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.
Expand Down
4 changes: 3 additions & 1 deletion src/handlers/project/status/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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";
import { ProjectStateError } from "../../../errors";

type StatusProjectHandlerConfig = {
projectManager: ProjectManager;
middlewares?: Middleware[];
};

type ProjectStatus = {
Expand All @@ -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",
Expand Down
44 changes: 10 additions & 34 deletions src/middleware/withTuiOnEmptyFlagsAndArgs.tsx
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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);
},
});
}
Expand Down
3 changes: 3 additions & 0 deletions src/router/args.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
20 changes: 12 additions & 8 deletions src/router/flags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { coerce, formatZodError, inspect } from "./schema";
// to true is exposed as `--no-<name>`: 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 `--<name>`.
// Everything else takes a value (`<name>` / variadic `<name...>`); a required
// non-boolean flag is made mandatory; defaults are forwarded.
// Everything else takes a value (`<name>` / variadic `<name...>`); defaults are forwarded.
export function toOption(flag: Flag): Option {
const info = inspect(flag.schema);
const long = `--${flag.name}`;
Expand All @@ -23,15 +22,14 @@ 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);
Comment on lines +25 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do we think about using commander's helpGroup? ref: https://github.com/tj/commander.js/blob/master/examples/help-groups.js

output would look like:

  Required Options:
    --id <id>          the ID of the harness

  Options:
    -h, --help         display help

this would render required flags in a dedicated section while keeping presentation separate from validation. also, we should avoid makeOptionMandatory(), since it rejects missing flags before the TUI middleware can run

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that idea! I think @jariy17 is working on something similar about grouping flags, so I think we can revisit this once that lands.

if (info.hasDefault) {
option.default(info.defaultValue);
} else if (info.boolean) {
option.default(false);
}
if (info.required && !info.boolean) {
option.makeOptionMandatory(true);
}
if (flag.group) {
option.helpGroup(flag.group);
}
Expand All @@ -58,7 +56,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();
}

Expand All @@ -67,8 +65,14 @@ 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<string, unknown>): 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 },
Expand Down
8 changes: 8 additions & 0 deletions src/router/handler.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -110,6 +111,7 @@ type CreateHandlerInput<
arguments?: A;
handle?: HandleFn<F, A>;
children?: Handler[];
middlewares?: Middleware[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the router should know about the middleware, not all the handlers.

@Hweinstock Hweinstock Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah this is only needed because we have handlers that want middleware that isn't on its parent router (ex. some project commands support tui, some don't).

However, if we move the tui middleware to the root handler this should be able to go away.

};

const noOpHandler = async (_ctx: Context, _flags: any, _args: any): Promise<void> => {};
Expand All @@ -121,6 +123,7 @@ class BaseHandler implements Handler {
_arguments: Argument[];
_handle: HandleFn<any, any>;
_children: Handler[];
_middlewares: Middleware[];

constructor(
input: CreateHandlerInput<readonly Flag<string, any>[], readonly Argument<string, any>[]>,
Expand All @@ -131,6 +134,7 @@ class BaseHandler implements Handler {
this._arguments = (input.arguments ?? []) as Argument[];
this._handle = (input.handle ?? noOpHandler) as HandleFn<any, any>;
this._children = input.children ?? [];
this._middlewares = input.middlewares ?? [];
}

name(): string {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/router/middleware.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand Down
19 changes: 14 additions & 5 deletions src/router/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand All @@ -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"]);
Expand All @@ -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"]);

Expand Down Expand Up @@ -367,11 +370,13 @@ 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()));

// Omitting the mandatory option makes Commander reject before the handler runs.
await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow();
await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow(
"required option '--harness-id <harness-id>' not specified",
);
});

// --- flag inheritance (group-level / global flags) -------------------------
Expand Down Expand Up @@ -502,6 +507,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"]);

Expand All @@ -528,7 +534,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({
Expand All @@ -542,6 +548,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"]);

Expand All @@ -564,7 +571,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"] });
});

Expand All @@ -581,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 () => {
Expand All @@ -596,6 +604,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()));

Expand Down
Loading
Loading