From c1e480b8cf74c67a83edf20c0dedabea0b6bb522 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Fri, 28 Aug 2026 21:30:32 +0000 Subject: [PATCH 1/4] feat: add a project command menu to the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `project` was listed in the root TUI menu with no route to match it, so selecting it fell through to the catch-all HelpScreen — which prints the launching command's help and exits, leaving a blank frame and no TUI. Adds the project menu and routes its seven subcommands explicitly. None has a screen yet, so each reports NotImplementedError via Ink's exit(error): the TUI tears down, the terminal is restored, and the error travels the normal CLI path with an exit code. --- src/components/Root.tsx | 17 ++++ src/handlers/project/project.screen.test.tsx | 100 +++++++++++++++++++ src/handlers/project/screen.tsx | 40 ++++++++ src/testing/index.tsx | 2 +- src/testing/testIO.tsx | 30 ++++++ src/tui/tui.test.tsx | 24 +---- 6 files changed, 189 insertions(+), 24 deletions(-) create mode 100644 src/handlers/project/project.screen.test.tsx create mode 100644 src/handlers/project/screen.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 2c87399d9..37545a385 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -107,9 +107,16 @@ import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx"; import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx"; import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx"; +import { ProjectScreen, ProjectCommandNotImplementedScreen } from "../handlers/project/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; +// PROJECT_COMMANDS are the `agentcore project` subcommands that are listed in +// the menu but have no screen of their own yet. Each is routed explicitly so +// selecting it reports "not implemented" instead of falling through to the +// catch-all HelpScreen, which prints the launching command's help and quits. +const PROJECT_COMMANDS = ["create", "add", "remove", "dev", "deploy", "status", "build"] as const; + export interface RootProps { // path is the command path to the executing node (e.g. "/agentcore"). path: string; @@ -736,6 +743,16 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/identity/oauth2-credential-provider/get/:name/json" element={} /> + } /> + {PROJECT_COMMANDS.map((command) => ( + + } + /> + ))} } /> diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx new file mode 100644 index 000000000..9707c272d --- /dev/null +++ b/src/handlers/project/project.screen.test.tsx @@ -0,0 +1,100 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { + renderScreen, + waitForText, + cleanupScreens, + TestCoreClient, + ttyTestIO, +} from "../../testing"; +import { renderTuiAt } from "../../tui"; +import { NotImplementedError } from "../../errors"; +import { ValueContext } from "../../router"; + +afterEach(cleanupScreens); + +// `project` used to be listed in the root menu with no route to match it, so +// selecting it fell through to the catch-all HelpScreen — which prints the +// launching command's help and exits, leaving the user with a blank frame and no +// TUI. It now has a menu, and its subcommands report that they have no screen +// yet rather than pretending to. + +describe("project menu", () => { + test("lists every project subcommand", async () => { + const r = renderScreen("/agentcore/project"); + + await waitForText(r.lastFrame, "manage an AgentCore project"); + const frame = r.lastFrame()!; + for (const command of ["create", "add", "remove", "dev", "deploy", "status", "build"]) { + expect(frame).toContain(command); + } + r.unmount(); + }); + + test("is reachable from the root menu", async () => { + const r = renderScreen("/agentcore"); + + await waitForText(r.lastFrame, "manage an AgentCore project"); + await r.write("project"); + await waitForText(r.lastFrame, "❯ project"); + await r.press("return"); + + // The project menu, not the blank frame the catch-all used to produce. + await waitForText(r.lastFrame, "agentcore → project"); + expect(r.lastFrame()).toContain("create"); + r.unmount(); + }); + + test("esc returns to the root menu", async () => { + const r = renderScreen("/agentcore/project"); + + await waitForText(r.lastFrame, "agentcore → project"); + await r.press("escape"); + + await waitForText(r.lastFrame, "the platform for production AI agents"); + r.unmount(); + }); +}); + +describe("project subcommands without a screen", () => { + // Driven through renderTuiAt (the production mount path) rather than + // renderScreen: the behavior under test is that Ink's exit(error) rejects the + // promise the CLI awaits, which is what turns into an exit code and a message + // on stderr. ink-testing-library does not expose waitUntilExit, so it cannot + // observe this. + test.each(["create", "add", "remove", "dev", "deploy", "status", "build"])( + "%s tears down the TUI with NotImplementedError", + async (command) => { + const { streams } = ttyTestIO(); + + const rendering = renderTuiAt( + `/agentcore/project/${command}`, + ValueContext.EmptyContext(), + new TestCoreClient(), + streams.io, + ); + + await expect(rendering).rejects.toThrow(NotImplementedError); + await expect(rendering).rejects.toThrow(`'agentcore project ${command}'`); + }, + ); + + test("the error names the command to run instead", async () => { + const { streams } = ttyTestIO(); + + const caught: unknown = await renderTuiAt( + "/agentcore/project/deploy", + ValueContext.EmptyContext(), + new TestCoreClient(), + streams.io, + ).then( + () => undefined, + (error: unknown) => error, + ); + + expect(caught).toBeInstanceOf(NotImplementedError); + const error = caught as NotImplementedError; + expect(error.message).toContain("agentcore project deploy --help"); + // Surfaces as a plain CLI failure, not a crash. + expect(error.exitCode).toBe(1); + }); +}); diff --git a/src/handlers/project/screen.tsx b/src/handlers/project/screen.tsx new file mode 100644 index 000000000..a34d4a2d7 --- /dev/null +++ b/src/handlers/project/screen.tsx @@ -0,0 +1,40 @@ +import { useEffect } from "react"; +import { useApp } from "ink"; +import { RouterScreen } from "../../components/RouterScreen"; +import { NotImplementedError } from "../../errors"; +import type { ScreenProps } from "../types"; + +export function ProjectScreen(props: ScreenProps) { + return ; +} + +export interface ProjectCommandNotImplementedScreenProps extends ScreenProps { + // command is the project subcommand the user selected, e.g. "deploy". + command: string; +} + +// ProjectCommandNotImplementedScreen is the landing screen for a project +// subcommand that is listed in the menu but has no screen yet. The subcommands +// stay listed so the command surface remains discoverable, and selecting one +// fails honestly rather than rendering a screen that only restates `--help`. +// +// Ink's `exit(error)` rejects the `waitUntilExit()` that renderTuiAt awaits, so +// the error travels the normal CLI path: the TUI tears down, the terminal is +// restored, and runWithExitCode prints it and sets the exit code. Throwing +// during render would instead surface a React stack trace. +export function ProjectCommandNotImplementedScreen({ + command, +}: ProjectCommandNotImplementedScreenProps) { + const { exit } = useApp(); + + useEffect(() => { + exit( + new NotImplementedError( + `'agentcore project ${command}' has no interactive screen yet; ` + + `run 'agentcore project ${command} --help' to use it from the command line`, + ), + ); + }, [exit, command]); + + return null; +} diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 548d3fd16..44de6961e 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -1,6 +1,6 @@ export { parse, stringify } from "./serialization"; export { fixtureFactories, fixtureFetch, isRecording, matchGolden, settle } from "./fixtures"; -export { testIO, type TestIO } from "./testIO"; +export { testIO, ttyTestIO, type TestIO, type TtyInput } from "./testIO"; export { tick, waitFor, WaitForTimeoutError } from "./timing"; export { TestCoreClient, diff --git a/src/testing/testIO.tsx b/src/testing/testIO.tsx index 527c32a57..ef21f8cbe 100644 --- a/src/testing/testIO.tsx +++ b/src/testing/testIO.tsx @@ -53,3 +53,33 @@ export function testIO({ isTTY = false, stdin: stdinContent }: TestIOOptions = { stderr: err.read, }; } + +// TtyInput is the writable stdin of a TTY TestIO: tests push raw key sequences +// into it to drive a mounted TUI. +export interface TtyInput extends NodeJS.ReadStream { + write(chunk: string): boolean; +} + +// ttyTestIO builds a TestIO that Ink will accept as a real terminal: isTTY on +// every stream, the no-op setRawMode/ref/unref that Ink calls when it takes over +// stdin, and a fixed window size (Ink needs columns/rows to lay frames out). +// Use it for tests that mount the TUI through the production path +// (renderTui/renderTuiAt) rather than through ink-testing-library. +export function ttyTestIO(columns = 100, rows = 40): { streams: TestIO; stdin: TtyInput } { + const streams = testIO({ isTTY: true }); + const stdin = streams.io.stdin as TtyInput; + stdin.setRawMode = function () { + return this; + }; + stdin.ref = function () { + return this; + }; + stdin.unref = function () { + return this; + }; + Object.defineProperties(streams.io.stdout, { + columns: { configurable: true, value: columns }, + rows: { configurable: true, value: rows }, + }); + return { streams, stdin }; +} diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 6238ac6cc..97388848a 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -7,34 +7,12 @@ import { TestCoreClient, TestGlobalConfigAccessor, testIO, + ttyTestIO, tick, waitFor, } from "../testing"; import { ExitCode } from "../runnable"; -interface TtyInput extends NodeJS.ReadStream { - write(chunk: string): boolean; -} - -function ttyTestIO(): { streams: ReturnType; stdin: TtyInput } { - const streams = testIO({ isTTY: true }); - const stdin = streams.io.stdin as TtyInput; - stdin.setRawMode = function () { - return this; - }; - stdin.ref = function () { - return this; - }; - stdin.unref = function () { - return this; - }; - Object.defineProperties(streams.io.stdout, { - columns: { configurable: true, value: 100 }, - rows: { configurable: true, value: 40 }, - }); - return { streams, stdin }; -} - describe("renderJson", () => { test("pretty-prints a value as indented JSON to the given writer", () => { const lines: string[] = []; From ee58b98af62d1527b4efd8fda82caf6200598c2a Mon Sep 17 00:00:00 2001 From: gitikavj Date: Fri, 28 Aug 2026 21:41:35 +0000 Subject: [PATCH 2/4] docs: trim comments in the project TUI screens --- src/components/Root.tsx | 3 +-- src/handlers/project/project.screen.test.tsx | 6 ------ src/handlers/project/screen.tsx | 4 +--- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 37545a385..b6ddc465c 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -113,8 +113,7 @@ import type { Context } from "../router"; // PROJECT_COMMANDS are the `agentcore project` subcommands that are listed in // the menu but have no screen of their own yet. Each is routed explicitly so -// selecting it reports "not implemented" instead of falling through to the -// catch-all HelpScreen, which prints the launching command's help and quits. +// selecting it reports "not implemented" error const PROJECT_COMMANDS = ["create", "add", "remove", "dev", "deploy", "status", "build"] as const; export interface RootProps { diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx index 9707c272d..704be9d55 100644 --- a/src/handlers/project/project.screen.test.tsx +++ b/src/handlers/project/project.screen.test.tsx @@ -12,12 +12,6 @@ import { ValueContext } from "../../router"; afterEach(cleanupScreens); -// `project` used to be listed in the root menu with no route to match it, so -// selecting it fell through to the catch-all HelpScreen — which prints the -// launching command's help and exits, leaving the user with a blank frame and no -// TUI. It now has a menu, and its subcommands report that they have no screen -// yet rather than pretending to. - describe("project menu", () => { test("lists every project subcommand", async () => { const r = renderScreen("/agentcore/project"); diff --git a/src/handlers/project/screen.tsx b/src/handlers/project/screen.tsx index a34d4a2d7..783d11b16 100644 --- a/src/handlers/project/screen.tsx +++ b/src/handlers/project/screen.tsx @@ -14,9 +14,7 @@ export interface ProjectCommandNotImplementedScreenProps extends ScreenProps { } // ProjectCommandNotImplementedScreen is the landing screen for a project -// subcommand that is listed in the menu but has no screen yet. The subcommands -// stay listed so the command surface remains discoverable, and selecting one -// fails honestly rather than rendering a screen that only restates `--help`. +// subcommand that is listed in the menu but has no screen yet. // // Ink's `exit(error)` rejects the `waitUntilExit()` that renderTuiAt awaits, so // the error travels the normal CLI path: the TUI tears down, the terminal is From 02310709c0d3d0c6833b348cdc21ed9c2a66b218 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Fri, 28 Aug 2026 21:54:53 +0000 Subject: [PATCH 3/4] fix: open the project menu on a bare `agentcore project` The project router had no default handler, so `agentcore project` printed Commander help and exited 2 instead of opening the menu like every sibling router. Registers renderTui as its default and threads Core through createProjectHandler. Drives the subcommand tests off the compiled router rather than a hardcoded list, so they cover any subcommand added later and fail if one is missing a route in Root. Adds coverage for the bare entrypoint, which the path-mounting screen tests could not catch. --- src/handlers/index.tsx | 2 +- src/handlers/project/index.ts | 16 +++-- src/handlers/project/project.screen.test.tsx | 73 +++++++++++++++++--- src/handlers/project/screen.tsx | 7 +- src/testing/testIO.tsx | 9 ++- 5 files changed, 83 insertions(+), 24 deletions(-) diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 87def85c6..188f61224 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -50,7 +50,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); root.handler(createConfigHandler()); - root.handler(createProjectHandler({ projectManager: core.projectManager, io })); + root.handler(createProjectHandler({ core, io })); // Invoking with no subcommand launches the interactive TUI. root.default(renderTui(core, io)); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 774a2ca39..5ae74e1be 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -5,6 +5,8 @@ import { ContainerDevRunner } from "../../core/dev/container"; import { InspectorAssets } from "../../core/dev/inspectorAssets"; import { startOtelCollector } from "../../core/dev/otel/collector"; import { withProject } from "../../middleware"; +import { renderTui } from "../../tui"; +import type { Core } from "../types"; import { createCreateProjectHandler } from "./create"; import { createRemoveProjectHandler } from "./remove"; import { createDevProjectHandler } from "./dev"; @@ -16,16 +18,20 @@ import type { ProjectManager } from "./types"; import { createAddProjectResourceHandler } from "./add"; type ProjectHandlerConfig = { - projectManager: ProjectManager; + core: Core; io: AppIO; }; -export function createProjectHandler(config: ProjectHandlerConfig): Router { +export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router { + const projectManager: ProjectManager = core.projectManager; + const config = { projectManager, io }; const project = new Router("project", "manage an AgentCore project"); - project.handler( - createCreateProjectHandler({ projectManager: config.projectManager, io: config.io }), - ); + // Without a default, a bare `agentcore project` falls back to Commander's help + // and a usage exit code instead of the menu every sibling router opens. + project.default(renderTui(core, io)); + + project.handler(createCreateProjectHandler({ projectManager, io })); project.handler(createAddProjectResourceHandler(config)); project.handler( withProject({ projectManager: config.projectManager })( diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx index 704be9d55..e9fef9917 100644 --- a/src/handlers/project/project.screen.test.tsx +++ b/src/handlers/project/project.screen.test.tsx @@ -3,22 +3,43 @@ import { renderScreen, waitForText, cleanupScreens, + createSilentLogger, TestCoreClient, + TestGlobalConfigAccessor, + testIO, ttyTestIO, + waitFor, } from "../../testing"; import { renderTuiAt } from "../../tui"; import { NotImplementedError } from "../../errors"; -import { ValueContext } from "../../router"; +import { compile, ValueContext } from "../../router"; +import { createRootHandler } from "../index"; afterEach(cleanupScreens); +// projectSubcommands reads the project group's children off the compiled +// Commander tree, so tests driven by it cover any subcommand added later. +function projectSubcommands(): string[] { + const root = compile( + createRootHandler(new TestCoreClient(), { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }), + ValueContext.EmptyContext(), + ); + const project = root.commands.find((command) => command.name() === "project")!; + // `help` is Commander's own, not one of ours. + return project.commands.map((command) => command.name()).filter((name) => name !== "help"); +} + describe("project menu", () => { test("lists every project subcommand", async () => { const r = renderScreen("/agentcore/project"); await waitForText(r.lastFrame, "manage an AgentCore project"); const frame = r.lastFrame()!; - for (const command of ["create", "add", "remove", "dev", "deploy", "status", "build"]) { + for (const command of projectSubcommands()) { expect(frame).toContain(command); } r.unmount(); @@ -32,7 +53,6 @@ describe("project menu", () => { await waitForText(r.lastFrame, "❯ project"); await r.press("return"); - // The project menu, not the blank frame the catch-all used to produce. await waitForText(r.lastFrame, "agentcore → project"); expect(r.lastFrame()).toContain("create"); r.unmount(); @@ -50,12 +70,14 @@ describe("project menu", () => { }); describe("project subcommands without a screen", () => { - // Driven through renderTuiAt (the production mount path) rather than - // renderScreen: the behavior under test is that Ink's exit(error) rejects the - // promise the CLI awaits, which is what turns into an exit code and a message - // on stderr. ink-testing-library does not expose waitUntilExit, so it cannot - // observe this. - test.each(["create", "add", "remove", "dev", "deploy", "status", "build"])( + // renderTuiAt rather than renderScreen: ink-testing-library exposes no + // waitUntilExit, so it cannot observe the rejection under test. + // + // Reading the cases off the router also guards Root's hand-written + // PROJECT_COMMANDS: an unrouted subcommand hits the catch-all, which resolves + // instead of rejecting. Frames can't detect that — the catch-all exits before + // painting, so it and this screen both render empty. + test.each(projectSubcommands())( "%s tears down the TUI with NotImplementedError", async (command) => { const { streams } = ttyTestIO(); @@ -92,3 +114,36 @@ describe("project subcommands without a screen", () => { expect(error.exitCode).toBe(1); }); }); + +describe("agentcore project (no subcommand)", () => { + // Exercises the real CLI entrypoint; the screen tests mount a path directly + // and so never caught the missing default handler. + test("opens the interactive menu", async () => { + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(new TestCoreClient(), { + io: streams.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + const routing = root.route(["node", "agentcore", "project"]); + await waitFor(() => streams.stdout().includes("manage an AgentCore project")); + + stdin.write(String.fromCharCode(3)); // ctrl+c + await expect(routing).resolves.toBeUndefined(); + }); + + test("prints help instead of the TUI under --json", async () => { + const io = testIO(); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await root.route(["node", "agentcore", "project", "--json"]); + + expect(io.stdout()).toContain("Usage:"); + expect(io.stdout()).toContain("create"); + }); +}); diff --git a/src/handlers/project/screen.tsx b/src/handlers/project/screen.tsx index 783d11b16..9b56c61e2 100644 --- a/src/handlers/project/screen.tsx +++ b/src/handlers/project/screen.tsx @@ -16,10 +16,9 @@ export interface ProjectCommandNotImplementedScreenProps extends ScreenProps { // ProjectCommandNotImplementedScreen is the landing screen for a project // subcommand that is listed in the menu but has no screen yet. // -// Ink's `exit(error)` rejects the `waitUntilExit()` that renderTuiAt awaits, so -// the error travels the normal CLI path: the TUI tears down, the terminal is -// restored, and runWithExitCode prints it and sets the exit code. Throwing -// during render would instead surface a React stack trace. +// exit(error) rejects the waitUntilExit() that renderTuiAt awaits, so the TUI +// tears down and the error takes the normal CLI path. Throwing during render +// would surface a React stack trace instead. export function ProjectCommandNotImplementedScreen({ command, }: ProjectCommandNotImplementedScreenProps) { diff --git a/src/testing/testIO.tsx b/src/testing/testIO.tsx index ef21f8cbe..ade5e0114 100644 --- a/src/testing/testIO.tsx +++ b/src/testing/testIO.tsx @@ -60,11 +60,10 @@ export interface TtyInput extends NodeJS.ReadStream { write(chunk: string): boolean; } -// ttyTestIO builds a TestIO that Ink will accept as a real terminal: isTTY on -// every stream, the no-op setRawMode/ref/unref that Ink calls when it takes over -// stdin, and a fixed window size (Ink needs columns/rows to lay frames out). -// Use it for tests that mount the TUI through the production path -// (renderTui/renderTuiAt) rather than through ink-testing-library. +// ttyTestIO builds a TestIO that Ink accepts as a real terminal: isTTY, the +// no-op stdin methods Ink calls when it takes over, and a window size (Ink needs +// columns/rows to lay out frames). For tests that mount the TUI through +// renderTui/renderTuiAt rather than ink-testing-library. export function ttyTestIO(columns = 100, rows = 40): { streams: TestIO; stdin: TtyInput } { const streams = testIO({ isTTY: true }); const stdin = streams.io.stdin as TtyInput; From 70a953812d8536ca10a87a9079209568eb802219 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Sat, 29 Aug 2026 07:45:27 +0000 Subject: [PATCH 4/4] test: assert the project TUI entrypoint without relying on frames Ink only writes frames incrementally when interactive (`!isInCi && isTTY`), so waiting on rendered menu text passed locally and timed out on every CI platform. Asserts renderTui's TTY guard instead, which reaching at all proves the group routed to the TUI rather than to Commander help. --- src/handlers/project/project.screen.test.tsx | 27 +++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx index e9fef9917..cadb9461f 100644 --- a/src/handlers/project/project.screen.test.tsx +++ b/src/handlers/project/project.screen.test.tsx @@ -8,11 +8,11 @@ import { TestGlobalConfigAccessor, testIO, ttyTestIO, - waitFor, } from "../../testing"; import { renderTuiAt } from "../../tui"; -import { NotImplementedError } from "../../errors"; +import { InvalidEnvironmentError, NotImplementedError } from "../../errors"; import { compile, ValueContext } from "../../router"; +import { ExitCode } from "../../runnable"; import { createRootHandler } from "../index"; afterEach(cleanupScreens); @@ -118,19 +118,28 @@ describe("project subcommands without a screen", () => { describe("agentcore project (no subcommand)", () => { // Exercises the real CLI entrypoint; the screen tests mount a path directly // and so never caught the missing default handler. - test("opens the interactive menu", async () => { - const { streams, stdin } = ttyTestIO(); + // + // Asserts renderTui's TTY guard rather than a rendered frame: Ink only writes + // frames incrementally when interactive (`!isInCi && isTTY`), so asserting on + // frames here would pass locally and time out under CI. Reaching the guard at + // all proves the group routed to the TUI — Commander help neither throws nor + // touches stderr. + test("routes to the TUI rather than printing Commander help", async () => { + const io = testIO(); const root = createRootHandler(new TestCoreClient(), { - io: streams.io, + io: io.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), }); - const routing = root.route(["node", "agentcore", "project"]); - await waitFor(() => streams.stdout().includes("manage an AgentCore project")); + const caught: unknown = await root.route(["node", "agentcore", "project"]).then( + () => undefined, + (error: unknown) => error, + ); - stdin.write(String.fromCharCode(3)); // ctrl+c - await expect(routing).resolves.toBeUndefined(); + expect(caught).toBeInstanceOf(InvalidEnvironmentError); + expect((caught as InvalidEnvironmentError).exitCode).toBe(ExitCode.USAGE); + expect(io.stdout()).toBe(""); }); test("prints help instead of the TUI under --json", async () => {