Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ src/
├── schema/ # Schema definitions with Zod validators
├── lib/ # Shared utilities (ConfigIO, packaging)
├── cli/ # CLI implementation
│ ├── commands/ # CLI commands
│ ├── primitives/ # Resource primitives (add/remove logic per resource type)
│ ├── commands/ # CLI commands (thin Commander registration)
│ ├── tui/ # Terminal UI (Ink/React)
│ ├── operations/ # Business logic
│ ├── operations/ # Shared business logic (schema mapping, deploy, etc.)
│ ├── cdk/ # CDK toolkit wrapper for programmatic CDK operations
│ └── templates/ # Project templating
└── assets/ # Template assets vended to users
Expand Down Expand Up @@ -50,6 +51,25 @@ Note: CDK L3 constructs are in a separate package `@aws/agentcore-cdk`.

- MCP gateway and tool support (`add gateway`, `add mcp-tool`) - currently hidden

## Primitives Architecture

All resource types (agent, memory, identity, gateway, mcp-tool) are modeled as **primitives** — self-contained classes
in `src/cli/primitives/` that own the full add/remove lifecycle for their resource type.

Each primitive extends `BasePrimitive` and implements: `add()`, `remove()`, `previewRemove()`, `getRemovable()`,
`registerCommands()`, and `addScreen()`.

Current primitives:

- `AgentPrimitive` — agent creation (template + BYO), removal, credential resolution
- `MemoryPrimitive` — memory creation with strategies, removal
- `CredentialPrimitive` — credential/identity creation, .env management, removal
- `GatewayPrimitive` — MCP gateway creation/removal (hidden, coming soon)
- `GatewayTargetPrimitive` — MCP tool creation/removal with code generation (hidden, coming soon)

Singletons are created in `registry.ts` and wired into CLI commands via `cli.ts`. See `src/cli/AGENTS.md` for details on
adding new primitives.

## Vended CDK Project

When users run `agentcore create`, we vend a CDK project at `agentcore/cdk/` that:
Expand Down Expand Up @@ -88,3 +108,16 @@ See `docs/TESTING.md` for details.
## Related Package

- `@aws/agentcore-cdk` - CDK constructs used by vended projects

## Code Style

- Never use inline imports. Imports must always go at the top of the file.
- Wheverever there is a requirement to use something that returns a success result and an error message you must use
this format

```javascript
{ success: Boolean, error?:string}
```

- Always look for existing types before creating a new type inline.
- Re-usable constants must be defined in a constants file in the closest sensible subdirectory.
101 changes: 101 additions & 0 deletions src/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,107 @@ The `dev` command uses a strategy pattern with a `DevServer` base class and two

The server selection is based on `agent.build` (`CodeZip` or `Container`).

## Primitives Architecture

All resource types are modeled as **primitives** in `primitives/`. Each primitive is a self-contained class that owns
the full add/remove lifecycle for one resource type. CLI commands and TUI flows consume primitives polymorphically.

### Directory Structure

```
primitives/
├── BasePrimitive.ts # Abstract base class with shared helpers
├── AgentPrimitive.tsx # Agent add/remove (template + BYO paths)
├── MemoryPrimitive.tsx # Memory add/remove
├── CredentialPrimitive.tsx # Credential/identity add/remove + .env management
├── GatewayPrimitive.ts # MCP gateway add/remove (hidden, coming soon)
├── GatewayTargetPrimitive.ts # MCP tool add/remove + code gen (hidden, coming soon)
├── registry.ts # Singleton instances + ALL_PRIMITIVES array
├── credential-utils.ts # Shared credential env var name computation
├── constants.ts # SOURCE_CODE_NOTE and other shared constants
├── types.ts # AddResult, RemovableResource, RemovalResult, etc.
└── index.ts # Barrel exports
```

### BasePrimitive Contract

Every primitive extends `BasePrimitive<TAddOptions, TRemovable>` and implements:

