diff --git a/.prettierignore b/.prettierignore index 8eda17e39..03328eea2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,6 @@ CHANGELOG.md src/assets/**/*.md +src/assets/**/*.ts +src/assets/**/*.json +src/assets/**/*.template .github/scripts/prompts/ diff --git a/README.md b/README.md index 4abc7a033..42aeda5ec 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,12 @@ agentcore invoke ## Supported Frameworks -| Framework | Notes | -| ------------------- | ----------------------------- | -| Strands Agents | AWS-native, streaming support | -| LangChain/LangGraph | Graph-based workflows | -| Google ADK | Gemini models only | -| OpenAI Agents | OpenAI models only | +| Framework | Notes | +| ------------------- | --------------------------------------------------- | +| Strands Agents | AWS-native, streaming support (Python + TypeScript) | +| LangChain/LangGraph | Graph-based workflows | +| Google ADK | Gemini models only | +| OpenAI Agents | OpenAI models only | ## Supported Model Providers diff --git a/docs/TYPESCRIPT_SUPPORT_HANDOFF.md b/docs/TYPESCRIPT_SUPPORT_HANDOFF.md new file mode 100644 index 000000000..f792c7a35 --- /dev/null +++ b/docs/TYPESCRIPT_SUPPORT_HANDOFF.md @@ -0,0 +1,176 @@ +# TypeScript (Strands) support — work-in-progress handoff + +This file captures the state of an in-progress initiative to add TypeScript (Strands SDK) as a first-class language +option in `agentcore create`, alongside Python. + +**Full plan:** `~/.claude/plans/lets-add-typescript-to-jazzy-honey.md` (owner machine only; copy the plan content into +this file if it needs to travel — it already lives in the git history of the original chat thread.) + +## What's been merged in this checkpoint + +All changes typecheck clean (`npx tsc --noEmit` from the `agentcore-cli/` directory). + +1. **Schema constants** (`src/schema/constants.ts`) + - `DEFAULT_NODE_VERSION: NodeRuntime = 'NODE_22'` + - `DEFAULT_ENTRYPOINT_BY_LANGUAGE: Record<'Python' | 'TypeScript', string>` + - `DEFAULT_RUNTIME_BY_LANGUAGE: Record<'Python' | 'TypeScript', RuntimeVersion>` + +2. **UI unblock** (`src/cli/tui/screens/agent/types.ts`) + - TypeScript entry in `LANGUAGE_OPTIONS` is no longer `disabled: true`. + +3. **CLI validator** (`src/cli/commands/add/validate.ts`) + - Removed the hard reject of `--language TypeScript`. + - Added a new gate: when `language === 'TypeScript'`, only `Strands` is accepted as `--framework`; every other + framework returns a clear error. + +4. **CLI flag help** (`src/cli/commands/create/command.tsx`) + - `--language` description now mentions both Python and TypeScript. + +5. **TUI framework filter** (`src/cli/tui/screens/generate/types.ts` + `GenerateWizardUI.tsx`) + - `getSDKOptionsForProtocol(protocol, language?)` takes an optional language arg. + - When `language === 'TypeScript'` the list is filtered down to `Strands` only. + - `GenerateWizardUI` passes `wizard.config.language` into the call site. + +6. **Language-aware spec defaults** (`src/cli/operations/agent/generate/schema-mapper.ts`) + - `mapGenerateConfigToAgent` now branches on `config.language === 'TypeScript'`: + - `entrypoint` → `DEFAULT_ENTRYPOINT_BY_LANGUAGE.TypeScript` (`main.ts`) + - `runtimeVersion` → `DEFAULT_RUNTIME_BY_LANGUAGE.TypeScript` (`NODE_22`) + - Imports added from `'../../../../schema'` barrel. + +## What is NOT done yet (the big chunks) + +Tackling the remaining items requires a fresh context budget and ideally the actual Strands TS SDK and the AgentCore +runtime TS SDK installed locally for quick iteration. + +### 1. Template assets (the bulk of the work) + +Author files at `src/assets/typescript/http/strands/`: + +``` +base/ + gitignore.template + package.json # Handlebars — pins @strands-agents/sdk + bedrock-agentcore + tsconfig.json # target ES2022, module NodeNext, strict + main.ts # entrypoint — BedrockAgentCoreApp + Agent.stream() + README.md + mcp_client/client.ts # mirrors Python mcp_client/client.py semantics + model/load.ts # mirrors Python model/load.py (per-provider branches) +capabilities/ + memory/session.ts # mirrors Python capabilities/memory/session.py +``` + +**Confirmed SDK surface** (tarballs unpacked under `/tmp/strands-ts-check/` and `/tmp/bac-check/` in my session — +re-fetch with `npm pack` to inspect): + +- `@strands-agents/sdk@1.0.0-rc.4` + - Main: `Agent`, `tool`, `BedrockModel`, `McpClient` + - Provider subpaths: `@strands-agents/sdk/models/{bedrock,anthropic,openai,google}` + - MCP: `McpClient` takes `{ transport }` — `Transport` from `@modelcontextprotocol/sdk/shared/transport.js` + - Agent streaming: `agent.stream(input)` yields `AgentStreamEvent`; filter for `ContentBlockDelta` with `textDelta` + blocks to stream text output +- `bedrock-agentcore@0.2.2` + - `BedrockAgentCoreApp` from `bedrock-agentcore/runtime` (Fastify-based, runs on 8080) + - Identity HOFs `withAccessToken` / `withApiKey` from `bedrock-agentcore/identity` + +**Template shape for `main.ts`** (sketch; not committed): + +```ts +import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime'; +import { Agent, tool } from '@strands-agents/sdk'; +import { BedrockModel } from '@strands-agents/sdk/models/bedrock'; +import { loadModel } from './model/load.js'; +{{#if hasMemory}}import { getMemorySessionManager } from './memory/session.js';{{/if}} + +const app = new BedrockAgentCoreApp({ + invocationHandler: { + process: async function* (request, context) { + const agent = new Agent({ model: loadModel(), /* sessionManager, tools */ }); + for await (const event of agent.stream((request as { prompt: string }).prompt)) { + if (event.type === 'contentBlockDelta' && event.delta.type === 'textDelta') { + yield { data: event.delta.text }; + } + } + }, + }, +}); +app.run(); +``` + +Verify this against the actual SDK event shape before finalizing — the `stream()` event type names are in +`/tmp/strands-ts-check/package/dist/src/models/streaming.d.ts`. + +### 2. Container template + +Under `src/assets/container/typescript/`: + +- `Dockerfile` — base `public.ecr.aws/docker/library/node:22-slim`; copy `package.json` + `package-lock.json`, + `npm ci --omit=dev`, copy source, run `npx tsx main.ts` (or `tsc` build step + `node dist/main.js` if we want a build + artifact). Expose 8080. +- `dockerignore.template` — `node_modules`, `dist`, `.env*`, `.git/`. + +### 3. Dev server unblock + +`src/cli/operations/dev/config.ts:49-54` — the `isDevSupported` function actively rejects non-Python agents with "Dev +mode only supports Python agents." Remove that guard; the actual `codezip-dev-server.ts` already handles `!isPython` via +`npx tsx watch`. + +### 4. Node setup helper + wiring + +- New `src/cli/operations/node/setup.ts` mirroring `src/cli/operations/python/setup.ts` — exposes + `setupNodeProject({ projectDir })` that shells out to `npm install`. +- Wire into `src/cli/tui/screens/create/useCreateFlow.ts` around line 431 with a branch parallel to the Python setup + block. +- Extend `checkCreateDependencies({ language })` in `src/cli/external-requirements/checks.ts` (called from + `src/cli/commands/create/action.ts`) to verify `node` + `npm` when `language === 'TypeScript'`. + +### 5. Packaging dispatcher (verified — no change required) + +`src/lib/packaging/index.ts` already delegates to `NodeCodeZipPackager` when `isNodeRuntime(runtimeVersion)` is true. +Confirmed by reading — keep as-is. + +### 6. Tests + +- **Snapshots.** `src/assets/__tests__/assets.snapshot.test.ts` already has a TypeScript block (lines 106-120) that + auto-discovers `typescript/` files. After authoring templates, run `npm run test:update-snapshots` and review. +- **Create integ test.** `integ-tests/create-with-agent.test.ts` — duplicate the Python block for TypeScript, assert + `app//main.ts`, `package.json`, and `agentcore.json` has `runtimeVersion: NODE_22` + `entrypoint: main.ts`. +- **Dev-server test.** `src/cli/operations/dev/__tests__/codezip-dev-server.test.ts` — add a TS variant asserting + `getSpawnConfig()` returns `{ cmd: 'npx', args: ['tsx', 'watch', 'main.ts'], ... }`. +- **TUI harness walkthrough** mirroring an existing Python walkthrough under `integ-tests/tui/`. +- **E2E container deploy test.** `integ-tests/deploy-typescript-strands-container.test.ts`: scaffold → container build → + `agentcore deploy` against test account 325335451438 (per root CLAUDE.md, use `AWS_PROFILE=deploy`) → + `agentcore invoke --prompt "ping"` → teardown. Gate behind the same env flag used by other AWS integ tests so CI + without credentials skips cleanly. + +### 7. Documentation + +- `docs/frameworks.md` — add a "Supported languages" section. +- `docs/local-development.md` — TS dev loop (Node ≥ 18, `npx tsx watch`). +- `docs/commands.md` — `--language TypeScript` examples. +- `docs/container-builds.md` — TS Dockerfile example. +- `README.md` — one-line mention. + +## Verification plan (when templates are done) + +Refer to the full plan's section "Verification plan" — step-by-step from scratch-dir `agentcore create my-ts-agent` +through `agentcore dev`, `agentcore invoke`, and the container deploy + teardown. AWS account for deploys: +`325335451438` via `AWS_PROFILE=deploy`, per the workspace root CLAUDE.md. + +## Out of scope + +- LangChain/LangGraph, GoogleADK, OpenAIAgents templates for TypeScript. +- A2A and MCP protocol templates for TypeScript. +- pnpm / yarn support. +- BYO TypeScript path (already works today via `--type byo`). + +## Known gotchas + +- The Strands TS SDK is at `1.0.0-rc.4` (4 days old at time of writing). Pin exactly, and re-check the version + + event-type names before release. +- `BedrockAgentCoreApp` in the TS SDK is Fastify-based, not ASGI — no uvicorn equivalent needed, but the dev-server code + uses `npx tsx watch` which restarts the whole process on edits. Dev-experience parity with Python's uvicorn `--reload` + is good enough. +- The `isDevSupported` Python-only guard is easy to miss — remember to remove it. +- `BedrockAgentCoreApp.invocationHandler.process` can return an async generator; the runtime wraps it in SSE + automatically. The Python `@app.entrypoint async def invoke` equivalent on the TS side is yielding `{ data: string }` + objects from that generator. diff --git a/docs/commands.md b/docs/commands.md index f6f15b9ae..8268b50f0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -51,6 +51,13 @@ agentcore create \ # Skip agent creation agentcore create --name MyProject --no-agent +# TypeScript (Strands-only) +agentcore create \ + --name MyTsProject \ + --language TypeScript \ + --framework Strands \ + --model-provider Bedrock + # Preview without creating agentcore create --name MyProject --defaults --dry-run @@ -71,7 +78,7 @@ agentcore create \ | `--defaults` | Use defaults (Python, Strands, Bedrock, no memory) | | `--no-agent` | Skip agent creation | | `--type ` | `create` (default) or `import` | -| `--language ` | `Python` (default) | +| `--language ` | `Python` (default) or `TypeScript` (Strands-only; see [Frameworks](frameworks.md#supported-languages)) | | `--framework ` | `Strands`, `LangChain_LangGraph`, `GoogleADK`, `OpenAIAgents` | | `--model-provider

