Skip to content
Merged
60 changes: 60 additions & 0 deletions src/assets/templates/hello-world-python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# hello-world

A minimal AgentCore Runtime agent built with the
[Strands Agents SDK](https://strandsagents.com) — our recommended framework
for building agents on AWS Bedrock AgentCore.

## What's here

- `main.py` — the agent. A `BedrockAgentCoreApp` wraps a Strands `Agent`;
the `@app.entrypoint` function receives each invocation payload and streams
the agent's response back to the caller.
- `pyproject.toml` — Python dependencies, managed with
[uv](https://docs.astral.sh/uv/). `agentcore project create` has already run
`uv sync` for you (unless you passed `--skip-install`), so `.venv/` is ready.

## Run it locally

```bash
uv run main.py
```

The app listens on http://localhost:8080. Invoke it:

```bash
curl -X POST http://localhost:8080/invocations \
-H "Content-Type: application/json" \
-d '{"prompt": "Hello!"}'
```
Comment thread
tejaskash marked this conversation as resolved.

<!-- TODO: replace the uv run + curl instructions with `agentcore dev` and
`agentcore invoke` once those commands are available. -->

## Build your agent

Start in `main.py`:

- Change the `system_prompt` to shape your agent's behavior.
- Give it tools — Strands ships ready-made ones and makes custom tools a
decorator away:

```python
from strands import Agent, tool

@tool
def word_count(text: str) -> int:
"""Count words in text."""
return len(text.split())

agent = Agent(system_prompt="You are a helpful assistant.", tools=[word_count])
```

- Add dependencies with `uv add <package>`.

See the [Strands documentation](https://strandsagents.com/latest/documentation/docs/)
for multi-agent patterns, MCP tools, and model configuration.

## Deploy

Deploy from the project root with the AgentCore CLI; the CDK app under
`agentcore/cdk` provisions the Runtime that hosts this agent.
1 change: 1 addition & 0 deletions src/core/project/__snapshots__/manager.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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",
]
Expand Down
112 changes: 103 additions & 9 deletions src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ async function inTempDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "agentcore-manager-"));
tempDirectories.push(directory);
process.chdir(directory);
return directory;
// cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var
// symlink), matching the paths the manager derives from process.cwd().
return process.cwd();
}