- `kind` — resource identifier (`'agent'`, `'memory'`, `'identity'`, `'gateway'`, `'mcp-tool'`)
- `label` — human-readable name (`'Agent'`, `'Memory'`, `'Identity'`)
- `add(options)` — create a resource, returns `AddResult`
- `remove(name)` — remove a resource, returns `RemovalResult`
- `previewRemove(name)` — preview what removal will do
- `getRemovable()` — list resources available for removal
- `registerCommands(addCmd, removeCmd)` — register CLI subcommands

BasePrimitive provides shared helpers:

- `configIO` — shared ConfigIO instance for agentcore.json
- `readProjectSpec()` / `writeProjectSpec()` — read/write agentcore.json
- `checkDuplicate()` — validate name uniqueness
- `article` — indefinite article for grammar (`'a'` or `'an'`)
- `registerRemoveSubcommand(removeCmd)` — standard remove CLI handler (CLI mode + TUI fallback)

### Adding a New Primitive

1. Create `src/cli/primitives/NewPrimitive.ts` extending `BasePrimitive`
2. Implement all abstract methods (`add`, `remove`, `previewRemove`, `getRemovable`, `registerCommands`, `addScreen`)
3. Add a singleton to `registry.ts` and include it in `ALL_PRIMITIVES`
4. Export from `index.ts`
5. The primitive auto-registers its CLI subcommands via the loop in `cli.ts`

### Key Design Rules

- **Absorb, don't wrap.** Each primitive owns its logic directly. Do not create facade files that delegate to
primitives.
- **No backward-compatibility shims.** This is a CLI, not a library. If the CLI functions the same, delete old files.
- **Use `{ success, error? }` result format** throughout (never `{ ok, error }`). See `AddResult` and `RemovalResult`.
- **Dynamic imports for ink/React only.** TUI components (ink, react, screen components) must be dynamically imported
inside Commander action handlers to prevent esbuild async module propagation issues. All other imports go at the top
of the file. See the esbuild section below.

### esbuild Async Module Constraint

ink uses top-level `await` (via yoga-wasm). Any module that imports ink at the top level becomes async in esbuild's ESM
bundle. If the async propagation fails (e.g., through circular dependencies), esbuild generates `await` inside non-async
functions, causing a runtime `SyntaxError`. To prevent this:

- **Never import ink, react, or TUI screen components at the top of primitive files.**
- Use `await Promise.all([import('ink'), import('react'), import('...')])` inside Commander `.action()` handlers.
- This is the one exception to the "no inline imports" rule in the root AGENTS.md.
- `registry.ts` imports all primitive classes — if any primitive pulls in ink at the top level, all modules that import
from registry become async, causing cascading failures.

### Registry and Wiring

`registry.ts` creates singleton instances of all primitives:

```typescript
export const agentPrimitive = new AgentPrimitive();
export const memoryPrimitive = new MemoryPrimitive();
// ...
export const ALL_PRIMITIVES = [agentPrimitive, memoryPrimitive, ...];
```

`cli.ts` wires them into Commander:

```typescript
for (const primitive of ALL_PRIMITIVES) {
primitive.registerCommands(addCmd, removeCmd);
}
```

### TUI Hooks

TUI remove hooks in `tui/hooks/useRemove.ts` use generic helpers:

- `useRemovableResources<T>(loader)` — generic hook for loading removable resources from any primitive
- `useRemoveResource<TIdentifier>(removeFn, resourceType, getName)` — generic hook for removing any resource with
logging

Each resource-specific hook (e.g., `useRemovableAgents`, `useRemoveMemory`) is a thin wrapper around the generic.

## Commands Directory Structure

Commands live in `commands/`. Each command has its own directory with an `index.ts` barrel file and a file called
Expand Down
10 changes: 8 additions & 2 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { registerStatus } from './commands/status';
import { registerUpdate } from './commands/update';
import { registerValidate } from './commands/validate';
import { PACKAGE_VERSION } from './constants';
import { ALL_PRIMITIVES } from './primitives';
import { App } from './tui/App';
import { LayoutProvider } from './tui/context';
import { COMMAND_DESCRIPTIONS } from './tui/copy';
Expand Down Expand Up @@ -123,17 +124,22 @@ export function createProgram(): Command {
}

