diff --git a/src/components/Root.tsx b/src/components/Root.tsx
index 6e60611e9..ed25ae900 100644
--- a/src/components/Root.tsx
+++ b/src/components/Root.tsx
@@ -121,6 +121,7 @@ import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx
import { AddRuntimeScreen } from "../handlers/project/add/runtime/screen.tsx";
import { AddMemoryScreen } from "../handlers/project/add/memory/screen.tsx";
import { ProjectStatusScreen } from "../handlers/project/status/screen.tsx";
+import { ProjectRemoveScreen } from "../handlers/project/remove/screen.tsx";
import { HelpScreen, RootScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";
@@ -821,6 +822,18 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/project/add/memory"
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/handlers/project/index.ts b/src/handlers/project/index.ts
index b6a673360..2167c6695 100644
--- a/src/handlers/project/index.ts
+++ b/src/handlers/project/index.ts
@@ -39,6 +39,7 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router
"deploy",
"status",
"add",
+ "remove",
);
// Without a default, a bare `agentcore project` falls back to Commander's help
@@ -59,9 +60,14 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router
project.handler(createAddProjectResourceHandler(config, core));
project.handler(createExportProjectResourceHandler({ projectManager, core, io }));
project.handler(
- withProject({ projectManager: config.projectManager })(
- createRemoveProjectHandler({ projectManager: config.projectManager, io: config.io }),
- ),
+ createRemoveProjectHandler({
+ projectManager: config.projectManager,
+ io: config.io,
+ middlewares: [
+ withProject({ projectManager: config.projectManager }),
+ withTuiWhenInteractive(core, io),
+ ],
+ }),
);
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 3d7322c27..ea20242e9 100644
--- a/src/handlers/project/project.screen.test.tsx
+++ b/src/handlers/project/project.screen.test.tsx
@@ -75,7 +75,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", "status", "add"];
+ const withScreens = ["create", "deploy", "invoke", "build", "status", "add", "remove"];
const { screens, cliOnly } = menuEntries(r.lastFrame()!);
expect(screens.toSorted()).toEqual(withScreens.toSorted());
expect(cliOnly.toSorted()).toEqual(
diff --git a/src/handlers/project/remove/index.ts b/src/handlers/project/remove/index.ts
index df5f00edf..4f9a1b836 100644
--- a/src/handlers/project/remove/index.ts
+++ b/src/handlers/project/remove/index.ts
@@ -1,5 +1,5 @@
import { createInterface } from "node:readline/promises";
-import { argument, createHandler, flag, ProjectKey } from "../../../router";
+import { argument, createHandler, flag, ProjectKey, type Middleware } from "../../../router";
import { InputValidationError, UserCancellationError } from "../../../errors";
import z from "zod";
import type { AppIO } from "../../../io";
@@ -12,12 +12,14 @@ import { projectMutationResource, projectReference, type ProjectMutationResult }
type RemoveProjectResourceConfig = {
projectManager: ProjectManager;
io: AppIO;
+ middlewares?: Middleware[];
};
export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) =>
createHandler({
name: "remove",
description: "remove a resource from the project",
+ middlewares: config.middlewares,
flags: [
flag("name", "name of the resource to remove", z.string().min(1).optional()),
flag("gateway", "name of the parent Gateway for a Target", z.string().min(1).optional()),
diff --git a/src/handlers/project/remove/remove.screen.test.tsx b/src/handlers/project/remove/remove.screen.test.tsx
new file mode 100644
index 000000000..fcd6a756f
--- /dev/null
+++ b/src/handlers/project/remove/remove.screen.test.tsx
@@ -0,0 +1,443 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { ProjectSpecSchema, type ProjectSpec } from "../../../projectSchemas/project";
+import { ProjectKey } from "../../../router";
+import { resolveRuntimeTemplateShortcut } from "../shortcuts";
+import type { AddResourceInput, Project } from "../types";
+import {
+ renderScreen,
+ waitForText,
+ waitFor,
+ cleanupScreens,
+ TestCoreClient,
+} from "../../../testing";
+
+const RUNTIME = "agent_python_minimal";
+
+const originalCwd = process.cwd();
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ cleanupScreens();
+ process.chdir(originalCwd);
+ await Promise.all(temporaryDirectories.splice(0).map((dir) => rm(dir, { recursive: true })));
+});
+
+async function drain(generator: AsyncGenerator): Promise {
+ while (true) {
+ const next = await generator.next();
+ if (next.done) return next.value;
+ }
+}
+
+async function createProject(
+ core: TestCoreClient,
+ resources: AddResourceInput[] = [],
+): Promise<{ project: Project; specPath: string }> {
+ const root = await mkdtemp(join(tmpdir(), "agentcore-project-remove-"));
+ temporaryDirectories.push(root);
+ process.chdir(root);
+ let project = await drain(
+ core.projectManager.create({
+ name: "orders",
+ skipInstall: true,
+ skipGit: true,
+ scaffoldRuntimeInput: resolveRuntimeTemplateShortcut("agent-python-minimal"),
+ }),
+ );
+ for (const resource of resources) {
+ project = await drain(core.projectManager.addResource(project, resource));
+ }
+ return { project, specPath: join(project.rootPath, "agentcore", "agentcore.json") };
+}
+
+const POLICY: AddResourceInput[] = [
+ { resourceType: "policy-engine", resourceConfig: { name: "guard" } },
+ {
+ resourceType: "policy",
+ engineName: "guard",
+ resourceConfig: { name: "denyAll", statement: "permit(principal, action, resource);" },
+ },
+];
+
+function render(path: string, core: TestCoreClient, project: Project) {
+ return renderScreen(path, { core, withContext: (ctx) => ctx.withValue(ProjectKey, project) });
+}
+
+async function readSpec(specPath: string): Promise {
+ return ProjectSpecSchema.parse(JSON.parse(await readFile(specPath, "utf8")));
+}
+
+describe("project remove screen", () => {
+ test("displays every removable resource type", async () => {
+ const core = new TestCoreClient();
+ // One of every removable resource type (runtime is scaffolded by createProject).
+ const { project } = await createProject(core, [
+ {
+ resourceType: "harness",
+ resourceConfig: {
+ name: "assistant",
+ model: { provider: "bedrock", modelId: "us.amazon.nova-lite-v1:0" },
+ systemPrompt: "You are terse.",
+ },
+ },
+ {
+ resourceType: "memory",
+ resourceConfig: { name: "recall", eventExpiryDuration: 30, strategies: [] },
+ },
+ {
+ resourceType: "credential",
+ resourceConfig: {
+ authorizerType: "PaymentCredentialProvider",
+ name: "payCred",
+ provider: "CoinbaseCDP",
+ },
+ },
+ { resourceType: "config-bundle", resourceConfig: { name: "bundle", components: {} } },
+ {
+ resourceType: "online-eval",
+ resourceConfig: {
+ name: "quality",
+ samplingRate: 10,
+ agent: RUNTIME,
+ evaluators: ["Builtin.Helpfulness"],
+ },
+ },
+ {
+ resourceType: "evaluator",
+ resourceConfig: {
+ name: "judge",
+ level: "SESSION",
+ config: { codeBased: { managed: { codeLocation: "./evaluator" } } },
+ },
+ },
+ {
+ resourceType: "gateway",
+ resourceConfig: {
+ name: "tools",
+ targets: [],
+ authorizerType: "NONE",
+ enableSemanticSearch: true,
+ exceptionLevel: "NONE",
+ },
+ },
+ {
+ resourceType: "gateway-target",
+ gatewayName: "tools",
+ resourceConfig: {
+ name: "external",
+ targetType: "mcpServer",
+ endpoint: "https://example.com/mcp",
+ },
+ },
+ {
+ resourceType: "gateway-target",
+ gatewayName: "tools",
+ resourceConfig: { name: "web", targetType: "connector", connectorId: "web-search" },
+ },
+ { resourceType: "policy-engine", resourceConfig: { name: "guard" } },
+ {
+ resourceType: "policy",
+ engineName: "guard",
+ resourceConfig: { name: "denyAll", statement: "permit(principal, action, resource);" },
+ },
+ { resourceType: "payment-manager", resourceConfig: { name: "payments" } },
+ {
+ resourceType: "payment-connector",
+ managerName: "payments",
+ resourceConfig: { name: "conn", credentialName: "payCred" },
+ },
+ ]);
+ const r = render("/agentcore/project/remove", core, project);
+
+ await waitForText(r.lastFrame, "choose a resource to remove from project orders");
+ const frame = r.lastFrame()!;
+ for (const resourceType of [
+ "runtime",
+ "harness",
+ "memory",
+ "credential",
+ "config-bundle",
+ "online-eval",
+ "evaluator",
+ "gateway",
+ "gateway-target",
+ "gateway-connector",
+ "policy-engine",
+ "policy",
+ "payment-manager",
+ "payment-connector",
+ ]) {
+ expect(frame).toContain(resourceType);
+ }
+ r.unmount();
+ });
+
+ test("lists the resource types the project holds plus an all option", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core, POLICY);
+ const r = render("/agentcore/project/remove", core, project);
+
+ await waitForText(r.lastFrame, "choose a resource to remove from project orders");
+ const frame = r.lastFrame()!;
+ expect(frame).toContain("runtime");
+ expect(frame).toContain("policy-engine");
+ expect(frame).toContain("policy");
+ expect(frame).toContain("all");
+ r.unmount();
+ });
+
+ test("resolves the project from the working directory when not pinned in context", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core);
+ process.chdir(project.rootPath); // cd into the project, as a user would; no ProjectKey injected
+ const r = renderScreen("/agentcore/project/remove", { core });
+
+ await waitForText(r.lastFrame, "choose a resource to remove from project orders");
+ expect(r.lastFrame()).not.toContain("No AgentCore project");
+ r.unmount();
+ });
+
+ test("esc from the no-project screen returns to the project menu", async () => {
+ const core = new TestCoreClient();
+ const root = await mkdtemp(join(tmpdir(), "agentcore-no-project-"));
+ temporaryDirectories.push(root);
+ process.chdir(root);
+ const r = renderScreen("/agentcore/project/remove", { core });
+
+ await waitForText(r.lastFrame, "No AgentCore project found");
+ await r.press("escape");
+ await waitForText(r.lastFrame, "agentcore → project");
+ r.unmount();
+ });
+
+ test("an empty project offers no resources to remove and no all option", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core);
+ const { project: empty } = await core.projectManager.removeResource(project, {
+ resourceType: "runtime",
+ name: project.spec.runtimes[0]!.name,
+ });
+ const r = render("/agentcore/project/remove", core, empty);
+
+ await waitForText(r.lastFrame, "This project has no resources to remove.");
+ expect(r.lastFrame()).not.toContain("all");
+ // Nothing to navigate or select here — only esc/ctrl+c are advertised.
+ expect(r.lastFrame()).toContain("esc");
+ expect(r.lastFrame()).not.toContain("filter");
+ r.unmount();
+ });
+
+ test("an empty resource-type list advertises only esc/ctrl+c", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core); // only a scaffolded runtime, no harnesses
+ const r = render("/agentcore/project/remove/harness", core, project);
+
+ await waitForText(r.lastFrame, "This project has no harness resources.");
+ expect(r.lastFrame()).toContain("esc");
+ expect(r.lastFrame()).not.toContain("filter");
+ r.unmount();
+ });
+
+ test("remove all on an empty project shows nothing to remove", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core);
+ const { project: empty } = await core.projectManager.removeResource(project, {
+ resourceType: "runtime",
+ name: project.spec.runtimes[0]!.name,
+ });
+ const r = render("/agentcore/project/remove/all", core, empty);
+
+ await waitForText(r.lastFrame, "This project has no resources to remove.");
+ // Static message screen — only esc/ctrl+c are advertised.
+ expect(r.lastFrame()).toContain("esc");
+ expect(r.lastFrame()).not.toContain("filter");
+ r.unmount();
+ });
+
+ test("the all row counts the sum of every resource", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core, POLICY);
+ const r = render("/agentcore/project/remove", core, project);
+
+ await waitForText(r.lastFrame, "all");
+ // `all` = the sum of the listed rows: 1 runtime + 1 policy-engine + 1 policy.
+ expect(r.lastFrame()).toMatch(/all\s+3/);
+ r.unmount();
+ });
+
+ test("selecting a type lists that type's resources", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core);
+ const r = render("/agentcore/project/remove", core, project);
+
+ await waitForText(r.lastFrame, "runtime");
+ await r.press("return");
+ await waitForText(r.lastFrame, "choose a runtime to remove");
+ expect(r.lastFrame()).toContain(RUNTIME);
+ r.unmount();
+ });
+
+ test("esc on the resource list returns to the resource-type list", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core);
+ const r = render("/agentcore/project/remove/runtime", core, project);
+
+ await waitForText(r.lastFrame, "choose a runtime to remove");
+ await r.press("escape");
+ await waitForText(r.lastFrame, "choose a resource to remove from project orders");
+ r.unmount();
+ });
+
+ test("esc on the resource-type list returns to the project menu", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core);
+ const r = render("/agentcore/project/remove", core, project);
+
+ await waitForText(r.lastFrame, "choose a resource to remove from project orders");
+ await r.press("escape");
+ await waitForText(r.lastFrame, "agentcore → project");
+ r.unmount();
+ });
+
+ test("confirming a removal deletes the resource from the spec", async () => {
+ const core = new TestCoreClient();
+ const { project, specPath } = await createProject(core);
+ const r = render("/agentcore/project/remove/runtime/0", core, project);
+
+ await waitForText(r.lastFrame, `Remove runtime '${RUNTIME}' from project orders?`);
+ expect(r.lastFrame()).toContain("(y/N)");
+ await r.write("y");
+ await waitForText(r.lastFrame, "Resource removed");
+
+ expect((await readSpec(specPath)).runtimes).toEqual([]);
+ r.unmount();
+ });
+
+ test("enter after success refreshes the list when the project is pinned in context", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core, POLICY);
+ // ProjectKey is set in context (as the command wiring does): useProject uses
+ // it as initialData and never refetches, so the removal must update the cache.
+ const r = render("/agentcore/project/remove/runtime/0", core, project);
+
+ await waitForText(r.lastFrame, `Remove runtime '${RUNTIME}' from project orders?`);
+ await r.write("y");
+ await waitForText(r.lastFrame, "Resource removed");
+ await r.press("return");
+
+ await waitFor(() => {
+ const frame = r.lastFrame() ?? "";
+ return (
+ frame.includes("choose a resource to remove from project orders") &&
+ frame.includes("policy") &&
+ !frame.includes("runtime")
+ );
+ });
+ r.unmount();
+ });
+
+ test("enter after success returns to the resource-type selector with fresh data", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core, POLICY);
+ process.chdir(project.rootPath); // cwd-resolve path, so the selector refreshes from disk
+ const r = renderScreen("/agentcore/project/remove/runtime/0", { core });
+
+ await waitForText(r.lastFrame, `Remove runtime '${RUNTIME}' from project orders?`);
+ await r.write("y");
+ await waitForText(r.lastFrame, "Resource removed");
+ await r.press("return");
+
+ // Back on the resource-type selector, refreshed off disk: policy remains,
+ // the just-removed runtime is gone.
+ await waitFor(() => {
+ const frame = r.lastFrame() ?? "";
+ return (
+ frame.includes("choose a resource to remove from project orders") &&
+ frame.includes("policy") &&
+ !frame.includes("runtime")
+ );
+ });
+ r.unmount();
+ });
+
+ test("declining leaves the resource in place", async () => {
+ const core = new TestCoreClient();
+ const { project, specPath } = await createProject(core);
+ const r = render("/agentcore/project/remove/runtime/0", core, project);
+
+ await waitForText(r.lastFrame, `Remove runtime '${RUNTIME}' from project orders?`);
+ await r.write("n");
+ await waitFor(() => !(r.lastFrame() ?? "").includes(`Remove runtime '${RUNTIME}'`));
+
+ expect((await readSpec(specPath)).runtimes.map((runtime) => runtime.name)).toEqual([RUNTIME]);
+ r.unmount();
+ });
+
+ test("lists a nested resource with its parent and removes it", async () => {
+ const core = new TestCoreClient();
+ const { project, specPath } = await createProject(core, POLICY);
+ const list = render("/agentcore/project/remove/policy", core, project);
+
+ await waitForText(list.lastFrame, "choose a policy to remove");
+ const frame = list.lastFrame()!;
+ expect(frame).toContain("engine"); // parent column header
+ expect(frame).toContain("guard"); // parent value
+ expect(frame).toContain("denyAll"); // policy name
+ list.unmount();
+
+ const confirm = render("/agentcore/project/remove/policy/0", core, project);
+ await waitForText(confirm.lastFrame, "Remove policy 'denyAll' from project orders?");
+ expect(confirm.lastFrame()).toContain("guard"); // parent shown in the summary
+ await confirm.write("y");
+ await waitForText(confirm.lastFrame, "Resource removed");
+
+ expect((await readSpec(specPath)).policyEngines[0]!.policies).toEqual([]);
+ confirm.unmount();
+ });
+
+ test("the remove-all summary itemizes the resource types being removed", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core, POLICY);
+ const r = render("/agentcore/project/remove/all", core, project);
+
+ await waitForText(r.lastFrame, "Remove every resource from project orders?");
+ const frame = r.lastFrame()!;
+ expect(frame).toContain("runtime");
+ expect(frame).toContain("policy-engine");
+ expect(frame).toContain("policy");
+ r.unmount();
+ });
+
+ test("enter after remove-all refreshes the list when the project is pinned in context", async () => {
+ const core = new TestCoreClient();
+ const { project } = await createProject(core, POLICY);
+ const r = render("/agentcore/project/remove/all", core, project); // ProjectKey pinned in context
+
+ await waitForText(r.lastFrame, "Remove every resource from project orders?");
+ await r.write("y");
+ await waitForText(r.lastFrame, "All resources removed");
+ await r.press("return");
+
+ // Back on the picker, refreshed from the emptied project.
+ await waitForText(r.lastFrame, "This project has no resources to remove.");
+ r.unmount();
+ });
+
+ test("removing all empties every resource collection", async () => {
+ const core = new TestCoreClient();
+ const { project, specPath } = await createProject(core, POLICY);
+ const r = render("/agentcore/project/remove/all", core, project);
+
+ await waitForText(r.lastFrame, "Remove every resource from project orders?");
+ await r.write("y");
+ await waitForText(r.lastFrame, "All resources removed");
+
+ const spec = await readSpec(specPath);
+ expect(spec.runtimes).toEqual([]);
+ expect(spec.policyEngines).toEqual([]);
+ r.unmount();
+ });
+});
diff --git a/src/handlers/project/remove/screen.tsx b/src/handlers/project/remove/screen.tsx
new file mode 100644
index 000000000..14ebb0430
--- /dev/null
+++ b/src/handlers/project/remove/screen.tsx
@@ -0,0 +1,376 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { Text, useInput } from "ink";
+import { useRef, type ReactElement } from "react";
+import { useNavigate, useParams } from "react-router";
+import { Layout } from "../../../components/Layout";
+import { ConfirmAction, type SummaryRows } from "../../../components/ConfirmAction";
+import { DataTable, type DataTableColumn } from "../../../components/ui/data-table";
+import { ProjectKey } from "../../../router";
+import type { ProjectSpec } from "../../../projectSchemas/project";
+import type { Project, RemoveResourceInput } from "../types";
+import type { ScreenProps } from "../../types";
+import { ProjectGate, projectQueryKey } from "../ProjectGate";
+
+type RootResourceType =
+ | "runtime"
+ | "harness"
+ | "memory"
+ | "credential"
+ | "config-bundle"
+ | "online-eval"
+ | "evaluator"
+ | "gateway"
+ | "policy-engine"
+ | "payment-manager";
+
+type RemovableResourceType =
+ RootResourceType | "gateway-target" | "gateway-connector" | "policy" | "payment-connector";
+
+type RemovableResource = RemoveResourceInput & { parentName?: string };
+
+type RemovableResourcePickerConfig = {
+ resourceType: RemovableResourceType;
+ parentColumnLabel?: string;
+ listResources: (spec: ProjectSpec) => RemovableResource[];
+};
+
+function rootResourceTypePickerConfig(
+ resourceType: RootResourceType,
+ listFromSpec: (spec: ProjectSpec) => { name: string }[],
+): RemovableResourcePickerConfig {
+ return {
+ resourceType,
+ listResources: (spec) => listFromSpec(spec).map(({ name }) => ({ resourceType, name })),
+ };
+}
+
+const RESOURCE_PICKER_CONFIGS: RemovableResourcePickerConfig[] = [
+ rootResourceTypePickerConfig("runtime", (spec) => spec.runtimes),
+ rootResourceTypePickerConfig("harness", (spec) => spec.harnesses),
+ rootResourceTypePickerConfig("memory", (spec) => spec.memories),
+ rootResourceTypePickerConfig("credential", (spec) => spec.credentials),
+ rootResourceTypePickerConfig("config-bundle", (spec) => spec.configBundles),
+ rootResourceTypePickerConfig("online-eval", (spec) => spec.onlineEvalConfigs),
+ rootResourceTypePickerConfig("evaluator", (spec) => spec.evaluators),
+ rootResourceTypePickerConfig("gateway", (spec) => spec.agentCoreGateways),
+ rootResourceTypePickerConfig("policy-engine", (spec) => spec.policyEngines),
+ rootResourceTypePickerConfig("payment-manager", (spec) => spec.payments ?? []),
+ {
+ resourceType: "gateway-target",
+ parentColumnLabel: "gateway",
+ listResources: (spec) =>
+ spec.agentCoreGateways.flatMap((gateway) =>
+ gateway.targets
+ .filter((target) => target.targetType !== "connector")
+ .map((target) => ({
+ resourceType: "gateway-target",
+ gatewayName: gateway.name,
+ name: target.name,
+ parentName: gateway.name,
+ })),
+ ),
+ },
+ {
+ resourceType: "gateway-connector",
+ parentColumnLabel: "gateway",
+ listResources: (spec) =>
+ spec.agentCoreGateways.flatMap((gateway) =>
+ gateway.targets
+ .filter((target) => target.targetType === "connector")
+ .map((target) => ({
+ resourceType: "gateway-target",
+ gatewayName: gateway.name,
+ name: target.name,
+ parentName: gateway.name,
+ })),
+ ),
+ },
+ {
+ resourceType: "policy",
+ parentColumnLabel: "policy engine",
+ listResources: (spec) =>
+ spec.policyEngines.flatMap((engine) =>
+ engine.policies.map((policy) => ({
+ resourceType: "policy",
+ engineName: engine.name,
+ name: policy.name,
+ parentName: engine.name,
+ })),
+ ),
+ },
+ {
+ resourceType: "payment-connector",
+ parentColumnLabel: "payment manager",
+ listResources: (spec) =>
+ (spec.payments ?? []).flatMap((manager) =>
+ manager.connectors.map((connector) => ({
+ resourceType: "payment-connector",
+ managerName: manager.name,
+ name: connector.name,
+ parentName: manager.name,
+ })),
+ ),
+ },
+];
+
+const PROJECT_MENU = "/agentcore/project";
+const REMOVE_ROOT = "/agentcore/project/remove";
+
+// Nothing to navigate or select on an empty list or the nothing-to-remove message.
+const STATIC_KEY_HINTS = [
+ { key: "esc", label: "back" },
+ { key: "ctrl+c", label: "quit" },
+];
+
+const KEY_HINTS = [
+ { key: "↑↓/jk", label: "navigate" },
+ { key: "/", label: "filter" },
+ { key: "enter", label: "select" },
+ ...STATIC_KEY_HINTS,
+];
+
+function resourceTypeCounts(
+ spec: ProjectSpec,
+): { resourceType: RemovableResourceType; count: number }[] {
+ return RESOURCE_PICKER_CONFIGS.flatMap((config) => {
+ const count = config.listResources(spec).length;
+ return count > 0 ? [{ resourceType: config.resourceType, count }] : [];
+ });
+}
+
+export function ProjectRemoveScreen({ ctx, core }: ScreenProps) {
+ const { resourceType, resourceIndex } = useParams();
+ const navigate = useNavigate();
+
+ return (
+ navigate(PROJECT_MENU)}
+ >
+ {(project): ReactElement => {
+ if (resourceType === "all") {
+ return ;
+ }
+ const config = RESOURCE_PICKER_CONFIGS.find((c) => c.resourceType === resourceType);
+ if (!config) {
+ return ;
+ }
+ if (resourceIndex !== undefined) {
+ const resource = config.listResources(project.spec)[Number(resourceIndex)];
+ if (resource) {
+ return (
+
+ );
+ }
+ }
+ return ;
+ }}
+
+ );
+}
+
+type ResourceTypeRow = Record & { resourceType: string; count: string };
+
+const resourceTypeColumns = [
+ { key: "resourceType", header: "resource", flex: true },
+ { key: "count", header: "count", width: 8, align: "right" },
+] satisfies DataTableColumn[];
+
+function ResourceTypePicker({ project }: { project: Project }) {
+ const navigate = useNavigate();
+
+ const counts = resourceTypeCounts(project.spec);
+ const rows: ResourceTypeRow[] = counts.map(({ resourceType, count }) => ({
+ resourceType,
+ count: String(count),
+ }));
+ const total = counts.reduce((sum, { count }) => sum + count, 0);
+ if (total > 0) {
+ rows.push({ resourceType: "all", count: String(total) });
+ }
+
+ return (
+ 0 ? KEY_HINTS : STATIC_KEY_HINTS}
+ >
+ navigate(`${REMOVE_ROOT}/${row.resourceType}`)}
+ onEscape={() => navigate(PROJECT_MENU)}
+ />
+
+ );
+}
+
+type ResourceRow = Record & { index: string; name: string; parentName: string };
+
+function ResourcePicker({
+ project,
+ config,
+}: {
+ project: Project;
+ config: RemovableResourcePickerConfig;
+}) {
+ const navigate = useNavigate();
+ const rows: ResourceRow[] = config.listResources(project.spec).map((resource, index) => ({
+ index: String(index),
+ name: resource.name,
+ parentName: resource.parentName ?? "",
+ }));
+ const columns: DataTableColumn[] = config.parentColumnLabel
+ ? [
+ { key: "name", header: "name", flex: true },
+ { key: "parentName", header: config.parentColumnLabel, width: 30 },
+ ]
+ : [{ key: "name", header: "name", flex: true }];
+
+ return (
+ 0 ? KEY_HINTS : STATIC_KEY_HINTS}
+ >
+ navigate(`${REMOVE_ROOT}/${config.resourceType}/${row.index}`)}
+ onEscape={() => navigate(REMOVE_ROOT)}
+ />
+
+ );
+}
+
+function RemoveConfirm({
+ project,
+ core,
+ config,
+ resource,
+}: {
+ project: Project;
+ core: ScreenProps["core"];
+ config: RemovableResourcePickerConfig;
+ resource: RemovableResource;
+}) {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const removedProject = useRef(null);
+
+ const rows: SummaryRows = {
+ type: config.resourceType,
+ ...(resource.parentName ? { [config.parentColumnLabel ?? "parent"]: resource.parentName } : {}),
+ project: project.name,
+ };
+
+ return (
+ {
+ const result = await core.projectManager.removeResource(project, resource);
+ removedProject.current = result.project;
+ return {
+ rows: {
+ removed: `${config.resourceType} '${resource.name}'`,
+ ...(result.removedEnvKeys.length > 0
+ ? { "env removed": result.removedEnvKeys.join(", ") }
+ : {}),
+ },
+ };
+ }}
+ successTitle="Resource removed"
+ runningLabel="Removing resource…"
+ onCancel={() => navigate(`${REMOVE_ROOT}/${config.resourceType}`)}
+ onDone={() => {
+ // On the way out (not mid-action, which would drop the success panel), hand the
+ // picker the post-removal project — the seeded query won't refetch on its own.
+ if (removedProject.current) {
+ queryClient.setQueryData(projectQueryKey(), removedProject.current);
+ }
+ navigate(REMOVE_ROOT);
+ }}
+ />
+ );
+}
+
+function RemoveAllConfirm({ project, core }: { project: Project; core: ScreenProps["core"] }) {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const removedProject = useRef(null);
+
+ const counts = resourceTypeCounts(project.spec);
+ const nothingToRemove = counts.length === 0;
+ useInput(
+ (_input, key) => {
+ if (key.escape) navigate(REMOVE_ROOT);
+ },
+ { isActive: nothingToRemove },
+ );
+ if (nothingToRemove) {
+ return (
+
+ This project has no resources to remove.
+
+ );
+ }
+
+ const summary: SummaryRows = Object.fromEntries(
+ counts.map(({ resourceType, count }) => [resourceType, String(count)]),
+ );
+
+ return (
+ navigate(REMOVE_ROOT)}
+ action={async () => {
+ const result = await core.projectManager.removeAllResources(project);
+ removedProject.current = result.project;
+ return {
+ rows: {
+ removed: "all resources",
+ ...(result.removedEnvKeys.length > 0
+ ? { "env removed": result.removedEnvKeys.join(", ") }
+ : {}),
+ },
+ };
+ }}
+ successTitle="All resources removed"
+ runningLabel="Removing all resources…"
+ onDone={() => {
+ if (removedProject.current) {
+ queryClient.setQueryData(projectQueryKey(), removedProject.current);
+ }
+ navigate(REMOVE_ROOT);
+ }}
+ />
+ );
+}