` | `Bedrock`, `Anthropic`, `OpenAI`, `Gemini` | | `--build ` | `CodeZip` (default) or `Container` (see [Container Builds](container-builds.md)) | diff --git a/docs/container-builds.md b/docs/container-builds.md index 61d65bcde..4abdde81c 100644 --- a/docs/container-builds.md +++ b/docs/container-builds.md @@ -48,6 +48,28 @@ The template uses `ghcr.io/astral-sh/uv:python3.12-bookworm-slim` as the base im You can customize the Dockerfile freely — add system packages, change the base image, or use multi-stage builds. +### TypeScript Dockerfile + +For TypeScript agents, the generated `Dockerfile` uses `public.ecr.aws/docker/library/node:22-slim`: + +- **Layer caching**: `package.json` (+ `package-lock.json` if present) is copied first, then `npm ci --omit=dev` runs + (falls back to `npm install` when no lockfile is present) +- **Non-root**: Runs as `bedrock_agentcore` (UID 1000), matching the Python image +- **Entrypoint**: `npx tsx main.ts` — no compile step, so dev and container runtime share the same entry shape +- **Ports**: Exposes 8080 / 8000 / 9000 to match the HTTP / MCP / A2A contract + +Example `agentcore.json` for a TypeScript container agent: + +```json +{ + "name": "MyTsAgent", + "build": "Container", + "entrypoint": "main.ts", + "codeLocation": "app/MyTsAgent/", + "runtimeVersion": "NODE_22" +} +``` + ## Configuration In `agentcore.json`, set `"build": "Container"`: diff --git a/docs/frameworks.md b/docs/frameworks.md index 673c3789d..43f70a70c 100644 --- a/docs/frameworks.md +++ b/docs/frameworks.md @@ -3,6 +3,17 @@ AgentCore CLI supports multiple agent frameworks for template-based agent creation, plus a BYO (Bring Your Own) option for existing code. +## Supported Languages + +| Language | Supported Frameworks | Runtime | Notes | +| ---------- | -------------------- | ------------ | ---------------------------------------------------------------------------------- | +| Python | All frameworks | Python 3.12+ | Default language. Uses `uv` for dependency management. | +| TypeScript | Strands only | Node 22 | Uses `npm` + `tsx` for the dev loop. Other frameworks are not yet available in TS. | + +Pass `--language TypeScript` to `agentcore create` or `agentcore add agent` to scaffold a TypeScript project. The +framework is restricted to `Strands`; other values are rejected. See +[Local Development](local-development.md#typescript-agents) for the TS dev loop. + ## Available Frameworks | Framework | Supported Model Providers | @@ -26,8 +37,13 @@ AWS's native agent framework designed for Amazon Bedrock. **Model providers:** Bedrock, Anthropic, OpenAI, Gemini +**Languages:** Python, TypeScript + ```bash agentcore create --framework Strands --model-provider Bedrock + +# TypeScript variant +agentcore create --framework Strands --model-provider Bedrock --language TypeScript ``` ### LangChain / LangGraph @@ -162,8 +178,8 @@ agentcore add agent \ Not all frameworks support all protocol modes. MCP protocol is a standalone tool server with no framework. -| Protocol | Supported Frameworks | -| -------- | ------------------------------------------------------------- | +| Protocol | Supported Frameworks | +| -------- | ----------------------------------------------------- | | **HTTP** | Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents | -| **MCP** | None (standalone tool server) | -| **A2A** | Strands, GoogleADK, LangChain_LangGraph | +| **MCP** | None (standalone tool server) | +| **A2A** | Strands, GoogleADK, LangChain_LangGraph | diff --git a/docs/local-development.md b/docs/local-development.md index d13033768..178e50bea 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -42,6 +42,16 @@ The dev server automatically: 2. Runs `uv sync` to install dependencies from `pyproject.toml` 3. Starts uvicorn with your agent +### TypeScript Agents + +TypeScript agents (Strands-only) use Node 22 and `tsx` for the dev loop: + +1. Runs `npm install` on first scaffold to populate `node_modules/` from `package.json` +2. Starts the agent with `npx tsx watch main.ts` — file changes reload automatically +3. No compile step is required; `tsx` executes `.ts` sources directly + +Set `AGENTCORE_SKIP_INSTALL=1` to skip `npm install` if you want to manage dependencies yourself. + ### API Keys For non-Bedrock providers, add keys to `agentcore/.env.local`: diff --git a/integ-tests/create-with-agent.test.ts b/integ-tests/create-with-agent.test.ts index 69f0594b8..aa7871462 100644 --- a/integ-tests/create-with-agent.test.ts +++ b/integ-tests/create-with-agent.test.ts @@ -71,3 +71,51 @@ describe('integration: create with Python agent', () => { expect(await exists(join(agentDir, '.venv')), '.venv/ should exist in agent directory').toBeTruthy(); }); }); + +describe('integration: create with TypeScript agent', () => { + let testDir: string; + + beforeAll(async () => { + testDir = join(tmpdir(), `agentcore-integ-ts-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + }); + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it.skipIf(!hasNpm || !hasGit)('scaffolds a TypeScript Strands agent with main.ts entrypoint', async () => { + const name = `TsAgent${Date.now().toString().slice(-6)}`; + // Skip the real npm install to keep the test fast and offline-safe. + const result = await runCLI( + [ + 'create', + '--name', + name, + '--language', + 'TypeScript', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir, + { skipInstall: true } + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const agentDir = join(json.projectPath, 'app', json.agentName || name); + expect(await exists(join(agentDir, 'main.ts')), 'main.ts should exist').toBeTruthy(); + expect(await exists(join(agentDir, 'package.json')), 'package.json should exist').toBeTruthy(); + expect(await exists(join(agentDir, 'tsconfig.json')), 'tsconfig.json should exist').toBeTruthy(); + expect(await exists(join(agentDir, 'model', 'load.ts')), 'model/load.ts should exist').toBeTruthy(); + expect(await exists(join(agentDir, 'mcp_client', 'client.ts')), 'mcp_client/client.ts should exist').toBeTruthy(); + }); +}); diff --git a/integ-tests/tui/create-typescript-strands.test.ts b/integ-tests/tui/create-typescript-strands.test.ts new file mode 100644 index 000000000..66e30c860 --- /dev/null +++ b/integ-tests/tui/create-typescript-strands.test.ts @@ -0,0 +1,119 @@ +/** + * TUI Integration Test: Create flow with TypeScript + Strands + * + * Drives the TUI `create` wizard through the basic path with + * `--language TypeScript --framework Strands`, confirms the scaffold + * completes, and verifies agentcore.json ends up with + * runtimeVersion === "NODE_22" and entrypoint === "main.ts". + */ +import { TuiSession, WaitForTimeoutError } from '../../src/tui-harness/index.js'; +import { createMinimalProjectDir } from './helpers.js'; +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const CLI_DIST = join(__dirname, '..', '..', 'dist', 'cli', 'index.mjs'); +const SCREENSHOTS_DIR = '/tmp/tui-test-create-typescript/screenshots'; + +function saveTextScreenshot(session: TuiSession, name: string): string { + const screen = session.readScreen({ numbered: true }); + const nonEmpty = screen.lines.filter((l: string) => l.trim() !== ''); + const { cols, rows } = screen.dimensions; + const header = `Screenshot: ${name} (${cols}x${rows})`; + const border = '='.repeat(Math.max(header.length, 60)); + const text = `${border}\n${header}\n${border}\n${nonEmpty.join('\n')}\n${border}\n`; + const path = join(SCREENSHOTS_DIR, `${name}.txt`); + writeFileSync(path, text, 'utf-8'); + return path; +} + +async function safeWaitFor(session: TuiSession, pattern: string | RegExp, timeoutMs = 10_000): Promise { + try { + await session.waitFor(pattern, timeoutMs); + return true; + } catch (err) { + if (err instanceof WaitForTimeoutError) return false; + throw err; + } +} + +function readAgentcoreJson(projectDir: string): Record { + return JSON.parse(readFileSync(join(projectDir, 'agentcore', 'agentcore.json'), 'utf-8')); +} + +describe('Create Flow: TypeScript + Strands via TUI', () => { + let session: TuiSession; + + beforeAll(() => { + mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + }); + + afterEach(async () => { + if (session?.alive) await session.close(); + }); + + it('scaffolds a TypeScript Strands agent with runtimeVersion NODE_22 and entrypoint main.ts', async () => { + const { dir: parentDir, cleanup } = await createMinimalProjectDir({ projectName: 'ts-create-test' }); + + try { + session = await TuiSession.launch({ + command: process.execPath, + args: [ + CLI_DIST, + 'create', + '--name', + 'TsTuiCreate', + '--language', + 'TypeScript', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + ], + cwd: parentDir, + cols: 120, + rows: 35, + env: { AGENTCORE_SKIP_INSTALL: '1' }, + }); + + const atAdvanced = await safeWaitFor(session, 'Advanced', 15_000); + if (!atAdvanced) saveTextScreenshot(session, 'ts-01-advanced-fail'); + expect(atAdvanced, 'Should reach Advanced config step').toBe(true); + saveTextScreenshot(session, 'ts-01-advanced'); + + await session.sendSpecialKey('down'); + await session.sendSpecialKey('enter'); + + const atConfirm = await safeWaitFor(session, /confirm|review/i, 10_000); + if (!atConfirm) saveTextScreenshot(session, 'ts-02-confirm-fail'); + expect(atConfirm, 'Should reach confirm step').toBe(true); + saveTextScreenshot(session, 'ts-02-confirm'); + + await session.sendKeys('y'); + + const created = await safeWaitFor(session, /created|success|Commands/i, 30_000); + saveTextScreenshot(session, 'ts-03-result'); + expect(created, 'Scaffold should complete').toBe(true); + + const entries = readdirSync(parentDir); + const projectDirName = entries.find(e => e.startsWith('TsTuiCreate') || e === 'TsTuiCreate'); + expect(projectDirName, 'Project directory should exist').toBeDefined(); + + const projectPath = join(parentDir, projectDirName!); + const config = readAgentcoreJson(projectPath); + const agents = config.runtimes as Record[]; + expect(agents.length).toBeGreaterThan(0); + + const agent = agents[0]!; + expect(agent.runtimeVersion).toBe('NODE_22'); + expect(agent.entrypoint).toBe('main.ts'); + } finally { + await cleanup(); + } + }, 60_000); +}); diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 0962b1b0e..a41cbd51e 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -447,6 +447,8 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "cdk/tsconfig.json", "container/python/Dockerfile", "container/python/dockerignore.template", + "container/typescript/Dockerfile", + "container/typescript/dockerignore.template", "evaluators/python-lambda/execution-role-policy.json", "evaluators/python-lambda/lambda_function.py", "evaluators/python-lambda/pyproject.toml", @@ -543,6 +545,19 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "python/mcp/standalone/base/main.py", "python/mcp/standalone/base/pyproject.toml", "typescript/.gitkeep", + "typescript/http/strands/base/README.md", + "typescript/http/strands/base/gitignore.template", + "typescript/http/strands/base/main.ts", + "typescript/http/strands/base/mcp_client/client.ts", + "typescript/http/strands/base/model/load.ts", + "typescript/http/strands/base/package.json", + "typescript/http/strands/base/tsconfig.json", + "typescript/http/vercelai/base/README.md", + "typescript/http/vercelai/base/gitignore.template", + "typescript/http/vercelai/base/main.ts", + "typescript/http/vercelai/base/model/load.ts", + "typescript/http/vercelai/base/package.json", + "typescript/http/vercelai/base/tsconfig.json", ] `; @@ -5609,3 +5624,713 @@ When modifying JSON config files: `; exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/.gitkeep should match snapshot 1`] = `""`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/README.md should match snapshot 1`] = ` +"This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a \`.gitignore\` file, an +\`agentcore/\` folder which represents the configurations and state associated with this project. Other \`agentcore\` +commands like \`deploy\`, \`dev\`, and \`invoke\` rely on the configuration stored here. + +## Agent Root + +The main entrypoint to your app is defined in \`main.ts\`. Using the AgentCore SDK \`BedrockAgentCoreApp\`, this file +defines a Fastify-based HTTP app that streams tokens from your chosen Agent framework SDK. + +\`model/load.ts\` instantiates your chosen model provider. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| \`{{identityProviders.[0].envVarName}}\` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| \`LOCAL_DEV\` | No | Set to \`1\` to use \`.env.local\` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, \`node_modules/\` is already populated with dependencies. + +\`agentcore dev\` will start a local server on 0.0.0.0:8080 using \`npx tsx watch main.ts\` for hot reload. + +In a new terminal, you can invoke that server with: + +\`agentcore invoke --dev "What can you do"\` + +# Deployment + +After providing credentials, \`agentcore deploy\` will deploy your project into Amazon Bedrock AgentCore. + +Use \`agentcore invoke\` to invoke your deployed agent. +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/gitignore.template should match snapshot 1`] = ` +"# Environment variables +.env +.env.* + +# Node +node_modules/ +dist/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/main.ts should match snapshot 1`] = ` +"import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime'; +import { Agent, tool } from '@strands-agents/sdk'; +import { loadModel } from './model/load.js'; +{{#if hasGateway}} +import { getAllGatewayMcpClients } from './mcp_client/client.js'; +{{else}} +import { getStreamableHttpMcpClient } from './mcp_client/client.js'; +{{/if}} +{{#if sessionStorageMountPath}} +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +{{/if}} + +// Define a collection of MCP clients +{{#if hasGateway}} +const mcpClients = getAllGatewayMcpClients(); +{{else}} +const mcpClients = [getStreamableHttpMcpClient()].filter(Boolean); +{{/if}} + +// Define a collection of tools used by the model +const tools: unknown[] = []; + +// Define a simple function tool +const addNumbers = tool({ + name: 'add_numbers', + description: 'Return the sum of two numbers', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + }, + handler: async ({ a, b }: { a: number; b: number }) => a + b, +}); +tools.push(addNumbers); + +{{#if sessionStorageMountPath}} +const SESSION_STORAGE_PATH = '{{sessionStorageMountPath}}'; + +function safeResolve(p: string): string { + const base = path.resolve(SESSION_STORAGE_PATH); + const resolved = path.resolve(base, p.replace(/^\\/+/, '')); + if (!resolved.startsWith(base)) { + throw new Error(\`Path '\${p}' is outside the storage boundary\`); + } + return resolved; +} + +const fileRead = tool({ + name: 'file_read', + description: 'Read a file from persistent storage. The path is relative to the storage root.', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }, + handler: async ({ path: p }: { path: string }) => { + try { + return await fs.readFile(safeResolve(p), 'utf-8'); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }, +}); + +const fileWrite = tool({ + name: 'file_write', + description: 'Write content to a file in persistent storage. The path is relative to the storage root.', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' }, content: { type: 'string' } }, + required: ['path', 'content'], + }, + handler: async ({ path: p, content }: { path: string; content: string }) => { + try { + const full = safeResolve(p); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, content, 'utf-8'); + return \`Written to \${p}\`; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }, +}); + +const listFiles = tool({ + name: 'list_files', + description: 'List files in persistent storage. The directory is relative to the storage root.', + inputSchema: { + type: 'object', + properties: { directory: { type: 'string' } }, + }, + handler: async ({ directory = '' }: { directory?: string }) => { + try { + const entries = await fs.readdir(safeResolve(directory)); + return entries.length > 0 ? entries.join('\\n') : '(empty directory)'; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }, +}); + +tools.push(fileRead, fileWrite, listFiles); +{{/if}} + +// Add MCP clients to tools if available +for (const mcpClient of mcpClients) { + if (mcpClient) { + tools.push(mcpClient); + } +} + +const SYSTEM_PROMPT = \` +You are a helpful assistant. Use tools when appropriate. +{{#if sessionStorageMountPath}} +You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. +{{/if}} +\`; + +let cachedAgent: Agent | null = null; + +function getOrCreateAgent(): Agent { + if (!cachedAgent) { + cachedAgent = new Agent({ + model: loadModel(), + systemPrompt: SYSTEM_PROMPT, + tools, + }); + } + return cachedAgent; +} + +const app = new BedrockAgentCoreApp({ + invocationHandler: { + async *process(payload: { prompt?: string }, context: { sessionId?: string; userId?: string }) { + const agent = getOrCreateAgent(); + + for await (const event of agent.stream(payload.prompt ?? '')) { + if ( + event.type === 'modelStreamUpdateEvent' && + event.event?.type === 'modelContentBlockDeltaEvent' && + event.event.delta?.type === 'textDelta' + ) { + yield { data: event.event.delta.text }; + } + } + }, + }, +}); + +app.run(); +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/mcp_client/client.ts should match snapshot 1`] = ` +"import { McpClient } from '@strands-agents/sdk'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +import { withAccessToken } from 'bedrock-agentcore/identity'; +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +async function getBearerToken{{snakeCase name}}(): Promise { + return withAccessToken( + { + providerName: '{{credentialProviderName}}', + scopes: [{{#if scopes}}'{{scopes}}'{{/if}}], + authFlow: 'M2M', + }, + async (accessToken: string) => accessToken + )(); +} + +{{/if}} +{{/each}} +{{#each gatewayProviders}} +export function get{{snakeCase name}}McpClient(): McpClient | null { + const url = process.env.{{envVarName}}; + if (!url) { + console.warn('{{envVarName}} not set — {{name}} gateway tools unavailable'); + return null; + } + {{#if (eq authType "CUSTOM_JWT")}} + const transport = new StreamableHTTPClientTransport(new URL(url), { + requestInit: { + headers: async () => { + const token = await getBearerToken{{snakeCase name}}(); + return token ? { Authorization: \`Bearer \${token}\` } : {}; + }, + }, + }); + {{else if (eq authType "AWS_IAM")}} + // AWS_IAM gateway auth for TypeScript is not yet supported — add SigV4 signing + // to the transport's requestInit when the mcp-proxy-for-aws TS package is available. + const transport = new StreamableHTTPClientTransport(new URL(url)); + {{else}} + const transport = new StreamableHTTPClientTransport(new URL(url)); + {{/if}} + return new McpClient({ transport }); +} + +{{/each}} +export function getAllGatewayMcpClients(): Array { + const clients: Array = []; + {{#each gatewayProviders}} + clients.push(get{{snakeCase name}}McpClient()); + {{/each}} + return clients; +} +{{else}} +{{#if isVpc}} +// VPC mode: external MCP endpoints are not reachable without a NAT gateway. +// Add an AgentCore Gateway with \`agentcore add gateway\`, or configure your own endpoint below. + +export function getStreamableHttpMcpClient(): McpClient | null { + return null; +} +{{else}} +// ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication +const EXAMPLE_MCP_ENDPOINT = 'https://mcp.exa.ai/mcp'; + +export function getStreamableHttpMcpClient(): McpClient { + // to use an MCP server that supports bearer authentication, add a headers() callback to requestInit + const transport = new StreamableHTTPClientTransport(new URL(EXAMPLE_MCP_ENDPOINT)); + return new McpClient({ transport }); +} +{{/if}} +{{/if}} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/model/load.ts should match snapshot 1`] = ` +"{{#if (eq modelProvider "Bedrock")}} +import { BedrockModel } from '@strands-agents/sdk/models/bedrock'; + +export function loadModel(): BedrockModel { + return new BedrockModel({ modelId: 'global.anthropic.claude-sonnet-4-5-20250929-v1:0' }); +} +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import { AnthropicModel } from '@strands-agents/sdk/models/anthropic'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} not found. Add \${IDENTITY_ENV_VAR}=your-key to .env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +export function loadModel(): AnthropicModel { + return new AnthropicModel({ + clientArgs: { apiKey: getApiKey }, + modelId: 'claude-sonnet-4-5-20250929', + maxTokens: 5000, + }); +} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import { OpenAIModel } from '@strands-agents/sdk/models/openai'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} not found. Add \${IDENTITY_ENV_VAR}=your-key to .env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +export function loadModel(): OpenAIModel { + return new OpenAIModel({ + clientArgs: { apiKey: getApiKey }, + modelId: 'gpt-4.1', + }); +} +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import { GoogleModel } from '@strands-agents/sdk/models/google'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} not found. Add \${IDENTITY_ENV_VAR}=your-key to .env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +export function loadModel(): GoogleModel { + return new GoogleModel({ + clientArgs: { apiKey: getApiKey }, + modelId: 'gemini-2.5-flash', + }); +} +{{/if}} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/package.json should match snapshot 1`] = ` +"{ + "name": "{{name}}", + "version": "0.1.0", + "description": "AgentCore Runtime Application using Strands TypeScript SDK", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch main.ts" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.943.0", + "@modelcontextprotocol/sdk": "^1.25.2", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.57.2", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.2", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-metrics": "^1.30.1", + "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/sdk-trace-node": "^1.30.1", + "@strands-agents/sdk": "1.0.0-rc.4", + "bedrock-agentcore": "0.2.2", + "express": "^5.1.0", + "tsx": "^4.19.0", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "overrides": { + "bedrock-agentcore": { + "@strands-agents/sdk": "$@strands-agents/sdk" + } + } +} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/tsconfig.json should match snapshot 1`] = ` +"{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/README.md should match snapshot 1`] = ` +"This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a \`.gitignore\` file, an +\`agentcore/\` folder which represents the configurations and state associated with this project. Other \`agentcore\` +commands like \`deploy\`, \`dev\`, and \`invoke\` rely on the configuration stored here. + +## Agent Root + +The main entrypoint to your app is defined in \`main.ts\`. Using the AgentCore SDK \`BedrockAgentCoreApp\`, this file +defines an HTTP app that streams tokens using the Vercel AI SDK's \`streamText\` API. + +\`model/load.ts\` instantiates your chosen model provider. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| \`{{identityProviders.[0].envVarName}}\` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| \`LOCAL_DEV\` | No | Set to \`1\` to use \`.env.local\` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, \`node_modules/\` is already populated with dependencies. + +\`agentcore dev\` will start a local server on 0.0.0.0:8080 using \`npx tsx watch main.ts\` for hot reload. + +In a new terminal, you can invoke that server with: + +\`agentcore invoke --dev "What can you do"\` + +# Deployment + +After providing credentials, \`agentcore deploy\` will deploy your project into Amazon Bedrock AgentCore. + +Use \`agentcore invoke\` to invoke your deployed agent. +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/gitignore.template should match snapshot 1`] = ` +"# Environment variables +.env +.env.* + +# Node +node_modules/ +dist/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/main.ts should match snapshot 1`] = ` +"import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime'; +import { streamText } from 'ai'; +import { loadModel } from './model/load.js'; + +const SYSTEM_PROMPT = \`You are a helpful assistant.\`; + +const app = new BedrockAgentCoreApp({ + invocationHandler: { + async *process(payload: { prompt?: string }, context: { sessionId?: string; userId?: string }) { + const result = streamText({ + model: loadModel(), + system: SYSTEM_PROMPT, + prompt: payload.prompt ?? '', + }); + + for await (const chunk of result.textStream) { + yield { data: chunk }; + } + }, + }, +}); + +app.run(); +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/model/load.ts should match snapshot 1`] = ` +"{{#if (eq modelProvider "Bedrock")}} +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; + +const provider = fromNodeProviderChain(); + +const bedrock = createAmazonBedrock({ + region: process.env.AWS_REGION ?? 'us-east-1', + credentialProvider: async () => { + const creds = await provider(); + return { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + }; + }, +}); + +export function loadModel() { + return bedrock('us.anthropic.claude-sonnet-4-5-20250929-v1:0'); +} +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import { createAnthropic } from '@ai-sdk/anthropic'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} not found. Add \${IDENTITY_ENV_VAR}=your-key to .env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +const anthropic = createAnthropic({ apiKey: getApiKey }); + +export function loadModel() { + return anthropic('claude-sonnet-4-5-20250929'); +} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import { createOpenAI } from '@ai-sdk/openai'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} not found. Add \${IDENTITY_ENV_VAR}=your-key to .env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +const openai = createOpenAI({ apiKey: getApiKey }); + +export function loadModel() { + return openai('gpt-4.1'); +} +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} not found. Add \${IDENTITY_ENV_VAR}=your-key to .env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +const google = createGoogleGenerativeAI({ apiKey: getApiKey }); + +export function loadModel() { + return google('gemini-2.5-flash'); +} +{{/if}} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/package.json should match snapshot 1`] = ` +"{ + "name": "{{name}}", + "version": "0.1.0", + "description": "AgentCore Runtime Application using Vercel AI SDK", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch main.ts" + }, + "dependencies": { + "ai": "^6.0.0", + {{#if (eq modelProvider "Bedrock")}} + "@ai-sdk/amazon-bedrock": "^4.0.0", + "@aws-sdk/credential-providers": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "Anthropic")}} + "@ai-sdk/anthropic": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "OpenAI")}} + "@ai-sdk/openai": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "Gemini")}} + "@ai-sdk/google": "^3.0.0", + {{/if}} + "@opentelemetry/api": "^1.9.0", + "bedrock-agentcore": "0.2.2", + "tsx": "^4.19.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/tsconfig.json should match snapshot 1`] = ` +"{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} +" +`; diff --git a/src/assets/container/typescript/Dockerfile b/src/assets/container/typescript/Dockerfile new file mode 100644 index 000000000..df9c6bac1 --- /dev/null +++ b/src/assets/container/typescript/Dockerfile @@ -0,0 +1,25 @@ +FROM public.ecr.aws/docker/library/node:22-slim + +WORKDIR /app + +ENV NODE_ENV=production \ + DOCKER_CONTAINER=1 + +RUN userdel -r node 2>/dev/null || true +RUN useradd -m -u 1000 bedrock_agentcore + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev || npm install --omit=dev + +COPY --chown=bedrock_agentcore:bedrock_agentcore . . + +USER bedrock_agentcore + +# AgentCore Runtime service contract ports +# https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-service-contract.html +# 8080: HTTP Mode +# 8000: MCP Mode +# 9000: A2A Mode +EXPOSE 8080 8000 9000 + +CMD ["npx", "tsx", "main.ts"] diff --git a/src/assets/container/typescript/dockerignore.template b/src/assets/container/typescript/dockerignore.template new file mode 100644 index 000000000..4fe494a08 --- /dev/null +++ b/src/assets/container/typescript/dockerignore.template @@ -0,0 +1,24 @@ +# Node +node_modules/ +dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ + +# Testing +coverage/ + +# Secrets and environment files +.env +.env.* + +# Version control +.git/ + +# AgentCore build artifacts +.agentcore/artifacts/ +*.zip diff --git a/src/assets/typescript/http/strands/base/README.md b/src/assets/typescript/http/strands/base/README.md new file mode 100644 index 000000000..2f285b39b --- /dev/null +++ b/src/assets/typescript/http/strands/base/README.md @@ -0,0 +1,37 @@ +This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a `.gitignore` file, an +`agentcore/` folder which represents the configurations and state associated with this project. Other `agentcore` +commands like `deploy`, `dev`, and `invoke` rely on the configuration stored here. + +## Agent Root + +The main entrypoint to your app is defined in `main.ts`. Using the AgentCore SDK `BedrockAgentCoreApp`, this file +defines a Fastify-based HTTP app that streams tokens from your chosen Agent framework SDK. + +`model/load.ts` instantiates your chosen model provider. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| `{{identityProviders.[0].envVarName}}` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| `LOCAL_DEV` | No | Set to `1` to use `.env.local` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, `node_modules/` is already populated with dependencies. + +`agentcore dev` will start a local server on 0.0.0.0:8080 using `npx tsx watch main.ts` for hot reload. + +In a new terminal, you can invoke that server with: + +`agentcore invoke --dev "What can you do"` + +# Deployment + +After providing credentials, `agentcore deploy` will deploy your project into Amazon Bedrock AgentCore. + +Use `agentcore invoke` to invoke your deployed agent. diff --git a/src/assets/typescript/http/strands/base/gitignore.template b/src/assets/typescript/http/strands/base/gitignore.template new file mode 100644 index 000000000..feb4f544d --- /dev/null +++ b/src/assets/typescript/http/strands/base/gitignore.template @@ -0,0 +1,22 @@ +# Environment variables +.env +.env.* + +# Node +node_modules/ +dist/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/src/assets/typescript/http/strands/base/main.ts b/src/assets/typescript/http/strands/base/main.ts new file mode 100644 index 000000000..82e983012 --- /dev/null +++ b/src/assets/typescript/http/strands/base/main.ts @@ -0,0 +1,154 @@ +import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime'; +import { Agent, tool } from '@strands-agents/sdk'; +import { loadModel } from './model/load.js'; +{{#if hasGateway}} +import { getAllGatewayMcpClients } from './mcp_client/client.js'; +{{else}} +import { getStreamableHttpMcpClient } from './mcp_client/client.js'; +{{/if}} +{{#if sessionStorageMountPath}} +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +{{/if}} + +// Define a collection of MCP clients +{{#if hasGateway}} +const mcpClients = getAllGatewayMcpClients(); +{{else}} +const mcpClients = [getStreamableHttpMcpClient()].filter(Boolean); +{{/if}} + +// Define a collection of tools used by the model +const tools: unknown[] = []; + +// Define a simple function tool +const addNumbers = tool({ + name: 'add_numbers', + description: 'Return the sum of two numbers', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + }, + handler: async ({ a, b }: { a: number; b: number }) => a + b, +}); +tools.push(addNumbers); + +{{#if sessionStorageMountPath}} +const SESSION_STORAGE_PATH = '{{sessionStorageMountPath}}'; + +function safeResolve(p: string): string { + const base = path.resolve(SESSION_STORAGE_PATH); + const resolved = path.resolve(base, p.replace(/^\/+/, '')); + if (!resolved.startsWith(base)) { + throw new Error(`Path '${p}' is outside the storage boundary`); + } + return resolved; +} + +const fileRead = tool({ + name: 'file_read', + description: 'Read a file from persistent storage. The path is relative to the storage root.', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }, + handler: async ({ path: p }: { path: string }) => { + try { + return await fs.readFile(safeResolve(p), 'utf-8'); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }, +}); + +const fileWrite = tool({ + name: 'file_write', + description: 'Write content to a file in persistent storage. The path is relative to the storage root.', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' }, content: { type: 'string' } }, + required: ['path', 'content'], + }, + handler: async ({ path: p, content }: { path: string; content: string }) => { + try { + const full = safeResolve(p); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, content, 'utf-8'); + return `Written to ${p}`; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }, +}); + +const listFiles = tool({ + name: 'list_files', + description: 'List files in persistent storage. The directory is relative to the storage root.', + inputSchema: { + type: 'object', + properties: { directory: { type: 'string' } }, + }, + handler: async ({ directory = '' }: { directory?: string }) => { + try { + const entries = await fs.readdir(safeResolve(directory)); + return entries.length > 0 ? entries.join('\n') : '(empty directory)'; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }, +}); + +tools.push(fileRead, fileWrite, listFiles); +{{/if}} + +// Add MCP clients to tools if available +for (const mcpClient of mcpClients) { + if (mcpClient) { + tools.push(mcpClient); + } +} + +const SYSTEM_PROMPT = ` +You are a helpful assistant. Use tools when appropriate. +{{#if sessionStorageMountPath}} +You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. +{{/if}} +`; + +let cachedAgent: Agent | null = null; + +function getOrCreateAgent(): Agent { + if (!cachedAgent) { + cachedAgent = new Agent({ + model: loadModel(), + systemPrompt: SYSTEM_PROMPT, + tools, + }); + } + return cachedAgent; +} + +const app = new BedrockAgentCoreApp({ + invocationHandler: { + async *process(payload: { prompt?: string }, context: { sessionId?: string; userId?: string }) { + const agent = getOrCreateAgent(); + + for await (const event of agent.stream(payload.prompt ?? '')) { + if ( + event.type === 'modelStreamUpdateEvent' && + event.event?.type === 'modelContentBlockDeltaEvent' && + event.event.delta?.type === 'textDelta' + ) { + yield { data: event.event.delta.text }; + } + } + }, + }, +}); + +app.run(); diff --git a/src/assets/typescript/http/strands/base/mcp_client/client.ts b/src/assets/typescript/http/strands/base/mcp_client/client.ts new file mode 100644 index 000000000..3ac4445a7 --- /dev/null +++ b/src/assets/typescript/http/strands/base/mcp_client/client.ts @@ -0,0 +1,75 @@ +import { McpClient } from '@strands-agents/sdk'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +import { withAccessToken } from 'bedrock-agentcore/identity'; +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +async function getBearerToken{{snakeCase name}}(): Promise { + return withAccessToken( + { + providerName: '{{credentialProviderName}}', + scopes: [{{#if scopes}}'{{scopes}}'{{/if}}], + authFlow: 'M2M', + }, + async (accessToken: string) => accessToken + )(); +} + +{{/if}} +{{/each}} +{{#each gatewayProviders}} +export function get{{snakeCase name}}McpClient(): McpClient | null { + const url = process.env.{{envVarName}}; + if (!url) { + console.warn('{{envVarName}} not set — {{name}} gateway tools unavailable'); + return null; + } + {{#if (eq authType "CUSTOM_JWT")}} + const transport = new StreamableHTTPClientTransport(new URL(url), { + requestInit: { + headers: async () => { + const token = await getBearerToken{{snakeCase name}}(); + return token ? { Authorization: `Bearer ${token}` } : {}; + }, + }, + }); + {{else if (eq authType "AWS_IAM")}} + // AWS_IAM gateway auth for TypeScript is not yet supported — add SigV4 signing + // to the transport's requestInit when the mcp-proxy-for-aws TS package is available. + const transport = new StreamableHTTPClientTransport(new URL(url)); + {{else}} + const transport = new StreamableHTTPClientTransport(new URL(url)); + {{/if}} + return new McpClient({ transport }); +} + +{{/each}} +export function getAllGatewayMcpClients(): Array { + const clients: Array = []; + {{#each gatewayProviders}} + clients.push(get{{snakeCase name}}McpClient()); + {{/each}} + return clients; +} +{{else}} +{{#if isVpc}} +// VPC mode: external MCP endpoints are not reachable without a NAT gateway. +// Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. + +export function getStreamableHttpMcpClient(): McpClient | null { + return null; +} +{{else}} +// ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication +const EXAMPLE_MCP_ENDPOINT = 'https://mcp.exa.ai/mcp'; + +export function getStreamableHttpMcpClient(): McpClient { + // to use an MCP server that supports bearer authentication, add a headers() callback to requestInit + const transport = new StreamableHTTPClientTransport(new URL(EXAMPLE_MCP_ENDPOINT)); + return new McpClient({ transport }); +} +{{/if}} +{{/if}} diff --git a/src/assets/typescript/http/strands/base/model/load.ts b/src/assets/typescript/http/strands/base/model/load.ts new file mode 100644 index 000000000..470fcb357 --- /dev/null +++ b/src/assets/typescript/http/strands/base/model/load.ts @@ -0,0 +1,83 @@ +{{#if (eq modelProvider "Bedrock")}} +import { BedrockModel } from '@strands-agents/sdk/models/bedrock'; + +export function loadModel(): BedrockModel { + return new BedrockModel({ modelId: 'global.anthropic.claude-sonnet-4-5-20250929-v1:0' }); +} +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import { AnthropicModel } from '@strands-agents/sdk/models/anthropic'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} not found. Add ${IDENTITY_ENV_VAR}=your-key to .env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +export function loadModel(): AnthropicModel { + return new AnthropicModel({ + clientArgs: { apiKey: getApiKey }, + modelId: 'claude-sonnet-4-5-20250929', + maxTokens: 5000, + }); +} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import { OpenAIModel } from '@strands-agents/sdk/models/openai'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} not found. Add ${IDENTITY_ENV_VAR}=your-key to .env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +export function loadModel(): OpenAIModel { + return new OpenAIModel({ + clientArgs: { apiKey: getApiKey }, + modelId: 'gpt-4.1', + }); +} +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import { GoogleModel } from '@strands-agents/sdk/models/google'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} not found. Add ${IDENTITY_ENV_VAR}=your-key to .env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +export function loadModel(): GoogleModel { + return new GoogleModel({ + clientArgs: { apiKey: getApiKey }, + modelId: 'gemini-2.5-flash', + }); +} +{{/if}} diff --git a/src/assets/typescript/http/strands/base/package.json b/src/assets/typescript/http/strands/base/package.json new file mode 100644 index 000000000..e16b598e2 --- /dev/null +++ b/src/assets/typescript/http/strands/base/package.json @@ -0,0 +1,37 @@ +{ + "name": "{{name}}", + "version": "0.1.0", + "description": "AgentCore Runtime Application using Strands TypeScript SDK", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch main.ts" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.943.0", + "@modelcontextprotocol/sdk": "^1.25.2", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.57.2", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.2", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-metrics": "^1.30.1", + "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/sdk-trace-node": "^1.30.1", + "@strands-agents/sdk": "1.0.0-rc.4", + "bedrock-agentcore": "0.2.2", + "express": "^5.1.0", + "tsx": "^4.19.0", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "overrides": { + "bedrock-agentcore": { + "@strands-agents/sdk": "$@strands-agents/sdk" + } + } +} diff --git a/src/assets/typescript/http/strands/base/tsconfig.json b/src/assets/typescript/http/strands/base/tsconfig.json new file mode 100644 index 000000000..c199ae076 --- /dev/null +++ b/src/assets/typescript/http/strands/base/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/assets/typescript/http/vercelai/base/README.md b/src/assets/typescript/http/vercelai/base/README.md new file mode 100644 index 000000000..d73be5bdf --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/README.md @@ -0,0 +1,37 @@ +This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a `.gitignore` file, an +`agentcore/` folder which represents the configurations and state associated with this project. Other `agentcore` +commands like `deploy`, `dev`, and `invoke` rely on the configuration stored here. + +## Agent Root + +The main entrypoint to your app is defined in `main.ts`. Using the AgentCore SDK `BedrockAgentCoreApp`, this file +defines an HTTP app that streams tokens using the Vercel AI SDK's `streamText` API. + +`model/load.ts` instantiates your chosen model provider. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| `{{identityProviders.[0].envVarName}}` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| `LOCAL_DEV` | No | Set to `1` to use `.env.local` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, `node_modules/` is already populated with dependencies. + +`agentcore dev` will start a local server on 0.0.0.0:8080 using `npx tsx watch main.ts` for hot reload. + +In a new terminal, you can invoke that server with: + +`agentcore invoke --dev "What can you do"` + +# Deployment + +After providing credentials, `agentcore deploy` will deploy your project into Amazon Bedrock AgentCore. + +Use `agentcore invoke` to invoke your deployed agent. diff --git a/src/assets/typescript/http/vercelai/base/gitignore.template b/src/assets/typescript/http/vercelai/base/gitignore.template new file mode 100644 index 000000000..feb4f544d --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/gitignore.template @@ -0,0 +1,22 @@ +# Environment variables +.env +.env.* + +# Node +node_modules/ +dist/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/src/assets/typescript/http/vercelai/base/main.ts b/src/assets/typescript/http/vercelai/base/main.ts new file mode 100644 index 000000000..2d71e985b --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/main.ts @@ -0,0 +1,23 @@ +import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime'; +import { streamText } from 'ai'; +import { loadModel } from './model/load.js'; + +const SYSTEM_PROMPT = `You are a helpful assistant.`; + +const app = new BedrockAgentCoreApp({ + invocationHandler: { + async *process(payload: { prompt?: string }, context: { sessionId?: string; userId?: string }) { + const result = streamText({ + model: loadModel(), + system: SYSTEM_PROMPT, + prompt: payload.prompt ?? '', + }); + + for await (const chunk of result.textStream) { + yield { data: chunk }; + } + }, + }, +}); + +app.run(); diff --git a/src/assets/typescript/http/vercelai/base/model/load.ts b/src/assets/typescript/http/vercelai/base/model/load.ts new file mode 100644 index 000000000..c42657075 --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/model/load.ts @@ -0,0 +1,94 @@ +{{#if (eq modelProvider "Bedrock")}} +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; + +const provider = fromNodeProviderChain(); + +const bedrock = createAmazonBedrock({ + region: process.env.AWS_REGION ?? 'us-east-1', + credentialProvider: async () => { + const creds = await provider(); + return { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + }; + }, +}); + +export function loadModel() { + return bedrock('us.anthropic.claude-sonnet-4-5-20250929-v1:0'); +} +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import { createAnthropic } from '@ai-sdk/anthropic'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} not found. Add ${IDENTITY_ENV_VAR}=your-key to .env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +const anthropic = createAnthropic({ apiKey: getApiKey }); + +export function loadModel() { + return anthropic('claude-sonnet-4-5-20250929'); +} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import { createOpenAI } from '@ai-sdk/openai'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} not found. Add ${IDENTITY_ENV_VAR}=your-key to .env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +const openai = createOpenAI({ apiKey: getApiKey }); + +export function loadModel() { + return openai('gpt-4.1'); +} +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR]; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} not found. Add ${IDENTITY_ENV_VAR}=your-key to .env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME }, async (apiKey: string) => apiKey)(); +} + +const google = createGoogleGenerativeAI({ apiKey: getApiKey }); + +export function loadModel() { + return google('gemini-2.5-flash'); +} +{{/if}} diff --git a/src/assets/typescript/http/vercelai/base/package.json b/src/assets/typescript/http/vercelai/base/package.json new file mode 100644 index 000000000..e429c27dc --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/package.json @@ -0,0 +1,36 @@ +{ + "name": "{{name}}", + "version": "0.1.0", + "description": "AgentCore Runtime Application using Vercel AI SDK", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch main.ts" + }, + "dependencies": { + "ai": "^6.0.0", + {{#if (eq modelProvider "Bedrock")}} + "@ai-sdk/amazon-bedrock": "^4.0.0", + "@aws-sdk/credential-providers": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "Anthropic")}} + "@ai-sdk/anthropic": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "OpenAI")}} + "@ai-sdk/openai": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "Gemini")}} + "@ai-sdk/google": "^3.0.0", + {{/if}} + "@opentelemetry/api": "^1.9.0", + "bedrock-agentcore": "0.2.2", + "tsx": "^4.19.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/src/assets/typescript/http/vercelai/base/tsconfig.json b/src/assets/typescript/http/vercelai/base/tsconfig.json new file mode 100644 index 000000000..c199ae076 --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index 25c5f4c9a..b99ea2f4e 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -318,7 +318,7 @@ export async function invokeAgentRuntimeStreaming(options: InvokeAgentRuntimeOpt agentRuntimeArn: options.runtimeArn, payload: new TextEncoder().encode(JSON.stringify({ prompt: options.payload })), contentType: 'application/json', - accept: 'application/json', + accept: 'application/json, text/event-stream', runtimeSessionId: options.sessionId, runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, ...(options.baggage && { baggage: options.baggage }), @@ -414,7 +414,7 @@ export async function invokeAgentRuntime(options: InvokeAgentRuntimeOptions): Pr agentRuntimeArn: options.runtimeArn, payload: new TextEncoder().encode(JSON.stringify({ prompt: options.payload })), contentType: 'application/json', - accept: 'application/json', + accept: 'application/json, text/event-stream', runtimeSessionId: options.sessionId, runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, ...(options.baggage && { baggage: options.baggage }), diff --git a/src/cli/commands/add/__tests__/add-agent.test.ts b/src/cli/commands/add/__tests__/add-agent.test.ts index 4d4353c36..cee0dfbeb 100644 --- a/src/cli/commands/add/__tests__/add-agent.test.ts +++ b/src/cli/commands/add/__tests__/add-agent.test.ts @@ -98,7 +98,7 @@ describe('add agent command', () => { expect(json.error.includes('Invalid framework'), `Error: ${json.error}`).toBeTruthy(); }); - it('rejects TypeScript for create path', async () => { + it('rejects TypeScript with a non-Strands framework', async () => { const result = await runCLI( [ 'add', @@ -108,7 +108,7 @@ describe('add agent command', () => { '--language', 'TypeScript', '--framework', - 'Strands', + 'LangChain_LangGraph', '--model-provider', 'Bedrock', '--memory', @@ -121,7 +121,7 @@ describe('add agent command', () => { expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); expect(json.success).toBe(false); - expect(json.error.includes('Python'), `Error should mention Python: ${json.error}`).toBeTruthy(); + expect(json.error.includes('Strands'), `Error should mention Strands: ${json.error}`).toBeTruthy(); }); it('validates framework/model compatibility', async () => { diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index b304afa5c..2be895494 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -173,12 +173,25 @@ describe('validate', () => { }); // AC5: Create path language restrictions - it('returns error for create path with TypeScript or Other', () => { - let result = validateAddAgentOptions({ ...validAgentOptionsCreate, language: 'TypeScript' }); + it('accepts TypeScript with Strands and rejects TypeScript with other frameworks', () => { + let result = validateAddAgentOptions({ + ...validAgentOptionsCreate, + language: 'TypeScript', + framework: 'Strands', + }); + expect(result.valid).toBe(true); + + result = validateAddAgentOptions({ + ...validAgentOptionsCreate, + language: 'TypeScript', + framework: 'LangChain_LangGraph', + }); expect(result.valid).toBe(false); - expect(result.error?.includes('Python')).toBeTruthy(); + expect(result.error?.includes('Strands')).toBeTruthy(); + }); - result = validateAddAgentOptions({ ...validAgentOptionsCreate, language: 'Other' }); + it('returns error for create path with Other language', () => { + const result = validateAddAgentOptions({ ...validAgentOptionsCreate, language: 'Other' }); expect(result.valid).toBe(false); expect(result.error?.includes('Python')).toBeTruthy(); }); diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 15ec081ab..4a6275010 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -116,6 +116,14 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes } options.protocol = protocolResult.data; + // TypeScript only supports HTTP today; MCP and A2A templates have not been authored yet + if (protocolResult.data !== 'HTTP' && options.language === 'TypeScript') { + return { + valid: false, + error: `${protocolResult.data} protocol is not yet supported for TypeScript. Use --protocol HTTP or --language Python.`, + }; + } + const isByoPath = options.type === 'byo'; const isImportPath = options.type === 'import'; @@ -236,11 +244,19 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes return { valid: false, error: '--code-location is required for BYO path' }; } } else { - if (options.language === 'TypeScript') { - return { valid: false, error: 'Create path only supports Python (TypeScript templates not yet available)' }; - } if (options.language === 'Other') { - return { valid: false, error: 'Create path only supports Python' }; + return { valid: false, error: 'Create path only supports Python or TypeScript' }; + } + if ( + options.language === 'TypeScript' && + options.framework && + options.framework !== 'Strands' && + options.framework !== 'VercelAI' + ) { + return { + valid: false, + error: `Framework ${options.framework} is not yet available for TypeScript. Only Strands and Vercel AI SDK are supported.`, + }; } if (!options.memory) { diff --git a/src/cli/commands/create/__tests__/validate.test.ts b/src/cli/commands/create/__tests__/validate.test.ts index 8c118ebf5..ad1c999f7 100644 --- a/src/cli/commands/create/__tests__/validate.test.ts +++ b/src/cli/commands/create/__tests__/validate.test.ts @@ -125,13 +125,27 @@ describe('validateCreateOptions', () => { expect(result.error).toContain('Invalid language'); }); - it('returns invalid for TypeScript language', () => { + it('accepts TypeScript with Strands framework', () => { const result = validateCreateOptions( { name: 'TestProj4', language: 'TypeScript', framework: 'Strands', modelProvider: 'Bedrock', memory: 'none' }, testDir ); + expect(result.valid).toBe(true); + }); + + it('rejects TypeScript with a non-Strands framework', () => { + const result = validateCreateOptions( + { + name: 'TestProj4b', + language: 'TypeScript', + framework: 'LangChain_LangGraph', + modelProvider: 'Bedrock', + memory: 'none', + }, + testDir + ); expect(result.valid).toBe(false); - expect(result.error).toContain('TypeScript is not yet supported'); + expect(result.error).toContain('is not yet available for TypeScript'); }); it('returns invalid for invalid framework', () => { diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index a00397f38..d3f9fdb31 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -10,7 +10,7 @@ import type { } from '../../../schema'; import { getErrorMessage } from '../../errors'; import { checkCreateDependencies } from '../../external-requirements'; -import { initGitRepo, setupPythonProject, writeEnvFile, writeGitignore } from '../../operations'; +import { initGitRepo, setupNodeProject, setupPythonProject, writeEnvFile, writeGitignore } from '../../operations'; import { createConfigBundleForAgent } from '../../operations/agent/config-bundle-defaults'; import { mapGenerateConfigToRenderConfig, @@ -303,6 +303,24 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P onProgress?.('Set up Python environment', 'done'); } + // Set up Node environment if needed (unless skipped) + if (language === 'TypeScript' && !skipInstall) { + onProgress?.('Set up Node environment', 'start'); + const agentDir = join(projectRoot, APP_DIR, name); + const nodeResult = await setupNodeProject({ projectDir: agentDir }); + if (nodeResult.status === 'success') { + onProgress?.('Set up Node environment', 'done'); + } else { + const firstLine = (nodeResult.error ?? '').split('\n').find(l => l.trim().length > 0) ?? ''; + const warn = + nodeResult.status === 'npm_not_found' + ? 'npm not found on PATH. Install Node.js 20+ and run `npm install` in the agent directory.' + : `npm install failed${firstLine ? `: ${firstLine.replace(/^npm (error|warn) /i, '').slice(0, 160)}` : ''}. Run \`npm install\` in ${agentDir} to retry and see the full error.`; + depWarnings.push(warn); + onProgress?.('Set up Node environment', 'done'); + } + } + return { success: true, projectPath: projectRoot, @@ -337,6 +355,13 @@ export function getDryRunInfo(options: { wouldCreate.push(`${projectRoot}/app/${name}/`); wouldCreate.push(`${projectRoot}/app/${name}/main.py`); wouldCreate.push(`${projectRoot}/app/${name}/pyproject.toml`); + } else if (language === 'TypeScript') { + wouldCreate.push(`${projectRoot}/app/${name}/`); + wouldCreate.push(`${projectRoot}/app/${name}/main.ts`); + wouldCreate.push(`${projectRoot}/app/${name}/package.json`); + wouldCreate.push(`${projectRoot}/app/${name}/tsconfig.json`); + wouldCreate.push(`${projectRoot}/app/${name}/model/load.ts`); + wouldCreate.push(`${projectRoot}/app/${name}/mcp_client/client.ts`); } return { diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 42c8c7403..dab51070a 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -185,7 +185,7 @@ export const registerCreate = (program: Command) => { .option('--no-agent', 'Skip agent creation [non-interactive]') .option('--defaults', 'Use defaults (Python, Strands, Bedrock, no memory) [non-interactive]') .option('--build ', 'Build type: CodeZip or Container (default: CodeZip) [non-interactive]') - .option('--language ', 'Target language (default: Python) [non-interactive]') + .option('--language ', 'Target language: Python or TypeScript (default: Python) [non-interactive]') .option( '--framework ', 'Agent framework (Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents) [non-interactive]' diff --git a/src/cli/commands/create/validate.ts b/src/cli/commands/create/validate.ts index a59c7d752..0c67a6009 100644 --- a/src/cli/commands/create/validate.ts +++ b/src/cli/commands/create/validate.ts @@ -113,6 +113,14 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val } } + // TypeScript only supports HTTP today; MCP and A2A templates have not been authored yet + if (protocol !== 'HTTP' && options.language === 'TypeScript') { + return { + valid: false, + error: `${protocol} protocol is not yet supported for TypeScript. Use --protocol HTTP or --language Python.`, + }; + } + // MCP protocol: only name, language, and build type required if (protocol === 'MCP') { if (options.framework) { @@ -161,7 +169,7 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val // Validate language const langResult = TargetLanguageSchema.safeParse(options.language); if (!langResult.success) { - return { valid: false, error: `Invalid language: ${options.language}. Use Python` }; + return { valid: false, error: `Invalid language: ${options.language}. Use Python or TypeScript` }; } // Validate framework @@ -184,9 +192,12 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val return { valid: false, error: `Invalid model provider: ${options.modelProvider}` }; } - // Validate language is supported - if (options.language === 'TypeScript') { - return { valid: false, error: 'TypeScript is not yet supported. Currently supported: Python' }; + // TypeScript is Strands-only for now + if (options.language === 'TypeScript' && fwResult.data !== 'Strands' && fwResult.data !== 'VercelAI') { + return { + valid: false, + error: `Framework ${options.framework} is not yet available for TypeScript. Only Strands and Vercel AI SDK are supported.`, + }; } // Validate framework/model compatibility diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index eac485d8c..9e3e941e6 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -283,9 +283,7 @@ export const registerDev = (program: Command) => { const supportedAgents = getDevSupportedAgents(project); if (supportedAgents.length === 0) { - render( - - ); + render(); process.exit(1); } diff --git a/src/cli/external-requirements/__tests__/detect.test.ts b/src/cli/external-requirements/__tests__/detect.test.ts index dfaa1894e..2c3634ad5 100644 --- a/src/cli/external-requirements/__tests__/detect.test.ts +++ b/src/cli/external-requirements/__tests__/detect.test.ts @@ -20,6 +20,7 @@ describe('detectContainerRuntime', () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' }); + if (args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -36,6 +37,7 @@ describe('detectContainerRuntime', () => { mockRunSubprocessCapture.mockImplementation((bin: string, args: string[]) => { if (bin === 'podman' && args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'podman version 4.5.0\n', stderr: '' }); + if (bin === 'podman' && args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -57,6 +59,7 @@ describe('detectContainerRuntime', () => { if (bin === 'docker' && args[0] === '--version') return Promise.resolve({ code: 1, stdout: '', stderr: 'error' }); if (bin === 'podman' && args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'podman version 4.5.0\n', stderr: '' }); + if (bin === 'podman' && args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); // finch --version also fails if (bin === 'finch' && args[0] === '--version') return Promise.resolve({ code: 1, stdout: '', stderr: 'error' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); @@ -71,6 +74,7 @@ describe('detectContainerRuntime', () => { mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\nExtra info line\n', stderr: '' }); + if (args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -82,6 +86,7 @@ describe('detectContainerRuntime', () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); + if (args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -90,20 +95,30 @@ describe('detectContainerRuntime', () => { expect(result.runtime?.version).toBe(''); }); - it('does not call docker info to check daemon status', async () => { + it('skips runtime when build --help check fails with non-zero exit', async () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { - if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' }); + if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: '1.0.0\n', stderr: '' }); + if (args[0] === 'build') return Promise.resolve({ code: 1, stdout: '', stderr: 'unknown command "build"' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); - await detectContainerRuntime(); + const result = await detectContainerRuntime(); + expect(result.runtime).toBeNull(); + }); + + it('skips runtime when build --help exits 0 but stderr indicates unknown command (shim/wrapper)', async () => { + mockCheckSubprocess.mockResolvedValue(true); + mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { + // Shim exits 0 for everything but prints error to stderr + if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: '1.0.0\n', stderr: '' }); + if (args[0] === 'build') + return Promise.resolve({ code: 0, stdout: '', stderr: 'Error: unknown command "build" for "ada"' }); + return Promise.resolve({ code: 0, stdout: '', stderr: '' }); + }); - // Verify 'info' was never called — this is the key behavioral change - const infoCalls = mockRunSubprocessCapture.mock.calls.filter( - (call: unknown[]) => (call[1] as string[])[0] === 'info' - ); - expect(infoCalls).toHaveLength(0); + const result = await detectContainerRuntime(); + expect(result.runtime).toBeNull(); }); }); @@ -112,6 +127,7 @@ describe('requireContainerRuntime', () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' }); + if (args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); diff --git a/src/cli/external-requirements/detect.ts b/src/cli/external-requirements/detect.ts index 054a4d82b..7e8a4bb12 100644 --- a/src/cli/external-requirements/detect.ts +++ b/src/cli/external-requirements/detect.ts @@ -20,11 +20,8 @@ export interface DetectionResult { /** * Detect available container runtime. - * Checks docker, podman, finch in order; returns the first that is installed. - * Does not probe the daemon (e.g., `docker info`) — that would require socket - * access and can trigger OS password prompts on systems where the user is not - * in the docker group. Actual daemon availability is validated when the runtime - * is used (build, run, etc.). + * Checks docker, podman, finch in order; returns the first that is installed + * and capable of running container operations. */ export async function detectContainerRuntime(): Promise { for (const runtime of CONTAINER_RUNTIMES) { @@ -36,6 +33,14 @@ export async function detectContainerRuntime(): Promise { const result = await runSubprocessCapture(runtime, ['--version']); if (result.code !== 0) continue; + // Validate the binary actually supports container operations. + // Some environments have shims (e.g., toolbox wrappers) that respond to + // --version but don't support build/run commands. These shims may exit 0 + // even on failure, so also check stderr for error indicators. + const buildCheck = await runSubprocessCapture(runtime, ['build', '--help']); + if (buildCheck.code !== 0) continue; + if (buildCheck.stderr && /unknown command|not found/i.test(buildCheck.stderr)) continue; + const version = result.stdout.trim().split('\n')[0] ?? 'unknown'; return { runtime: { runtime, binary: runtime, version } }; } diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index 3ed449236..3d94066b4 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -9,7 +9,12 @@ import type { MemoryStrategyType, ModelProvider, } from '../../../../schema'; -import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, DEFAULT_STRATEGY_NAMESPACES } from '../../../../schema'; +import { + DEFAULT_ENTRYPOINT_BY_LANGUAGE, + DEFAULT_EPISODIC_REFLECTION_NAMESPACES, + DEFAULT_RUNTIME_BY_LANGUAGE, + DEFAULT_STRATEGY_NAMESPACES, +} from '../../../../schema'; import { GatewayPrimitive } from '../../../primitives/GatewayPrimitive'; import { buildAuthorizerConfigFromJwtConfig } from '../../../primitives/auth-utils'; import { @@ -116,9 +121,11 @@ export function mapGenerateConfigToAgent(config: GenerateConfig): AgentEnvSpec { name: config.projectName, build: config.buildType ?? 'CodeZip', ...(config.dockerfile && { dockerfile: config.dockerfile }), - entrypoint: DEFAULT_PYTHON_ENTRYPOINT as FilePath, + entrypoint: (config.language === 'TypeScript' + ? DEFAULT_ENTRYPOINT_BY_LANGUAGE.TypeScript + : DEFAULT_PYTHON_ENTRYPOINT) as FilePath, codeLocation: codeLocation as DirectoryPath, - runtimeVersion: DEFAULT_PYTHON_VERSION, + runtimeVersion: config.language === 'TypeScript' ? DEFAULT_RUNTIME_BY_LANGUAGE.TypeScript : DEFAULT_PYTHON_VERSION, networkMode, protocol, ...(networkMode === 'VPC' && @@ -271,12 +278,15 @@ export async function mapGenerateConfigToRenderConfig( sdkFramework: config.sdk, targetLanguage: config.language, modelProvider: config.modelProvider, - hasMemory: isMcp ? false : config.memory !== 'none', + hasMemory: isMcp || config.language === 'TypeScript' ? false : config.memory !== 'none', hasIdentity: isMcp ? false : identityProviders.length > 0, hasGateway: gatewayProviders.length > 0, isVpc: config.networkMode === 'VPC', buildType: config.buildType, - memoryProviders: isMcp ? [] : mapMemoryOptionToMemoryProviders(config.memory, config.projectName), + memoryProviders: + isMcp || config.language === 'TypeScript' + ? [] + : mapMemoryOptionToMemoryProviders(config.memory, config.projectName), identityProviders: isMcp ? [] : identityProviders, gatewayProviders, gatewayAuthTypes: [...new Set(gatewayProviders.map(g => g.authType))], diff --git a/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts b/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts index 35e06ecd5..ea00b4615 100644 --- a/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts @@ -1,7 +1,9 @@ import { CodeZipDevServer } from '../codezip-dev-server'; import type { DevConfig } from '../config'; import type { DevServerCallbacks, DevServerOptions } from '../dev-server'; +import { spawnSync } from 'child_process'; import { EventEmitter } from 'events'; +import { existsSync } from 'fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockSpawn = vi.fn(); @@ -14,6 +16,9 @@ vi.mock('fs', () => ({ existsSync: vi.fn(() => true), })); +const mockSpawnSync = vi.mocked(spawnSync); +const mockExistsSync = vi.mocked(existsSync); + vi.mock('../../../../lib/utils/platform', () => ({ getVenvExecutable: (venvPath: string, executable: string) => `${venvPath}/bin/${executable}`, })); @@ -121,6 +126,82 @@ describe('CodeZipDevServer spawn config', () => { expect(env.MY_KEY).toBe('secret'); }); + it('TypeScript HTTP: uses npx tsx watch with the entry file', async () => { + const config: DevConfig = { + agentName: 'TsAgent', + module: 'main.ts', + directory: '/project/app', + hasConfig: true, + isPython: false, + buildType: 'CodeZip', + protocol: 'HTTP', + }; + + const server = new CodeZipDevServer(config, defaultOptions); + await server.start(); + + expect(mockSpawn).toHaveBeenCalledWith( + 'npx', + ['tsx', 'watch', 'main.ts'], + expect.objectContaining({ cwd: '/project/app' }) + ); + const env = mockSpawn.mock.calls[0]![2].env; + expect(env.PORT).toBe('8080'); + expect(env.LOCAL_DEV).toBe('1'); + }); + + it('TypeScript: installs node dependencies when node_modules missing', async () => { + mockExistsSync.mockImplementation((p: unknown) => { + const s = String(p); + if (s.endsWith('node_modules')) return false; + if (s.endsWith('pnpm-lock.yaml')) return false; + if (s.endsWith('yarn.lock')) return false; + return true; + }); + mockSpawnSync.mockClear(); + mockSpawnSync.mockReturnValue({ + status: 0, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + } as any); + + const config: DevConfig = { + agentName: 'TsAgent', + module: 'main.ts', + directory: '/project/app', + hasConfig: true, + isPython: false, + buildType: 'CodeZip', + protocol: 'HTTP', + }; + + const server = new CodeZipDevServer(config, defaultOptions); + await server.start(); + + expect(mockSpawnSync).toHaveBeenCalledWith('npm', ['install'], expect.objectContaining({ cwd: '/project/app' })); + mockExistsSync.mockImplementation(() => true); + }); + + it('TypeScript: skips install when node_modules exists', async () => { + mockExistsSync.mockImplementation(() => true); + mockSpawnSync.mockClear(); + + const config: DevConfig = { + agentName: 'TsAgent', + module: 'main.ts', + directory: '/project/app', + hasConfig: true, + isPython: false, + buildType: 'CodeZip', + protocol: 'HTTP', + }; + + const server = new CodeZipDevServer(config, defaultOptions); + await server.start(); + + expect(mockSpawnSync).not.toHaveBeenCalledWith('npm', ['install'], expect.anything()); + }); + it('MCP: extracts file from module:function entrypoint', async () => { const config: DevConfig = { agentName: 'McpAgent', diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index 844ab437c..3d942ca7c 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -35,13 +35,14 @@ describe('getDevConfig', () => { name: 'TestProject', version: 1, managedBy: 'CDK' as const, + // Agent with no entrypoint — not dev-supported runtimes: [ { - name: 'NodeAgent', + name: 'BrokenAgent', build: 'CodeZip', - runtimeVersion: 'NODE_20', - entrypoint: filePath('index.js'), // Not a Python agent - codeLocation: dirPath('./agents/node'), + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath(''), + codeLocation: dirPath('./agents/broken'), protocol: 'HTTP', }, ], @@ -127,18 +128,18 @@ describe('getDevConfig', () => { ); }); - it('throws when specified agent is not Python', () => { + it('returns TypeScript config when project has a Node agent with .ts entrypoint', () => { const project: AgentCoreProjectSpec = { name: 'TestProject', version: 1, managedBy: 'CDK' as const, runtimes: [ { - name: 'NodeAgent', + name: 'TsAgent', build: 'CodeZip', - runtimeVersion: 'NODE_20', - entrypoint: filePath('index.js'), - codeLocation: dirPath('./agents/node'), + runtimeVersion: 'NODE_22', + entrypoint: filePath('main.ts'), + codeLocation: dirPath('./agents/ts'), protocol: 'HTTP', }, ], @@ -153,7 +154,10 @@ describe('getDevConfig', () => { httpGateways: [], }; - expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); + const config = getDevConfig(workingDir, project, undefined, 'TsAgent'); + expect(config).not.toBeNull(); + expect(config?.agentName).toBe('TsAgent'); + expect(config?.isPython).toBe(false); }); it('resolves directory from codeLocation relative to configRoot', () => { @@ -529,7 +533,7 @@ describe('getDevSupportedAgents', () => { expect(getDevSupportedAgents(project)).toEqual([]); }); - it('returns empty array when no agents are Python', () => { + it('returns Node agents as dev-supported alongside Python', () => { const project: AgentCoreProjectSpec = { name: 'TestProject', version: 1, @@ -538,8 +542,8 @@ describe('getDevSupportedAgents', () => { { name: 'NodeAgent', build: 'CodeZip', - runtimeVersion: 'NODE_20', - entrypoint: filePath('index.js'), + runtimeVersion: 'NODE_22', + entrypoint: filePath('main.ts'), codeLocation: dirPath('./agents/node'), protocol: 'HTTP', }, @@ -555,10 +559,12 @@ describe('getDevSupportedAgents', () => { httpGateways: [], }; - expect(getDevSupportedAgents(project)).toEqual([]); + const supported = getDevSupportedAgents(project); + expect(supported).toHaveLength(1); + expect(supported[0]?.name).toBe('NodeAgent'); }); - it('returns only Python agents with entrypoints', () => { + it('returns both Python and Node agents with entrypoints', () => { const project: AgentCoreProjectSpec = { name: 'TestProject', version: 1, @@ -575,8 +581,8 @@ describe('getDevSupportedAgents', () => { { name: 'NodeAgent', build: 'CodeZip', - runtimeVersion: 'NODE_20', - entrypoint: filePath('index.js'), + runtimeVersion: 'NODE_22', + entrypoint: filePath('main.ts'), codeLocation: dirPath('./agents/node'), protocol: 'HTTP', }, @@ -593,8 +599,7 @@ describe('getDevSupportedAgents', () => { }; const supported = getDevSupportedAgents(project); - expect(supported).toHaveLength(1); - expect(supported[0]?.name).toBe('PythonAgent'); + expect(supported.map(a => a.name)).toEqual(['PythonAgent', 'NodeAgent']); }); it('includes Container agents with entrypoints', () => { diff --git a/src/cli/operations/dev/__tests__/container-dev-server.test.ts b/src/cli/operations/dev/__tests__/container-dev-server.test.ts index e8510ce94..bfd374e91 100644 --- a/src/cli/operations/dev/__tests__/container-dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/container-dev-server.test.ts @@ -215,8 +215,8 @@ describe('ContainerDevServer', () => { const server = new ContainerDevServer(defaultConfig, defaultOptions); await server.start(); - // spawnSync only called once for rm (build uses async spawn) - expect(mockSpawnSync).toHaveBeenCalledTimes(1); + // rm call uses spawnSync (build uses async spawn); resolveHostCredentials may also call spawnSync + expect(mockSpawnSync).toHaveBeenCalledWith('docker', expect.arrayContaining(['rm', '-f']), expect.anything()); // First spawn call is the build const buildCall = mockSpawn.mock.calls[0]!; const buildArgs = buildCall[1] as string[]; @@ -377,7 +377,24 @@ describe('ContainerDevServer', () => { expect(spawnArgs).toContain('AWS_SESSION_TOKEN=FwoGZXIvYXdzEBY'); expect(spawnArgs).toContain('AWS_REGION=us-east-1'); expect(spawnArgs).toContain('AWS_DEFAULT_REGION=us-west-2'); + expect(spawnArgs).not.toContain('AWS_PROFILE=dev-profile'); + }); + + it('forwards AWS_PROFILE when no explicit credentials are set', async () => { + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_SESSION_TOKEN; + process.env.AWS_REGION = 'us-east-1'; + process.env.AWS_PROFILE = 'dev-profile'; + + mockSuccessfulPrepare(); + + const server = new ContainerDevServer(defaultConfig, defaultOptions); + await server.start(); + + const spawnArgs = getSpawnArgs(); expect(spawnArgs).toContain('AWS_PROFILE=dev-profile'); + expect(spawnArgs).toContain('AWS_REGION=us-east-1'); }); it('does not include AWS env vars when not set', async () => { diff --git a/src/cli/operations/dev/codezip-dev-server.ts b/src/cli/operations/dev/codezip-dev-server.ts index 31804a036..024d251fb 100644 --- a/src/cli/operations/dev/codezip-dev-server.ts +++ b/src/cli/operations/dev/codezip-dev-server.ts @@ -67,6 +67,35 @@ function ensurePythonVenv( return true; } +/** + * Ensures Node dependencies are installed. Runs the appropriate package manager + * install if `node_modules` is missing. Detects pnpm/yarn via lockfile, else npm. + */ +function ensureNodeDeps(cwd: string, onLog: (level: LogLevel, message: string) => void): boolean { + if (existsSync(join(cwd, 'node_modules'))) { + return true; + } + + let cmd = 'npm'; + let args = ['install']; + if (existsSync(join(cwd, 'pnpm-lock.yaml'))) { + cmd = 'pnpm'; + args = ['install']; + } else if (existsSync(join(cwd, 'yarn.lock'))) { + cmd = 'yarn'; + args = ['install']; + } + + onLog('system', 'Installing Node dependencies...'); + const result = spawnSync(cmd, args, { cwd, stdio: 'pipe' }); + if (result.status !== 0) { + onLog('error', `Failed to install Node dependencies: ${result.stderr?.toString() || 'unknown error'}`); + return false; + } + onLog('system', 'Node dependencies ready'); + return true; +} + /** * Locate the directory containing OpenTelemetry's auto-instrumentation sitecustomize.py. * When this directory is prepended to PYTHONPATH, Python will execute sitecustomize.py @@ -99,7 +128,7 @@ export class CodeZipDevServer extends DevServer { return Promise.resolve( this.config.isPython ? ensurePythonVenv(this.config.directory, this.options.callbacks.onLog, this.config.protocol) - : true + : ensureNodeDeps(this.config.directory, this.options.callbacks.onLog) ); } @@ -118,9 +147,11 @@ export class CodeZipDevServer extends DevServer { } if (!isPython) { + // TS entrypoint is already a file path like "main.ts" — pass it straight to tsx. + const entryFile = module.split(':')[0] ?? module; return { cmd: 'npx', - args: ['tsx', 'watch', (module.split(':')[0] ?? module).replace(/\./g, '/') + '.ts'], + args: ['tsx', 'watch', entryFile], cwd: directory, env, }; diff --git a/src/cli/operations/dev/config.ts b/src/cli/operations/dev/config.ts index fd13637bb..95b855124 100644 --- a/src/cli/operations/dev/config.ts +++ b/src/cli/operations/dev/config.ts @@ -29,8 +29,7 @@ function isPythonAgent(agent: AgentEnvSpec): boolean { * Checks if dev mode is supported for the given agent. * * Requirements: - * - Agent must target Python (TypeScript support not yet implemented) - * - CodeZip agents must have entrypoint + * - Agent must have an entrypoint */ function isDevSupported(agent: AgentEnvSpec): DevSupportResult { if (!agent.entrypoint) { @@ -40,19 +39,6 @@ function isDevSupported(agent: AgentEnvSpec): DevSupportResult { }; } - // Container agents are supported for dev mode (requires local container runtime) - if (agent.build === 'Container') { - return { supported: true }; - } - - // Currently only Python is supported for CodeZip dev mode - if (!isPythonAgent(agent)) { - return { - supported: false, - reason: `Dev mode only supports Python agents. Agent "${agent.name}" does not appear to be a Python agent.`, - }; - } - return { supported: true }; } diff --git a/src/cli/operations/dev/container-dev-server.ts b/src/cli/operations/dev/container-dev-server.ts index 10ef21b3f..e2d487272 100644 --- a/src/cli/operations/dev/container-dev-server.ts +++ b/src/cli/operations/dev/container-dev-server.ts @@ -123,17 +123,58 @@ export class ContainerDevServer extends DevServer { }); } + /** + * Resolve AWS credentials on the host so containers never need credential_process + * tools (e.g. ada) that aren't installed inside the image. + */ + private resolveHostCredentials(): Record | null { + const profile = process.env.AWS_PROFILE ?? 'default'; + try { + const result = spawnSync('aws', ['configure', 'export-credentials', '--format', 'env', '--profile', profile], { + encoding: 'utf-8', + timeout: 10_000, + env: { ...process.env }, + }); + if (result.status !== 0 || !result.stdout) return null; + + const creds: Record = {}; + for (const line of result.stdout.split('\n')) { + const match = /^export\s+(AWS_\w+)=(.+)$/.exec(line); + if (match?.[1] && match[2]) creds[match[1]] = match[2]; + } + return creds.AWS_ACCESS_KEY_ID ? creds : null; + } catch { + return null; + } + } + protected getSpawnConfig(): SpawnConfig { const { port, envVars = {} } = this.options; - // Forward AWS credentials from host environment into the container + // Forward AWS credentials from host environment into the container. + // When explicit credentials are present, omit AWS_PROFILE so SDK credential + // chains prefer the env var credentials over profile-based resolution (which + // can fail when the container user cannot read the mounted ~/.aws files). + // If no explicit creds exist, resolve them on the host via `aws configure + // export-credentials` so containers don't need tools like ada/SSO browsers. + let hasExplicitCreds = !!process.env.AWS_ACCESS_KEY_ID; + if (!hasExplicitCreds) { + const resolved = this.resolveHostCredentials(); + if (resolved) { + for (const [k, v] of Object.entries(resolved)) { + process.env[k] = v; + } + hasExplicitCreds = true; + } + } + const awsEnvKeys = [ 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_REGION', 'AWS_DEFAULT_REGION', - 'AWS_PROFILE', + ...(hasExplicitCreds ? [] : ['AWS_PROFILE']), ]; const awsEnvVars: Record = {}; for (const key of awsEnvKeys) { @@ -142,18 +183,19 @@ export class ContainerDevServer extends DevServer { } } - // Mount ~/.aws to a neutral path accessible by any container user, and set - // AWS SDK env vars to point to it. This supports SSO, profiles, and credential files - // regardless of what USER the Dockerfile specifies. + // Mount ~/.aws only when we couldn't resolve explicit credentials. + // This avoids containers hitting credential_process commands (e.g. ada) + // that aren't installed inside the image. const awsDir = join(homedir(), '.aws'); const awsContainerPath = '/aws-config'; - const awsMountArgs = existsSync(awsDir) ? ['-v', `${awsDir}:${awsContainerPath}:ro`] : []; - const awsConfigEnv = existsSync(awsDir) - ? { - AWS_CONFIG_FILE: `${awsContainerPath}/config`, - AWS_SHARED_CREDENTIALS_FILE: `${awsContainerPath}/credentials`, - } - : {}; + const awsMountArgs = !hasExplicitCreds && existsSync(awsDir) ? ['-v', `${awsDir}:${awsContainerPath}:ro`] : []; + const awsConfigEnv = + !hasExplicitCreds && existsSync(awsDir) + ? { + AWS_CONFIG_FILE: `${awsContainerPath}/config`, + AWS_SHARED_CREDENTIALS_FILE: `${awsContainerPath}/credentials`, + } + : {}; // Environment variables: AWS creds + config paths + user env + container-specific overrides. // OTEL env vars (endpoint + protocol) are passed via envVars from the caller, diff --git a/src/cli/operations/dev/web-ui/handlers/start.ts b/src/cli/operations/dev/web-ui/handlers/start.ts index 41857bda1..0441c5e42 100644 --- a/src/cli/operations/dev/web-ui/handlers/start.ts +++ b/src/cli/operations/dev/web-ui/handlers/start.ts @@ -91,29 +91,29 @@ async function doStartAgent( const agentIndex = ctx.options.agents.findIndex(a => a.name === agentName); const { onLog } = ctx.options; - // A2A agents use a fixed framework port (9000) that can't be overridden via env vars — - // serve_a2a() accepts port as a function parameter, not from the environment. - // MCP agents (FastMCP) also use a fixed port: FastMCP.__init__ passes port=8000 as a - // pydantic BaseSettings init kwarg, which takes priority over the FASTMCP_PORT env var - // we set. So MCP agents always bind to 8000 regardless of environment configuration. + // Several frameworks bind to a fixed port that ignores the PORT env var: + // - A2A: serve_a2a() accepts port as a function parameter, not from env → 9000 + // - MCP (FastMCP): pydantic BaseSettings init kwarg overrides env → 8000 + // - TS HTTP (BedrockAgentCoreApp): hardcodes `const PORT = 8080` in run() → 8080 + // For Python HTTP agents, uvicorn takes --port as a CLI arg so we can assign any port. + // TODO: Remove isFixedPortTS once bedrock-agentcore respects the PORT env var. const isA2A = config.protocol === 'A2A'; const isMCP = config.protocol === 'MCP'; - const targetPort = isA2A ? 9000 : isMCP ? 8000 : ctx.options.uiPort + 1 + (agentIndex >= 0 ? agentIndex : 0); + const isFixedPortTS = !config.isPython && config.protocol === 'HTTP'; + const fixedPort = isA2A ? 9000 : isMCP ? 8000 : isFixedPortTS ? 8080 : undefined; + const targetPort = fixedPort ?? ctx.options.uiPort + 1 + (agentIndex >= 0 ? agentIndex : 0); const agentPort = await findAvailablePort(targetPort); - if (isA2A && agentPort !== 9000) { + if (fixedPort && agentPort !== fixedPort) { + const reason = isA2A + ? 'A2A agents require port 9000.' + : isMCP + ? 'MCP agents require port 8000 (FastMCP default).' + : 'TypeScript agents require port 8080 (BedrockAgentCoreApp default).'; return { success: false, name: agentName, port: 0, - error: `Port 9000 is in use. A2A agents require port 9000.`, - }; - } - if (isMCP && agentPort !== 8000) { - return { - success: false, - name: agentName, - port: 0, - error: `Port 8000 is in use. MCP agents require port 8000 (FastMCP default).`, + error: `Port ${fixedPort} is in use. ${reason}`, }; } if (agentPort !== targetPort) { diff --git a/src/cli/operations/index.ts b/src/cli/operations/index.ts index c09332659..f16f1b2cc 100644 --- a/src/cli/operations/index.ts +++ b/src/cli/operations/index.ts @@ -4,6 +4,7 @@ export * from './dev'; export * from './fetch-access'; export * from './init'; export * from './mcp'; +export * from './node'; export * from './python'; export * from './remove'; export * from './resolve-agent'; diff --git a/src/cli/operations/node/__tests__/setup.test.ts b/src/cli/operations/node/__tests__/setup.test.ts new file mode 100644 index 000000000..4f30b36f9 --- /dev/null +++ b/src/cli/operations/node/__tests__/setup.test.ts @@ -0,0 +1,107 @@ +import * as lib from '../../../../lib/index.js'; +import { checkNpmAvailable, installNodeDependencies, setupNodeProject } from '../setup.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../lib/index.js', async () => { + const actual = await vi.importActual('../../../../lib/index.js'); + return { + ...actual, + checkSubprocess: vi.fn(), + runSubprocessCapture: vi.fn(), + }; +}); + +const mockCheckSubprocess = vi.mocked(lib.checkSubprocess); +const mockRunSubprocessCapture = vi.mocked(lib.runSubprocessCapture); + +describe('checkNpmAvailable', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns true when npm is available', async () => { + mockCheckSubprocess.mockResolvedValue(true); + + expect(await checkNpmAvailable()).toBe(true); + expect(mockCheckSubprocess).toHaveBeenCalledWith('npm', ['--version']); + }); + + it('returns false when npm is not available', async () => { + mockCheckSubprocess.mockResolvedValue(false); + + expect(await checkNpmAvailable()).toBe(false); + }); +}); + +describe('installNodeDependencies', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns success when install succeeds', async () => { + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + + const result = await installNodeDependencies('/project'); + + expect(result.status).toBe('success'); + expect(mockRunSubprocessCapture).toHaveBeenCalledWith('npm', ['install'], { cwd: '/project' }); + }); + + it('returns install_failed on error', async () => { + mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: 'some output', stderr: '', signal: null }); + + const result = await installNodeDependencies('/project'); + + expect(result.status).toBe('install_failed'); + expect(result.error).toBe('some output'); + }); +}); + +describe('setupNodeProject', () => { + const origEnv = process.env.AGENTCORE_SKIP_INSTALL; + + afterEach(() => { + vi.clearAllMocks(); + if (origEnv !== undefined) process.env.AGENTCORE_SKIP_INSTALL = origEnv; + else delete process.env.AGENTCORE_SKIP_INSTALL; + }); + + it('skips install when AGENTCORE_SKIP_INSTALL is set', async () => { + process.env.AGENTCORE_SKIP_INSTALL = '1'; + + const result = await setupNodeProject({ projectDir: '/project' }); + + expect(result.status).toBe('success'); + expect(mockCheckSubprocess).not.toHaveBeenCalled(); + }); + + it('returns npm_not_found when npm is not available', async () => { + delete process.env.AGENTCORE_SKIP_INSTALL; + mockCheckSubprocess.mockResolvedValue(false); + + const result = await setupNodeProject({ projectDir: '/project' }); + + expect(result.status).toBe('npm_not_found'); + expect(result.error).toContain('npm'); + }); + + it('returns install_failed when npm install fails', async () => { + delete process.env.AGENTCORE_SKIP_INSTALL; + mockCheckSubprocess.mockResolvedValue(true); + mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: '', stderr: 'npm fail', signal: null }); + + const result = await setupNodeProject({ projectDir: '/project' }); + + expect(result.status).toBe('install_failed'); + }); + + it('returns success when full setup succeeds', async () => { + delete process.env.AGENTCORE_SKIP_INSTALL; + mockCheckSubprocess.mockResolvedValue(true); + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + + const result = await setupNodeProject({ projectDir: '/project' }); + + expect(result.status).toBe('success'); + }); +}); diff --git a/src/cli/operations/node/index.ts b/src/cli/operations/node/index.ts new file mode 100644 index 000000000..79b911e54 --- /dev/null +++ b/src/cli/operations/node/index.ts @@ -0,0 +1,8 @@ +export { + checkNpmAvailable, + installNodeDependencies, + setupNodeProject, + type NodeSetupResult, + type NodeSetupStatus, + type NodeSetupOptions, +} from './setup'; diff --git a/src/cli/operations/node/setup.ts b/src/cli/operations/node/setup.ts new file mode 100644 index 000000000..5be415478 --- /dev/null +++ b/src/cli/operations/node/setup.ts @@ -0,0 +1,51 @@ +import { checkSubprocess, runSubprocessCapture } from '../../../lib'; + +export type NodeSetupStatus = 'success' | 'npm_not_found' | 'install_failed'; + +export interface NodeSetupResult { + status: NodeSetupStatus; + error?: string; +} + +export interface NodeSetupOptions { + projectDir: string; +} + +/** + * Check if npm is available on the system. + */ +export async function checkNpmAvailable(): Promise { + return checkSubprocess('npm', ['--version']); +} + +/** + * Install dependencies using npm install. + * Uses `npm install` (not `npm ci`) because fresh scaffolds don't ship a lockfile. + */ +export async function installNodeDependencies(projectDir: string): Promise { + const result = await runSubprocessCapture('npm', ['install'], { cwd: projectDir }); + if (result.code === 0) { + return { status: 'success' }; + } + return { status: 'install_failed', error: result.stderr || result.stdout }; +} + +/** + * Set up a Node.js project: run `npm install`. + * Returns a result with status and optional error details. + */ +export async function setupNodeProject(options: NodeSetupOptions): Promise { + if (process.env.AGENTCORE_SKIP_INSTALL) return { status: 'success' }; + + const { projectDir } = options; + + const npmAvailable = await checkNpmAvailable(); + if (!npmAvailable) { + return { + status: 'npm_not_found', + error: "'npm' not found. Install Node.js from https://nodejs.org/", + }; + } + + return installNodeDependencies(projectDir); +} diff --git a/src/cli/templates/VercelAIRenderer.ts b/src/cli/templates/VercelAIRenderer.ts new file mode 100644 index 000000000..0fd1f9f93 --- /dev/null +++ b/src/cli/templates/VercelAIRenderer.ts @@ -0,0 +1,9 @@ +import { BaseRenderer } from './BaseRenderer'; +import { TEMPLATE_ROOT } from './templateRoot'; +import type { AgentRenderConfig } from './types'; + +export class VercelAIRenderer extends BaseRenderer { + constructor(config: AgentRenderConfig) { + super(config, 'vercelai', TEMPLATE_ROOT, config.protocol ?? 'http'); + } +} diff --git a/src/cli/templates/index.ts b/src/cli/templates/index.ts index 3e57beb8d..e41e563b3 100644 --- a/src/cli/templates/index.ts +++ b/src/cli/templates/index.ts @@ -4,6 +4,7 @@ import { LangGraphRenderer } from './LangGraphRenderer'; import { McpRenderer } from './McpRenderer'; import { OpenAIAgentsRenderer } from './OpenAIAgentsRenderer'; import { StrandsRenderer } from './StrandsRenderer'; +import { VercelAIRenderer } from './VercelAIRenderer'; import type { AgentRenderConfig } from './types'; export { BaseRenderer, type RendererContext } from './BaseRenderer'; @@ -14,6 +15,7 @@ export { LangGraphRenderer } from './LangGraphRenderer'; export { McpRenderer } from './McpRenderer'; export { OpenAIAgentsRenderer } from './OpenAIAgentsRenderer'; export { StrandsRenderer } from './StrandsRenderer'; +export { VercelAIRenderer } from './VercelAIRenderer'; export type { AgentRenderConfig } from './types'; /** @@ -34,6 +36,8 @@ export function createRenderer(config: AgentRenderConfig): BaseRenderer { return new LangGraphRenderer(config); case 'OpenAIAgents': return new OpenAIAgentsRenderer(config); + case 'VercelAI': + return new VercelAIRenderer(config); default: { const _exhaustive: never = config.sdkFramework; throw new Error(`Unsupported SDK framework: ${String(_exhaustive)}`); diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index a580b8477..c964b2419 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -195,7 +195,9 @@ export function useDevServer(options: { // Detect when server is actually ready (only once) if ( !serverReady && - (message.includes('Application startup complete') || message.includes('Uvicorn running')) + (message.includes('Application startup complete') || + message.includes('Uvicorn running') || + message.includes('Server listening')) ) { serverReady = true; setStatus('running'); diff --git a/src/cli/tui/screens/agent/types.ts b/src/cli/tui/screens/agent/types.ts index c708bcac0..f4ad9ae27 100644 --- a/src/cli/tui/screens/agent/types.ts +++ b/src/cli/tui/screens/agent/types.ts @@ -156,7 +156,7 @@ export const AGENT_TYPE_OPTIONS = [ export const LANGUAGE_OPTIONS = [ { id: 'Python', title: 'Python' }, - { id: 'TypeScript', title: 'TypeScript (coming soon)', disabled: true }, + { id: 'TypeScript', title: 'TypeScript' }, { id: 'Other', title: 'Other' }, ] as const; diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 9d1742810..a4ea4fa2a 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -2,7 +2,7 @@ import { APP_DIR, CONFIG_DIR, ConfigIO, findConfigRoot, setEnvVar, setSessionPro import type { DeployedState } from '../../../../schema'; import { getErrorMessage } from '../../../errors'; import { CreateLogger } from '../../../logging'; -import { initGitRepo, setupPythonProject, writeEnvFile, writeGitignore } from '../../../operations'; +import { initGitRepo, setupNodeProject, setupPythonProject, writeEnvFile, writeGitignore } from '../../../operations'; import { createConfigBundleForAgent } from '../../../operations/agent/config-bundle-defaults'; import { mapGenerateConfigToRenderConfig, @@ -63,6 +63,9 @@ function getCreateSteps(projectName: string, agentConfig: AddAgentConfig | null) if (agentConfig.language === 'Python' && agentConfig.agentType === 'create') { steps.push({ label: 'Set up Python environment', status: 'pending' }); } + if (agentConfig.language === 'TypeScript' && agentConfig.agentType === 'create') { + steps.push({ label: 'Set up Node environment', status: 'pending' }); + } } steps.push({ label: 'Prepare agentcore/ directory', status: 'pending' }); @@ -457,6 +460,36 @@ export function useCreateFlow(cwd: string): CreateFlowState { } stepIndex++; } + + // Step: Set up Node environment (if TypeScript and create path) + if (addAgentConfig.language === 'TypeScript' && addAgentConfig.agentType === 'create') { + logger.startStep('Set up Node environment'); + updateStep(stepIndex, { status: 'running' }); + const agentDir = join(projectRoot, APP_DIR, addAgentConfig.name); + logger.logSubStep(`Agent directory: ${agentDir}`); + logger.logSubStep('Running npm install...'); + const result = await setupNodeProject({ projectDir: agentDir }); + + if (result.status === 'success') { + logger.endStep('success'); + updateStep(stepIndex, { status: 'success' }); + } else { + const firstLine = (result.error ?? '').split('\n').find(l => l.trim().length > 0) ?? ''; + const shortReason = firstLine.replace(/^npm (error|warn) /i, '').slice(0, 160); + const warnMsg = + result.status === 'npm_not_found' + ? 'npm not found on PATH. Install Node.js 20+ from https://nodejs.org/ and rerun `npm install` in the agent directory.' + : `npm install failed${shortReason ? `: ${shortReason}` : ''}. Run \`npm install\` in ${agentDir} to see the full error.`; + if (result.error) { + for (const line of result.error.split('\n')) { + if (line.trim().length > 0) logger.logSubStep(line); + } + } + logger.endStep('warn', warnMsg); + updateStep(stepIndex, { status: 'warn', warn: warnMsg }); + } + stepIndex++; + } } // Step: Create CDK project diff --git a/src/cli/tui/screens/dev/DevScreen.tsx b/src/cli/tui/screens/dev/DevScreen.tsx index 0040aaf30..dfe798549 100644 --- a/src/cli/tui/screens/dev/DevScreen.tsx +++ b/src/cli/tui/screens/dev/DevScreen.tsx @@ -427,7 +427,7 @@ export function DevScreen(props: DevScreenProps) { No agents defined in project. - Dev mode requires at least one Python agent with an entrypoint. + Dev mode requires at least one agent with an entrypoint. Run agentcore add agent to create one. diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index 9c6c79599..96f5fb4e1 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -32,6 +32,7 @@ import { PROTOCOL_OPTIONS, STEP_LABELS, getModelProviderOptionsForSdk, + getProtocolOptionsForLanguage, getSDKOptionsForProtocol, } from './types'; import type { useGenerateWizard } from './useGenerateWizard'; @@ -77,14 +78,17 @@ export function GenerateWizardUI({ return LANGUAGE_OPTIONS.map(o => ({ id: o.id, title: o.title, - disabled: 'disabled' in o ? o.disabled : undefined, })); case 'buildType': return BUILD_TYPE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })); case 'protocol': - return PROTOCOL_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })); + return getProtocolOptionsForLanguage(wizard.config.language).map(o => ({ + id: o.id, + title: o.title, + description: o.description, + })); case 'sdk': - return getSDKOptionsForProtocol(wizard.config.protocol).map(o => ({ + return getSDKOptionsForProtocol(wizard.config.protocol, wizard.config.language).map(o => ({ id: o.id, title: o.title, description: o.description, diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index 1b764ea80..2f9a304fd 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -106,7 +106,7 @@ export const STEP_LABELS: Record = { export const LANGUAGE_OPTIONS = [ { id: 'Python', title: 'Python' }, - { id: 'TypeScript', title: 'TypeScript (coming soon)', disabled: true }, + { id: 'TypeScript', title: 'TypeScript' }, ] as const; export const BUILD_TYPE_OPTIONS = [ @@ -121,19 +121,36 @@ export const PROTOCOL_OPTIONS = [ { id: 'AGUI', title: 'AG-UI', description: 'Stream rich agent events to frontends' }, ] as const; +/** + * Get protocol options filtered by target language. + * TypeScript only supports HTTP. + */ +export function getProtocolOptionsForLanguage(language?: TargetLanguage) { + if (language === 'TypeScript') { + return PROTOCOL_OPTIONS.filter(option => option.id === 'HTTP'); + } + return [...PROTOCOL_OPTIONS]; +} + export const SDK_OPTIONS = [ { id: 'Strands', title: 'Strands Agents SDK', description: 'AWS native agent framework' }, { id: 'LangChain_LangGraph', title: 'LangChain + LangGraph', description: 'Popular open-source frameworks' }, { id: 'GoogleADK', title: 'Google ADK', description: 'Google Agent Development Kit' }, { id: 'OpenAIAgents', title: 'OpenAI Agents', description: 'OpenAI native agent SDK' }, + { id: 'VercelAI', title: 'Vercel AI SDK', description: 'Vercel AI SDK for TypeScript agents' }, ] as const; /** - * Get SDK options filtered by protocol compatibility. + * Get SDK options filtered by protocol compatibility and target language. + * TypeScript currently only supports Strands. */ -export function getSDKOptionsForProtocol(protocol: ProtocolMode) { +export function getSDKOptionsForProtocol(protocol: ProtocolMode, language?: TargetLanguage) { const supportedFrameworks = PROTOCOL_FRAMEWORK_MATRIX[protocol]; - return SDK_OPTIONS.filter(option => supportedFrameworks.includes(option.id)); + const byProtocol = SDK_OPTIONS.filter(option => supportedFrameworks.includes(option.id)); + if (language === 'TypeScript') { + return byProtocol.filter(option => option.id === 'Strands' || option.id === 'VercelAI'); + } + return byProtocol; } export const MODEL_PROVIDER_OPTIONS = [ diff --git a/src/cli/tui/screens/generate/useGenerateWizard.ts b/src/cli/tui/screens/generate/useGenerateWizard.ts index 411cfb151..2267856c5 100644 --- a/src/cli/tui/screens/generate/useGenerateWizard.ts +++ b/src/cli/tui/screens/generate/useGenerateWizard.ts @@ -53,7 +53,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { if (config.modelProvider === 'Bedrock') { filtered = filtered.filter(s => s !== 'apiKey'); } - if (sdkSelected && config.sdk === 'Strands') { + if (sdkSelected && config.sdk === 'Strands' && config.language !== 'TypeScript') { const advancedIndex = filtered.indexOf('advanced'); filtered = [...filtered.slice(0, advancedIndex), 'memory', ...filtered.slice(advancedIndex)]; } @@ -102,6 +102,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { config.buildType, config.modelProvider, config.sdk, + config.language, config.protocol, config.networkMode, config.authorizerType, @@ -126,7 +127,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { }, []); const setLanguage = useCallback((language: GenerateConfig['language']) => { - setConfig(c => ({ ...c, language })); + setConfig(c => ({ ...c, language, memory: language === 'TypeScript' ? 'none' : c.memory })); setStep('buildType'); }, []); @@ -164,34 +165,34 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { // Non-Bedrock providers need API key step if (modelProvider !== 'Bedrock') { setStep('apiKey'); - } else if (config.sdk === 'Strands') { + } else if (config.sdk === 'Strands' && config.language !== 'TypeScript') { setStep('memory'); } else { setStep('advanced'); } }, - [config.sdk] + [config.sdk, config.language] ); const setApiKey = useCallback( (apiKey: string | undefined) => { setConfig(c => ({ ...c, apiKey })); - if (config.sdk === 'Strands') { + if (config.sdk === 'Strands' && config.language !== 'TypeScript') { setStep('memory'); } else { setStep('advanced'); } }, - [config.sdk] + [config.sdk, config.language] ); const skipApiKey = useCallback(() => { - if (config.sdk === 'Strands') { + if (config.sdk === 'Strands' && config.language !== 'TypeScript') { setStep('memory'); } else { setStep('advanced'); } - }, [config.sdk]); + }, [config.sdk, config.language]); const setMemory = useCallback((memory: MemoryOption) => { setConfig(c => ({ ...c, memory })); diff --git a/src/schema/constants.ts b/src/schema/constants.ts index d235a0df1..f08f8d253 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; // Feature Constants (shared across all schemas) // ============================================================================ -export const SDKFrameworkSchema = z.enum(['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents']); +export const SDKFrameworkSchema = z.enum(['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents', 'VercelAI']); export type SDKFramework = z.infer; export const TargetLanguageSchema = z.enum(['Python', 'TypeScript', 'Other']); @@ -47,6 +47,7 @@ export const SDK_MODEL_PROVIDER_MATRIX: Record; +/** Default Node.js runtime version for new TypeScript agents */ +export const DEFAULT_NODE_VERSION: NodeRuntime = 'NODE_22'; + /** Combined runtime version schema supporting both Python and Node/TypeScript runtimes */ export const RuntimeVersionSchema = z.union([PythonRuntimeSchema, NodeRuntimeSchema]); export type RuntimeVersion = z.infer; +/** Default entrypoint filename for each target language (create path). */ +export const DEFAULT_ENTRYPOINT_BY_LANGUAGE: Record<'Python' | 'TypeScript', string> = { + Python: 'main.py', + TypeScript: 'main.ts', +}; + +/** Default runtime version for each target language (create path). */ +export const DEFAULT_RUNTIME_BY_LANGUAGE: Record<'Python' | 'TypeScript', RuntimeVersion> = { + Python: DEFAULT_PYTHON_VERSION, + TypeScript: DEFAULT_NODE_VERSION, +}; + export const NetworkModeSchema = z.enum(['PUBLIC', 'VPC']); export type NetworkMode = z.infer; @@ -171,7 +189,7 @@ export type ProtocolMode = z.infer; * MCP is a standalone tool server with no framework. */ export const PROTOCOL_FRAMEWORK_MATRIX: Record = { - HTTP: ['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents'] as const, + HTTP: ['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents', 'VercelAI'] as const, MCP: [] as const, A2A: ['Strands', 'GoogleADK', 'LangChain_LangGraph'] as const, AGUI: ['Strands', 'LangChain_LangGraph', 'GoogleADK'] as const,