afterEach(async () => {
Expand All @@ -24,14 +26,28 @@ afterEach(async () => {
);
});

function manager(): FsProjectManager {
return new FsProjectManager({ logger: createSilentLogger() });
// A manager whose runner records commands instead of spawning them.
Comment thread
tejaskash marked this conversation as resolved.
function manager(): { manager: FsProjectManager; commands: { command: string[]; cwd: string }[] } {
const commands: { command: string[]; cwd: string }[] = [];
return {
manager: new FsProjectManager({
logger: createSilentLogger(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
checkTool: async () => {}, // CI hosts don't have uv installed
}),
commands,
};
}

describe("FsProjectManager.create", () => {
test("scaffolds the expected file tree into a fresh directory", async () => {
const directory = await inTempDirectory();
await manager().create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON });
await manager().manager.create({
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
});

const projectRoot = join(directory, "example");
const manifest = (await readdir(projectRoot, { recursive: true, withFileTypes: true }))
Expand All @@ -46,7 +62,10 @@ describe("FsProjectManager.create", () => {

test("writes a deploy-ready agentcore.json registering the template agent", async () => {
const directory = await inTempDirectory();
await manager().create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON });
await manager().manager.create({
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
});

const configDir = join(directory, "example", "agentcore");
const spec = await Bun.file(join(configDir, "agentcore.json")).json();
Expand All @@ -66,17 +85,92 @@ describe("FsProjectManager.create", () => {
await inTempDirectory();
const input = { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON };

await manager().create(input);
await expect(manager().create(input)).rejects.toBeInstanceOf(ProjectFileExistsError);
await manager().manager.create(input);
await expect(manager().manager.create(input)).rejects.toBeInstanceOf(ProjectFileExistsError);
});

test("runs npm install, uv sync, and git init after scaffolding", async () => {
const directory = await inTempDirectory();
const { manager: subject, commands } = manager();
await subject.create({ name: "example", template: PROJECT_TEMPLATES.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: ["git", "init"], cwd: projectRoot },
]);
});

test("skipInstall skips npm install and uv sync", async () => {
const directory = await inTempDirectory();
const { manager: subject, commands } = manager();
await subject.create({
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
skipInstall: true,
});

expect(commands).toEqual([{ command: ["git", "init"], cwd: join(directory, "example") }]);
});

test("skipGit skips git init", async () => {
await inTempDirectory();
const { manager: subject, commands } = manager();
await subject.create({
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
skipGit: true,
});

expect(commands.map(({ command }) => command[0])).toEqual(["npm", "uv"]);
});

test("reports each step through onProgress", async () => {
await inTempDirectory();
const messages: string[] = [];
await manager().manager.create({
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
onProgress: (event) => messages.push(event.message),
});

expect(messages).toEqual([
"Scaffolding project files...",
"Installing CDK dependencies (npm install)...",
"Syncing Python dependencies (uv sync)...",
"Initializing git repository...",
]);
});

test("a failed step propagates and leaves the scaffolded files in place", async () => {
Comment thread
tejaskash marked this conversation as resolved.
const directory = await inTempDirectory();
const failing = new FsProjectManager({
logger: createSilentLogger(),
runner: async () => {
throw new Error("npm exploded");
},
checkTool: async () => {},
});

await expect(
failing.create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }),
).rejects.toThrow("npm exploded");
expect(await Bun.file(join(directory, "example", "agentcore", "agentcore.json")).exists()).toBe(
true,
);
});

test("refuses to create a project inside an existing project", async () => {
const directory = await inTempDirectory();
await manager().create({ name: "root", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON });
await manager().manager.create({
name: "root",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
});

process.chdir(join(directory, "root"));
await expect(
manager().create({ name: "child", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }),
manager().manager.create({ name: "child", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }),
).rejects.toBeInstanceOf(NestedProjectError);
});
});
38 changes: 38 additions & 0 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import type {
ProjectManager,
} from "../../handlers/project/types";
import type { Logger } from "../../logging";
import { requireTool, runProcess, type ProcessRunner } from "../../io";
import { projectTree } from "./compose";
import { defaultSource, type AssetSource } from "./source";
import { TEMPLATES } from "./templates";
import { writeTree } from "./tree";

/** Walks up from directory looking for the agentcore/agentcore.json project marker. */
Expand All @@ -27,6 +29,8 @@ function enclosingProjectRoot(directory: string): string | undefined {
type ProjectManagerConfig = {
logger: Logger;
source?: AssetSource; // Bun executable or dist/assets depending on runtime
runner?: ProcessRunner; // injectable so tests never spawn real processes
checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH
};

/**
Expand All @@ -35,10 +39,14 @@ type ProjectManagerConfig = {
export class FsProjectManager implements ProjectManager {
private readonly logger: Logger;
private readonly source: AssetSource;
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;

constructor(config: ProjectManagerConfig) {
this.logger = config.logger;
this.source = config.source ?? defaultSource();
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
}

public resolve(_input: ResolveProjectInput): Promise<Project> {
Expand All @@ -54,9 +62,39 @@ export class FsProjectManager implements ProjectManager {
const destination = join(process.cwd(), input.name);
this.logger.debug(`scaffolding project "${input.name}" from template "${input.template}"`);

input.onProgress?.({ message: "Scaffolding project files..." });
const tree = await projectTree(input.name, input.template, this.source);
await writeTree(tree, destination);

// A failed step leaves the scaffolded files in place; the error tells the
// user how to rerun the step by hand.
if (!input.skipInstall) {
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");
input.onProgress?.({ message: "Installing CDK dependencies (npm install)..." });
await this.run(["npm", "install"], join(destination, "agentcore", "cdk"));

const appDir = join(destination, "app", TEMPLATES[input.template].appDir);
if (existsSync(join(appDir, "pyproject.toml"))) {
await this.checkTool(
"uv",
"Install uv: https://docs.astral.sh/uv/getting-started/installation/",
);
input.onProgress?.({ message: "Syncing Python dependencies (uv sync)..." });
await this.run(["uv", "sync"], appDir);
}
}

if (!input.skipGit) {
await this.checkTool("git", "Install git: https://git-scm.com/downloads");
input.onProgress?.({ message: "Initializing git repository..." });
await this.run(["git", "init"], destination);
}

return { name: input.name };
}

// Runs a command with its output streamed to the file logger.
private run(command: string[], cwd: string): Promise<void> {
return this.runner(command, { cwd, onOutput: (chunk) => this.logger.debug(chunk) });
}
}
2 changes: 1 addition & 1 deletion src/handlers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router
root.handler(createGatewayHandler(core, io));
root.handler(createEvalHandler(core, io));
root.handler(createConfigHandler());
root.handler(createProjectHandler({ projectManager: core.projectManager }));
root.handler(createProjectHandler({ projectManager: core.projectManager, io }));

// Invoking with no subcommand launches the interactive TUI.
root.default(renderTui(core, io));
Expand Down
15 changes: 14 additions & 1 deletion src/handlers/project/create/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import z from "zod";
import { createHandler, flag } from "../../../router";
import type { AppIO } from "../../../io";
import { PROJECT_TEMPLATES, ProjectNameSchema, type ProjectManager } from "../types";

type CreateProjectHandlerConfig = {
projectManager: ProjectManager;
io: AppIO;
};

export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) =>
Expand All @@ -17,11 +19,22 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) =
"project template to scaffold from",
z.enum(PROJECT_TEMPLATES).default(PROJECT_TEMPLATES.HELLO_WORLD_PYTHON),
),
flag(
"skip-install",
"skip installing dependencies (npm install, uv sync)",
z.boolean().default(false),
Comment thread
tejaskash marked this conversation as resolved.
),
flag("skip-git", "skip initializing a git repository", z.boolean().default(false)),
],
handle: async (_ctx, flags) => {
await config.projectManager.create({
// Progress and success go to stderr, keeping stdout for machine output.
const project = await config.projectManager.create({
name: flags["project-name"],
template: flags["template"],
skipInstall: flags["skip-install"],
skipGit: flags["skip-git"],
onProgress: (event) => config.io.stderr.write(`${event.message}\n`),
});
config.io.stderr.write(`Created project '${project.name}' in ./${project.name}\n`);
},
});
6 changes: 5 additions & 1 deletion src/handlers/project/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router } from "../../router";
import type { AppIO } from "../../io";
import { createCreateProjectHandler } from "./create";
import { createAddProjectHandler } from "./add";
import { createRemoveProjectHandler } from "./remove";
Expand All @@ -10,12 +11,15 @@ import type { ProjectManager } from "./types";

type ProjectHandlerConfig = {
projectManager: ProjectManager;
io: AppIO;
};

export function createProjectHandler(config: ProjectHandlerConfig): Router {
const project = new Router("project", "manage an AgentCore project");

project.handler(createCreateProjectHandler({ projectManager: config.projectManager }));
project.handler(
createCreateProjectHandler({ projectManager: config.projectManager, io: config.io }),
);
project.handler(createAddProjectHandler());
project.handler(createRemoveProjectHandler());
project.handler(createDevProjectHandler());
Expand Down
Loading
Loading