diff --git a/src/core/project/__snapshots__/manager.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap index 357f70497..0c6f46b2d 100644 --- a/src/core/project/__snapshots__/manager.test.ts.snap +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -17,8 +17,8 @@ exports[`FsProjectManager.create scaffolds the expected file tree into a fresh d "agentcore/cdk/package.json", "agentcore/cdk/test/cdk.test.ts", "agentcore/cdk/tsconfig.json", - "app/hello-world/README.md", - "app/hello-world/main.py", - "app/hello-world/pyproject.toml", + "app/hello_world/README.md", + "app/hello_world/main.py", + "app/hello_world/pyproject.toml", ] `; diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index ffbcbbe3b..955e88add 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -7,7 +7,7 @@ import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets"; import { ProjectSpecSchema } from "../../projectSchemas/project"; import { FsProjectManager } from "./manager"; import { - PROJECT_TEMPLATES, + RUNTIME_TEMPLATE_SHORTCUTS, type CreateProjectInput, type DeployResult, type Project, @@ -16,6 +16,9 @@ import { import { createSilentLogger } from "../../testing"; import type { DeployBackendInput, ProjectBackend } from "./backends/types"; +const HELLO_WORLD_PYTHON = RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python"]; +const HELLO_WORLD_PYTHON_CONTAINER = RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python-container"]; + const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -71,7 +74,7 @@ describe("FsProjectManager.create", () => { const directory = await inTempDirectory(); await runCreate(manager().manager, { name: "example", - template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, }); const projectRoot = join(directory, "example"); @@ -89,7 +92,7 @@ describe("FsProjectManager.create", () => { const directory = await inTempDirectory(); await runCreate(manager().manager, { name: "example", - template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, }); const configDir = join(directory, "example", "agentcore"); @@ -100,7 +103,7 @@ describe("FsProjectManager.create", () => { name: "hello_world", build: "CodeZip", entrypoint: "main.py", - codeLocation: "app/hello-world", + codeLocation: "app/hello_world", runtimeVersion: "PYTHON_3_14", }, ]); @@ -111,10 +114,10 @@ describe("FsProjectManager.create", () => { const directory = await inTempDirectory(); await runCreate(manager().manager, { name: "example", - template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON_CONTAINER, + scaffoldRuntimeInput: HELLO_WORLD_PYTHON_CONTAINER, }); - const appDir = join(directory, "example", "app", "hello-world"); + const appDir = join(directory, "example", "app", "hello_world"); // dockerignore.template must render to .dockerignore (the fsTree regex fix). expect(await Bun.file(join(appDir, ".dockerignore")).exists()).toBe(true); expect(await Bun.file(join(appDir, "dockerignore.template")).exists()).toBe(false); @@ -126,7 +129,10 @@ describe("FsProjectManager.create", () => { test("refuses to overwrite an existing project", async () => { await inTempDirectory(); - const input = { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }; + const input: CreateProjectInput = { + name: "example", + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, + }; await runCreate(manager().manager, input); await expect(runCreate(manager().manager, input)).rejects.toBeInstanceOf(ProjectStateError); @@ -135,12 +141,15 @@ describe("FsProjectManager.create", () => { test("runs npm install, uv sync, and git init after scaffolding", async () => { const directory = await inTempDirectory(); const { manager: subject, commands } = manager(); - await runCreate(subject, { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + await runCreate(subject, { + name: "example", + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, + }); const projectRoot = join(directory, "example"); expect(commands).toEqual([ { command: ["npm", "install"], cwd: join(projectRoot, "agentcore", "cdk") }, - { command: ["uv", "sync"], cwd: join(projectRoot, "app", "hello-world") }, + { command: ["uv", "sync"], cwd: join(projectRoot, "app", "hello_world") }, { command: ["git", "init"], cwd: projectRoot }, ]); }); @@ -150,7 +159,7 @@ describe("FsProjectManager.create", () => { const { manager: subject, commands } = manager(); await runCreate(subject, { name: "example", - template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, skipInstall: true, }); @@ -162,7 +171,7 @@ describe("FsProjectManager.create", () => { const { manager: subject, commands } = manager(); await runCreate(subject, { name: "example", - template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, skipGit: true, }); @@ -173,7 +182,7 @@ describe("FsProjectManager.create", () => { await inTempDirectory(); const { events, project } = await runCreate(manager().manager, { name: "example", - template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, }); expect(events.map((event) => event.message)).toEqual([ @@ -198,7 +207,7 @@ describe("FsProjectManager.create", () => { }); await expect( - runCreate(failing, { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }), + runCreate(failing, { name: "example", scaffoldRuntimeInput: HELLO_WORLD_PYTHON }), ).rejects.toThrow("npm exploded"); expect(await Bun.file(join(directory, "example", "agentcore", "agentcore.json")).exists()).toBe( true, @@ -209,14 +218,14 @@ describe("FsProjectManager.create", () => { const directory = await inTempDirectory(); await runCreate(manager().manager, { name: "root", - template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, }); process.chdir(join(directory, "root")); await expect( runCreate(manager().manager, { name: "child", - template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, }), ).rejects.toBeInstanceOf(ProjectStateError); }); @@ -232,7 +241,7 @@ describe("FsProjectManager.build", () => { ): Promise { const { project } = await runCreate(subject, { name: "example", - template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, skipInstall: true, skipGit: true, }); @@ -456,7 +465,10 @@ describe("FsProjectManager.resolve", () => { test("round-trips a project it just created", async () => { const root = await inTempDirectory(); const subject = manager().manager; - await runCreate(subject, { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + await runCreate(subject, { + name: "example", + scaffoldRuntimeInput: HELLO_WORLD_PYTHON, + }); // Resolve from a nested path to prove the walk-up to the project root. const resolved = await subject.resolve({ filePath: join(root, "example", "app") }); @@ -496,7 +508,7 @@ describe("FsProjectManager.resolve", () => { name: "hello_world", build: "CodeZip", entrypoint: "main.py", - codeLocation: "app/hello-world", + codeLocation: "app/hello_world", }, ], }), diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index ef8176da4..83cbff2c0 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -23,7 +23,7 @@ import { } from "../../io"; import { defaultSource, type AssetSource } from "./source"; import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal"; -import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates"; +import { createHarnessTreeFromSpec, createProjectTree } from "./templates"; import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project"; import { enclosingProjectRoot } from "./fsUtils"; import { @@ -98,11 +98,11 @@ export class FsProjectManager implements ProjectManager { ); } + const scaffoldRuntimeInput = input.scaffoldRuntimeInput; const destination = join(process.cwd(), input.name); - this.logger.debug(`scaffolding project "${input.name}" from template "${input.template}"`); yield { message: "Creating project tree" }; - const tree = await createProjectTreeFromTemplate(input.name, input.template, this.source); + const tree = await createProjectTree(input.name, scaffoldRuntimeInput, this.source); await tree.write(destination); // A failed step leaves the scaffolded files in place; the error tells the @@ -112,7 +112,7 @@ export class FsProjectManager implements ProjectManager { yield { message: "Installing CDK dependencies with npm" }; await this.run(["npm", "install"], join(destination, "agentcore", "cdk")); - const appDir = join(destination, "app", TEMPLATES[input.template].appDir); + const appDir = join(destination, "app", scaffoldRuntimeInput.runtimeName); if (existsSync(join(appDir, "pyproject.toml"))) { await this.checkTool( "uv", diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index 661b2db5e..8630a1d99 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -1,9 +1,12 @@ import { ZodError, z } from "zod"; -import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types"; import { HarnessSpecSchema } from "../../projectSchemas/harness"; import { FsTreeNode } from "./fsTree"; import type { AssetSource } from "./source"; import { InputValidationError } from "../../errors/errors"; +import { + RUNTIME_TEMPLATE_SHORTCUTS, + type ScaffoldRuntimeInput, +} from "../../handlers/project/types"; type TemplateSpec = { runtimes?: unknown[]; @@ -16,17 +19,14 @@ type TemplateSpec = { * sections it registers in agentcore.json. Adding a template is one entry here plus its assets. */ type Template = { - /** Directory under app/ the template code is written to. */ - appDir: string; /** Asset directory relative to the asset root, expanded into the app directory. */ assetDir: string; /** Resource sections this template contributes to agentcore.json. */ spec: TemplateSpec; }; -export const TEMPLATES: Record = { - [PROJECT_TEMPLATES.HELLO_WORLD_PYTHON]: { - appDir: "hello-world", +const TEMPLATES: Record = { + [buildRuntimeTemplateKey(RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python"])]: { assetDir: "templates/hello-world-python", spec: { runtimes: [ @@ -34,7 +34,7 @@ export const TEMPLATES: Record = { name: "hello_world", build: "CodeZip", entrypoint: "main.py", - codeLocation: "app/hello-world", + codeLocation: "app/hello_world", // Required for CodeZip builds: the CDK construct library rejects a // CodeZip runtime with no runtimeVersion, and it is what selects the // packager. Container builds take their version from the image. @@ -43,8 +43,7 @@ export const TEMPLATES: Record = { ], }, }, - [PROJECT_TEMPLATES.HELLO_WORLD_PYTHON_CONTAINER]: { - appDir: "hello-world", + [buildRuntimeTemplateKey(RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python-container"])]: { assetDir: "templates/hello-world-python-container", spec: { runtimes: [ @@ -52,7 +51,7 @@ export const TEMPLATES: Record = { name: "hello_world", build: "Container", entrypoint: "main.py", - codeLocation: "app/hello-world", + codeLocation: "app/hello_world", dockerfile: "Dockerfile", }, ], @@ -60,6 +59,14 @@ export const TEMPLATES: Record = { }, }; +function buildRuntimeTemplateKey(input: ScaffoldRuntimeInput): string { + return `runtime_${input.build}_${input.framework}_${input.language}_${input.memory}_${input.modelProvider}`; +} + +function resolveTemplate(input: ScaffoldRuntimeInput): Template | undefined { + return TEMPLATES[buildRuntimeTemplateKey(input)]; +} + /** Serializes a value as pretty-printed JSON with a trailing newline. */ const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; @@ -67,21 +74,23 @@ const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; * Builds the agentcore.json spec by adding the template's resource sections to the shared base. * The base fields and template sections never overlap so this is a plain spread. */ -function agentcoreSpec(name: string, template: ProjectTemplate): unknown { +function agentcoreSpec(name: string, template: Template): unknown { return { name, version: 1, managedBy: "CDK", - ...TEMPLATES[template].spec, + ...template.spec, }; } -export async function createProjectTreeFromTemplate( +export async function createProjectTree( name: string, - template: ProjectTemplate, + input: ScaffoldRuntimeInput, src: AssetSource, ): Promise { - const { appDir, assetDir } = TEMPLATES[template]; + const template = resolveTemplate(input); + if (!template) + throw new InputValidationError(`unable to find template that matches given parameters`); return FsTreeNode.createDirectory(".", [ FsTreeNode.createFile(".gitignore", () => src.read("templates/shared/gitignore.template")), FsTreeNode.createDirectory("agentcore", [ @@ -90,7 +99,10 @@ export async function createProjectTreeFromTemplate( FsTreeNode.createFile("aws-targets.json", async () => json([])), FsTreeNode.createFile(".env.local", () => src.read("templates/shared/env.local.template")), ]), - FsTreeNode.createDirectory("app", [await FsTreeNode.fromAssetSource(src, assetDir, appDir)]), + FsTreeNode.createDirectory("app", [ + // TODO: replace this hardcoded "hello_world" with the runtime name once templates are more flexible. + await FsTreeNode.fromAssetSource(src, template.assetDir, "hello_world"), + ]), ]); } diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index e59297533..9028917ef 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -51,29 +51,39 @@ async function inProject(name = "TestProject"): Promise { // TODO: Replace NotImplementedError assertions with output assertions once // FsProjectManager.addResource supports the "runtime" resource type. describe("project add runtime", () => { - const byo = ["--code-location", "app/my_agent"]; const template = ["--template", "hello-world-python"]; + const allScaffoldingFlags = [ + "--build", + "CodeZip", + "--language", + "Python", + "--framework", + "none", + "--model-provider", + "Bedrock", + "--memory", + "none", + ]; + test.each<[string, string[]]>([ - ["minimal — name only (defaults to template)", ["--name", "my_agent"]], - ["explicit template path", ["--name", "my_agent", ...template]], - ["minimal — BYO path with build", ["--name", "my_agent", ...byo, "--build", "CodeZip"]], - [ - "BYO container with dockerfile", - ["--name", "my_agent", ...byo, "--build", "Container", "--dockerfile", "Dockerfile"], - ], + ["template preset", ["--name", "my_agent", ...template]], + ["custom — all scaffolding flags", ["--name", "my_agent", ...allScaffoldingFlags]], [ - "entrypoint + runtime-version for CodeZip", + "custom — container build", [ "--name", "my_agent", - ...byo, "--build", - "CodeZip", - "--entrypoint", - "app.py:main", - "--runtime-version", - "PYTHON_3_13", + "Container", + "--language", + "Python", + "--framework", + "none", + "--model-provider", + "Bedrock", + "--memory", + "none", ], ], ["description", ["--name", "my_agent", ...template, "--description", "A test agent"]], @@ -171,48 +181,6 @@ describe("project add runtime", () => { ], ], ["tags", ["--name", "my_agent", ...template, "--tags", '{"team":"ml","env":"prod"}']], - [ - "dockerfile + build-context-path", - [ - "--name", - "my_agent", - ...byo, - "--build", - "Container", - "--dockerfile", - "docker/Dockerfile.gpu", - "--build-context-path", - ".", - ], - ], - [ - "custom-docker-build-args with dockerfile", - [ - "--name", - "my_agent", - ...byo, - "--build", - "Container", - "--dockerfile", - "Dockerfile", - "--custom-docker-build-args", - '{"AGENT_NAME":"my_agent","VERSION":"1.0"}', - ], - ], - [ - "custom-docker-build-args with build-context-path", - [ - "--name", - "my_agent", - ...byo, - "--build", - "Container", - "--build-context-path", - ".", - "--custom-docker-build-args", - '{"AGENT_NAME":"my_agent"}', - ], - ], [ "additional-policies", [ @@ -223,49 +191,6 @@ describe("project add runtime", () => { "arn:aws:iam::123456789012:policy/MyPolicy", ], ], - ["protocol shortcut", ["--name", "my_agent", ...template, "--protocol", "MCP"]], - [ - "memory — create with strategies", - [ - "--name", - "my_agent", - ...template, - "--memory", - '{"mode":"create","strategies":["SEMANTIC","EPISODIC"]}', - ], - ], - [ - "memory — existing by ARN", - [ - "--name", - "my_agent", - ...template, - "--memory", - '{"mode":"existing","arn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/MyMem"}', - ], - ], - ["memory — disabled", ["--name", "my_agent", ...template, "--memory", '{"mode":"disabled"}']], - ["model-provider — openai", ["--name", "my_agent", ...template, "--model-provider", "openai"]], - [ - "build on template path (overlay)", - ["--name", "my_agent", ...template, "--build", "Container"], - ], - [ - "network-config with vpcId", - [ - "--name", - "my_agent", - ...byo, - "--build", - "Container", - "--dockerfile", - "Dockerfile", - "--network-mode", - "VPC", - "--network-config", - '{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"],"vpcId":"vpc-0123456789abcdef0"}', - ], - ], ])("%s — accepts flags", async (_label, flags) => { await inProject(); await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(NotImplementedError); @@ -274,64 +199,43 @@ describe("project add runtime", () => { test.each<[string, string[]]>([ ["missing --name", ["--template", "hello-world-python"]], [ - "--template and --code-location are mutually exclusive", - ["--name", "my_agent", "--template", "hello-world-python", "--code-location", "app/agent"], - ], - [ - "--custom-docker-build-args requires --dockerfile or --build-context-path", + "missing --build without --template", [ "--name", "my_agent", - ...byo, - "--build", - "Container", - "--custom-docker-build-args", - '{"KEY":"value"}', + "--language", + "Python", + "--framework", + "none", + "--model-provider", + "Bedrock", + "--memory", + "none", ], ], [ - "invalid JSON in --network-config", - ["--name", "my_agent", ...template, "--network-config", "{bad}"], + "--template and --build are mutually exclusive", + ["--name", "my_agent", "--template", "hello-world-python", "--build", "Container"], ], [ - "--entrypoint is only available on BYO path", - ["--name", "my_agent", ...template, "--entrypoint", "main.py"], + "--template and --language are mutually exclusive", + ["--name", "my_agent", "--template", "hello-world-python", "--language", "Python"], ], [ - "--runtime-version is only available on BYO path", - ["--name", "my_agent", ...template, "--runtime-version", "PYTHON_3_13"], + "--template and --framework are mutually exclusive", + ["--name", "my_agent", "--template", "hello-world-python", "--framework", "none"], ], [ - "--dockerfile is only available on BYO path", - ["--name", "my_agent", ...template, "--dockerfile", "Dockerfile"], + "--template and --model-provider are mutually exclusive", + ["--name", "my_agent", "--template", "hello-world-python", "--model-provider", "Bedrock"], ], [ - "--build-context-path is only available on BYO path", - ["--name", "my_agent", ...template, "--build-context-path", "."], + "--template and --memory are mutually exclusive", + ["--name", "my_agent", "--template", "hello-world-python", "--memory", "none"], ], [ - "--custom-docker-build-args is only available on BYO path", - ["--name", "my_agent", ...template, "--custom-docker-build-args", '{"KEY":"val"}'], - ], - [ - "--memory is only available on template path", - ["--name", "my_agent", ...byo, "--memory", '{"mode":"disabled"}'], - ], - [ - "--model-provider is only available on template path", - ["--name", "my_agent", ...byo, "--model-provider", "openai"], - ], - [ - "--api-key is only available on template path", - ["--name", "my_agent", ...byo, "--api-key", "-"], - ], - [ - "--api-key rejects an inline secret value", - ["--name", "my_agent", ...template, "--api-key", "sk-inline"], - ], - [ - "invalid memory JSON schema", - ["--name", "my_agent", ...template, "--memory", '{"mode":"invalid"}'], + "invalid JSON in --network-config", + ["--name", "my_agent", ...template, "--network-config", "{bad}"], ], ])("%s", async (_label, flags) => { await inProject(); diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 4bdaacd6d..322fe5c60 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -5,66 +5,53 @@ import { parseJsonFlag, parseTags } from "../../../utils"; import { InputValidationError } from "../../../../errors"; import { type EnvVar, BuildTypeSchema } from "../../../../projectSchemas/runtime"; import { RuntimeAuthorizerTypeSchema } from "../../../../projectSchemas/auth"; -import { - NetworkModeSchema, - ProtocolModeSchema, - RuntimeVersionSchema, -} from "../../../../projectSchemas/constants"; +import { NetworkModeSchema, ProtocolModeSchema } from "../../../../projectSchemas/constants"; import { SourceResolver } from "../../../../io"; -import { RUNTIME_TEMPLATES } from "../../types"; import { - runtimeMemoryConfigSchema, - runtimeModelProviderSchema, - RuntimeResourceConfigSchema, -} from "./types"; + RUNTIME_TEMPLATE_SHORTCUT_NAMES, + RUNTIME_TEMPLATE_SHORTCUTS, + ScaffoldRuntimeInputSchema, +} from "../../types"; +import { RuntimeResourceConfigSchema } from "./types"; export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => createHandler({ name: "runtime", - description: - "adds a runtime to the current project either from a template or from existing local code", + description: "adds a runtime to the current project", flags: [ flag("name", "the name of the runtime", z.string().optional()), flag("description", "an optional description of the runtime", z.string().optional()), - flag("template", "template to scaffold from", z.enum(RUNTIME_TEMPLATES).optional()), flag( - "role-arn", - "IAM role ARN that provides permissions for the runtime", - z.string().optional(), + "template", + "a preset of flags to be leveraged in scaffolding the runtime. mutually exclusive with all runtime scaffolding flags", + z.enum(RUNTIME_TEMPLATE_SHORTCUT_NAMES).optional(), ), - flag("code-location", "path to existing agent source code (BYO path)", z.string().optional()), flag("build", "build type: CodeZip or Container", BuildTypeSchema.optional()), - flag("entrypoint", "entrypoint file, e.g. main.py:handler (BYO only)", z.string().optional()), - flag("protocol", "server protocol: HTTP, MCP, A2A, AGUI", ProtocolModeSchema.optional()), flag( - "api-key", - "API key source for non-bedrock model providers: '-' for stdin, 'file://path' for file", - z.string().optional(), - { sensitive: true }, - ), - flag( - "model-provider", - "model provider (template only)", - runtimeModelProviderSchema.optional(), + "language", + "target language for the scaffolded runtime code", + z.enum(["Python"]).optional(), ), flag( - "runtime-version", - "language runtime, e.g. PYTHON_3_13, NODE_22 (BYO CodeZip only)", - RuntimeVersionSchema.optional(), + "framework", + "agent framework for the scaffolded runtime code", + z.enum(["none"]).optional(), ), flag( - "dockerfile", - "dockerfile path for the container build (BYO Container only)", - z.string().optional(), + "model-provider", + "model provider for the scaffolded runtime code", + z.enum(["Bedrock"]).optional(), ), flag( - "build-context-path", - "docker build context directory relative to project root (BYO Container only)", + "api-key", + "API key for non-Bedrock providers: '-' for stdin, 'file://path' for file", z.string().optional(), + { sensitive: true }, ), + flag("memory", "memory option for the scaffolded runtime", z.enum(["none"]).optional()), flag( - "custom-docker-build-args", - "docker build args as JSON key/value object (BYO Container only)", + "role-arn", + "IAM role ARN that provides permissions for the runtime", z.string().optional(), ), flag( @@ -72,6 +59,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "additional IAM policy ARNs or policy document paths for the execution role", z.array(z.string()).optional(), ), + flag("protocol", "server protocol: HTTP, MCP, A2A, AGUI", ProtocolModeSchema.optional()), flag( "network-mode", "network mode for the runtime environment (PUBLIC or VPC)", @@ -104,61 +92,53 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "filesystem mount configurations (JSON)", z.string().optional(), ), - flag( - "memory", - "memory configuration (JSON with mode: disabled | create | existing) (template only)", - z.string().optional(), - ), flag("tags", "tags as key=value (repeatable) or JSON object", z.array(z.string()).optional()), ], handle: async (ctx, flags) => { if (!flags.name) throw new InputValidationError("required option '--name ' not specified"); - if (flags.template && flags["code-location"]) - throw new InputValidationError("--template and --code-location are mutually exclusive"); - - const isTemplate = !flags["code-location"]; - const template = flags.template ?? RUNTIME_TEMPLATES.HELLO_WORLD_PYTHON; - const templateOnlyFlags = (["memory", "model-provider", "api-key"] as const).filter( - (f) => flags[f], - ); - const byoOnlyFlags = ( - [ - "entrypoint", - "runtime-version", - "dockerfile", - "build-context-path", - "custom-docker-build-args", - ] as const - ).filter((f) => flags[f]); + const scaffoldingFlags = [ + "build", + "language", + "framework", + "model-provider", + "api-key", + "memory", + ] as const; + const presentScaffoldingFlags = scaffoldingFlags.filter((f) => flags[f] !== undefined); + const isTemplate = flags["template"] !== undefined; - if (isTemplate && byoOnlyFlags.length > 0) - throw new InputValidationError( - `--${byoOnlyFlags[0]} is only available on the BYO path (--code-location)`, - ); - if (!isTemplate && templateOnlyFlags.length > 0) + if (isTemplate && presentScaffoldingFlags.length > 0) throw new InputValidationError( - `--${templateOnlyFlags[0]} is only available on the template path (--template)`, + `--template and --${presentScaffoldingFlags[0]} are mutually exclusive`, ); - const inputEnvironmentVariables = parseJsonFlag>( - "environment-variables", - flags["environment-variables"], - ); - const memoryConfiguration = parseMemoryConfig(flags["memory"]); - - const entrypoint = flags.entrypoint ?? "main.py"; + const isCustom = presentScaffoldingFlags.length > 0; const source = new SourceResolver({ stdin: config.io.stdin }); const apiKey = await source.resolveSecret("api-key", flags["api-key"]); - if (flags["custom-docker-build-args"] && !flags.dockerfile && !flags["build-context-path"]) - throw new InputValidationError( - "--custom-docker-build-args requires --dockerfile or --build-context-path", - ); + const scaffoldRuntimeInput = isTemplate + ? RUNTIME_TEMPLATE_SHORTCUTS[flags.template!] + : isCustom + ? parseScaffoldRuntimeInput({ + runtimeName: flags.name, + build: flags.build, + language: flags.language, + framework: flags.framework, + modelProvider: flags["model-provider"], + apiKey, + memory: flags.memory, + }) + : RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python"]; - const infraConfig = { + const inputEnvironmentVariables = parseJsonFlag>( + "environment-variables", + flags["environment-variables"], + ); + + const runtimeInput = { name: flags.name, description: flags.description, executionRoleArn: flags["role-arn"], @@ -182,31 +162,9 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flags["filesystem-configurations"], ), tags: parseTags(flags["tags"]), + scaffoldRuntimeInput, }; - const runtimeInput = isTemplate - ? { - source: "template" as const, - template, - memory: memoryConfiguration, - modelProvider: { apiKey, provider: flags["model-provider"] }, - ...infraConfig, - } - : { - source: "byo" as const, - codeLocation: flags["code-location"]!, - build: flags.build, - entrypoint, - runtimeVersion: flags["runtime-version"], - dockerfile: flags.dockerfile, - buildContextPath: flags["build-context-path"], - customDockerBuildArgs: parseJsonFlag( - "custom-docker-build-args", - flags["custom-docker-build-args"], - ), - ...infraConfig, - }; - const result = RuntimeResourceConfigSchema.safeParse(runtimeInput); if (!result.success) throw new InputValidationError(z.prettifyError(result.error), { cause: result.error }); @@ -223,16 +181,12 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => }, }); -function parseMemoryConfig( - raw: string | undefined, -): z.infer | undefined { - if (!raw) return undefined; - const parsed = parseJsonFlag>("memory", raw); - const result = runtimeMemoryConfigSchema.safeParse(parsed); - if (!result.success) throw new InputValidationError(z.prettifyError(result.error)); - return result.data; -} - function toEnvironmentVariables(envVars: Record | undefined): EnvVar[] { return envVars ? Object.entries(envVars).map(([name, value]) => ({ name, value })) : []; } + +function parseScaffoldRuntimeInput(input: Record) { + const result = ScaffoldRuntimeInputSchema.safeParse(input); + if (!result.success) throw new InputValidationError(z.prettifyError(result.error)); + return result.data; +} diff --git a/src/handlers/project/add/runtime/types.ts b/src/handlers/project/add/runtime/types.ts index f47fa20ae..9331e5051 100644 --- a/src/handlers/project/add/runtime/types.ts +++ b/src/handlers/project/add/runtime/types.ts @@ -1,29 +1,6 @@ -import { MemoryStrategyType } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { ProjectRuntimeSchema } from "../../../../projectSchemas/runtime"; -import { RUNTIME_TEMPLATES } from "../../types"; - -export const runtimeModelProviderSchema = z.enum(["bedrock", "anthropic", "openai", "gemini"]); -export type RuntimeModelProvider = z.infer; - -export const runtimeModelProviderConfigSchema = z.object({ - provider: runtimeModelProviderSchema.optional(), - apiKey: z.string().min(1).optional(), -}); -export type RuntimeModelProviderConfig = z.infer; - -export const runtimeMemoryConfigSchema = z.discriminatedUnion("mode", [ - z.object({ mode: z.literal("disabled") }), - z.object({ - mode: z.literal("create"), - strategies: z.array(z.enum(Object.values(MemoryStrategyType))), - }), - z.object({ - mode: z.literal("existing"), - arn: z.string().min(1), - }), -]); -export type RuntimeMemoryConfig = z.input; +import { ScaffoldRuntimeInputSchema } from "../../types"; const RuntimeInfraConfigSchema = z.object({ name: ProjectRuntimeSchema.shape.name, @@ -42,26 +19,7 @@ const RuntimeInfraConfigSchema = z.object({ tags: ProjectRuntimeSchema.shape.tags, }); -const RuntimeByoConfigSchema = RuntimeInfraConfigSchema.extend({ - source: z.literal("byo"), - codeLocation: z.string().min(1), - build: ProjectRuntimeSchema.shape.build.optional(), - entrypoint: ProjectRuntimeSchema.shape.entrypoint.optional(), - runtimeVersion: ProjectRuntimeSchema.shape.runtimeVersion, - dockerfile: ProjectRuntimeSchema.shape.dockerfile, - buildContextPath: ProjectRuntimeSchema.shape.buildContextPath, - customDockerBuildArgs: ProjectRuntimeSchema.shape.customDockerBuildArgs, +export const RuntimeResourceConfigSchema = RuntimeInfraConfigSchema.extend({ + scaffoldRuntimeInput: ScaffoldRuntimeInputSchema, }); - -const RuntimeTemplateConfigSchema = RuntimeInfraConfigSchema.extend({ - source: z.literal("template"), - template: z.enum(RUNTIME_TEMPLATES), - memory: runtimeMemoryConfigSchema.optional(), - modelProvider: runtimeModelProviderConfigSchema.optional(), -}); - -export const RuntimeResourceConfigSchema = z.discriminatedUnion("source", [ - RuntimeByoConfigSchema, - RuntimeTemplateConfigSchema, -]); export type RuntimeResourceConfig = z.infer; diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index 6ee6dd7e9..3504b6270 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -1,8 +1,15 @@ import z from "zod"; import { createHandler, flag } from "../../../router"; -import type { AppIO } from "../../../io"; -import { PROJECT_TEMPLATES, type ProjectManager } from "../types"; +import { SourceResolver, type AppIO } from "../../../io"; +import { + RUNTIME_TEMPLATE_SHORTCUT_NAMES, + RUNTIME_TEMPLATE_SHORTCUTS, + ScaffoldRuntimeInputSchema, + type CreateProjectInput, + type ProjectManager, +} from "../types"; import { ProjectNameSchema } from "../../../projectSchemas/project"; +import { InputValidationError } from "../../../errors"; type CreateProjectHandlerConfig = { projectManager: ProjectManager; @@ -17,9 +24,37 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = flag("name", "name of the project to create", ProjectNameSchema), flag( "template", - "project template to scaffold from", - z.enum(PROJECT_TEMPLATES).default(PROJECT_TEMPLATES.HELLO_WORLD_PYTHON), + "a preset of flags to be leveraged in scaffolding the runtime. mutually exclusive with all runtime scaffolding flags", + z.enum(RUNTIME_TEMPLATE_SHORTCUT_NAMES).optional(), ), + flag( + "build", + "build type for the scaffolded runtime code", + z.enum(["CodeZip", "Container"]).optional(), + ), + flag( + "language", + "target language for the scaffolded runtime code", + z.enum(["Python"]).optional(), + ), + flag( + "framework", + "agent framework for the scaffolded runtime code", + z.enum(["none"]).optional(), + ), + flag( + "model-provider", + "model provider for the scaffolded runtime code", + z.enum(["Bedrock"]).optional(), + ), + flag( + "api-key", + "API key for non-Bedrock providers: '-' for stdin, 'file://path' for file", + z.string().optional(), + { sensitive: true }, + ), + flag("memory", "memory option for the scaffolded runtime", z.enum(["none"]).optional()), + flag("runtime-name", "name of the scaffolded runtime", z.string().optional()), flag( "skip-install", "skip installing dependencies (npm install, uv sync)", @@ -28,16 +63,59 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = flag("skip-git", "skip initializing a git repository", z.boolean().default(false)), ], handle: async (_ctx, flags) => { - // Progress and success go to stderr, keeping stdout for machine output. - for await (const event of config.projectManager.create({ + const scaffoldingFlags = [ + "build", + "language", + "framework", + "model-provider", + "api-key", + "memory", + "runtime-name", + ] as const; + + const presentScaffoldingFlags = scaffoldingFlags.filter((f) => flags[f] !== undefined); + const isTemplate = flags["template"] !== undefined; + if (presentScaffoldingFlags.length > 0 && isTemplate) + throw new InputValidationError( + `--template and --${presentScaffoldingFlags[0]} are mutually exclusive`, + ); + + const isCustom = presentScaffoldingFlags.length > 0; + + const source = new SourceResolver({ stdin: config.io.stdin }); + const apiKey = await source.resolveSecret("api-key", flags["api-key"]); + + const scaffoldRuntimeInput = isTemplate + ? RUNTIME_TEMPLATE_SHORTCUTS[flags["template"]!] + : isCustom + ? parseScaffoldRuntimeInput({ + runtimeName: flags["runtime-name"] ?? flags["name"], + build: flags["build"], + language: flags["language"], + framework: flags["framework"], + modelProvider: flags["model-provider"], + apiKey, + memory: flags["memory"], + }) + : RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python"]; + + const createInput: CreateProjectInput = { name: flags["name"], - template: flags["template"], skipInstall: flags["skip-install"], skipGit: flags["skip-git"], - })) { + scaffoldRuntimeInput, + }; + + for await (const event of config.projectManager.create(createInput)) { config.io.stderr.write(`${event.message}\n`); } config.io.stderr.write(`Created project '${flags["name"]}' in ./${flags["name"]}\n`); }, }); + +function parseScaffoldRuntimeInput(input: Record) { + const result = ScaffoldRuntimeInputSchema.safeParse(input); + if (!result.success) throw new InputValidationError(z.prettifyError(result.error)); + return result.data; +} diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 45a97dcea..a103c59ea 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -86,12 +86,18 @@ describe("project create", () => { test("runs the post-scaffold steps and reports progress on stderr", async () => { const directory = await inTempDirectory(); - const { io, core } = await run(["create", "--name", "MyAgent"]); + const { io, core } = await run([ + "create", + "--name", + "MyAgent", + "--template", + "hello-world-python", + ]); const projectRoot = join(directory, "MyAgent"); expect(core.projectCommands).toEqual([ { command: ["npm", "install"], cwd: join(projectRoot, "agentcore", "cdk") }, - { command: ["uv", "sync"], cwd: join(projectRoot, "app", "hello-world") }, + { command: ["uv", "sync"], cwd: join(projectRoot, "app", "hello_world") }, { command: ["git", "init"], cwd: projectRoot }, ]); expect(io.stderr()).toContain("Creating project tree"); @@ -108,6 +114,109 @@ describe("project create", () => { expect(core.projectCommands).toEqual([]); }); + test("rejects --template combined with scaffolding flags", async () => { + await inTempDirectory(); + await expect( + run([ + "create", + "--name", + "MyAgent", + "--template", + "hello-world-python", + "--build", + "Container", + ]), + ).rejects.toThrow(/--template and --build are mutually exclusive/); + }); + + test("scaffolds from explicit custom flags", async () => { + const directory = await inTempDirectory(); + await run([ + "create", + "--name", + "MyAgent", + "--build", + "CodeZip", + "--language", + "Python", + "--framework", + "none", + "--model-provider", + "Bedrock", + "--memory", + "none", + "--skip-install", + "--skip-git", + ]); + + const projectRoot = join(directory, "MyAgent"); + expect(await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).exists()).toBe(true); + }); + + test("rejects an invalid --runtime-name before scaffolding", async () => { + const directory = await inTempDirectory(); + await expect( + run([ + "create", + "--name", + "MyProject", + "--runtime-name", + "../MyAgent", + "--build", + "CodeZip", + "--language", + "Python", + "--framework", + "none", + "--model-provider", + "Bedrock", + "--memory", + "none", + "--skip-install", + "--skip-git", + ]), + ).rejects.toThrow(/Must begin with a letter/); + + expect(existsSync(join(directory, "MyProject"))).toBe(false); + }); + + test("rejects an API key with the Bedrock model provider before scaffolding", async () => { + const directory = await inTempDirectory(); + await expect( + run( + [ + "create", + "--name", + "MyProject", + "--build", + "CodeZip", + "--language", + "Python", + "--framework", + "none", + "--model-provider", + "Bedrock", + "--api-key", + "-", + "--memory", + "none", + "--skip-install", + "--skip-git", + ], + { stdin: "secret-key" }, + ), + ).rejects.toThrow(/API keys are not compatible with Bedrock model providers/); + + expect(existsSync(join(directory, "MyProject"))).toBe(false); + }); + + test("rejects incomplete custom flags", async () => { + await inTempDirectory(); + await expect( + run(["create", "--name", "MyAgent", "--build", "CodeZip", "--language", "Python"]), + ).rejects.toThrow(); + }); + test("rejects an unknown --template value", async () => { await inTempDirectory(); await expect(run(["create", "--name", "MyAgent", "--template", "nonsense"])).rejects.toThrow(); @@ -601,7 +710,7 @@ describe("project build", () => { test("resolves the project from a nested directory", async () => { const projectRoot = await inBuildableProject(); - process.chdir(join(projectRoot, "app", "hello-world")); + process.chdir(join(projectRoot, "app", "hello_world")); const { core } = await run(["build"]); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index a1c2c0907..684af5bae 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -6,34 +6,66 @@ import type { ProjectSpecSchema } from "../../projectSchemas/project"; import z from "zod"; import type { RuntimeResourceConfig } from "./add/runtime/types"; import type { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-config"; +import { AgentNameSchema, BuildTypeSchema } from "../../projectSchemas/runtime"; import type { AgentCoreGateway, AgentCoreGatewayTarget } from "../../projectSchemas/gateway"; -/** Available runtime templates for scaffolding agent code. A subset of {@link PROJECT_TEMPLATES} describing runtimes only */ -export const RUNTIME_TEMPLATES = { - HELLO_WORLD_PYTHON: "hello-world-python", - HELLO_WORLD_PYTHON_CONTAINER: "hello-world-python-container", -} as const; - -export type RuntimeTemplate = (typeof RUNTIME_TEMPLATES)[keyof typeof RUNTIME_TEMPLATES]; - -/** Available project templates for scaffolding new AgentCore projects. */ -export const PROJECT_TEMPLATES = { - ...RUNTIME_TEMPLATES, -} as const; - -export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[keyof typeof PROJECT_TEMPLATES]; - -export type CreateProjectInput = { +export const RUNTIME_TEMPLATE_SHORTCUTS = { + "hello-world-python": { + runtimeName: "hello_world", + build: "CodeZip", + language: "Python", + framework: "none", + modelProvider: "Bedrock", + memory: "none", + }, + "hello-world-python-container": { + runtimeName: "hello_world", + build: "Container", + language: "Python", + framework: "none", + modelProvider: "Bedrock", + memory: "none", + }, +} as const satisfies Record; + +export type RuntimeTemplateShortcutName = keyof typeof RUNTIME_TEMPLATE_SHORTCUTS; + +export const RUNTIME_TEMPLATE_SHORTCUT_NAMES = Object.keys( + RUNTIME_TEMPLATE_SHORTCUTS, +) as unknown as readonly [RuntimeTemplateShortcutName, ...RuntimeTemplateShortcutName[]]; + +type CreateProjectInputBase = { /** The name of the project; also the directory it is scaffolded into. */ name: string; - /** The project template to scaffold from. */ - template: ProjectTemplate; /** Skip installing dependencies (npm install, uv sync). */ skipInstall?: boolean; /** Skip initializing a git repository. */ skipGit?: boolean; }; +/** Set of flags needed to scaffold a new Runtime-based agent **/ +export const ScaffoldRuntimeInputSchema = z + .object({ + runtimeName: AgentNameSchema, + build: BuildTypeSchema, + language: z.enum(["Python"]), + framework: z.enum(["none"]), + modelProvider: z.enum(["Bedrock"]), + apiKey: z.string().min(1).optional(), + memory: z.enum(["none"]), + }) + .refine(({ modelProvider, apiKey }) => !(modelProvider === "Bedrock" && apiKey !== undefined), { + message: "API keys are not compatible with Bedrock model providers", + path: ["apiKey"], + }); + +export type ScaffoldRuntimeInput = z.infer; + +export type CreateProjectInput = CreateProjectInputBase & { + /** The resolved template parameters. The handler maps --template to these before calling the manager. */ + scaffoldRuntimeInput: ScaffoldRuntimeInput; +}; + /** A progress step reported while a long-running project operation runs. */ export type ProjectEvent = { message: string;