diff --git a/src/cli/commands/tag/__tests__/action.test.ts b/src/cli/commands/tag/__tests__/action.test.ts deleted file mode 100644 index 6ac044cc5..000000000 --- a/src/cli/commands/tag/__tests__/action.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { addTag, listTags, removeDefaultTag, removeTag, setDefaultTag } from '../action.js'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { mockReadProjectSpec, mockWriteProjectSpec, mockFindConfigRoot } = vi.hoisted(() => ({ - mockReadProjectSpec: vi.fn(), - mockWriteProjectSpec: vi.fn(), - mockFindConfigRoot: vi.fn(), -})); - -vi.mock('../../../../lib/index.js', () => ({ - ConfigIO: class { - readProjectSpec = mockReadProjectSpec; - writeProjectSpec = mockWriteProjectSpec; - }, - 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' }, - runtimes: [ - { - name: 'myAgent', - build: 'CodeZip', - entrypoint: 'main.py', - codeLocation: 'app/myAgent', - runtimeVersion: 'python3.13', - protocol: 'HTTP', - }, - ], - memories: [{ name: 'myMemory', eventExpiryDuration: 30, strategies: [] }], - credentials: [], - agentCoreGateways: [ - { - name: 'myGateway', - targets: [], - authorizerType: 'NONE', - enableSemanticSearch: true, - exceptionLevel: 'NONE', - }, - ], -}); - -beforeEach(() => { - vi.clearAllMocks(); - mockFindConfigRoot.mockReturnValue('/fake/config/root'); - mockReadProjectSpec.mockResolvedValue(baseSpec()); - mockWriteProjectSpec.mockResolvedValue(undefined); -}); - -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.runtimes[0].tags).toEqual({ env: 'prod' }); - }); - - it('adds tag to gateway and writes project spec', async () => { - const result = await addTag('gateway:myGateway', 'env', 'prod'); - expect(result.success).toBe(true); - expect(mockWriteProjectSpec).toHaveBeenCalledTimes(1); - const written = mockWriteProjectSpec.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'); - }); - - it('rejects empty tag key', async () => { - await expect(addTag('agent:myAgent', '', 'value')).rejects.toThrow('Invalid tag key'); - }); - - it('rejects tag key exceeding 128 chars', async () => { - await expect(addTag('agent:myAgent', 'k'.repeat(129), 'value')).rejects.toThrow('Invalid tag key'); - }); - - it('rejects tag value exceeding 256 chars', async () => { - await expect(addTag('agent:myAgent', 'key', 'v'.repeat(257))).rejects.toThrow('Invalid tag value'); - }); - - it('rejects tag key with invalid characters', async () => { - await expect(addTag('agent:myAgent', 'key\x00bad', 'value')).rejects.toThrow('Invalid tag key'); - }); - - it('rejects tag value with invalid characters', async () => { - await expect(addTag('agent:myAgent', 'key', 'value\x00bad')).rejects.toThrow('Invalid tag value'); - }); - - it('rejects agentcore: prefixed keys', async () => { - await expect(addTag('agent:myAgent', 'agentcore:custom', 'value')).rejects.toThrow('managed by the system'); - }); -}); - -describe('removeTag', () => { - it('removes tag from agent', async () => { - const spec = baseSpec(); - (spec.runtimes[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.runtimes[0].tags).toEqual({ team: 'a' }); - }); - - it('throws when key not found with hint about defaults', async () => { - await expect(removeTag('agent:myAgent', 'nonexistent')).rejects.toThrow('remove-defaults'); - }); - - it('rejects agentcore: prefixed keys', async () => { - await expect(removeTag('agent:myAgent', 'agentcore:created-by')).rejects.toThrow('managed by the system'); - }); -}); - -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' }); - }); - - it('rejects agentcore: prefixed keys', async () => { - await expect(setDefaultTag('agentcore:custom', 'value')).rejects.toThrow('managed by the system'); - }); -}); - -describe('removeDefaultTag', () => { - it('rejects removal of agentcore: prefixed system tags', async () => { - await expect(removeDefaultTag('agentcore:created-by')).rejects.toThrow('managed by the system'); - }); - - it('removes user-defined project-level default tag', async () => { - const spec = baseSpec(); - (spec.tags as Record).team = 'platform'; - mockReadProjectSpec.mockResolvedValue(spec); - - const result = await removeDefaultTag('team'); - expect(result.success).toBe(true); - const written = mockWriteProjectSpec.mock.calls[0]![0]; - expect(written.tags).toEqual({ 'agentcore:created-by': 'agentcore-cli' }); - }); - - 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 deleted file mode 100644 index 3cedbc149..000000000 --- a/src/cli/commands/tag/action.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { ConfigIO, NoProjectError, findConfigRoot } from '../../../lib'; -import { TagKeySchema, TagValueSchema } from '../../../schema/schemas/primitives/tags'; -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.runtimes ?? []) { - 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 (now in project spec after mcp.json merge) - for (const gateway of spec.agentCoreGateways ?? []) { - resources.push({ - type: 'gateway', - name: gateway.name, - tags: { ...projectDefaults, ...(gateway.tags ?? {}) }, - }); - } - - // Collect evaluators - for (const evaluator of spec.evaluators ?? []) { - resources.push({ - type: 'evaluator', - name: evaluator.name, - tags: { ...projectDefaults, ...(evaluator.tags ?? {}) }, - }); - } - - // Collect policy engines - for (const engine of spec.policyEngines ?? []) { - resources.push({ - type: 'policy-engine', - name: engine.name, - tags: { ...projectDefaults, ...(engine.tags ?? {}) }, - }); - } - - // Collect online eval configs - for (const config of spec.onlineEvalConfigs ?? []) { - resources.push({ - type: 'online-eval-config', - name: config.name, - tags: { ...projectDefaults, ...(config.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 }; -} - -function validateTagKeyValue(key: string, value: string): void { - if (key.startsWith('agentcore:')) { - throw new Error('Tag keys starting with "agentcore:" are managed by the system and cannot be modified.'); - } - const keyResult = TagKeySchema.safeParse(key); - if (!keyResult.success) { - throw new Error(`Invalid tag key: ${keyResult.error.issues[0]?.message ?? 'validation failed'}`); - } - const valueResult = TagValueSchema.safeParse(value); - if (!valueResult.success) { - throw new Error(`Invalid tag value: ${valueResult.error.issues[0]?.message ?? 'validation failed'}`); - } -} - -export async function addTag(resourceRefStr: string, key: string, value: string): Promise<{ success: boolean }> { - validateTagKeyValue(key, value); - const ref = parseResourceRef(resourceRefStr); - const configIO = getConfigIO(); - - if ( - ref.type === 'agent' || - ref.type === 'memory' || - ref.type === 'evaluator' || - ref.type === 'policy-engine' || - ref.type === 'online-eval-config' - ) { - const spec = await configIO.readProjectSpec(); - let collection: { name: string; tags?: Record }[] | undefined; - if (ref.type === 'agent') collection = spec.runtimes; - else if (ref.type === 'memory') collection = spec.memories; - else if (ref.type === 'evaluator') collection = spec.evaluators; - else if (ref.type === 'policy-engine') collection = spec.policyEngines; - else if (ref.type === 'online-eval-config') collection = spec.onlineEvalConfigs; - - 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 spec = await configIO.readProjectSpec(); - const gateway = spec.agentCoreGateways.find(g => g.name === ref.name); - if (!gateway) { - throw new Error(`gateway "${ref.name}" not found in project.`); - } - gateway.tags = { ...(gateway.tags ?? {}), [key]: value }; - await configIO.writeProjectSpec(spec); - } - - return { success: true }; -} - -export async function removeTag(resourceRefStr: string, key: string): Promise<{ success: boolean }> { - if (key.startsWith('agentcore:')) { - throw new Error('Tag keys starting with "agentcore:" are managed by the system and cannot be modified.'); - } - const ref = parseResourceRef(resourceRefStr); - const configIO = getConfigIO(); - const spec = await configIO.readProjectSpec(); - - let collection: { name: string; tags?: Record }[] | undefined; - if (ref.type === 'agent') collection = spec.runtimes; - else if (ref.type === 'memory') collection = spec.memories; - else if (ref.type === 'evaluator') collection = spec.evaluators; - else if (ref.type === 'policy-engine') collection = spec.policyEngines; - else if (ref.type === 'online-eval-config') collection = spec.onlineEvalConfigs; - else if (ref.type === 'gateway') collection = spec.agentCoreGateways; - - 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}". ` + - `If this is an inherited project default, use "tag remove-defaults --key ${key}" instead.` - ); - } - delete resource.tags[key]; - if (Object.keys(resource.tags).length === 0) { - resource.tags = undefined; - } - await configIO.writeProjectSpec(spec); - - return { success: true }; -} - -export async function setDefaultTag(key: string, value: string): Promise<{ success: boolean }> { - validateTagKeyValue(key, value); - 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 }> { - if (key.startsWith('agentcore:')) { - throw new Error('Tag keys starting with "agentcore:" are managed by the system and cannot be modified.'); - } - 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 }; -} - -export async function getAvailableResources(): Promise { - const result = await listTags(); - return result.resources.map(r => `${r.type}:${r.name}`); -} diff --git a/src/cli/commands/tag/types.ts b/src/cli/commands/tag/types.ts deleted file mode 100644 index a1d000d38..000000000 --- a/src/cli/commands/tag/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type TaggableResourceType = - | 'agent' - | 'memory' - | 'gateway' - | 'evaluator' - | 'policy-engine' - | 'online-eval-config'; - -export const TAGGABLE_RESOURCE_TYPES: TaggableResourceType[] = [ - 'agent', - 'memory', - 'gateway', - 'evaluator', - 'policy-engine', - 'online-eval-config', -]; - -export interface ResourceRef { - type: TaggableResourceType; - name: string; -} - -export interface ResourceTagInfo { - type: TaggableResourceType; - name: string; - tags: Record; -} - -export interface TagListResult { - projectDefaults: Record; - resources: ResourceTagInfo[]; -}