diff --git a/AGENTS.md b/AGENTS.md index ada495f3..f0b7e8f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ Note: CDK L3 constructs are in a separate package `@aws/agentcore-cdk`. - `dev` - Local development server (CodeZip: uvicorn with hot-reload; Container: Docker build + run with volume mount) - `invoke` - Invoke agents (local or deployed) - `package` - Package agent artifacts without deploying (zip for CodeZip, container image build for Container) +- `tag` - Manage resource tags (list, add, remove, set-defaults, remove-defaults) - `validate` - Validate configuration files - `update` - Check for CLI updates - `help` - Display help information diff --git a/docs/commands.md b/docs/commands.md index 73c03d44..d248807b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -333,6 +333,46 @@ agentcore remove all --dry-run # Preview --- +## Tagging + +### tag + +Manage AWS resource tags on your AgentCore project. Tags are applied to deployed CloudFormation resources (agents, +memories, gateways). Credentials are not taggable since they're deployed via the AgentCore Identity API. + +```bash +# List all tags (project defaults + per-resource) +agentcore tag list +agentcore tag list --json +agentcore tag list --resource agent:MyAgent + +# Add a tag to a specific resource +agentcore tag add --resource agent:MyAgent --key environment --value prod + +# Remove a tag from a resource +agentcore tag remove --resource agent:MyAgent --key environment + +# Set a project-level default tag (inherited by all resources) +agentcore tag set-defaults --key team --value platform + +# Remove a project-level default tag +agentcore tag remove-defaults --key team +``` + +Resource references use `type:name` format. Taggable types: `agent`, `memory`, `gateway`. + +Per-resource tags override project-level defaults when keys conflict. Projects created with the CLI include +`agentcore:created-by` and `agentcore:project-name` as defaults. + +| Flag | Description | +| ------------------ | ------------------------------ | +| `--resource ` | Resource reference (type:name) | +| `--key ` | Tag key (max 128 chars) | +| `--value ` | Tag value (max 256 chars) | +| `--json` | JSON output | + +--- + ## Development ### dev diff --git a/docs/configuration.md b/docs/configuration.md index d32d41c7..e21cd2d9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,6 +22,11 @@ Main project configuration using a **flat resource model**. Agents, memories, an { "name": "MyProject", "version": 1, + "tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "MyProject", + "environment": "dev" + }, "agents": [ { "type": "AgentCoreRuntime", @@ -61,6 +66,7 @@ Main project configuration using a **flat resource model**. Agents, memories, an | ------------- | -------- | ----------------------------------------------------------- | | `name` | Yes | Project name (1-23 chars, alphanumeric, starts with letter) | | `version` | Yes | Schema version (integer, currently `1`) | +| `tags` | No | Project-level default tags (inherited by all resources) | | `agents` | Yes | Array of agent specifications | | `memories` | Yes | Array of memory resources | | `credentials` | Yes | Array of credential providers (API key or OAuth) | @@ -98,6 +104,7 @@ Main project configuration using a **flat resource model**. Agents, memories, an | `networkMode` | No | `"PUBLIC"` (default) or `"PRIVATE"` | | `envVars` | No | Custom environment variables | | `instrumentation` | No | OpenTelemetry settings | +| `tags` | No | Per-agent tags (override project defaults) | ### Runtime Versions @@ -121,12 +128,13 @@ Main project configuration using a **flat resource model**. Agents, memories, an } ``` -| Field | Required | Description | -| --------------------- | -------- | --------------------------------------- | -| `type` | Yes | Always `"AgentCoreMemory"` | -| `name` | Yes | Memory name (1-48 chars) | -| `eventExpiryDuration` | Yes | Days until events expire (7-365) | -| `strategies` | Yes | Array of memory strategies (at least 1) | +| Field | Required | Description | +| --------------------- | -------- | ------------------------------------------- | +| `type` | Yes | Always `"AgentCoreMemory"` | +| `name` | Yes | Memory name (1-48 chars) | +| `eventExpiryDuration` | Yes | Days until events expire (7-365) | +| `strategies` | Yes | Array of memory strategies (at least 1) | +| `tags` | No | Per-memory tags (override project defaults) | ### Memory Strategies @@ -231,6 +239,7 @@ Gateway and MCP tool configuration. Gateways, their targets, and standalone MCP | `targets` | Yes | Array of gateway targets | | `authorizerType` | No | `"NONE"` (default), `"AWS_IAM"`, or `"CUSTOM_JWT"` | | `authorizerConfiguration` | No | Required when `authorizerType` is `"CUSTOM_JWT"` (see below) | +| `tags` | No | Per-gateway tags (override project defaults) | ### CUSTOM_JWT Authorizer Configuration diff --git a/integ-tests/tag.test.ts b/integ-tests/tag.test.ts new file mode 100644 index 00000000..2aa5ca1a --- /dev/null +++ b/integ-tests/tag.test.ts @@ -0,0 +1,97 @@ +import { createTestProject, runCLI } from '../src/test-utils/index.js'; +import type { TestProject } from '../src/test-utils/index.js'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('integration: tag command', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('creates project with auto-tags in agentcore.json', async () => { + const specPath = join(project.projectPath, 'agentcore', 'agentcore.json'); + const spec = JSON.parse(await readFile(specPath, 'utf-8')); + expect(spec.tags).toEqual({ + 'agentcore:created-by': 'agentcore-cli', + 'agentcore:project-name': expect.any(String), + }); + }); + + it('set-defaults adds a project-level tag', async () => { + const result = await runCLI( + ['tag', 'set-defaults', '--key', 'environment', '--value', 'dev', '--json'], + project.projectPath + ); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.success).toBe(true); + + // Verify in agentcore.json + const specPath = join(project.projectPath, 'agentcore', 'agentcore.json'); + const spec = JSON.parse(await readFile(specPath, 'utf-8')); + expect(spec.tags.environment).toBe('dev'); + }); + + it('tag add sets a per-resource tag', async () => { + // Get the agent name from spec + const specPath = join(project.projectPath, 'agentcore', 'agentcore.json'); + const spec = JSON.parse(await readFile(specPath, 'utf-8')); + const agentName = spec.agents[0]?.name; + if (!agentName) return; // Skip if no agent + + const result = await runCLI( + ['tag', 'add', '--resource', `agent:${agentName}`, '--key', 'cost-center', '--value', '12345', '--json'], + project.projectPath + ); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + + // Verify in agentcore.json + const updatedSpec = JSON.parse(await readFile(specPath, 'utf-8')); + expect(updatedSpec.agents[0].tags).toEqual({ 'cost-center': '12345' }); + }); + + it('tag list returns JSON output', async () => { + const result = await runCLI(['tag', 'list', '--json'], project.projectPath); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.projectDefaults).toBeDefined(); + expect(output.resources).toBeInstanceOf(Array); + }); + + it('tag remove removes a per-resource tag', async () => { + const specPath = join(project.projectPath, 'agentcore', 'agentcore.json'); + const spec = JSON.parse(await readFile(specPath, 'utf-8')); + const agentName = spec.agents[0]?.name; + if (!agentName || !spec.agents[0].tags?.['cost-center']) return; + + const result = await runCLI( + ['tag', 'remove', '--resource', `agent:${agentName}`, '--key', 'cost-center', '--json'], + project.projectPath + ); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + + const updatedSpec = JSON.parse(await readFile(specPath, 'utf-8')); + expect(updatedSpec.agents[0].tags).toBeUndefined(); + }); + + it('remove-defaults removes a project-level tag', async () => { + const result = await runCLI(['tag', 'remove-defaults', '--key', 'environment', '--json'], project.projectPath); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + + const specPath = join(project.projectPath, 'agentcore', 'agentcore.json'); + const spec = JSON.parse(await readFile(specPath, 'utf-8')); + expect(spec.tags.environment).toBeUndefined(); + }); +}); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 4d992ad7..f0e6f777 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -8,6 +8,7 @@ import { registerLogs } from './commands/logs'; import { registerPackage } from './commands/package'; import { registerRemove } from './commands/remove'; import { registerStatus } from './commands/status'; +import { registerTag } from './commands/tag'; import { registerTraces } from './commands/traces'; import { registerUpdate } from './commands/update'; import { registerValidate } from './commands/validate'; @@ -136,6 +137,7 @@ export function registerCommands(program: Command) { registerPackage(program); const removeCmd = registerRemove(program); registerStatus(program); + registerTag(program); registerTraces(program); registerUpdate(program); registerValidate(program); diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index 7055b09a..ec0be121 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -27,6 +27,10 @@ function createDefaultProjectSpec(projectName: string): AgentCoreProjectSpec { return { name: projectName, version: 1, + tags: { + 'agentcore:created-by': 'agentcore-cli', + 'agentcore:project-name': projectName, + }, agents: [], memories: [], credentials: [], diff --git a/src/cli/commands/tag/__tests__/action.test.ts b/src/cli/commands/tag/__tests__/action.test.ts new file mode 100644 index 00000000..a0875454 --- /dev/null +++ b/src/cli/commands/tag/__tests__/action.test.ts @@ -0,0 +1,164 @@ +import { addTag, listTags, removeDefaultTag, removeTag, setDefaultTag } from '../action.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockReadProjectSpec, + mockWriteProjectSpec, + mockReadMcpSpec, + mockWriteMcpSpec, + mockConfigExists, + mockFindConfigRoot, +} = vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockWriteProjectSpec: vi.fn(), + mockReadMcpSpec: vi.fn(), + mockWriteMcpSpec: vi.fn(), + mockConfigExists: vi.fn(), + mockFindConfigRoot: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + readMcpSpec = mockReadMcpSpec; + writeMcpSpec = mockWriteMcpSpec; + configExists = mockConfigExists; + }, + findConfigRoot: mockFindConfigRoot, + NoProjectError: class NoProjectError extends Error { + constructor() { + super('No AgentCore project found'); + this.name = 'NoProjectError'; + } + }, +})); + +const baseSpec = () => ({ + name: 'TestProject', + version: 1, + tags: { 'agentcore:created-by': 'agentcore-cli' }, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'myAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: 'app/myAgent', + runtimeVersion: 'python3.13', + protocol: 'HTTP', + }, + ], + memories: [{ type: 'AgentCoreMemory', name: 'myMemory', eventExpiryDuration: 30, strategies: [] }], + credentials: [], +}); + +const baseMcpSpec = () => ({ + agentCoreGateways: [ + { + name: 'myGateway', + targets: [], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + }, + ], +}); + +beforeEach(() => { + vi.clearAllMocks(); + mockFindConfigRoot.mockReturnValue('/fake/config/root'); + mockReadProjectSpec.mockResolvedValue(baseSpec()); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockReadMcpSpec.mockResolvedValue(baseMcpSpec()); + mockWriteMcpSpec.mockResolvedValue(undefined); + mockConfigExists.mockReturnValue(true); +}); + +describe('listTags', () => { + it('returns project defaults and all resources with merged tags', async () => { + const result = await listTags(); + expect(result.projectDefaults).toEqual({ 'agentcore:created-by': 'agentcore-cli' }); + expect(result.resources).toHaveLength(3); + expect(result.resources[0]).toEqual({ + type: 'agent', + name: 'myAgent', + tags: { 'agentcore:created-by': 'agentcore-cli' }, + }); + }); + + it('filters by resource ref', async () => { + const result = await listTags('agent:myAgent'); + expect(result.resources).toHaveLength(1); + expect(result.resources[0]!.name).toBe('myAgent'); + }); + + it('throws on nonexistent resource', async () => { + await expect(listTags('agent:nonexistent')).rejects.toThrow('not found'); + }); +}); + +describe('addTag', () => { + it('adds tag to agent and writes spec', async () => { + const result = await addTag('agent:myAgent', 'env', 'prod'); + expect(result.success).toBe(true); + expect(mockWriteProjectSpec).toHaveBeenCalledTimes(1); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.agents[0].tags).toEqual({ env: 'prod' }); + }); + + it('adds tag to gateway and writes mcp spec', async () => { + const result = await addTag('gateway:myGateway', 'env', 'prod'); + expect(result.success).toBe(true); + expect(mockWriteMcpSpec).toHaveBeenCalledTimes(1); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + expect(written.agentCoreGateways[0].tags).toEqual({ env: 'prod' }); + }); + + it('throws for invalid resource ref', async () => { + await expect(addTag('invalid', 'key', 'value')).rejects.toThrow('Invalid resource reference'); + }); + + it('throws for nonexistent resource', async () => { + await expect(addTag('agent:noSuchAgent', 'key', 'value')).rejects.toThrow('not found'); + }); +}); + +describe('removeTag', () => { + it('removes tag from agent', async () => { + const spec = baseSpec(); + (spec.agents[0] as Record).tags = { env: 'prod', team: 'a' }; + mockReadProjectSpec.mockResolvedValue(spec); + + const result = await removeTag('agent:myAgent', 'env'); + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.agents[0].tags).toEqual({ team: 'a' }); + }); + + it('throws when key not found', async () => { + await expect(removeTag('agent:myAgent', 'nonexistent')).rejects.toThrow('Tag key'); + }); +}); + +describe('setDefaultTag', () => { + it('sets project-level default tag', async () => { + const result = await setDefaultTag('team', 'platform'); + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.tags).toEqual({ 'agentcore:created-by': 'agentcore-cli', team: 'platform' }); + }); +}); + +describe('removeDefaultTag', () => { + it('removes project-level default tag', async () => { + const result = await removeDefaultTag('agentcore:created-by'); + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.tags).toBeUndefined(); + }); + + it('throws when key not found', async () => { + await expect(removeDefaultTag('nonexistent')).rejects.toThrow('not found'); + }); +}); diff --git a/src/cli/commands/tag/action.ts b/src/cli/commands/tag/action.ts new file mode 100644 index 00000000..75b0ed23 --- /dev/null +++ b/src/cli/commands/tag/action.ts @@ -0,0 +1,163 @@ +import { ConfigIO, NoProjectError, findConfigRoot } from '../../../lib'; +import type { ResourceRef, ResourceTagInfo, TagListResult, TaggableResourceType } from './types'; +import { TAGGABLE_RESOURCE_TYPES } from './types'; + +function getConfigIO(): ConfigIO { + const configRoot = findConfigRoot(); + if (!configRoot) { + throw new NoProjectError(); + } + return new ConfigIO({ baseDir: configRoot }); +} + +function parseResourceRef(ref: string): ResourceRef { + const colonIndex = ref.indexOf(':'); + if (colonIndex === -1) { + throw new Error(`Invalid resource reference "${ref}". Expected format: type:name (e.g., agent:MyAgent)`); + } + const type = ref.substring(0, colonIndex) as TaggableResourceType; + const name = ref.substring(colonIndex + 1); + + if (!TAGGABLE_RESOURCE_TYPES.includes(type)) { + throw new Error(`Invalid resource type "${type}". Taggable types: ${TAGGABLE_RESOURCE_TYPES.join(', ')}`); + } + if (!name) { + throw new Error(`Resource name is required in reference "${ref}".`); + } + return { type, name }; +} + +export async function listTags(resourceFilter?: string): Promise { + const configIO = getConfigIO(); + const spec = await configIO.readProjectSpec(); + const projectDefaults = spec.tags ?? {}; + const resources: ResourceTagInfo[] = []; + + // Collect agents + for (const agent of spec.agents ?? []) { + resources.push({ + type: 'agent', + name: agent.name, + tags: { ...projectDefaults, ...(agent.tags ?? {}) }, + }); + } + + // Collect memories + for (const memory of spec.memories ?? []) { + resources.push({ + type: 'memory', + name: memory.name, + tags: { ...projectDefaults, ...(memory.tags ?? {}) }, + }); + } + + // Collect gateways from mcp spec + if (configIO.configExists('mcp')) { + const mcpSpec = await configIO.readMcpSpec(); + for (const gateway of mcpSpec.agentCoreGateways ?? []) { + resources.push({ + type: 'gateway', + name: gateway.name, + tags: { ...projectDefaults, ...(gateway.tags ?? {}) }, + }); + } + } + + // Apply filter if specified + if (resourceFilter) { + const ref = parseResourceRef(resourceFilter); + const filtered = resources.filter(r => r.type === ref.type && r.name === ref.name); + if (filtered.length === 0) { + throw new Error(`Resource "${resourceFilter}" not found.`); + } + return { projectDefaults, resources: filtered }; + } + + return { projectDefaults, resources }; +} + +export async function addTag(resourceRefStr: string, key: string, value: string): Promise<{ success: boolean }> { + const ref = parseResourceRef(resourceRefStr); + const configIO = getConfigIO(); + + if (ref.type === 'agent' || ref.type === 'memory') { + const spec = await configIO.readProjectSpec(); + const collection = ref.type === 'agent' ? spec.agents : spec.memories; + const resource = (collection ?? []).find(r => r.name === ref.name); + if (!resource) { + throw new Error(`${ref.type} "${ref.name}" not found in project.`); + } + resource.tags = { ...(resource.tags ?? {}), [key]: value }; + await configIO.writeProjectSpec(spec); + } else if (ref.type === 'gateway') { + const mcpSpec = await configIO.readMcpSpec(); + const gateway = mcpSpec.agentCoreGateways.find(g => g.name === ref.name); + if (!gateway) { + throw new Error(`gateway "${ref.name}" not found in MCP spec.`); + } + gateway.tags = { ...(gateway.tags ?? {}), [key]: value }; + await configIO.writeMcpSpec(mcpSpec); + } + + return { success: true }; +} + +export async function removeTag(resourceRefStr: string, key: string): Promise<{ success: boolean }> { + const ref = parseResourceRef(resourceRefStr); + const configIO = getConfigIO(); + + if (ref.type === 'agent' || ref.type === 'memory') { + const spec = await configIO.readProjectSpec(); + const collection = ref.type === 'agent' ? spec.agents : spec.memories; + const resource = (collection ?? []).find(r => r.name === ref.name); + if (!resource) { + throw new Error(`${ref.type} "${ref.name}" not found in project.`); + } + if (!resource.tags || !(key in resource.tags)) { + throw new Error(`Tag key "${key}" not found on ${ref.type} "${ref.name}".`); + } + delete resource.tags[key]; + if (Object.keys(resource.tags).length === 0) { + resource.tags = undefined; + } + await configIO.writeProjectSpec(spec); + } else if (ref.type === 'gateway') { + const mcpSpec = await configIO.readMcpSpec(); + const gateway = mcpSpec.agentCoreGateways.find(g => g.name === ref.name); + if (!gateway) { + throw new Error(`gateway "${ref.name}" not found in MCP spec.`); + } + if (!gateway.tags || !(key in gateway.tags)) { + throw new Error(`Tag key "${key}" not found on gateway "${ref.name}".`); + } + delete gateway.tags[key]; + if (Object.keys(gateway.tags).length === 0) { + gateway.tags = undefined; + } + await configIO.writeMcpSpec(mcpSpec); + } + + return { success: true }; +} + +export async function setDefaultTag(key: string, value: string): Promise<{ success: boolean }> { + const configIO = getConfigIO(); + const spec = await configIO.readProjectSpec(); + spec.tags = { ...(spec.tags ?? {}), [key]: value }; + await configIO.writeProjectSpec(spec); + return { success: true }; +} + +export async function removeDefaultTag(key: string): Promise<{ success: boolean }> { + const configIO = getConfigIO(); + const spec = await configIO.readProjectSpec(); + if (!spec.tags || !(key in spec.tags)) { + throw new Error(`Default tag key "${key}" not found.`); + } + delete spec.tags[key]; + if (Object.keys(spec.tags).length === 0) { + spec.tags = undefined; + } + await configIO.writeProjectSpec(spec); + return { success: true }; +} diff --git a/src/cli/commands/tag/command.tsx b/src/cli/commands/tag/command.tsx new file mode 100644 index 00000000..351d81cc --- /dev/null +++ b/src/cli/commands/tag/command.tsx @@ -0,0 +1,145 @@ +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +import { addTag, listTags, removeDefaultTag, removeTag, setDefaultTag } from './action'; +import type { Command } from '@commander-js/extra-typings'; +import { Text, render } from 'ink'; + +export const registerTag = (program: Command) => { + const tag = program.command('tag').description(COMMAND_DESCRIPTIONS.tag); + + tag + .command('list') + .description('List tags on all taggable resources') + .option('--resource ', 'Filter by resource (type:name, e.g. agent:MyAgent)') + .option('--json', 'Output as JSON') + .action(async options => { + try { + const result = await listTags(options.resource); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log('Project Defaults:'); + const defaults = Object.entries(result.projectDefaults); + if (defaults.length === 0) { + console.log(' (none)'); + } else { + for (const [k, v] of defaults) { + console.log(` ${k} = ${v}`); + } + } + console.log(); + if (result.resources.length === 0) { + console.log('No taggable resources found.'); + } else { + for (const resource of result.resources) { + console.log(`${resource.type}:${resource.name}`); + const entries = Object.entries(resource.tags); + if (entries.length === 0) { + console.log(' (no tags)'); + } else { + for (const [k, v] of entries) { + console.log(` ${k} = ${v}`); + } + } + } + } + } + } catch (err) { + render({err instanceof Error ? err.message : String(err)}); + process.exit(1); + } + }); + + tag + .command('add') + .description('Add a tag to a resource') + .requiredOption('--resource ', 'Resource reference (type:name, e.g. agent:MyAgent)') + .requiredOption('--key ', 'Tag key') + .requiredOption('--value ', 'Tag value') + .option('--json', 'Output as JSON') + .action(async options => { + try { + const result = await addTag(options.resource, options.key, options.value); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + render( + + Tag added: {options.key} = {options.value} on {options.resource} + + ); + } + } catch (err) { + render({err instanceof Error ? err.message : String(err)}); + process.exit(1); + } + }); + + tag + .command('remove') + .description('Remove a tag from a resource') + .requiredOption('--resource ', 'Resource reference (type:name, e.g. agent:MyAgent)') + .requiredOption('--key ', 'Tag key to remove') + .option('--json', 'Output as JSON') + .action(async options => { + try { + const result = await removeTag(options.resource, options.key); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + render( + + Tag removed: {options.key} from {options.resource} + + ); + } + } catch (err) { + render({err instanceof Error ? err.message : String(err)}); + process.exit(1); + } + }); + + tag + .command('set-defaults') + .description('Set a project-level default tag') + .requiredOption('--key ', 'Tag key') + .requiredOption('--value ', 'Tag value') + .option('--json', 'Output as JSON') + .action(async options => { + try { + const result = await setDefaultTag(options.key, options.value); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + render( + + Default tag set: {options.key} = {options.value} + + ); + } + } catch (err) { + render({err instanceof Error ? err.message : String(err)}); + process.exit(1); + } + }); + + tag + .command('remove-defaults') + .description('Remove a project-level default tag') + .requiredOption('--key ', 'Tag key to remove') + .option('--json', 'Output as JSON') + .action(async options => { + try { + const result = await removeDefaultTag(options.key); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + render(Default tag removed: {options.key}); + } + } catch (err) { + render({err instanceof Error ? err.message : String(err)}); + process.exit(1); + } + }); + + return tag; +}; diff --git a/src/cli/commands/tag/constants.ts b/src/cli/commands/tag/constants.ts new file mode 100644 index 00000000..7aec3d75 --- /dev/null +++ b/src/cli/commands/tag/constants.ts @@ -0,0 +1,6 @@ +import type { TaggableResourceType } from './types'; + +export const TAGGABLE_RESOURCE_TYPES: readonly TaggableResourceType[] = ['agent', 'memory', 'gateway']; + +export const NON_TAGGABLE_NOTE = + 'Credentials are not taggable (deployed via AgentCore Identity API, not CFN resources).'; diff --git a/src/cli/commands/tag/index.ts b/src/cli/commands/tag/index.ts new file mode 100644 index 00000000..c9c7ad6d --- /dev/null +++ b/src/cli/commands/tag/index.ts @@ -0,0 +1,3 @@ +export { registerTag } from './command'; +export { addTag, listTags, removeDefaultTag, removeTag, setDefaultTag } from './action'; +export type { ResourceRef, ResourceTagInfo, TaggableResourceType, TagListResult } from './types'; diff --git a/src/cli/commands/tag/types.ts b/src/cli/commands/tag/types.ts new file mode 100644 index 00000000..c28b8db0 --- /dev/null +++ b/src/cli/commands/tag/types.ts @@ -0,0 +1,18 @@ +export const TAGGABLE_RESOURCE_TYPES = ['agent', 'memory', 'gateway'] as const; +export type TaggableResourceType = (typeof TAGGABLE_RESOURCE_TYPES)[number]; + +export interface ResourceRef { + type: TaggableResourceType; + name: string; +} + +export interface ResourceTagInfo { + type: TaggableResourceType; + name: string; + tags: Record; +} + +export interface TagListResult { + projectDefaults: Record; + resources: ResourceTagInfo[]; +} diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 507365da..4fbd56f0 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -39,6 +39,7 @@ export const COMMAND_DESCRIPTIONS = { package: 'Package Bedrock AgentCore runtime artifacts.', remove: 'Remove AgentCore resources and project', status: 'Retrieve details of deployed AgentCore resources.', + tag: 'Manage resource tags.', traces: 'View and download agent traces.', update: 'Check for and install CLI updates', validate: 'Validate agentcore/ config files.', diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 2f04e182..24f68dfa 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -71,6 +71,10 @@ function createDefaultProjectSpec(projectName: string): AgentCoreProjectSpec { return { name: projectName, version: 1, + tags: { + 'agentcore:created-by': 'agentcore-cli', + 'agentcore:project-name': projectName, + }, agents: [], memories: [], credentials: [], diff --git a/src/cli/tui/screens/remove/useRemoveFlow.ts b/src/cli/tui/screens/remove/useRemoveFlow.ts index 2c8fea13..5b6f62d1 100644 --- a/src/cli/tui/screens/remove/useRemoveFlow.ts +++ b/src/cli/tui/screens/remove/useRemoveFlow.ts @@ -31,6 +31,10 @@ function createDefaultProjectSpec(projectName: string): AgentCoreProjectSpec { return { name: projectName, version: 1, + tags: { + 'agentcore:created-by': 'agentcore-cli', + 'agentcore:project-name': projectName, + }, agents: [], memories: [], credentials: [], diff --git a/src/schema/schemas/__tests__/tags.test.ts b/src/schema/schemas/__tests__/tags.test.ts new file mode 100644 index 00000000..56e3444d --- /dev/null +++ b/src/schema/schemas/__tests__/tags.test.ts @@ -0,0 +1,85 @@ +import { AgentCoreProjectSpecSchema } from '../agentcore-project.js'; +import { TagKeySchema, TagValueSchema, TagsSchema } from '../primitives/tags.js'; +import { describe, expect, it } from 'vitest'; + +describe('TagKeySchema', () => { + it('accepts valid keys', () => { + expect(TagKeySchema.parse('environment')).toBe('environment'); + expect(TagKeySchema.parse('agentcore:created-by')).toBe('agentcore:created-by'); + expect(TagKeySchema.parse('a'.repeat(128))).toHaveLength(128); + }); + + it('rejects empty string', () => { + expect(() => TagKeySchema.parse('')).toThrow(); + }); + + it('rejects keys longer than 128 characters', () => { + expect(() => TagKeySchema.parse('a'.repeat(129))).toThrow(); + }); +}); + +describe('TagValueSchema', () => { + it('accepts valid values', () => { + expect(TagValueSchema.parse('prod')).toBe('prod'); + expect(TagValueSchema.parse('')).toBe(''); + expect(TagValueSchema.parse('a'.repeat(256))).toHaveLength(256); + }); + + it('rejects values longer than 256 characters', () => { + expect(() => TagValueSchema.parse('a'.repeat(257))).toThrow(); + }); +}); + +describe('TagsSchema', () => { + it('accepts valid tags', () => { + const result = TagsSchema.parse({ environment: 'prod', team: 'platform' }); + expect(result).toEqual({ environment: 'prod', team: 'platform' }); + }); + + it('accepts undefined (optional)', () => { + expect(TagsSchema.parse(undefined)).toBeUndefined(); + }); + + it('accepts empty object', () => { + expect(TagsSchema.parse({})).toEqual({}); + }); +}); + +describe('AgentCoreProjectSpecSchema with tags', () => { + const validSpec = { + name: 'TestProject', + version: 1, + agents: [], + memories: [], + credentials: [], + }; + + it('accepts spec with project-level tags', () => { + const result = AgentCoreProjectSpecSchema.parse({ + ...validSpec, + tags: { 'agentcore:created-by': 'agentcore-cli', environment: 'dev' }, + }); + expect(result.tags).toEqual({ 'agentcore:created-by': 'agentcore-cli', environment: 'dev' }); + }); + + it('accepts spec without tags (backwards compatibility)', () => { + const result = AgentCoreProjectSpecSchema.parse(validSpec); + expect(result.tags).toBeUndefined(); + }); + + it('accepts spec with per-memory tags', () => { + const result = AgentCoreProjectSpecSchema.parse({ + ...validSpec, + memories: [ + { + type: 'AgentCoreMemory', + name: 'myMemory', + eventExpiryDuration: 30, + strategies: [], + tags: { 'cost-center': '12345' }, + }, + ], + }); + expect(result.memories[0]!.tags).toEqual({ 'cost-center': '12345' }); + }); +}); diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 2fb6a09a..af9fbad1 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -10,6 +10,7 @@ import { RuntimeVersionSchema as RuntimeVersionSchemaFromConstants, } from '../constants'; import type { DirectoryPath, FilePath } from '../types'; +import { TagsSchema } from './primitives/tags'; import { z } from 'zod'; // Re-export path types @@ -144,6 +145,7 @@ export const AgentEnvSpecSchema = z modelProvider: ModelProviderSchema.optional(), /** Protocol for the runtime (HTTP, MCP, A2A). */ protocol: ProtocolModeSchema, + tags: TagsSchema, }) .superRefine((data, ctx) => { if (data.networkMode === 'VPC' && !data.networkConfig) { diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index fda34160..4ad9a340 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -9,6 +9,7 @@ import { isReservedProjectName } from '../constants'; import { AgentEnvSpecSchema } from './agent-env'; import { DEFAULT_STRATEGY_NAMESPACES, MemoryStrategySchema, MemoryStrategyTypeSchema } from './primitives/memory'; +import { TagsSchema } from './primitives/tags'; import { uniqueBy } from './zod-util'; import { z } from 'zod'; @@ -63,6 +64,7 @@ export const MemorySchema = z.object({ type => `Duplicate memory strategy type: ${type}` ) ), + tags: TagsSchema, }); export type Memory = z.infer; @@ -118,6 +120,7 @@ export type Credential = z.infer; export const AgentCoreProjectSpecSchema = z.object({ name: ProjectNameSchema, version: z.number().int(), + tags: TagsSchema, agents: z .array(AgentEnvSpecSchema) diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index aa1e39df..857293ea 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -2,6 +2,7 @@ import { NetworkModeSchema, NodeRuntimeSchema, PythonRuntimeSchema } from '../co import type { DirectoryPath, FilePath } from '../types'; import { EnvVarNameSchema, GatewayNameSchema } from './agent-env'; import { ToolDefinitionSchema } from './mcp-defs'; +import { TagsSchema } from './primitives/tags'; import { z } from 'zod'; // ============================================================================ @@ -614,6 +615,7 @@ export const AgentCoreGatewaySchema = z enableSemanticSearch: z.boolean().default(true), /** Exception verbosity level. 'NONE' = generic errors (default), 'DEBUG' = verbose errors. */ exceptionLevel: GatewayExceptionLevelSchema.default('NONE'), + tags: TagsSchema, }) .strict() .refine( diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index e7f572e8..e84b3fb3 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -5,3 +5,5 @@ export { MemoryStrategySchema, MemoryStrategyTypeSchema, } from './memory'; +export type { Tags } from './tags'; +export { TagKeySchema, TagValueSchema, TagsSchema } from './tags'; diff --git a/src/schema/schemas/primitives/tags.ts b/src/schema/schemas/primitives/tags.ts new file mode 100644 index 00000000..deca1bdf --- /dev/null +++ b/src/schema/schemas/primitives/tags.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const TagKeySchema = z.string().min(1).max(128); +export const TagValueSchema = z.string().max(256); +export const TagsSchema = z.record(TagKeySchema, TagValueSchema).optional(); +export type Tags = z.infer;