diff --git a/README.md b/README.md
index 53f426695..eabb69dbc 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,9 @@ responses. `agentcore` wraps all of that behind one ergonomic tool.
Commands with operation flags run headlessly. Bare Harness, Runtime, Memory,
Identity, and Gateway branches and leaves open their interactive flows, as does
a bare `project create` in a terminal (any flag, `--json`, or a non-TTY stays
-headless).
+headless). A bare `project status` opens a Linked Resources view that groups
+the project's resources by agent and forwards to each deployed resource's
+detail page.
```
agentcore # interactive TUI
@@ -133,7 +135,7 @@ agentcore # interactive TUI
│ ├── invoke # invoke a deployed project resource
│ │ ├── runtime # use the existing Runtime invoke experience
│ │ └── harness # use the existing Harness invoke experience
-│ ├── status # inspect deployed project resources
+│ ├── status # inspect deployed project resources (TUI when run bare)
│ └── build # synthesize the project's CloudFormation templates
└── config # read/write global config values
```
diff --git a/src/components/Root.tsx b/src/components/Root.tsx
index d44b90b0c..a24ecea0f 100644
--- a/src/components/Root.tsx
+++ b/src/components/Root.tsx
@@ -114,6 +114,7 @@ import { BuildProjectScreen } from "../handlers/project/build/screen.tsx";
import { DeployProjectScreen } from "../handlers/project/deploy/screen.tsx";
import { ProjectCreateScreen } from "../handlers/project/create/screen.tsx";
import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx";
+import { ProjectStatusScreen } from "../handlers/project/status/screen.tsx";
import { HelpScreen, RootScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";
@@ -772,6 +773,10 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/project/create"
element={}
/>
+ }
+ />
{/* Every known command without a screen of its own: a group opens its
menu and a leaf its interactive help. Unknown routes retain the
help-and-exit fallback. */}
diff --git a/src/components/ui/tree-view/TreeView.tsx b/src/components/ui/tree-view/TreeView.tsx
index 9a0415854..23818afa5 100644
--- a/src/components/ui/tree-view/TreeView.tsx
+++ b/src/components/ui/tree-view/TreeView.tsx
@@ -6,6 +6,8 @@ import type { InkUITheme } from "../_core.js";
export interface TreeNode {
id: string;
label: string;
+ /** Muted text rendered after the label (e.g. a status or description). */
+ annotation?: string;
children?: TreeNode[];
icon?: string;
defaultExpanded?: boolean;
@@ -23,6 +25,11 @@ export interface TreeViewProps {
leafIcon?: string;
branchIcon?: string;
branchOpenIcon?: string;
+ /**
+ * Mark the focused row with a "❯ " prefix in the focus color instead of
+ * inverse video, matching the selection style of DataTable and the menus.
+ */
+ focusMarker?: boolean;
focus?: boolean;
theme?: InkUITheme;
}
@@ -62,6 +69,7 @@ export function TreeView({
leafIcon = "📄",
branchIcon = "📁",
branchOpenIcon = "📂",
+ focusMarker = false,
focus = true,
theme = darkTheme,
}: TreeViewProps): React.ReactElement {
@@ -149,6 +157,7 @@ export function TreeView({
return (
+ {focusMarker && {isFocused ? "❯ " : " "}}
{guides && depth > 0 && {guidePrefix}}
({
}
bold={isFocused}
dimColor={node.disabled}
- inverse={isFocused}
+ inverse={isFocused && !focusMarker}
>
{branchChar}
@@ -168,6 +177,12 @@ export function TreeView({
{showIcons ? ` ${icon} ` : " "}
{node.label}
+ {node.annotation !== undefined && (
+
+ {" "}
+ {node.annotation}
+
+ )}
);
})}
diff --git a/src/handlers/gateway/get/screen.tsx b/src/handlers/gateway/get/screen.tsx
index 1a456f462..db9fdf3e1 100644
--- a/src/handlers/gateway/get/screen.tsx
+++ b/src/handlers/gateway/get/screen.tsx
@@ -3,10 +3,10 @@ import { useNavigate, useParams } from "react-router";
import { JsonDetail } from "../../../components/JsonDetail";
import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen";
import type { ScreenProps } from "../../types";
-import { coreOptsFromCtx } from "../../utils";
+import { useCoreOpts } from "../../utils";
function useGatewayDetail({ ctx, core }: ScreenProps, gatewayId: string | undefined) {
- const opts = coreOptsFromCtx(ctx);
+ const opts = useCoreOpts(ctx);
return useQuery({
queryKey: ["gateway", opts.region, gatewayId],
queryFn: () => core.gateway.getGateway(gatewayId!, opts),
diff --git a/src/handlers/gateway/target/get/screen.tsx b/src/handlers/gateway/target/get/screen.tsx
index 51b4fc764..f6c95e382 100644
--- a/src/handlers/gateway/target/get/screen.tsx
+++ b/src/handlers/gateway/target/get/screen.tsx
@@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router";
import { JsonDetail } from "../../../../components/JsonDetail";
import type { ScreenProps } from "../../../types";
-import { coreOptsFromCtx } from "../../../utils";
+import { useCoreOpts } from "../../../utils";
export function GatewayTargetGetScreen(props: ScreenProps) {
const { gatewayId, targetId } = useParams();
- const opts = coreOptsFromCtx(props.ctx);
+ const opts = useCoreOpts(props.ctx);
const detail = useQuery({
queryKey: ["gateway-target", opts.region, gatewayId, targetId],
queryFn: () => props.core.gateway.getGatewayTarget(gatewayId!, targetId!, opts),
diff --git a/src/handlers/harness/get/screen.tsx b/src/handlers/harness/get/screen.tsx
index 2f86f1c2f..21da15693 100644
--- a/src/handlers/harness/get/screen.tsx
+++ b/src/handlers/harness/get/screen.tsx
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import type { ScreenProps } from "../../types";
-import { coreOptsFromCtx } from "../../utils";
+import { useCoreOpts } from "../../utils";
import { JsonDetail } from "../../../components/JsonDetail";
import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen";
@@ -45,7 +45,7 @@ const ACTIONS: { name: string; description: string; to: (id: string) => string }
// harness's flows (detail JSON, endpoints, versions, invoke, exec). The harness
// ID comes from the `:harnessId` route path value.
function useHarnessDetail({ ctx, core }: ScreenProps, harnessId: string | undefined) {
- const opts = coreOptsFromCtx(ctx);
+ const opts = useCoreOpts(ctx);
return useQuery({
queryKey: ["harness", opts.region, harnessId],
queryFn: () => core.harness.getHarness(harnessId!, opts),
diff --git a/src/handlers/memory/get/screen.tsx b/src/handlers/memory/get/screen.tsx
index 1904fde3e..f37342403 100644
--- a/src/handlers/memory/get/screen.tsx
+++ b/src/handlers/memory/get/screen.tsx
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router";
import { JsonDetail } from "../../../components/JsonDetail";
import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen";
import type { ScreenProps } from "../../types";
-import { coreOptsFromCtx } from "../../utils";
+import { useCoreOpts } from "../../utils";
const ACTIONS = [
{
@@ -34,7 +34,7 @@ const ACTIONS = [
] as const;
function useMemoryDetail({ ctx, core }: ScreenProps, memoryId: string | undefined) {
- const opts = coreOptsFromCtx(ctx);
+ const opts = useCoreOpts(ctx);
return useQuery({
queryKey: ["memory", opts.region, memoryId, "full"],
queryFn: () => core.memory.getMemory(memoryId!, "full", opts),
diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts
index 0d4b22794..e97707a05 100644
--- a/src/handlers/project/index.ts
+++ b/src/handlers/project/index.ts
@@ -35,6 +35,7 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router
"invoke",
"build",
"deploy",
+ "status",
);
// Without a default, a bare `agentcore project` falls back to Commander's help
@@ -99,11 +100,26 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router
),
);
project.handler(createProjectInvokeHandler(core, io));
- project.handler(
- withProject({ projectManager: config.projectManager })(
- createStatusProjectHandler({ projectManager: config.projectManager }),
- ),
- );
+ // 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 (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));
// 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/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx
index e088de6d4..7999be300 100644
--- a/src/handlers/project/project.screen.test.tsx
+++ b/src/handlers/project/project.screen.test.tsx
@@ -89,7 +89,7 @@ describe("project menu: command-line-only subcommands", () => {
const r = renderScreen("/agentcore/project");
await waitForText(r.lastFrame, "command line only");
- const withScreens = ["create", "deploy", "invoke", "build"];
+ const withScreens = ["create", "deploy", "invoke", "build", "status"];
const { screens, cliOnly } = menuEntries(r.lastFrame()!);
expect(screens.toSorted()).toEqual(withScreens.toSorted());
expect(cliOnly.toSorted()).toEqual(
diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts
index 450cd4fb2..c91ccccb7 100644
--- a/src/handlers/project/status/index.test.ts
+++ b/src/handlers/project/status/index.test.ts
@@ -8,6 +8,8 @@ import {
TestCoreClient,
TestGlobalConfigAccessor,
testIO,
+ ttyTestIO,
+ waitFor,
} from "../../../testing";
import type { ProjectBackend } from "../../../core/project";
import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets";
@@ -45,8 +47,7 @@ function fakeBackend(deployed: ResolvedProjectResource[]) {
return { targets, backend };
}
-function testStatusCommand(deployed: ResolvedProjectResource[] = []) {
- const io = testIO();
+function testStatusCommand(deployed: ResolvedProjectResource[] = [], io = testIO()) {
const fake = fakeBackend(deployed);
const root = createRootHandler(new TestCoreClient({ backends: { CDK: fake.backend } }), {
io: io.io,
@@ -232,3 +233,61 @@ describe("project status handler", () => {
);
});
});
+
+describe("project status dispatch", () => {
+ // The bare-invocation tests above run without a TTY and assert the exact
+ // JSON envelope, which pins the non-TTY headless path; these cover how a TTY
+ // changes (and does not change) the dispatch.
+ test("bare status in a TTY session opens the TUI instead of printing JSON", async () => {
+ const tty = ttyTestIO();
+ const subject = testStatusCommand([HARNESS_ROW], tty.streams);
+ await inProject(subject);
+
+ // outcome never rejects, so a mid-pump failure cannot trip bun's
+ // unhandled-rejection detection before the final assertion.
+ const outcome = subject.run().then(
+ () => ({ ok: true as const }),
+ (error: unknown) => ({ ok: false as const, error }),
+ );
+ let settled = false;
+ void outcome.finally(() => {
+ settled = true;
+ });
+
+ // The screen never finishes on its own; Ctrl+C (re-sent until the app
+ // reacts) closes it and resolves the route cleanly.
+ await waitFor(
+ () => {
+ if (!settled) tty.stdin.write("\x03");
+ return settled;
+ },
+ 5000,
+ 150,
+ );
+ expect(await outcome).toEqual({ ok: true });
+ expect(subject.io.stdout()).not.toContain('"projectName"');
+ }, 10000);
+
+ test("an explicitly passed --target stays headless even in a TTY", async () => {
+ const subject = testStatusCommand([HARNESS_ROW], ttyTestIO().streams);
+ await inProject(subject);
+
+ await subject.run(["--target", "default"]);
+
+ expect(subject.json()).toMatchObject({ projectName: "orders", target: "default" });
+ });
+
+ test("--json stays headless even in a TTY", async () => {
+ const subject = testStatusCommand([HARNESS_ROW], ttyTestIO().streams);
+ await inProject(subject);
+
+ await subject.run(["--json"]);
+
+ expect(subject.json()).toEqual({
+ projectName: "orders",
+ target: "default",
+ region: "us-east-1",
+ resources: [HARNESS_ROW],
+ });
+ });
+});
diff --git a/src/handlers/project/status/screen.tsx b/src/handlers/project/status/screen.tsx
new file mode 100644
index 000000000..dd118184a
--- /dev/null
+++ b/src/handlers/project/status/screen.tsx
@@ -0,0 +1,281 @@
+import { useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Box, Text, useInput } from "ink";
+import { useNavigate } from "react-router";
+import { Layout } from "../../../components/Layout";
+import { TreeView, type TreeNode } from "../../../components/ui/tree-view";
+import { darkTheme } from "../../../components/ui/_core.js";
+import { ProjectKey } from "../../../router";
+import type { ScreenProps } from "../../types";
+import type { DeployableResource, Project, ResolvedProjectResource } from "../types";
+import { LoadingFrame, ProjectGate } from "../ProjectGate";
+
+const theme = darkTheme;
+
+const BREADCRUMB = ["agentcore", "project", "status"];
+const PROJECT_MENU = "/agentcore/project";
+// The TUI opens only from a bare invocation (an explicit --target keeps the
+// headless report), so the screen always shows the default target, like the
+// invoke picker.
+const TARGET_NAME = "default";
+
+const RESOURCE_TYPE_LABELS: Record = {
+ runtime: "Runtime",
+ harness: "Harness",
+ memory: "Memory",
+ "knowledge-base": "Knowledge Base",
+ credential: "Credential",
+ evaluator: "Evaluator",
+ "online-eval": "Online Eval",
+ gateway: "Gateway",
+ "gateway-target": "Gateway Target",
+ "policy-engine": "Policy Engine",
+ policy: "Policy",
+ "config-bundle": "Config Bundle",
+ "payment-manager": "Payment Manager",
+ "payment-connector": "Payment Connector",
+};
+
+// The detail routes a deployed resource can forward to. Types without a detail
+// screen are listed but not navigable.
+const DETAIL_ROUTES: Partial string>> = {
+ runtime: (id) => `/agentcore/runtime/get/${encodeURIComponent(id)}`,
+ harness: (id) => `/agentcore/harness/get/${encodeURIComponent(id)}`,
+ memory: (id) => `/agentcore/memory/get/${encodeURIComponent(id)}`,
+ gateway: (id) => `/agentcore/gateway/get/${encodeURIComponent(id)}`,
+};
+
+// resolveProjectResources reports most ids as ARNs (e.g.
+// arn:aws:bedrock-agentcore:::memory/) while the
+// detail routes and Core clients take the bare service id, so the id is the
+// resource path after the type. Ids that are not ARNs (a gateway target's id,
+// for one) pass through unchanged.
+function serviceIdFromArn(id: string): string {
+ const match = /^arn:[^:]*:[^:]*:[^:]*:[^:]*:[^/]+\/(.+)$/.exec(id);
+ return match?.[1] ?? id;
+}
+
+type ResourceNodeData = {
+ // route is where enter forwards; unset rows show `hint` instead.
+ route?: string;
+ hint?: string;
+};
+
+type StatusNode = TreeNode;
+
+// routeFor resolves the detail route for a deployed resource. Gateway targets
+// have a detail screen too, but its route needs the owning gateway's id, which
+// only the parent row carries.
+function routeFor(
+ resource: ResolvedProjectResource,
+ parent?: ResolvedProjectResource,
+): string | undefined {
+ if (resource.deploymentState !== "deployed") return undefined;
+ if (resource.resourceType === "gateway-target") {
+ if (parent?.deploymentState !== "deployed") return undefined;
+ const gatewayId = encodeURIComponent(serviceIdFromArn(parent.id));
+ return `/agentcore/gateway/target/get/${gatewayId}/${encodeURIComponent(resource.id)}`;
+ }
+ return DETAIL_ROUTES[resource.resourceType]?.(serviceIdFromArn(resource.id));
+}
+
+// buildStatusNodes groups the resolved resources by agent. Each entry in
+// spec.runtimes (code agents) and spec.harnesses (managed harness agents) is a
+// top-level group holding the agent's own deployed resource; everything not
+// attributable to an agent lands in a shared "project" group so nothing the
+// project declares is dropped.
+//
+// Memories group under runtime agents: the CDK L3 injects a MEMORY__ID
+// env var for every declared memory into every runtime (see
+// src/core/project/templates/runtime.ts), so each declared memory is reachable
+// from each runtime agent. A harness's memory binding lives in its own
+// harness.json (HarnessMemoryRefSchema), not in the project spec this report is
+// built from — a managed one is provisioned inside the harness and never
+// appears here — so harness groups list just the harness itself and memories a
+// harness may reference by name stay visible under the project group.
+export function buildStatusNodes(
+ spec: Project["spec"],
+ resources: ResolvedProjectResource[],
+): StatusNode[] {
+ const byKey = new Map(resources.map((r) => [`${r.resourceType}:${r.name}`, r]));
+ const claimed = new Set();
+ const claim = (resourceType: DeployableResource, name: string) => {
+ const resource = byKey.get(`${resourceType}:${name}`);
+ if (resource) claimed.add(`${resourceType}:${name}`);
+ return resource;
+ };
+
+ // The type column width, minus the guide characters each extra depth adds,
+ // keeps names aligned across the tree.
+ const typeWidth =
+ Math.max(...resources.map((r) => RESOURCE_TYPE_LABELS[r.resourceType].length), 0) + 2;
+
+ const resourceNode = (
+ resource: ResolvedProjectResource,
+ parentId: string,
+ depth: number,
+ parent?: ResolvedProjectResource,
+ ): StatusNode => {
+ const id = `${parentId}/${resource.resourceType}:${resource.name}`;
+ const type = RESOURCE_TYPE_LABELS[resource.resourceType];
+ const route = routeFor(resource, parent);
+ const deployed = resource.deploymentState === "deployed";
+ return {
+ id,
+ label: `${type.padEnd(Math.max(typeWidth - 2 * (depth - 1), type.length + 1))}${resource.name}`,
+ annotation: deployed ? "deployed" : "local-only",
+ // A declared-but-undeployed resource has nothing to fetch, so its row
+ // cannot be selected; deployed types without a detail screen stay
+ // selectable and explain themselves instead.
+ disabled: !deployed,
+ defaultExpanded: true,
+ children: resource.children?.map((child) => resourceNode(child, id, depth + 1, resource)),
+ data: route ? { route } : { hint: `${type} ${resource.name} has no detail view.` },
+ };
+ };
+
+ const agentGroups: StatusNode[] = [
+ ...spec.runtimes.map(({ name }): StatusNode => {
+ const id = `agent:${name}`;
+ const children = [
+ claim("runtime", name),
+ ...spec.memories.map(({ name: memoryName }) => claim("memory", memoryName)),
+ ].filter((resource) => resource !== undefined);
+ return {
+ id,
+ label: name,
+ annotation: "agent",
+ defaultExpanded: true,
+ children: children.map((resource) => resourceNode(resource, id, 1)),
+ };
+ }),
+ ...spec.harnesses.map(({ name }): StatusNode => {
+ const id = `agent:${name}`;
+ const children = [claim("harness", name)].filter((resource) => resource !== undefined);
+ return {
+ id,
+ label: name,
+ annotation: "agent",
+ defaultExpanded: true,
+ children: children.map((resource) => resourceNode(resource, id, 1)),
+ };
+ }),
+ ];
+
+ const shared = resources.filter((r) => !claimed.has(`${r.resourceType}:${r.name}`));
+ const sharedGroup: StatusNode[] = shared.length
+ ? [
+ {
+ id: "project",
+ label: "project",
+ annotation: "shared resources",
+ defaultExpanded: true,
+ children: shared.map((resource) => resourceNode(resource, "project", 1)),
+ },
+ ]
+ : [];
+
+ return [...agentGroups, ...sharedGroup];
+}
+
+// The project comes from the launch context when a project command opened the
+// TUI, and is resolved from the cwd otherwise — the gate reports the CLI's own
+// not-found guidance when there is none.
+export function ProjectStatusScreen({ ctx, core }: ScreenProps) {
+ const navigate = useNavigate();
+ return (
+ navigate(PROJECT_MENU)}
+ >
+ {(project) => }
+
+ );
+}
+
+function ProjectStatusView({
+ core,
+ project,
+}: Pick & {
+ project: Project;
+}) {
+ const navigate = useNavigate();
+ const [hint, setHint] = useState();
+
+ const status = useQuery({
+ queryKey: ["project-status", project.rootPath, TARGET_NAME],
+ queryFn: () => core.projectManager.resolveProjectResources(project, { target: TARGET_NAME }),
+ });
+
+ const nodes = useMemo(
+ () => (status.data ? buildStatusNodes(project.spec, status.data.resources) : []),
+ [project, status.data],
+ );
+
+ const goBack = () => navigate(PROJECT_MENU);
+ // Only once the tree is up — while loading or on an error the LoadingFrame
+ // below owns escape (and retry).
+ useInput((_input, key) => {
+ if (key.escape && status.data !== undefined) goBack();
+ });
+
+ if (!status.data) {
+ return (
+
+ );
+ }
+
+ const select = (node: StatusNode) => {
+ if (node.data?.route) {
+ // The detail screens fetch in their context's region, which is the
+ // ambient one — link with ?region= so the destination fetches where the
+ // project actually deployed (see useCoreOpts). Escape there is a history
+ // pop back here.
+ const region = encodeURIComponent(status.data.target.region);
+ navigate(`${node.data.route}?region=${region}`);
+ return;
+ }
+ setHint(node.data?.hint);
+ };
+
+ return (
+
+
+ Linked Resources
+
+ {nodes.length === 0 ? (
+
+ No resources are declared in this project. Run `agentcore project add` to declare one.
+
+ ) : (
+
+ )}
+
+ {hint !== undefined && (
+
+ {hint}
+
+ )}
+
+
+ );
+}
diff --git a/src/handlers/project/status/status.screen.test.tsx b/src/handlers/project/status/status.screen.test.tsx
new file mode 100644
index 000000000..110b7500e
--- /dev/null
+++ b/src/handlers/project/status/status.screen.test.tsx
@@ -0,0 +1,284 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import type {
+ GetAgentRuntimeResponse,
+ GetHarnessResponse,
+ GetMemoryOutput,
+} from "@aws-sdk/client-bedrock-agentcore-control";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { ProjectSpecSchema } from "../../../projectSchemas/project";
+import { ProjectKey } from "../../../router";
+import {
+ cleanupScreens,
+ flatFrame,
+ renderScreen,
+ TestCoreClient,
+ waitForFlatText,
+ waitForText,
+} from "../../../testing";
+import type { Project, ResolvedProjectResource } from "../types";
+
+const originalCwd = process.cwd();
+const tempDirectories: string[] = [];
+
+afterEach(cleanupScreens);
+afterEach(async () => {
+ process.chdir(originalCwd);
+ await Promise.all(
+ tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
+ );
+});
+
+// The target region differs from the base context's us-east-1 on purpose: the
+// detail screens must fetch where the project deployed, not in the ambient
+// region.
+const TARGET = { name: "default", account: "111122223333", region: "eu-west-1" } as const;
+const ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}`;
+const RUNTIME_ID = "checkout-AbCdEf1234";
+const MEMORY_ID = "recallMemory-XyZ123";
+const HARNESS_ID = "support-AbCdEf1234";
+
+function project(spec: Record): Project {
+ return {
+ name: "orders",
+ rootPath: "/tmp/orders",
+ spec: ProjectSpecSchema.parse({ name: "orders", version: 1, ...spec }),
+ };
+}
+
+const RUNTIME_PROJECT = project({
+ runtimes: [
+ {
+ name: "checkout",
+ build: "CodeZip",
+ entrypoint: "main.py",
+ codeLocation: "app/checkout",
+ runtimeVersion: "PYTHON_3_14",
+ },
+ ],
+ memories: [{ name: "recall", eventExpiryDuration: 30 }],
+});
+
+const deployed = (
+ resourceType: ResolvedProjectResource["resourceType"],
+ name: string,
+ id: string,
+ children?: ResolvedProjectResource[],
+): ResolvedProjectResource => ({
+ resourceType,
+ name,
+ ...(children ? { children } : {}),
+ deploymentState: "deployed",
+ id,
+});
+
+const localOnly = (
+ resourceType: ResolvedProjectResource["resourceType"],
+ name: string,
+): ResolvedProjectResource => ({ resourceType, name, deploymentState: "local-only" });
+
+const RUNTIME_RESOURCES: ResolvedProjectResource[] = [
+ deployed("runtime", "checkout", `${ARN}:runtime/${RUNTIME_ID}`),
+ deployed("memory", "recall", `${ARN}:memory/${MEMORY_ID}`),
+];
+
+function core(resources: ResolvedProjectResource[] = RUNTIME_RESOURCES): TestCoreClient {
+ const value = new TestCoreClient();
+ value.projectManager.resolveProjectResources = async () => ({ resources, target: TARGET });
+ value.runtime.setGetResponse({
+ agentRuntimeId: RUNTIME_ID,
+ agentRuntimeArn: `${ARN}:runtime/${RUNTIME_ID}`,
+ status: "READY",
+ } as GetAgentRuntimeResponse);
+ value.memory.setGetResponse({
+ memory: { id: MEMORY_ID, name: "recall", arn: `${ARN}:memory/${MEMORY_ID}`, status: "ACTIVE" },
+ } as GetMemoryOutput);
+ value.harness.setGetResponse({
+ harness: { harnessId: HARNESS_ID, harnessName: "support", arn: `${ARN}:harness/${HARNESS_ID}` },
+ } as GetHarnessResponse);
+ return value;
+}
+
+function renderStatus(value: TestCoreClient, seed: Project = RUNTIME_PROJECT) {
+ return renderScreen("/agentcore/project/status", {
+ core: value,
+ withContext: (ctx) => ctx.withValue(ProjectKey, seed),
+ });
+}
+
+// focusedLine returns the line carrying the ❯ marker.
+function focusedLine(frame: string | undefined): string {
+ return (frame ?? "").split("\n").find((line) => line.includes("❯")) ?? "";
+}
+
+describe("project status screen", () => {
+ test("groups the runtime agent's Runtime and Memory beneath it", async () => {
+ const screen = renderStatus(core());
+
+ await waitForText(screen.lastFrame, "Linked Resources");
+ const frame = flatFrame(screen.lastFrame);
+ expect(frame).toContain("checkout agent");
+ expect(frame).toMatch(/Runtime\s+checkout deployed/);
+ expect(frame).toMatch(/Memory\s+recall deployed/);
+ // Both resources are attributed to the agent — no shared group appears.
+ expect(frame).not.toContain("project shared resources");
+ });
+
+ test("keeps unattributable resources visible under a shared project group", async () => {
+ const screen = renderStatus(
+ core([
+ ...RUNTIME_RESOURCES,
+ deployed("gateway", "tools", `${ARN}:gateway/tools-GwId12345`, [
+ deployed("gateway-target", "search", "TARGETID123"),
+ ]),
+ localOnly("credential", "svc-key"),
+ ]),
+ );
+
+ await waitForText(screen.lastFrame, "Linked Resources");
+ const frame = flatFrame(screen.lastFrame);
+ expect(frame).toContain("project shared resources");
+ expect(frame).toMatch(/Gateway\s+tools deployed/);
+ expect(frame).toMatch(/Gateway Target\s+search deployed/);
+ expect(frame).toMatch(/Credential\s+svc-key local-only/);
+ });
+
+ test("marks declared-but-undeployed rows local-only and skips them when navigating", async () => {
+ const screen = renderStatus(
+ core([
+ deployed("runtime", "checkout", `${ARN}:runtime/${RUNTIME_ID}`),
+ localOnly("memory", "recall"),
+ ]),
+ );
+
+ await waitForText(screen.lastFrame, "local-only");
+ // Down from the agent group focuses the Runtime; the disabled Memory row
+ // cannot take focus, so a second press leaves the focus where it is.
+ await screen.press("down");
+ expect(focusedLine(screen.lastFrame())).toContain("Runtime");
+ await screen.press("down");
+ expect(focusedLine(screen.lastFrame())).toContain("Runtime");
+ });
+
+ test("enter on the Runtime opens the Runtime detail page in the target region", async () => {
+ const value = core();
+ const screen = renderStatus(value);
+
+ await waitForText(screen.lastFrame, "Linked Resources");
+ await screen.press("down");
+ await screen.press("return");
+
+ await waitForText(screen.lastFrame, "agentcore → runtime → get → " + RUNTIME_ID);
+ await waitForText(screen.lastFrame, "READY");
+ const call = value.runtime.calls.find(({ method }) => method === "getRuntime")!;
+ expect(call.args[0]).toBe(RUNTIME_ID);
+ expect(call.args[1]).toMatchObject({ region: TARGET.region });
+ });
+
+ test("enter on the Memory opens the Memory detail page in the target region", async () => {
+ const value = core();
+ const screen = renderStatus(value);
+
+ await waitForText(screen.lastFrame, "Linked Resources");
+ await screen.press("down");
+ await screen.press("down");
+ await screen.press("return");
+
+ await waitForText(screen.lastFrame, "agentcore → memory → get → " + MEMORY_ID);
+ await waitForText(screen.lastFrame, "ACTIVE");
+ const call = value.memory.calls.find(({ method }) => method === "getMemory")!;
+ expect(call.args[0]).toBe(MEMORY_ID);
+ expect(call.args[2]).toMatchObject({ region: TARGET.region });
+ });
+
+ test("enter on a Harness opens the Harness detail page", async () => {
+ const screen = renderStatus(
+ core([deployed("harness", "support", `${ARN}:harness/${HARNESS_ID}`)]),
+ project({ harnesses: [{ name: "support", path: "app/support" }] }),
+ );
+
+ await waitForText(screen.lastFrame, "Linked Resources");
+ await screen.press("down");
+ await screen.press("return");
+
+ await waitForText(screen.lastFrame, "agentcore → harness → get → " + HARNESS_ID);
+ });
+
+ test("escape from a detail page returns to the status screen", async () => {
+ const screen = renderStatus(core());
+
+ await waitForText(screen.lastFrame, "Linked Resources");
+ await screen.press("down");
+ await screen.press("return");
+ await waitForText(screen.lastFrame, "agentcore → runtime → get");
+
+ await screen.press("escape");
+ await waitForText(screen.lastFrame, "Linked Resources");
+ });
+
+ test("a deployed resource without a detail screen explains itself instead of navigating", async () => {
+ const screen = renderStatus(
+ core([...RUNTIME_RESOURCES, deployed("credential", "svc-key", `${ARN}:token-vault/default`)]),
+ );
+
+ await waitForText(screen.lastFrame, "Linked Resources");
+ // group → runtime → memory → project group → credential.
+ for (let press = 0; press < 4; press++) await screen.press("down");
+ expect(focusedLine(screen.lastFrame())).toContain("Credential");
+ await screen.press("return");
+
+ await waitForText(screen.lastFrame, "Credential svc-key has no detail view.");
+ expect(screen.lastFrame()).toContain("Linked Resources");
+ });
+
+ test("left and right arrows collapse and expand an agent group", async () => {
+ const screen = renderStatus(core());
+
+ await waitForText(screen.lastFrame, "Linked Resources");
+ expect(screen.lastFrame()).toContain("Runtime");
+ await screen.press("left");
+ expect(screen.lastFrame()).not.toContain("Runtime");
+ await screen.press("right");
+ await waitForText(screen.lastFrame, "Runtime");
+ });
+
+ test("esc returns to the project command menu", async () => {
+ const screen = renderStatus(core());
+
+ await waitForText(screen.lastFrame, "Linked Resources");
+ await screen.press("escape");
+ await waitForText(screen.lastFrame, "manage an AgentCore project");
+ });
+
+ test("shows resolution errors with the standard treatment", async () => {
+ const value = core();
+ value.projectManager.resolveProjectResources = async () => {
+ throw new Error("No deployment targets are configured for project 'orders'.");
+ };
+ const screen = renderStatus(value);
+
+ await waitForText(screen.lastFrame, "No deployment targets are configured");
+ expect(screen.lastFrame()).toContain("✗");
+ await screen.press("escape");
+ await waitForText(screen.lastFrame, "manage an AgentCore project");
+ });
+
+ test("an empty project reports that nothing is declared", async () => {
+ const screen = renderStatus(core([]), project({}));
+
+ await waitForText(screen.lastFrame, "No resources are declared in this project.");
+ });
+
+ test("reports the CLI's own guidance outside a project", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "agentcore-status-no-project-"));
+ tempDirectories.push(directory);
+ process.chdir(directory);
+ const screen = renderScreen("/agentcore/project/status", { core: core() });
+
+ await waitForFlatText(screen.lastFrame, "No AgentCore project found");
+ expect(flatFrame(screen.lastFrame)).toContain("agentcore project create");
+ await screen.press("escape");
+ await waitForText(screen.lastFrame, "manage an AgentCore project");
+ });
+});
diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx
index a144e28ee..a44c49be0 100644
--- a/src/handlers/runtime/get/screen.tsx
+++ b/src/handlers/runtime/get/screen.tsx
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router";
import { JsonDetail } from "../../../components/JsonDetail";
import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen";
import type { ScreenProps } from "../../types";
-import { coreOptsFromCtx } from "../../utils";
+import { useCoreOpts } from "../../utils";
const ACTIONS = [
{
@@ -30,7 +30,7 @@ const ACTIONS = [
] as const;
function useRuntimeDetail({ ctx, core }: ScreenProps, runtimeId: string | undefined) {
- const opts = coreOptsFromCtx(ctx);
+ const opts = useCoreOpts(ctx);
return useQuery({
queryKey: ["runtime", opts.region, runtimeId],
queryFn: () => core.runtime.getRuntime(runtimeId!, opts),
diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx
index a87ec614e..4c796cd40 100644
--- a/src/handlers/utils.tsx
+++ b/src/handlers/utils.tsx
@@ -1,3 +1,4 @@
+import { useSearchParams } from "react-router";
import type { Context } from "../router";
import type z from "zod";
import type { CoreOptions } from "../core/types";
@@ -17,6 +18,19 @@ export function coreOptsFromCtx(ctx: Context): CoreOptions {
};
}
+// useCoreOpts is coreOptsFromCtx for screens that can be linked to across
+// regions. The context's region is the ambient one resolved at launch, which is
+// not necessarily where the resource a screen is asked to show lives — project
+// status forwards to detail pages on a target that may be deployed elsewhere,
+// and links there with `?region=`. A region in the query string
+// wins; without one the context's region applies as usual.
+export function useCoreOpts(ctx: Context): CoreOptions {
+ const [search] = useSearchParams();
+ const region = search.get("region");
+ const opts = coreOptsFromCtx(ctx);
+ return region ? { ...opts, region } : opts;
+}
+
// parseJsonFlag parses a flag's raw string as JSON, typed as the API structure
// the flag mirrors. Structured API parameters (model/tools/memory/...) are
// accepted as JSON documents rather than exploded into dozens of leaf flags;