From 39578a05d204125a5b22ba910b3eb18ceec7a76f Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:02:46 -0400 Subject: [PATCH 1/8] fix: Add structured exit codes for different failure categories (#11) Create src/cli/exit-codes.ts with ExitCode constants and getExitCode() mapping function that classifies errors into specific exit codes. --- src/cli/exit-codes.ts | 146 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 src/cli/exit-codes.ts diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts new file mode 100644 index 000000000..d328c9566 --- /dev/null +++ b/src/cli/exit-codes.ts @@ -0,0 +1,146 @@ +import { + getErrorMessage, + isAccessDeniedError, + isChangesetInProgressError, + isExpiredTokenError, + isNoCredentialsError, + isStackInProgressError, +} from './errors.js'; + +/** + * Structured exit codes for different CLI failure categories. + * These enable programmatic distinction between failure types + * in CI/CD pipelines and wrapper scripts. + */ +export const ExitCode = { + /** Command succeeded */ + SUCCESS: 0, + /** Unknown/unhandled error */ + GENERAL_ERROR: 1, + /** Invalid CLI arguments or missing required flags */ + INVALID_ARGS: 2, + /** AWS credentials expired or invalid */ + AUTH_EXPIRED: 3, + /** IAM permission error */ + ACCESS_DENIED: 4, + /** Requested agent doesn't exist */ + AGENT_NOT_FOUND: 5, + /** Deployment/CloudFormation failure */ + DEPLOY_FAILED: 6, +} as const; + +/** + * Union type of all valid exit code values. + */ +export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; + +/** + * Determines the appropriate exit code for a given error. + * + * Checks error types in priority order: + * 1. Access denied (IAM permission errors) + * 2. Expired/invalid credentials + * 3. Missing credentials + * 4. Commander invalid argument errors + * 5. CloudFormation deployment failures + * 6. Agent not found + * 7. Default: general error + * + * @param err - The error to classify + * @returns The appropriate exit code number + */ +export function getExitCode(err: unknown): number { + // 1. Access denied errors (IAM permission issues) + if (isAccessDeniedError(err)) { + return ExitCode.ACCESS_DENIED; + } + + // 2. Expired or invalid credentials + if (isExpiredTokenError(err)) { + return ExitCode.AUTH_EXPIRED; + } + + // 3. Missing credentials (also an auth issue) + if (isNoCredentialsError(err)) { + return ExitCode.AUTH_EXPIRED; + } + + // 4. Commander invalid argument errors + if (isCommanderInvalidArgError(err)) { + return ExitCode.INVALID_ARGS; + } + + // 5. CloudFormation deployment failures + if (isStackInProgressError(err) || isChangesetInProgressError(err)) { + return ExitCode.DEPLOY_FAILED; + } + + // 6. Agent not found + if (isAgentNotFoundError(err)) { + return ExitCode.AGENT_NOT_FOUND; + } + + // 7. Default: general error + return ExitCode.GENERAL_ERROR; +} + +/** + * Checks if an error is a Commander.js invalid argument error. + * Commander sets specific `code` and `exitCode` properties on its errors. + */ +function isCommanderInvalidArgError(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false; + } + + const error = err as Record; + + // Commander.js sets code property for specific error types + if ( + error.code === 'commander.invalidArgument' || + error.code === 'commander.missingArgument' || + error.code === 'commander.missingMandatoryOptionValue' || + error.code === 'commander.optionMissingArgument' + ) { + return true; + } + + // Commander.js sets exitCode to 2 for argument validation errors + if (error.exitCode === 2 && isCommanderError(error)) { + return true; + } + + return false; +} + +/** + * Checks if an error originates from Commander.js by inspecting the + * constructor name. This avoids importing Commander directly. + */ +function isCommanderError(err: Record): boolean { + if (err.constructor && typeof err.constructor === 'function') { + const name = err.constructor.name; + return name === 'CommanderError' || name === 'InvalidArgumentError'; + } + return false; +} + +/** + * Checks if an error indicates that a requested agent was not found. + * Uses message-based pattern matching as a best-effort classification. + */ +function isAgentNotFoundError(err: unknown): boolean { + const message = getErrorMessage(err).toLowerCase(); + + // Match patterns like "Agent 'my-agent' not found" + if (message.includes('agent') && (message.includes('not found') || message.includes('not deployed'))) { + return true; + } + + // Match patterns like "No agents defined in agentcore.json" + if (message.includes('no agents defined')) { + return true; + } + + return false; +} From dc1bd756c2397c03bddcd589e47aa36705eec5b1 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:02:56 -0400 Subject: [PATCH 2/8] fix: Update top-level error handler to use getExitCode() (#11) Replace hardcoded process.exit(1) calls with process.exit(getExitCode(err)) in all 3 error handlers in src/cli/index.ts. --- src/cli/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 9006973ee..597a66fbf 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,18 +1,19 @@ #!/usr/bin/env node import { main } from './cli.js'; import { getErrorMessage } from './errors.js'; +import { getExitCode } from './exit-codes.js'; // Global safety net — prevent raw stack traces from reaching the user process.on('uncaughtException', err => { console.error(`Error: ${getErrorMessage(err)}`); - process.exit(1); + process.exit(getExitCode(err)); }); process.on('unhandledRejection', reason => { console.error(`Error: ${getErrorMessage(reason)}`); - process.exit(1); + process.exit(getExitCode(reason)); }); main(process.argv).catch(err => { console.error(getErrorMessage(err)); - process.exit(1); + process.exit(getExitCode(err)); }); From ad1ff5d0cd42e481082ea0293d3103027d26f9ef Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:03:33 -0400 Subject: [PATCH 3/8] test: Add comprehensive unit tests for exit code mapping (#11) Cover all exit code mappings including access denied, expired tokens, no credentials, commander errors, deploy failures, agent not found, default general error, and priority ordering. --- src/cli/__tests__/exit-codes.test.ts | 227 +++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 src/cli/__tests__/exit-codes.test.ts diff --git a/src/cli/__tests__/exit-codes.test.ts b/src/cli/__tests__/exit-codes.test.ts new file mode 100644 index 000000000..68147acae --- /dev/null +++ b/src/cli/__tests__/exit-codes.test.ts @@ -0,0 +1,227 @@ +import { ExitCode, getExitCode } from '../exit-codes.js'; +import { describe, expect, it } from 'vitest'; + +describe('exit-codes', () => { + describe('ExitCode', () => { + it('has correct values for all exit codes', () => { + expect(ExitCode.SUCCESS).toBe(0); + expect(ExitCode.GENERAL_ERROR).toBe(1); + expect(ExitCode.INVALID_ARGS).toBe(2); + expect(ExitCode.AUTH_EXPIRED).toBe(3); + expect(ExitCode.ACCESS_DENIED).toBe(4); + expect(ExitCode.AGENT_NOT_FOUND).toBe(5); + expect(ExitCode.DEPLOY_FAILED).toBe(6); + }); + + it('has all expected keys', () => { + const keys = Object.keys(ExitCode); + expect(keys).toContain('SUCCESS'); + expect(keys).toContain('GENERAL_ERROR'); + expect(keys).toContain('INVALID_ARGS'); + expect(keys).toContain('AUTH_EXPIRED'); + expect(keys).toContain('ACCESS_DENIED'); + expect(keys).toContain('AGENT_NOT_FOUND'); + expect(keys).toContain('DEPLOY_FAILED'); + expect(keys).toHaveLength(7); + }); + }); + + describe('getExitCode', () => { + describe('access denied errors → EXIT_ACCESS_DENIED (4)', () => { + it('returns 4 for AccessDeniedException', () => { + expect(getExitCode({ name: 'AccessDeniedException' })).toBe(ExitCode.ACCESS_DENIED); + }); + + it('returns 4 for AccessDenied', () => { + expect(getExitCode({ name: 'AccessDenied' })).toBe(ExitCode.ACCESS_DENIED); + }); + }); + + describe('expired token errors → EXIT_AUTH_EXPIRED (3)', () => { + const expiredTokenNames = [ + 'ExpiredToken', + 'ExpiredTokenException', + 'TokenRefreshRequired', + 'CredentialsExpired', + 'InvalidIdentityToken', + 'UnauthorizedAccess', + 'InvalidClientTokenId', + 'SignatureDoesNotMatch', + 'RequestExpired', + ]; + + it('returns 3 for all expired token error names', () => { + for (const name of expiredTokenNames) { + expect(getExitCode({ name }), `Should return 3 for error.name: ${name}`).toBe(ExitCode.AUTH_EXPIRED); + } + }); + + it('returns 3 for expired token error Code properties', () => { + for (const Code of expiredTokenNames) { + expect(getExitCode({ Code }), `Should return 3 for error.Code: ${Code}`).toBe(ExitCode.AUTH_EXPIRED); + } + }); + + it('returns 3 for expired token message patterns', () => { + const patterns = [ + 'expired token', + 'token has expired', + 'credentials have expired', + 'security token included in the request is expired', + 'the security token included in the request is invalid', + ]; + for (const pattern of patterns) { + expect(getExitCode(new Error(pattern)), `Should return 3 for message: ${pattern}`).toBe( + ExitCode.AUTH_EXPIRED + ); + } + }); + + it('returns 3 for nested expired token errors', () => { + expect(getExitCode({ cause: { name: 'ExpiredToken' } })).toBe(ExitCode.AUTH_EXPIRED); + }); + }); + + describe('no credentials errors → EXIT_AUTH_EXPIRED (3)', () => { + it('returns 3 for AwsCredentialsError', () => { + expect(getExitCode({ name: 'AwsCredentialsError' })).toBe(ExitCode.AUTH_EXPIRED); + }); + + it('returns 3 for no credentials message patterns', () => { + const patterns = ['no aws credentials found', 'could not load credentials', 'credentials not found']; + for (const pattern of patterns) { + expect(getExitCode(new Error(pattern)), `Should return 3 for message: ${pattern}`).toBe( + ExitCode.AUTH_EXPIRED + ); + } + }); + }); + + describe('commander invalid argument errors → EXIT_INVALID_ARGS (2)', () => { + it('returns 2 for commander.invalidArgument code', () => { + const err = new Error('invalid argument'); + (err as Record).code = 'commander.invalidArgument'; + expect(getExitCode(err)).toBe(ExitCode.INVALID_ARGS); + }); + + it('returns 2 for commander.missingArgument code', () => { + const err = new Error('missing argument'); + (err as Record).code = 'commander.missingArgument'; + expect(getExitCode(err)).toBe(ExitCode.INVALID_ARGS); + }); + + it('returns 2 for commander.missingMandatoryOptionValue code', () => { + const err = new Error('missing option value'); + (err as Record).code = 'commander.missingMandatoryOptionValue'; + expect(getExitCode(err)).toBe(ExitCode.INVALID_ARGS); + }); + + it('returns 2 for commander.optionMissingArgument code', () => { + const err = new Error('option missing argument'); + (err as Record).code = 'commander.optionMissingArgument'; + expect(getExitCode(err)).toBe(ExitCode.INVALID_ARGS); + }); + }); + + describe('deploy failed errors → EXIT_DEPLOY_FAILED (6)', () => { + it('returns 6 for stack in progress errors', () => { + const states = [ + 'UPDATE_IN_PROGRESS', + 'CREATE_IN_PROGRESS', + 'DELETE_IN_PROGRESS', + 'ROLLBACK_IN_PROGRESS', + ]; + for (const state of states) { + expect( + getExitCode(new Error(`Stack is in ${state} state`)), + `Should return 6 for state: ${state}` + ).toBe(ExitCode.DEPLOY_FAILED); + } + }); + + it('returns 6 for stack cannot be updated errors', () => { + expect( + getExitCode(new Error('Stack is in UPDATE_ROLLBACK_IN_PROGRESS state and cannot be updated')) + ).toBe(ExitCode.DEPLOY_FAILED); + }); + + it('returns 6 for stack currently being updated', () => { + expect(getExitCode(new Error('stack is currently being updated'))).toBe(ExitCode.DEPLOY_FAILED); + }); + + it('returns 6 for changeset in progress errors', () => { + expect( + getExitCode( + new Error( + 'InvalidChangeSetStatusException: An operation on this ChangeSet is currently in progress.' + ) + ) + ).toBe(ExitCode.DEPLOY_FAILED); + }); + + it('returns 6 for changeset currently in progress', () => { + expect(getExitCode(new Error('ChangeSet is currently in progress'))).toBe(ExitCode.DEPLOY_FAILED); + }); + }); + + describe('agent not found errors → EXIT_AGENT_NOT_FOUND (5)', () => { + it('returns 5 for agent not found message', () => { + expect(getExitCode(new Error("Agent 'my-agent' not found"))).toBe(ExitCode.AGENT_NOT_FOUND); + }); + + it('returns 5 for agent not deployed message', () => { + expect(getExitCode(new Error("Agent 'my-agent' is not deployed"))).toBe(ExitCode.AGENT_NOT_FOUND); + }); + + it('returns 5 for no agents defined message', () => { + expect(getExitCode(new Error('No agents defined in agentcore.json'))).toBe(ExitCode.AGENT_NOT_FOUND); + }); + }); + + describe('default → EXIT_GENERAL_ERROR (1)', () => { + it('returns 1 for generic errors', () => { + expect(getExitCode(new Error('some random error'))).toBe(ExitCode.GENERAL_ERROR); + }); + + it('returns 1 for null', () => { + expect(getExitCode(null)).toBe(ExitCode.GENERAL_ERROR); + }); + + it('returns 1 for undefined', () => { + expect(getExitCode(undefined)).toBe(ExitCode.GENERAL_ERROR); + }); + + it('returns 1 for string errors', () => { + expect(getExitCode('string error')).toBe(ExitCode.GENERAL_ERROR); + }); + + it('returns 1 for empty objects', () => { + expect(getExitCode({})).toBe(ExitCode.GENERAL_ERROR); + }); + + it('returns 1 for number errors', () => { + expect(getExitCode(123)).toBe(ExitCode.GENERAL_ERROR); + }); + }); + + describe('priority ordering', () => { + it('access denied takes priority over message-based matching', () => { + // An error that could match both access denied and agent not found + const err = { name: 'AccessDeniedException', message: 'Agent not found' }; + expect(getExitCode(err)).toBe(ExitCode.ACCESS_DENIED); + }); + + it('expired token takes priority over agent not found message matching', () => { + // An error that could match both expired token and agent not found + const err = { name: 'ExpiredToken', message: 'Agent not found' }; + expect(getExitCode(err)).toBe(ExitCode.AUTH_EXPIRED); + }); + + it('access denied takes priority over expired token', () => { + // AccessDenied should NOT be classified as expired token + expect(getExitCode({ name: 'AccessDeniedException' })).toBe(ExitCode.ACCESS_DENIED); + expect(getExitCode({ name: 'AccessDenied' })).toBe(ExitCode.ACCESS_DENIED); + }); + }); + }); +}); From 3f81301e468c065947a449389ca833c1e817e241 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:09:23 -0400 Subject: [PATCH 4/8] fix: resolve typecheck issues in exit-codes.ts Use direct property casts instead of Record index access to avoid noUncheckedIndexedAccess issues with constructor.name. --- src/cli/exit-codes.ts | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index d328c9566..aad4ef513 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -93,38 +93,30 @@ function isCommanderInvalidArgError(err: unknown): boolean { return false; } - const error = err as Record; + const code = (err as { code?: string }).code; // Commander.js sets code property for specific error types if ( - error.code === 'commander.invalidArgument' || - error.code === 'commander.missingArgument' || - error.code === 'commander.missingMandatoryOptionValue' || - error.code === 'commander.optionMissingArgument' + code === 'commander.invalidArgument' || + code === 'commander.missingArgument' || + code === 'commander.missingMandatoryOptionValue' || + code === 'commander.optionMissingArgument' ) { return true; } // Commander.js sets exitCode to 2 for argument validation errors - if (error.exitCode === 2 && isCommanderError(error)) { - return true; + const exitCode = (err as { exitCode?: number }).exitCode; + if (exitCode === 2) { + const constructorName = err.constructor?.name; + if (constructorName === 'CommanderError' || constructorName === 'InvalidArgumentError') { + return true; + } } return false; } -/** - * Checks if an error originates from Commander.js by inspecting the - * constructor name. This avoids importing Commander directly. - */ -function isCommanderError(err: Record): boolean { - if (err.constructor && typeof err.constructor === 'function') { - const name = err.constructor.name; - return name === 'CommanderError' || name === 'InvalidArgumentError'; - } - return false; -} - /** * Checks if an error indicates that a requested agent was not found. * Uses message-based pattern matching as a best-effort classification. From 0161bc999ff82c8b4c801e9cca3cdc6baf1cc3ce Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:09:54 -0400 Subject: [PATCH 5/8] fix: use typed cast for Commander error detection Cast to a specific interface instead of Record to ensure type-safe property access for code, exitCode, and constructor. --- src/cli/exit-codes.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index aad4ef513..44aba8584 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -93,22 +93,21 @@ function isCommanderInvalidArgError(err: unknown): boolean { return false; } - const code = (err as { code?: string }).code; + const error = err as { code?: string; exitCode?: number; constructor?: { name?: string } }; // Commander.js sets code property for specific error types if ( - code === 'commander.invalidArgument' || - code === 'commander.missingArgument' || - code === 'commander.missingMandatoryOptionValue' || - code === 'commander.optionMissingArgument' + error.code === 'commander.invalidArgument' || + error.code === 'commander.missingArgument' || + error.code === 'commander.missingMandatoryOptionValue' || + error.code === 'commander.optionMissingArgument' ) { return true; } // Commander.js sets exitCode to 2 for argument validation errors - const exitCode = (err as { exitCode?: number }).exitCode; - if (exitCode === 2) { - const constructorName = err.constructor?.name; + if (error.exitCode === 2) { + const constructorName = error.constructor?.name; if (constructorName === 'CommanderError' || constructorName === 'InvalidArgumentError') { return true; } From e6821934c4940961f42b89dd634e2a9225e4b6bb Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:15:18 -0400 Subject: [PATCH 6/8] fix: simplify type assertions in exit-codes.ts for strict typecheck (#11) Use specific type assertions instead of Record to avoid noUncheckedIndexedAccess issues with constructor property access. --- src/cli/exit-codes.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index 44aba8584..89d4ec3f2 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -93,7 +93,7 @@ function isCommanderInvalidArgError(err: unknown): boolean { return false; } - const error = err as { code?: string; exitCode?: number; constructor?: { name?: string } }; + const error = err as { code?: string; exitCode?: number }; // Commander.js sets code property for specific error types if ( @@ -106,9 +106,10 @@ function isCommanderInvalidArgError(err: unknown): boolean { } // Commander.js sets exitCode to 2 for argument validation errors + // combined with a Commander-specific constructor name if (error.exitCode === 2) { - const constructorName = error.constructor?.name; - if (constructorName === 'CommanderError' || constructorName === 'InvalidArgumentError') { + const ctorName = (err as { constructor?: { name?: string } }).constructor?.name; + if (ctorName === 'CommanderError' || ctorName === 'InvalidArgumentError') { return true; } } From cd3ea8bc8dd51b3cad564d5525ed4059df604e12 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:22:01 -0400 Subject: [PATCH 7/8] fix: fix typecheck and prettier formatting in exit-codes tests (#11) Use Object.assign instead of type assertion for adding properties to Error objects. Reformat to match prettier output. --- src/cli/__tests__/exit-codes.test.ts | 43 +++++++++++----------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/src/cli/__tests__/exit-codes.test.ts b/src/cli/__tests__/exit-codes.test.ts index 68147acae..c78c66fb4 100644 --- a/src/cli/__tests__/exit-codes.test.ts +++ b/src/cli/__tests__/exit-codes.test.ts @@ -99,50 +99,44 @@ describe('exit-codes', () => { describe('commander invalid argument errors → EXIT_INVALID_ARGS (2)', () => { it('returns 2 for commander.invalidArgument code', () => { - const err = new Error('invalid argument'); - (err as Record).code = 'commander.invalidArgument'; + const err = Object.assign(new Error('invalid argument'), { code: 'commander.invalidArgument' }); expect(getExitCode(err)).toBe(ExitCode.INVALID_ARGS); }); it('returns 2 for commander.missingArgument code', () => { - const err = new Error('missing argument'); - (err as Record).code = 'commander.missingArgument'; + const err = Object.assign(new Error('missing argument'), { code: 'commander.missingArgument' }); expect(getExitCode(err)).toBe(ExitCode.INVALID_ARGS); }); it('returns 2 for commander.missingMandatoryOptionValue code', () => { - const err = new Error('missing option value'); - (err as Record).code = 'commander.missingMandatoryOptionValue'; + const err = Object.assign(new Error('missing option value'), { + code: 'commander.missingMandatoryOptionValue', + }); expect(getExitCode(err)).toBe(ExitCode.INVALID_ARGS); }); it('returns 2 for commander.optionMissingArgument code', () => { - const err = new Error('option missing argument'); - (err as Record).code = 'commander.optionMissingArgument'; + const err = Object.assign(new Error('option missing argument'), { + code: 'commander.optionMissingArgument', + }); expect(getExitCode(err)).toBe(ExitCode.INVALID_ARGS); }); }); describe('deploy failed errors → EXIT_DEPLOY_FAILED (6)', () => { it('returns 6 for stack in progress errors', () => { - const states = [ - 'UPDATE_IN_PROGRESS', - 'CREATE_IN_PROGRESS', - 'DELETE_IN_PROGRESS', - 'ROLLBACK_IN_PROGRESS', - ]; + const states = ['UPDATE_IN_PROGRESS', 'CREATE_IN_PROGRESS', 'DELETE_IN_PROGRESS', 'ROLLBACK_IN_PROGRESS']; for (const state of states) { - expect( - getExitCode(new Error(`Stack is in ${state} state`)), - `Should return 6 for state: ${state}` - ).toBe(ExitCode.DEPLOY_FAILED); + expect(getExitCode(new Error(`Stack is in ${state} state`)), `Should return 6 for state: ${state}`).toBe( + ExitCode.DEPLOY_FAILED + ); } }); it('returns 6 for stack cannot be updated errors', () => { - expect( - getExitCode(new Error('Stack is in UPDATE_ROLLBACK_IN_PROGRESS state and cannot be updated')) - ).toBe(ExitCode.DEPLOY_FAILED); + expect(getExitCode(new Error('Stack is in UPDATE_ROLLBACK_IN_PROGRESS state and cannot be updated'))).toBe( + ExitCode.DEPLOY_FAILED + ); }); it('returns 6 for stack currently being updated', () => { @@ -152,9 +146,7 @@ describe('exit-codes', () => { it('returns 6 for changeset in progress errors', () => { expect( getExitCode( - new Error( - 'InvalidChangeSetStatusException: An operation on this ChangeSet is currently in progress.' - ) + new Error('InvalidChangeSetStatusException: An operation on this ChangeSet is currently in progress.') ) ).toBe(ExitCode.DEPLOY_FAILED); }); @@ -206,19 +198,16 @@ describe('exit-codes', () => { describe('priority ordering', () => { it('access denied takes priority over message-based matching', () => { - // An error that could match both access denied and agent not found const err = { name: 'AccessDeniedException', message: 'Agent not found' }; expect(getExitCode(err)).toBe(ExitCode.ACCESS_DENIED); }); it('expired token takes priority over agent not found message matching', () => { - // An error that could match both expired token and agent not found const err = { name: 'ExpiredToken', message: 'Agent not found' }; expect(getExitCode(err)).toBe(ExitCode.AUTH_EXPIRED); }); it('access denied takes priority over expired token', () => { - // AccessDenied should NOT be classified as expired token expect(getExitCode({ name: 'AccessDeniedException' })).toBe(ExitCode.ACCESS_DENIED); expect(getExitCode({ name: 'AccessDenied' })).toBe(ExitCode.ACCESS_DENIED); }); From 7d66a7481b9e00fb39dd849ca7d525432eb20290 Mon Sep 17 00:00:00 2001 From: loopy-symphony Date: Fri, 20 Mar 2026 18:02:37 +0000 Subject: [PATCH 8/8] fix: add ResourceNotFoundException detection to agent-not-found exit code (#11) - Add ResourceNotFoundException error name check to isAgentNotFoundError() to match the AWS SDK error pattern used throughout the codebase - Add test for ResourceNotFoundException mapping to AGENT_NOT_FOUND (5) - Add test for agent not found in deployed state message pattern --- src/cli/__tests__/exit-codes.test.ts | 8 ++++++++ src/cli/exit-codes.ts | 11 ++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/cli/__tests__/exit-codes.test.ts b/src/cli/__tests__/exit-codes.test.ts index c78c66fb4..21a4af5a2 100644 --- a/src/cli/__tests__/exit-codes.test.ts +++ b/src/cli/__tests__/exit-codes.test.ts @@ -157,6 +157,10 @@ describe('exit-codes', () => { }); describe('agent not found errors → EXIT_AGENT_NOT_FOUND (5)', () => { + it('returns 5 for ResourceNotFoundException error name', () => { + expect(getExitCode({ name: 'ResourceNotFoundException' })).toBe(ExitCode.AGENT_NOT_FOUND); + }); + it('returns 5 for agent not found message', () => { expect(getExitCode(new Error("Agent 'my-agent' not found"))).toBe(ExitCode.AGENT_NOT_FOUND); }); @@ -168,6 +172,10 @@ describe('exit-codes', () => { it('returns 5 for no agents defined message', () => { expect(getExitCode(new Error('No agents defined in agentcore.json'))).toBe(ExitCode.AGENT_NOT_FOUND); }); + + it('returns 5 for agent not found in deployed state', () => { + expect(getExitCode(new Error("Agent 'my-agent' not found in deployed state"))).toBe(ExitCode.AGENT_NOT_FOUND); + }); }); describe('default → EXIT_GENERAL_ERROR (1)', () => { diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index 89d4ec3f2..51cca04e5 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -119,9 +119,18 @@ function isCommanderInvalidArgError(err: unknown): boolean { /** * Checks if an error indicates that a requested agent was not found. - * Uses message-based pattern matching as a best-effort classification. + * Matches AWS ResourceNotFoundException and message-based patterns. */ function isAgentNotFoundError(err: unknown): boolean { + if (err && typeof err === 'object') { + const error = err as Record; + + // Check AWS SDK error name for ResourceNotFoundException + if (typeof error.name === 'string' && error.name === 'ResourceNotFoundException') { + return true; + } + } + const message = getErrorMessage(err).toLowerCase(); // Match patterns like "Agent 'my-agent' not found"