diff --git a/src/components/Root.tsx b/src/components/Root.tsx
index 2c87399d9..b6ddc465c 100644
--- a/src/components/Root.tsx
+++ b/src/components/Root.tsx
@@ -107,9 +107,15 @@ 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" error
+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 +742,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/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
new file mode 100644
index 000000000..cadb9461f
--- /dev/null
+++ b/src/handlers/project/project.screen.test.tsx
@@ -0,0 +1,158 @@
+import { test, expect, describe, afterEach } from "bun:test";
+import {
+ renderScreen,
+ waitForText,
+ cleanupScreens,
+ createSilentLogger,
+ TestCoreClient,
+ TestGlobalConfigAccessor,
+ testIO,
+ ttyTestIO,
+} from "../../testing";
+import { renderTuiAt } from "../../tui";
+import { InvalidEnvironmentError, NotImplementedError } from "../../errors";
+import { compile, ValueContext } from "../../router";
+import { ExitCode } from "../../runnable";
+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 projectSubcommands()) {
+ 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");
+
+ 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", () => {
+ // 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();
+
+ 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);
+ });
+});
+
+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.
+ //
+ // 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: io.io,
+ logger: createSilentLogger(),
+ globalConfigAccessor: new TestGlobalConfigAccessor(),
+ });
+
+ const caught: unknown = await root.route(["node", "agentcore", "project"]).then(
+ () => undefined,
+ (error: unknown) => error,
+ );
+
+ 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 () => {
+ 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
new file mode 100644
index 000000000..9b56c61e2
--- /dev/null
+++ b/src/handlers/project/screen.tsx
@@ -0,0 +1,37 @@
+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.
+//
+// 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) {
+ 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..ade5e0114 100644
--- a/src/testing/testIO.tsx
+++ b/src/testing/testIO.tsx
@@ -53,3 +53,32 @@ 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 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;
+ 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[] = [];