From 2b317f0c081d385adaa036731abb99b9fe99d7c6 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 15 Sep 2026 19:56:59 +0000 Subject: [PATCH 1/3] feat(project): add interactive remove TUI screen --- .../project/remove/remove.screen.test.tsx | 443 ++++++++++++++++++ src/handlers/project/remove/screen.tsx | 376 +++++++++++++++ 2 files changed, 819 insertions(+) create mode 100644 src/handlers/project/remove/remove.screen.test.tsx create mode 100644 src/handlers/project/remove/screen.tsx 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..1e9d409f6 --- /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); + }} + /> + ); +} From b0894f822882874d66c194ad15dab3477569c0d6 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 15 Sep 2026 19:56:59 +0000 Subject: [PATCH 2/3] feat(project): wire remove into the project TUI --- src/components/Root.tsx | 13 +++++++++++++ src/handlers/project/index.ts | 12 +++++++++--- src/handlers/project/project.screen.test.tsx | 2 +- src/handlers/project/remove/index.ts | 4 +++- 4 files changed, 26 insertions(+), 5 deletions(-) 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()), From f98d65b2dce88d56f5097aef35965c155e2b9462 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 15 Sep 2026 20:05:05 +0000 Subject: [PATCH 3/3] refactor(remove): make customer facing messages more concise --- src/handlers/project/remove/screen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/handlers/project/remove/screen.tsx b/src/handlers/project/remove/screen.tsx index 1e9d409f6..14ebb0430 100644 --- a/src/handlers/project/remove/screen.tsx +++ b/src/handlers/project/remove/screen.tsx @@ -283,7 +283,7 @@ function RemoveConfirm({ rows={rows} trigger={{ kind: "confirm", - message: `Remove ${config.resourceType} '${resource.name}' from project ${project.name}? This edits agentcore.json; deployed infrastructure is untouched until you deploy.`, + message: `Remove ${config.resourceType} '${resource.name}' from project ${project.name}?`, }} isPending={false} error={null} @@ -346,7 +346,7 @@ function RemoveAllConfirm({ project, core }: { project: Project; core: ScreenPro rows={summary} trigger={{ kind: "confirm", - message: `Remove every resource from project ${project.name}? This empties each resource collection in agentcore.json; code under app/ is kept.`, + message: `Remove every resource from project ${project.name}?`, }} isPending={false} error={null}