From 81b0eb7cdd2dd2a9734946d521bdf36c9df1d92f Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 19 Aug 2026 17:34:07 +0000 Subject: [PATCH 1/6] feat(project): implement remove command --- src/core/project/manager.tsx | 29 ++++++ src/handlers/project/index.ts | 6 +- src/handlers/project/project.test.ts | 2 +- src/handlers/project/remove/index.test.ts | 115 ++++++++++++++++++++++ src/handlers/project/remove/index.ts | 26 ++++- src/handlers/project/remove/types.ts | 7 ++ src/handlers/project/types.ts | 8 ++ 7 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 src/handlers/project/remove/index.test.ts create mode 100644 src/handlers/project/remove/types.ts diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 4180cb03c..d1de622c7 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -9,6 +9,7 @@ import type { ProjectManager, ProjectEvent, ProjectResource, + RemoveResourceInput, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; import { @@ -199,6 +200,34 @@ export class FsProjectManager implements ProjectManager { } } + public async removeResource(project: Project, input: RemoveResourceInput): Promise { + const agentCoreSpecPath = join(project.rootPath, "agentcore", "agentcore.json"); + const projectSpecKey = toProjectSpecKey(input.resourceType); + + const existingProjectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema); + + const existingResources = existingProjectSpec[projectSpecKey]; + const newResources = existingResources.filter((r) => r.name !== input.name); + + if (newResources.length === existingResources.length) + this.logger.child({ input }).warn(`unable to find resource to delete, skipping.`); + + const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources }; + const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec); + + if (!newSpecParseResult.success) + throw new InputValidationError(z.prettifyError(newSpecParseResult.error), { + cause: newSpecParseResult.error, + }); + + const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data); + + return { + ...project, + spec: newProjectSpec, + }; + } + private async scaffoldHarness( outputPath: string, harnessSpec: z.input, diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 474ac5511..232879425 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -25,7 +25,11 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { createCreateProjectHandler({ projectManager: config.projectManager, io: config.io }), ); project.handler(createAddProjectResourceHandler(config)); - project.handler(createRemoveProjectHandler()); + project.handler( + withProject({ projectManager: config.projectManager })( + createRemoveProjectHandler({ projectManager: config.projectManager, io: config.io }), + ), + ); project.handler( withProject({ projectManager: config.projectManager })( createDevProjectHandler({ diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 8a15404c3..f7a770b21 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -25,7 +25,7 @@ async function run(args: string[], opts?: { core?: TestCoreClient }) { return { io, core }; } -describe.each(["remove", "deploy", "status"])("project %s", (command) => { +describe.each(["deploy", "status"])("project %s", (command) => { test("throws because it is not implemented yet", async () => { await expect(run([command])).rejects.toThrow(/not implemented/); }); diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts new file mode 100644 index 000000000..c3fe5cce7 --- /dev/null +++ b/src/handlers/project/remove/index.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import { InputValidationError } from "../../../errors"; + +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +async function inTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-remove-")); + tempDirectories.push(directory); + process.chdir(directory); + return process.cwd(); +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function run(args: string[]) { + const io = testIO(); + const core = new TestCoreClient(); + const root = createRootHandler(core, { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + await root.route(["node", "agentcore", "project", ...args]); + return { io, core }; +} + +async function inProject(name = "TestProject"): Promise { + const directory = await inTempDirectory(); + await run(["create", "--name", name, "--skip-install", "--skip-git"]); + const projectRoot = join(directory, name); + process.chdir(projectRoot); + return projectRoot; +} + +const RESOURCE_NAME = "test_resource"; + +type RemoveCase = { + label: string; + commands: string[][]; + specKey: string; + expectedRemaining: string[]; +}; + +describe("project remove", () => { + // Verifies that resources are removed from agentcore.json and the correct + // remaining resources are left. + test.each([ + { + label: "harness", + commands: [ + ["add", "harness", "--name", RESOURCE_NAME], + ["remove", "harness", "--name", RESOURCE_NAME], + ], + specKey: "harnesses", + expectedRemaining: [], + }, + { + label: "runtime", + commands: [["remove", "runtime", "--name", "hello_world"]], + specKey: "runtimes", + expectedRemaining: [], + }, + { + label: "removes one harness while leaving others intact", + commands: [ + ["add", "harness", "--name", "keep_me"], + ["add", "harness", "--name", "remove_me"], + ["remove", "harness", "--name", "remove_me"], + ], + specKey: "harnesses", + expectedRemaining: ["keep_me"], + }, + { + label: "removing a non-existent resource succeeds (no-op)", + commands: [["remove", "harness", "--name", "ghost"]], + specKey: "harnesses", + expectedRemaining: [], + }, + ])("$label", async ({ commands, specKey, expectedRemaining }) => { + const projectRoot = await inProject(); + + for (const cmd of commands) { + await run(cmd); + } + + const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + const remaining = (agentcoreJson[specKey] ?? []) as { name: string }[]; + expect(remaining.map((r) => r.name)).toEqual(expectedRemaining); + }); + + // Verifies that missing required inputs are rejected before calling the manager. + test.each<[string, string[]]>([ + ["missing resource argument", ["remove", "--name", "x"]], + ["missing --name flag", ["remove", "harness"]], + ])("%s", async (_label, args) => { + await inProject(); + await expect(run(args)).rejects.toBeInstanceOf(InputValidationError); + }); +}); diff --git a/src/handlers/project/remove/index.ts b/src/handlers/project/remove/index.ts index 548203e06..c09d262f6 100644 --- a/src/handlers/project/remove/index.ts +++ b/src/handlers/project/remove/index.ts @@ -1,11 +1,27 @@ -import { createHandler } from "../../../router"; -import { NotImplementedError } from "../../../errors"; +import { argument, createHandler, flag, ProjectKey } from "../../../router"; +import { InputValidationError } from "../../../errors"; +import z from "zod"; +import type { RemoveProjectResourceConfig } from "./types"; -export const createRemoveProjectHandler = () => +export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) => createHandler({ name: "remove", description: "remove a resource from the project", - handle: async () => { - throw new NotImplementedError("agentcore project remove is not implemented yet"); + flags: [flag("name", "name of the resource to remove", z.string().min(1).optional())], + arguments: [ + argument("resource", "type of resource to remove", z.enum(["harness", "runtime"]).optional()), + ], + handle: async (ctx, flags, args) => { + const resource = args["resource"]; + const name = flags["name"]; + if (!resource) throw new InputValidationError(`resource argument is required to remove`); + if (!name) throw new InputValidationError(`--name is required option`); + + await config.projectManager.removeResource(ctx.require(ProjectKey), { + resourceType: resource, + name, + }); + + config.io.stdout.write(`removed ${resource} with name '${name}' from project`); }, }); diff --git a/src/handlers/project/remove/types.ts b/src/handlers/project/remove/types.ts new file mode 100644 index 000000000..5e6a01ad6 --- /dev/null +++ b/src/handlers/project/remove/types.ts @@ -0,0 +1,7 @@ +import type { AppIO } from "../../../io"; +import type { ProjectManager } from "../types"; + +export type RemoveProjectResourceConfig = { + projectManager: ProjectManager; + io: AppIO; +}; diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index c187abb96..26b3671dd 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -53,6 +53,11 @@ export type AddResourceInput = export type ProjectResource = AddResourceInput["resourceType"]; +export type RemoveResourceInput = { + resourceType: ProjectResource; + name: string; +}; + /** * The primary interface for interacting with projects */ @@ -68,4 +73,7 @@ export interface ProjectManager { /** Add a resource to an existing AgentCore project. */ addResource(project: Project, input: AddResourceInput): AsyncGenerator; + + /** Remove a resource from an existing AgentCore project. */ + removeResource(project: Project, input: RemoveResourceInput): Promise; } From 3edf5fbeacb11d147dd681f45d0377cf02d16e26 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 19 Aug 2026 17:41:37 +0000 Subject: [PATCH 2/6] refactor(project): pull out spec path into common helper --- src/core/project/manager.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index d1de622c7..212f5bcbc 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -128,7 +128,7 @@ export class FsProjectManager implements ProjectManager { input: AddResourceInput, ): AsyncGenerator { const { resourceType, resourceConfig } = input; - const agentCoreSpecPath = join(project.rootPath, "agentcore", "agentcore.json"); + const agentCoreSpecPath = this.getProjectSpecPath(project); const projectSpecKey = toProjectSpecKey(resourceType); yield { message: `Reading project spec file at '${agentCoreSpecPath}'` }; @@ -200,8 +200,12 @@ export class FsProjectManager implements ProjectManager { } } + private getProjectSpecPath(project: Project): string { + return join(project.rootPath, "agentcore", "agentcore.json"); + } + public async removeResource(project: Project, input: RemoveResourceInput): Promise { - const agentCoreSpecPath = join(project.rootPath, "agentcore", "agentcore.json"); + const agentCoreSpecPath = this.getProjectSpecPath(project); const projectSpecKey = toProjectSpecKey(input.resourceType); const existingProjectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema); @@ -210,10 +214,14 @@ export class FsProjectManager implements ProjectManager { const newResources = existingResources.filter((r) => r.name !== input.name); if (newResources.length === existingResources.length) - this.logger.child({ input }).warn(`unable to find resource to delete, skipping.`); + this.logger + .child({ input }) + .warn(`unable to remove resource from project that does not exist.`); - const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources }; - const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec); + const newSpecParseResult = ProjectSpecSchema.safeParse({ + ...existingProjectSpec, + [projectSpecKey]: newResources, + }); if (!newSpecParseResult.success) throw new InputValidationError(z.prettifyError(newSpecParseResult.error), { From baad679b2fd871f839d4752b7cc8b38699569dbc Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 19 Aug 2026 17:45:00 +0000 Subject: [PATCH 3/6] refactor(test): simplify tests with inline resource name --- src/handlers/project/remove/index.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index c3fe5cce7..8437cdd96 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -48,8 +48,6 @@ async function inProject(name = "TestProject"): Promise { return projectRoot; } -const RESOURCE_NAME = "test_resource"; - type RemoveCase = { label: string; commands: string[][]; @@ -64,8 +62,8 @@ describe("project remove", () => { { label: "harness", commands: [ - ["add", "harness", "--name", RESOURCE_NAME], - ["remove", "harness", "--name", RESOURCE_NAME], + ["add", "harness", "--name", "my_harness"], + ["remove", "harness", "--name", "my_harness"], ], specKey: "harnesses", expectedRemaining: [], From 9381e8a8f082486505ed4fb1dfdaf661ff877e85 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 19 Aug 2026 17:46:49 +0000 Subject: [PATCH 4/6] refactor(remove): inline the remove resource config --- src/handlers/project/remove/index.ts | 8 +++++++- src/handlers/project/remove/types.ts | 7 ------- 2 files changed, 7 insertions(+), 8 deletions(-) delete mode 100644 src/handlers/project/remove/types.ts diff --git a/src/handlers/project/remove/index.ts b/src/handlers/project/remove/index.ts index c09d262f6..c64739398 100644 --- a/src/handlers/project/remove/index.ts +++ b/src/handlers/project/remove/index.ts @@ -1,7 +1,13 @@ import { argument, createHandler, flag, ProjectKey } from "../../../router"; import { InputValidationError } from "../../../errors"; import z from "zod"; -import type { RemoveProjectResourceConfig } from "./types"; +import type { AppIO } from "../../../io"; +import type { ProjectManager } from "../types"; + +type RemoveProjectResourceConfig = { + projectManager: ProjectManager; + io: AppIO; +}; export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) => createHandler({ diff --git a/src/handlers/project/remove/types.ts b/src/handlers/project/remove/types.ts deleted file mode 100644 index 5e6a01ad6..000000000 --- a/src/handlers/project/remove/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { AppIO } from "../../../io"; -import type { ProjectManager } from "../types"; - -export type RemoveProjectResourceConfig = { - projectManager: ProjectManager; - io: AppIO; -}; From 77f7c0dcb8233b8696f138cbcaf02fd020bc7561 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 19 Aug 2026 19:46:19 +0000 Subject: [PATCH 5/6] fix(project): remove scaffolded files on remove --- src/core/project/manager.tsx | 18 ++++++++++++++++++ src/handlers/project/remove/index.test.ts | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 212f5bcbc..593284be0 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -230,6 +230,24 @@ export class FsProjectManager implements ProjectManager { const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data); + try { + switch (input.resourceType) { + case "runtime": + case "harness": { + const outputPath = join(project.rootPath, "app", input.name); + await rm(outputPath, { recursive: true, force: true }); + break; + } + default: + break; + } + } catch (e) { + throw new ProjectStateError( + `unable to clean up scaffolded files for ${input.resourceType} with name ${input.name}.`, + { cause: e }, + ); + } + return { ...project, spec: newProjectSpec, diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index 8437cdd96..ca6265d5a 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -53,6 +54,7 @@ type RemoveCase = { commands: string[][]; specKey: string; expectedRemaining: string[]; + deletedDirs: string[]; }; describe("project remove", () => { @@ -67,12 +69,14 @@ describe("project remove", () => { ], specKey: "harnesses", expectedRemaining: [], + deletedDirs: ["app/my_harness"], }, { label: "runtime", commands: [["remove", "runtime", "--name", "hello_world"]], specKey: "runtimes", expectedRemaining: [], + deletedDirs: ["app/hello_world"], }, { label: "removes one harness while leaving others intact", @@ -83,14 +87,16 @@ describe("project remove", () => { ], specKey: "harnesses", expectedRemaining: ["keep_me"], + deletedDirs: ["app/remove_me"], }, { label: "removing a non-existent resource succeeds (no-op)", commands: [["remove", "harness", "--name", "ghost"]], specKey: "harnesses", expectedRemaining: [], + deletedDirs: [], }, - ])("$label", async ({ commands, specKey, expectedRemaining }) => { + ])("$label", async ({ commands, specKey, expectedRemaining, deletedDirs }) => { const projectRoot = await inProject(); for (const cmd of commands) { @@ -100,6 +106,10 @@ describe("project remove", () => { const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); const remaining = (agentcoreJson[specKey] ?? []) as { name: string }[]; expect(remaining.map((r) => r.name)).toEqual(expectedRemaining); + + for (const dir of deletedDirs) { + expect(existsSync(join(projectRoot, dir))).toBe(false); + } }); // Verifies that missing required inputs are rejected before calling the manager. From c0c28212174d5eb8976f067733eb45f970b3ead1 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 19 Aug 2026 21:30:13 +0000 Subject: [PATCH 6/6] Revert "fix(project): remove scaffolded files on remove" This reverts commit 77f7c0dcb8233b8696f138cbcaf02fd020bc7561. --- src/core/project/manager.tsx | 18 ------------------ src/handlers/project/remove/index.test.ts | 12 +----------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 593284be0..212f5bcbc 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -230,24 +230,6 @@ export class FsProjectManager implements ProjectManager { const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data); - try { - switch (input.resourceType) { - case "runtime": - case "harness": { - const outputPath = join(project.rootPath, "app", input.name); - await rm(outputPath, { recursive: true, force: true }); - break; - } - default: - break; - } - } catch (e) { - throw new ProjectStateError( - `unable to clean up scaffolded files for ${input.resourceType} with name ${input.name}.`, - { cause: e }, - ); - } - return { ...project, spec: newProjectSpec, diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index ca6265d5a..8437cdd96 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -54,7 +53,6 @@ type RemoveCase = { commands: string[][]; specKey: string; expectedRemaining: string[]; - deletedDirs: string[]; }; describe("project remove", () => { @@ -69,14 +67,12 @@ describe("project remove", () => { ], specKey: "harnesses", expectedRemaining: [], - deletedDirs: ["app/my_harness"], }, { label: "runtime", commands: [["remove", "runtime", "--name", "hello_world"]], specKey: "runtimes", expectedRemaining: [], - deletedDirs: ["app/hello_world"], }, { label: "removes one harness while leaving others intact", @@ -87,16 +83,14 @@ describe("project remove", () => { ], specKey: "harnesses", expectedRemaining: ["keep_me"], - deletedDirs: ["app/remove_me"], }, { label: "removing a non-existent resource succeeds (no-op)", commands: [["remove", "harness", "--name", "ghost"]], specKey: "harnesses", expectedRemaining: [], - deletedDirs: [], }, - ])("$label", async ({ commands, specKey, expectedRemaining, deletedDirs }) => { + ])("$label", async ({ commands, specKey, expectedRemaining }) => { const projectRoot = await inProject(); for (const cmd of commands) { @@ -106,10 +100,6 @@ describe("project remove", () => { const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); const remaining = (agentcoreJson[specKey] ?? []) as { name: string }[]; expect(remaining.map((r) => r.name)).toEqual(expectedRemaining); - - for (const dir of deletedDirs) { - expect(existsSync(join(projectRoot, dir))).toBe(false); - } }); // Verifies that missing required inputs are rejected before calling the manager.