aws-cli-agent 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { tool } from 'ai';
3
3
  import { z } from 'zod';
4
4
  import { confirm } from '@inquirer/prompts';
5
5
  import chalk from 'chalk';
6
+ import { FATAL_AWS_EXIT_CODES, FatalAwsCliError, UserCancelledError, wrapPrompt } from '../errors.js';
6
7
  const READ_ONLY_VERBS = [
7
8
  /^describe-/,
8
9
  /^list-/,
@@ -107,14 +108,40 @@ function runCaptured(cmd, args) {
107
108
  */
108
109
  function runInteractive(cmd, args) {
109
110
  return new Promise((resolve, reject) => {
111
+ // Detach our SIGINT handler for the duration of the child's lifetime.
112
+ // Background: when the user presses Ctrl-C inside an SSM session, the
113
+ // OS delivers SIGINT to the whole foreground process group — both the
114
+ // aws CLI child AND this Node process. Node's default SIGINT handler
115
+ // would terminate us, which tears down the inherited stdin file
116
+ // descriptor before the aws CLI has finished its own cleanup. The
117
+ // result is the aws CLI seeing "input/output error" on /dev/stdin and
118
+ // printing a confusing error to the user.
119
+ //
120
+ // By installing a no-op SIGINT listener, we override Node's default
121
+ // behavior: Node sees the signal as "handled" and doesn't terminate
122
+ // us. The aws CLI subprocess gets the signal too (same process group)
123
+ // and runs its own clean shutdown — closing the SSM session politely,
124
+ // exiting with code 0 or 130. We then continue normally.
125
+ //
126
+ // The listener is removed in `close` so SIGINT during normal agent
127
+ // reasoning still aborts the run as expected.
128
+ const noopSigint = () => {
129
+ /* deliberately empty — see comment above */
130
+ };
131
+ process.on('SIGINT', noopSigint);
110
132
  const proc = spawn(cmd, args, {
111
133
  env: process.env,
112
- stdio: 'inherit', // ← the fix: child reuses parent's stdin/stdout/stderr
134
+ stdio: 'inherit', // child reuses parent's stdin/stdout/stderr
135
+ });
136
+ proc.on('error', (err) => {
137
+ process.removeListener('SIGINT', noopSigint);
138
+ reject(err);
139
+ });
140
+ proc.on('close', (code) => {
141
+ process.removeListener('SIGINT', noopSigint);
142
+ // We can't observe stdout/stderr — they went to the user's terminal.
143
+ resolve({ stdout: '', stderr: '', code: code ?? 0 });
113
144
  });
114
- proc.on('error', reject);
115
- proc.on('close', (code) =>
116
- // We can't observe stdout/stderr — they went to the user's terminal.
117
- resolve({ stdout: '', stderr: '', code: code ?? 0 }));
118
145
  });
119
146
  }
120
147
  export function awsCliTool(opts) {
@@ -164,7 +191,7 @@ export function awsCliTool(opts) {
164
191
  if (useInteractive) {
165
192
  process.stderr.write(`${chalk.bold(' Mode: ')}${chalk.yellow('interactive')} (your terminal will be connected to the command)\n`);
166
193
  }
167
- const ok = await confirm({ message: 'Execute this command?', default: true });
194
+ const ok = await wrapPrompt((ctx) => confirm({ message: 'Execute this command?', default: true }, ctx));
168
195
  if (!ok) {
169
196
  opts.logger.warn('User declined command');
170
197
  // Record the declined call so the agent's end-of-run logic sees
@@ -208,9 +235,18 @@ export function awsCliTool(opts) {
208
235
  opts.logger.trace('stderr', stderr);
209
236
  }
210
237
  }
211
- else if (code !== 0) {
238
+ else if (code !== 0 && code !== 130) {
239
+ // Exit 130 in interactive mode = user pressed Ctrl-C to end their
240
+ // SSM session, shell, port-forward, etc. That's expected, not a
241
+ // failure. Anything else is genuine — log it.
212
242
  opts.logger.warn(`Interactive AWS CLI exited non-zero (${code})`);
213
243
  }
244
+ // SSM sessions and other interactive AWS CLI commands return 130
245
+ // when the user Ctrl-Cs to end the session. That's the normal way
246
+ // to leave a shell — treat it as success for ok/exit purposes so we
247
+ // don't surface it as an error in cli.ts. The real exit code is
248
+ // still recorded in the audit log for accuracy.
249
+ const effectivelyOk = code === 0 || (useInteractive && code === 130);
214
250
  // Audit captures whatever we have. For interactive runs stdout/stderr
215
251
  // are empty — that's accurate, the bytes went to the terminal — and
216
252
  // the audit entry serves as a record that "an interactive session
@@ -219,7 +255,7 @@ export function awsCliTool(opts) {
219
255
  cmd: display,
220
256
  profile,
221
257
  exitCode: code,
222
- ok: code === 0,
258
+ ok: effectivelyOk,
223
259
  stdout: useInteractive ? '[interactive session — output not captured]' : stdout,
224
260
  stderr: useInteractive ? '' : stderr,
225
261
  });
@@ -234,18 +270,33 @@ export function awsCliTool(opts) {
234
270
  : stdout,
235
271
  stderr: useInteractive ? '' : stderr,
236
272
  exitCode: code,
237
- ok: code === 0,
273
+ ok: effectivelyOk,
238
274
  });
239
275
  // For the agent's context, return a clear signal that interactive
240
276
  // mode ran so it doesn't try to parse fictional stdout.
241
277
  if (useInteractive) {
242
278
  return {
243
- ok: code === 0,
279
+ ok: effectivelyOk,
244
280
  exitCode: code,
245
281
  interactive: true,
246
282
  note: 'Interactive session ran. Output went directly to the user\'s terminal and was not captured. Do not summarize or describe its contents.',
247
283
  };
248
284
  }
285
+ // Classify failures. Fatal exit codes (252-255) indicate the call
286
+ // won't succeed without external intervention — bad credentials,
287
+ // missing resource, malformed request, AWS service failure. We
288
+ // throw FatalAwsCliError (after recording the audit trail above)
289
+ // rather than returning a result to the model: the throw unwinds
290
+ // the agent loop entirely, the user sees the AWS stderr in red,
291
+ // and we exit 1. The model never gets a chance to retry, because
292
+ // these classes of error don't get better with retries.
293
+ //
294
+ // Soft failures (other non-zero exits) ARE returned to the model
295
+ // as ordinary results. The model may retry with a different
296
+ // approach. maxSteps bounds the worst case if that goes nowhere.
297
+ if (code !== 0 && FATAL_AWS_EXIT_CODES.has(code)) {
298
+ throw new FatalAwsCliError(display, code, stderr);
299
+ }
249
300
  return {
250
301
  ok: code === 0,
251
302
  exitCode: code,
@@ -254,6 +305,11 @@ export function awsCliTool(opts) {
254
305
  };
255
306
  }
256
307
  catch (err) {
308
+ // FatalAwsCliError is our own signal — propagate it cleanly.
309
+ // UserCancelledError must propagate too (Ctrl-C at the approval
310
+ // prompt) or it'd get swallowed into a spawn-failure log entry.
311
+ if (err instanceof FatalAwsCliError || err instanceof UserCancelledError)
312
+ throw err;
257
313
  const msg = err instanceof Error ? err.message : String(err);
258
314
  opts.logger.error('Failed to spawn aws CLI', msg);
259
315
  opts.audit.logCommand({
@@ -7,6 +7,7 @@ import { z } from 'zod';
7
7
  import { select } from '@inquirer/prompts';
8
8
  import chalk from 'chalk';
9
9
  import { DEFAULT_SCRIPT_FOLDER } from '../paths.js';
10
+ import { wrapPrompt } from '../errors.js';
10
11
  function runProcess(cmd, args) {
11
12
  return new Promise((resolve, reject) => {
12
13
  const proc = spawn(cmd, args, { env: process.env });
@@ -74,7 +75,7 @@ export function bashScriptTool(opts) {
74
75
  // — auto-approving them would defeat a primary safety boundary. The
75
76
  // autoApprove flag remains in effect for individual aws CLI commands
76
77
  // (where read-only is a meaningful and enforceable category).
77
- const action = await select({
78
+ const action = await wrapPrompt((ctx) => select({
78
79
  message: 'What would you like to do with this script?',
79
80
  choices: [
80
81
  { value: 'execute', name: 'Execute now' },
@@ -82,7 +83,7 @@ export function bashScriptTool(opts) {
82
83
  { value: 'cancel', name: 'Cancel' },
83
84
  ],
84
85
  default: 'execute',
85
- });
86
+ }, ctx));
86
87
  if (action === 'cancel') {
87
88
  opts.logger.warn('User cancelled script');
88
89
  // Record the cancelled call so the agent's end-of-run logic sees
@@ -2,6 +2,7 @@ import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
3
  import { confirm, input, password, select } from '@inquirer/prompts';
4
4
  import chalk from 'chalk';
5
+ import { wrapPrompt } from '../errors.js';
5
6
  /**
6
7
  * Schema for a single question. Used both by `prompt_user` directly (single
7
8
  * question per call) and `prompt_user_multi` (batch of questions in one call).
@@ -44,28 +45,31 @@ async function askOne(q, logger) {
44
45
  if (!q.choices || q.choices.length === 0) {
45
46
  throw new Error('kind="choice" requires non-empty `choices`.');
46
47
  }
47
- const answer = await select({
48
+ // Capture narrowed value: TS loses the q.choices !== undefined
49
+ // narrowing across the closure boundary below.
50
+ const choices = q.choices;
51
+ const answer = await wrapPrompt((ctx) => select({
48
52
  message: q.message,
49
- choices: q.choices.map((c) => ({ value: c, name: c })),
53
+ choices: choices.map((c) => ({ value: c, name: c })),
50
54
  default: q.defaultValue,
51
- });
55
+ }, ctx));
52
56
  return answer;
53
57
  }
54
58
  case 'confirm': {
55
59
  const def = (q.defaultValue ?? 'yes').toLowerCase().startsWith('y');
56
- const answer = await confirm({ message: q.message, default: def });
60
+ const answer = await wrapPrompt((ctx) => confirm({ message: q.message, default: def }, ctx));
57
61
  return answer ? 'yes' : 'no';
58
62
  }
59
63
  case 'secret': {
60
64
  // Inquirer's password prompt masks input. Used for short secrets like
61
65
  // MFA codes; long-lived AWS credentials should always come from the
62
66
  // user's profile, not be typed here.
63
- const answer = await password({ message: q.message, mask: '*' });
67
+ const answer = await wrapPrompt((ctx) => password({ message: q.message, mask: '*' }, ctx));
64
68
  return answer;
65
69
  }
66
70
  case 'text':
67
71
  default: {
68
- const answer = await input({ message: q.message, default: q.defaultValue });
72
+ const answer = await wrapPrompt((ctx) => input({ message: q.message, default: q.defaultValue }, ctx));
69
73
  return answer;
70
74
  }
71
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aws-cli-agent",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Agentic AI assistant that turns natural-language requests into AWS CLI commands and runs them locally.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,23 +51,23 @@
51
51
  },
52
52
  "homepage": "https://github.com/trstnk/aws-cli-agent#readme",
53
53
  "dependencies": {
54
- "@ai-sdk/amazon-bedrock": "^4.0.106",
55
- "@ai-sdk/anthropic": "^3.0.78",
56
- "@ai-sdk/google": "^3.0.74",
57
- "@ai-sdk/openai": "^3.0.64",
58
- "@aws-sdk/credential-providers": "^3.1046.0",
59
- "@inquirer/prompts": "^7.3.0",
60
- "ai": "^6.0.183",
61
- "chalk": "^5.4.0",
62
- "commander": "^13.0.0",
54
+ "@ai-sdk/amazon-bedrock": "^4.0.111",
55
+ "@ai-sdk/anthropic": "^3.0.81",
56
+ "@ai-sdk/google": "^3.0.80",
57
+ "@ai-sdk/openai": "^3.0.67",
58
+ "@aws-sdk/credential-providers": "^3.1057.0",
59
+ "@inquirer/prompts": "^8.5.1",
60
+ "ai": "^6.0.193",
61
+ "chalk": "^5.6.2",
62
+ "commander": "^15.0.0",
63
63
  "zod": "^4.4.3"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@eslint/js": "^10.0.1",
67
- "@types/node": "^25.8.0",
68
- "eslint": "^10.4.0",
69
- "tsx": "^4.22.0",
67
+ "@types/node": "^25.9.1",
68
+ "eslint": "^10.4.1",
69
+ "tsx": "^4.22.3",
70
70
  "typescript": "^6.0.3",
71
- "typescript-eslint": "^8.59.3"
71
+ "typescript-eslint": "^8.60.0"
72
72
  }
73
73
  }