export function registerCommands(program: Command) {
registerAdd(program);
const addCmd = registerAdd(program);
registerDev(program);
registerDeploy(program);
registerCreate(program);
registerHelp(program);
registerInvoke(program);
registerPackage(program);
registerRemove(program);
const removeCmd = registerRemove(program);
registerStatus(program);
registerUpdate(program);
registerValidate(program);

// Register primitive subcommands (add agent, remove agent, add memory, etc.)
for (const primitive of ALL_PRIMITIVES) {
primitive.registerCommands(addCmd, removeCmd);
}
}

export const main = async (argv: string[]) => {
Expand Down
16 changes: 11 additions & 5 deletions src/cli/commands/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,22 @@ If removing a command file would remove behavior, the design is wrong.
- Commander registration only
- Parses CLI args and invokes handler or TUI

2. **Action File** (`commands/{name}/action.ts`)
- Contains business logic specific to this command
2. **Primitives** (`primitives/`) — for resource add/remove commands
- Each resource type (agent, memory, identity, gateway, mcp-tool) has a primitive class
- Primitives own add/remove logic, CLI subcommand registration, and TUI screen routing
- `add` and `remove` commands delegate to primitives via `primitive.registerCommands()`
- See `src/cli/AGENTS.md` for the full primitives architecture

3. **Action File** (`commands/{name}/action.ts`) — for non-resource commands
- Contains business logic specific to this command (e.g., deploy, invoke, package)
- Defines interfaces (e.g., `InvokeContext`, `PackageResult`)
- Implements handlers (e.g., `handleInvoke`, `loadPackageConfig`)
- Must be UI-agnostic where possible

3. **Operation** (`operations/{domain}/`)
- Own all shared decisions, sequencing, validation, side effects
4. **Operation** (`operations/{domain}/`)
- Shared utilities consumed by primitives and commands (schema mapping, template rendering)
- Must be UI-agnostic (no Ink, no process.exit)
- Reusable by TUI screens and command handlers alike
- Reusable by TUI screens, primitives, and command handlers alike

## Good vs Bad Examples

Expand Down
100 changes: 0 additions & 100 deletions src/cli/commands/add/__tests__/actions.test.ts

This file was deleted.

15 changes: 7 additions & 8 deletions src/cli/commands/add/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,17 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockReadProjectSpec = vi.fn();
const mockGetExistingGateways = vi.fn();
const mockConfigExists = vi.fn().mockReturnValue(true);
const mockReadMcpSpec = vi.fn();

vi.mock('../../../../lib/index.js', () => ({
ConfigIO: class {
readProjectSpec = mockReadProjectSpec;
configExists = mockConfigExists;
readMcpSpec = mockReadMcpSpec;
},
}));

vi.mock('../../../operations/mcp/create-mcp.js', () => ({
getExistingGateways: (...args: unknown[]) => mockGetExistingGateways(...args),
}));

// Helper: valid base options for each type
const validAgentOptionsByo: AddAgentOptions = {
name: 'TestAgent',
Expand Down Expand Up @@ -286,7 +285,7 @@ describe('validate', () => {
describe('validateAddGatewayTargetOptions', () => {
beforeEach(() => {
// By default, mock that the gateway from validGatewayTargetOptions exists
mockGetExistingGateways.mockResolvedValue(['my-gateway']);
mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [{ name: 'my-gateway' }] });
});

// AC15: Required fields validated
Expand All @@ -313,15 +312,15 @@ describe('validate', () => {
});

it('returns error when no gateways exist', async () => {
mockGetExistingGateways.mockResolvedValue([]);
mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] });
const result = await validateAddGatewayTargetOptions(validGatewayTargetOptions);
expect(result.valid).toBe(false);
expect(result.error).toContain('No gateways found');
expect(result.error).toContain('agentcore add gateway');
});

it('returns error when specified gateway does not exist', async () => {
mockGetExistingGateways.mockResolvedValue(['other-gateway']);
mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [{ name: 'other-gateway' }] });
const result = await validateAddGatewayTargetOptions(validGatewayTargetOptions);
expect(result.valid).toBe(false);
expect(result.error).toContain('Gateway "my-gateway" not found');
Expand Down
Loading
Loading