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
51 changes: 17 additions & 34 deletions src/handlers/project/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Router, type Handler } 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";
Expand Down Expand Up @@ -47,25 +47,14 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router
// The TTY gate wraps the middleware (rather than living inside it) so a
// piped/CI invocation also stays headless and reports the missing --name as
// a usage error instead of renderTui's "interactive mode requires a TTY".
const createProject = createCreateProjectHandler({
projectManager,
io,
});
const createProjectWithWizard = withTuiOnEmptyFlagsAndArgs(core, io)(createProject);
const isInteractive = () => io.stdin.isTTY === true && io.stdout.isTTY === true;
const createProjectDispatch: Handler = {
name: () => createProject.name(),
description: () => createProject.description(),
flags: () => createProject.flags(),
arguments: () => createProject.arguments(),
doesSupportTui: () => createProject.doesSupportTui(),
children: () => createProject.children(),
handle: (ctx, flags, args) =>
isInteractive()
? createProjectWithWizard.handle(ctx, flags, args)
: createProject.handle(ctx, flags, args),
};
project.handler(createProjectDispatch);
project.handler(
createCreateProjectHandler({
projectManager,
io,
middlewares: isInteractive() ? [withTuiOnEmptyFlagsAndArgs(core, io)] : [],
}),
);
project.handler(createAddProjectResourceHandler(config));
project.handler(createExportProjectResourceHandler({ projectManager, core, io }));
project.handler(
Expand Down Expand Up @@ -104,21 +93,15 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router
// invocation keeps the headless JSON report (same dispatch shape as create).
// 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.
const statusProject = createStatusProjectHandler({ projectManager: config.projectManager });
const statusProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(statusProject);
const statusProjectDispatch: Handler = {
name: () => statusProject.name(),
description: () => statusProject.description(),
flags: () => statusProject.flags(),
arguments: () => statusProject.arguments(),
doesSupportTui: () => statusProject.doesSupportTui(),
children: () => statusProject.children(),
handle: (ctx, flags, args) =>
isInteractive()
? statusProjectWithTui.handle(ctx, flags, args)
: statusProject.handle(ctx, flags, args),
};
project.handler(withProject({ projectManager: config.projectManager })(statusProjectDispatch));
const withStatusProject = withProject({ projectManager: config.projectManager });
project.handler(
createStatusProjectHandler({
projectManager: config.projectManager,
middlewares: isInteractive()
? [withStatusProject, withTuiOnEmptyFlagsAndArgs(core, io)]
: [withStatusProject],
}),
);
// withProject wraps only the commands that require an existing project, so
// `create` (which refuses to nest inside one) stays unaffected.
project.handler(
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);
},
});
}
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);
}
return option;
}

Expand All @@ -55,7 +53,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 @@ -64,8 +62,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 @@ -102,6 +103,7 @@ type CreateHandlerInput<
arguments?: A;
handle?: HandleFn<F, A>;
children?: Handler[];
middlewares?: Middleware[];
};

const noOpHandler = async (_ctx: Context, _flags: any, _args: any): Promise<void> => {};
Expand All @@ -113,6 +115,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 @@ -123,6 +126,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 @@ -152,6 +156,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
Loading
Loading