aws-cli-agent 0.4.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -4,8 +4,206 @@ All notable changes to this project are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
5
5
  versioning follows [SemVer](https://semver.org/).
6
6
 
7
- ## [0.4.0] - 2026-05-17
7
+ ## [0.5.0] - 2026-05-19
8
8
 
9
9
  ### Added
10
10
 
11
- - Initial official release. Agentic AI assistant that turns natural-language requests into AWS CLI commands and runs them locally.
11
+ - **Graceful error handling for AWS CLI failures.** AWS CLI exit codes
12
+ 252–255 (parse error, missing credentials, client error, server error)
13
+ are now classified as fatal and abort the agent loop immediately rather
14
+ than being fed back to the model for retry. The user sees the AWS
15
+ stderr printed verbatim in red; the process exits 1. Other non-zero
16
+ exits remain soft failures — the model can decide whether to retry,
17
+ bounded by `maxSteps` as before.
18
+ - **Ctrl-C handling.** Pressing Ctrl-C at any agent-driven prompt
19
+ (approval prompts, agent-asked questions, the bash script's
20
+ execute/save/cancel dialog) now exits cleanly with a "cancelled by
21
+ user" message on stderr and exit code 130 (SIGINT convention). No
22
+ stack trace, no red error, no "ran N commands" footer.
23
+ - **SSM-session Ctrl-C silenced.** When you Ctrl-C to end an interactive
24
+ AWS CLI session (SSM Session Manager shells, port-forwards, etc.),
25
+ exit code 130 is treated as a clean termination instead of an error.
26
+ The audit log still records the real exit code for accuracy.
27
+ - **`endReason` field on `RunResult`.** Internal API used by cli.ts to
28
+ pick the right exit code: `completed` (0), `cancelled` (130), or
29
+ `fatal` (1).
30
+
31
+ ### Changed
32
+
33
+ - **The "ran N commands" footer is now verbose-only.** Previously printed
34
+ on every multi-command run; now requires `--verbose` / `-v` to surface.
35
+ With verbose off, nothing aca generates reaches the terminal — only
36
+ the AWS CLI's verbatim output does, matching the README's promise.
37
+
38
+ ## [0.4.0] - 2026-05-18
39
+
40
+ ### Changed
41
+
42
+ - **Dependency upgrades.** Vercel AI SDK v4 → v6, zod v3 → v4, TypeScript
43
+ v5 → v6, ESLint v9 → v10, `@types/node` v22 → v25, and all `@ai-sdk/*`
44
+ provider packages to their v6-compatible majors (`@ai-sdk/anthropic`@3,
45
+ `@ai-sdk/openai`@3, `@ai-sdk/google`@3, `@ai-sdk/amazon-bedrock`@4).
46
+ Required code changes:
47
+ - `generateText({ maxSteps })` → `generateText({ stopWhen: stepCountIs(n) })`
48
+ - Tool definition field `parameters:` → `inputSchema:`
49
+ - Tool call payload `args` → `input`
50
+ - Usage fields `promptTokens` / `completionTokens` →
51
+ `inputTokens` / `outputTokens` (the data we write to `usage.log` keeps
52
+ the legacy names — they're a stable public interface, just remapped at
53
+ extraction time).
54
+ - `createOpenAI({ compatibility: 'strict' })` removed; the option no longer
55
+ exists.
56
+ - Zod v4 `.default({})` on object schemas now requires the fully-typed
57
+ default value; updated `LoggingSchema` and `autoApprove` defaults.
58
+ - Step events from `onStepFinish` dropped the `stepType` field; the debug
59
+ log now only mentions `finishReason`.
60
+
61
+ ### Fixed
62
+
63
+ - **Interactive AWS CLI commands now work.** Previously, commands like
64
+ `aws ssm start-session` (interactive shells), port-forwarding sessions, and
65
+ log tails with `--follow` appeared to hang — the child process's stdout was
66
+ being captured into a string for the agent's context, and the child's stdin
67
+ was never connected to the user's terminal. Now the host detects common
68
+ interactive patterns and uses `stdio: 'inherit'` for those commands, so the
69
+ user's terminal connects directly to the AWS CLI subprocess.
70
+ - **General log no longer echoes to the console.** Previously, the operational
71
+ `Logger` wrote both to `general.log` *and* to stderr at every level above
72
+ the threshold — meaning `--log-level debug` would spam debug lines into the
73
+ user's terminal. Now `Logger` is strictly file-only; the only things that
74
+ reach the console are (a) the AWS CLI's verbatim stdout, (b) approval
75
+ prompts, (c) error summaries, and (d) reasoning steps when `verbose` is
76
+ on. To watch operational logs live: `tail -f
77
+ ~/.local/state/aws-cli-agent/general.log`.
78
+
79
+ ### Added
80
+
81
+ - **`--interactive` / `-i` CLI flag** to force every AWS CLI command in a run
82
+ to inherit the user's terminal. Useful as an escape hatch for commands not
83
+ in the auto-detect list (`ssm start-session`, `ssm start-session` with
84
+ port-forward documents, `ecs execute-command`, `logs tail --follow`).
85
+ - **`interactive` parameter on `execute_aws_command` tool.** Lets the agent
86
+ explicitly mark a command as interactive when it knows the command needs
87
+ a TTY. For interactive runs, the agent receives a "do not summarize"
88
+ signal instead of stdout.
89
+ - **Auto-approve never applies to interactive commands.** Handing your
90
+ terminal to a subprocess is a meaningful event; it always prompts.
91
+
92
+ - **Prompt caching** for Anthropic and Bedrock providers (`caching: true` by
93
+ default). Marks the system prompt + tool definitions as cacheable; cache
94
+ reads cost ~10% of normal input tokens on these providers. OpenAI
95
+ auto-caches without our involvement; Google Gemini isn't supported yet.
96
+ Cache hit/miss tokens are recorded in `usage.log` as `cacheReadTokens` and
97
+ `cacheWriteTokens`. Typical cost reduction: ~60% off the input bill for
98
+ frequent users.
99
+ - **Usage log** — `usage.log` (JSONL) records token totals per `aca`
100
+ invocation: timestamp, provider, model, steps, prompt/completion/total
101
+ tokens. Enable/disable via `logging.usageLog` (default `true`). Sum the
102
+ day's tokens with `cat usage.log | jq -s 'map(.totalTokens) | add'`.
103
+ - **Interactive prompting** during the reasoning process. The `prompt_user`
104
+ tool now supports four question kinds: `text` (free-form), `choice` (pick
105
+ one from a finite list), `confirm` (yes/no), and `secret` (hidden input
106
+ for short secrets like MFA codes). New `prompt_user_multi` tool batches
107
+ several related questions into a single round so the agent doesn't need
108
+ multiple model round-trips to gather setup data.
109
+ - **Sharpened system prompt** with explicit anti-guessing rules and worked
110
+ examples of when to ask vs. when to discover. The agent is much more
111
+ likely to stop and ask when it isn't certain rather than picking a
112
+ plausible answer and acting on it.
113
+
114
+ ## [0.3.0] - 2026-05-15
115
+
116
+ ### Changed
117
+
118
+ - **Renamed** package from `ai-aws` to `aws-cli-agent`. Short CLI name is `aca`;
119
+ the long name `aws-cli-agent` works too. Install with
120
+ `npm install -g aws-cli-agent`.
121
+ - **Restructured logging config.** Replaced top-level `logLevel`, `audit.enabled`,
122
+ and `reasoning.enabled` with a nested `logging` object:
123
+ ```json
124
+ "logging": { "level": "error", "auditLog": true, "reasoningLog": false }
125
+ ```
126
+ Defaults are now: level `error` (was `info`), audit on (unchanged), reasoning
127
+ log **off** (was on).
128
+ - **Renamed general log file** from `ai-aws.log` to `general.log`.
129
+ - **`--verbose` is now reasoning-only.** Previously also bumped log level to
130
+ debug; now controls only whether reasoning is echoed to the console. Use
131
+ `--log-level debug` separately if you want a noisier general log.
132
+ - **Restructured Bedrock config** into a nested `bedrock` object:
133
+ ```json
134
+ "bedrock": { "region": "us-east-1", "profile": "shared-services" }
135
+ ```
136
+ Replaces the old top-level `bedrockRegion` / `bedrockProfile`.
137
+
138
+ ### Added
139
+
140
+ - **`defaultRegion`** config and `--region` CLI flag. The configured region
141
+ is auto-appended as `--region` to every AWS CLI call the agent makes —
142
+ unless the agent itself specified a region, in which case its choice wins.
143
+ - **Bash script "save to disk" option.** When the agent generates a script,
144
+ the user now sees a three-way prompt: Execute / Save to disk / Cancel.
145
+ The save path is shown inline so you know exactly where the file lands.
146
+ Folder is configurable via `scriptFolder`; default is
147
+ `$XDG_DATA_HOME/aws-cli-agent/scripts`.
148
+ - **Two npm-installable binary names**: `aws-cli-agent` and `aca` (same binary).
149
+ - **GitHub Actions CI** (lint, typecheck, build, smoke test on Node 20 & 22).
150
+ - **GitHub Actions Release** workflow (publishes to npm on tag push or release
151
+ publication, with provenance attestation).
152
+ - **Dependabot** config for npm and GitHub Actions.
153
+ - **Smoke test** script (`npm test`) that exercises the basic CLI surface
154
+ without needing cloud credentials.
155
+
156
+ ### Removed
157
+
158
+ - **`--quiet` / `-q` flag.** Use `--log-level error` (or `silent`) instead.
159
+ - **Top-level config keys** `logLevel`, `audit`, `reasoning`, `bedrockRegion`,
160
+ `bedrockProfile`. See "Changed" above.
161
+ - **`autoApprove` no longer applies to bash scripts.** Scripts always prompt
162
+ (Execute / Save / Cancel). The flag still skips approval for individual
163
+ AWS CLI calls.
164
+
165
+ ### Migration notes
166
+
167
+ The old `ai-aws` config at `~/.config/ai-aws/config.json` is not read or
168
+ migrated. Run `aca config` to write a fresh default at the new path. Translate
169
+ old → new keys:
170
+
171
+ | Old | New |
172
+ |---|---|
173
+ | `logLevel` | `logging.level` |
174
+ | `audit.enabled` | `logging.auditLog` |
175
+ | `reasoning.enabled` | `logging.reasoningLog` |
176
+ | `bedrockRegion` | `bedrock.region` |
177
+ | `bedrockProfile` | `bedrock.profile` |
178
+
179
+ History at the old `~/.local/state/ai-aws/` location won't be picked up.
180
+ If you want to keep it: `mv ~/.local/state/ai-aws ~/.local/state/aws-cli-agent`.
181
+
182
+ ## [0.2.0] - 2026-05-14
183
+
184
+ ### Added
185
+
186
+ - Amazon Bedrock as a provider option via `@ai-sdk/amazon-bedrock`. Uses the
187
+ standard AWS credential chain; no API key required. Configurable via
188
+ optional `bedrockRegion` and `bedrockProfile` (since superseded by nested
189
+ `bedrock` in 0.3.0).
190
+ - Audit log: append-only JSONL of every executed command/script with full
191
+ stdout/stderr/exit code. Bash scripts also log full source.
192
+ - Reasoning log: text record of agent reasoning steps and tool calls.
193
+ - ESLint 9 with flat config (`npm run lint`, `npm run lint:fix`).
194
+
195
+ ### Changed
196
+
197
+ - Output policy: stdout is reserved for the verbatim AWS CLI output. The agent
198
+ cannot rewrite or summarize results. Pipe to `jq`, `wc`, etc. like you would
199
+ with the AWS CLI directly.
200
+ - Moved `history.jsonl` from `$XDG_DATA_HOME` to `$XDG_STATE_HOME` alongside
201
+ the logs.
202
+
203
+ ## [0.1.0] - 2026-05-14
204
+
205
+ ### Added
206
+
207
+ - Initial release. Agentic AWS CLI assistant with multi-step tool calling
208
+ (Vercel AI SDK), local-only state, XDG-compliant paths, configurable
209
+ providers (Anthropic / OpenAI / Google), per-command approval prompts.
package/README.md CHANGED
@@ -33,6 +33,12 @@ The first example is interactive — the agent runs a read-only `describe-instan
33
33
  - **Audit log is your friend.** Every executed command — including its stdout, stderr, and exit code — lands in `audit.log` (JSONL). If you ever need to reconstruct what happened, it's all there. Don't disable `logging.auditLog` unless you have a specific reason.
34
34
  - **No warranty.** **You use this agent at your own risk.** The authors are not responsible for unintended AWS API calls, deleted resources, exceeded budgets, or any other damage caused by using this tool. If you wouldn't run `aws` commands blindly from a script you found in someone's gist, don't run `aca` blindly either.
35
35
 
36
+ ## Trademark & affiliation
37
+
38
+ `aws-cli-agent` (`aca`) is an independent project, not affiliated with or
39
+ endorsed by Amazon Web Services. "AWS" and "Amazon Web Services" are
40
+ trademarks of Amazon.com, Inc.
41
+
36
42
  ## Installation
37
43
 
38
44
  ```bash
@@ -370,4 +376,4 @@ Without this rule, the approval prompts and reasoning lines would land in the ne
370
376
 
371
377
  ## License
372
378
 
373
- MIT
379
+ MIT
package/dist/agent.d.ts CHANGED
@@ -28,6 +28,18 @@ export type RunResult = {
28
28
  finalError: string | null;
29
29
  /** Did the last execute_* call run successfully? */
30
30
  ranCommand: boolean;
31
+ /**
32
+ * How the run ended. cli.ts uses this to pick the exit code and decide
33
+ * whether to print the AWS stderr as a red error:
34
+ * - 'completed' — normal end, model stopped on its own (exit 0)
35
+ * - 'cancelled' — Ctrl-C at a prompt; cli.ts prints "cancelled by
36
+ * user" on stderr and exits 130 (the canonical SIGINT
37
+ * exit code)
38
+ * - 'fatal' — AWS CLI returned an unrecoverable exit code
39
+ * (252-255); finalError carries the stderr, cli.ts
40
+ * prints it in red and exits 1
41
+ */
42
+ endReason: 'completed' | 'cancelled' | 'fatal';
31
43
  };
32
44
  export declare function runAgent(opts: {
33
45
  input: string;
package/dist/agent.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { streamText, stepCountIs } from 'ai';
2
2
  import { createModel } from './providers.js';
3
3
  import { createTools } from './tools/index.js';
4
+ import { FatalAwsCliError, UserCancelledError } from './errors.js';
4
5
  const SYSTEM_PROMPT = `You are aws-cli-agent (aca), an agentic assistant that translates natural-language requests into AWS CLI commands and executes them locally on the user's machine.
5
6
 
6
7
  Capabilities (via tools):
@@ -37,7 +38,8 @@ Operating rules:
37
38
  8. Interactive commands: some AWS CLI commands require a real terminal — SSM Session Manager shells (\`ssm start-session\`), port-forwarding sessions (the same command with --document-name AWS-StartPortForwardingSession*), ECS Exec (\`ecs execute-command\`), log tails with --follow. For these, set \`interactive: true\` on the execute_aws_command call. The host will connect the user's terminal directly to the command and you will receive no stdout — DO NOT try to summarize or describe the output afterwards, since you can't see it. Common patterns auto-detect, but setting the flag explicitly is safer.
38
39
  9. The final action of a successful run MUST be either execute_aws_command (the user-requested action) or execute_bash_script. If the user cancels via prompt_user, stop gracefully and explain in one sentence.
39
40
  10. NEVER include credentials, API keys, secrets, or session tokens in commands or scripts. AWS credentials come from the user's existing profile.
40
- 11. Keep your reasoning concise one or two sentences per step. DO NOT summarize, restate, reformat, or describe the output of the AWS CLI. The CLI's stdout is shown to the user directly by the host program. Your only post-execution job is to stop. If anything went wrong, say so briefly; if it succeeded, you may stop without further commentary.`;
41
+ 11. Handling AWS CLI errors: if execute_aws_command returns a result with \`ok: false\` (and a non-zero exitCode), you may retry ONCE with a different approach if it's clearly worth trying wrong region, wrong profile, missing flag, fixable typo. Don't loop trying minor variations. The host caps total run length via maxSteps; respect it. Note: unrecoverable errors (auth failure, missing credentials, permission denied, malformed request, AWS service errors) terminate the run before you'd see them, so you don't need to handle those cases — they're handled for you.
42
+ 12. Keep your reasoning concise — one or two sentences per step. DO NOT summarize, restate, reformat, or describe the output of the AWS CLI. The CLI's stdout is shown to the user directly by the host program. Your only post-execution job is to stop. If anything went wrong, say so briefly; if it succeeded, you may stop without further commentary.`;
41
43
  export async function runAgent(opts) {
42
44
  const { input, config, logger, history, audit, reasoning, usage } = opts;
43
45
  const executions = [];
@@ -144,61 +146,159 @@ export async function runAgent(opts) {
144
146
  // Two execution sites collaborate to print one step:
145
147
  // 1. text-end (here) → reasoning text line
146
148
  // 2. onToolCallStart (callback above) → tool: line, then execute()
147
- for await (const part of result.fullStream) {
148
- switch (part.type) {
149
- case 'start-step': {
150
- stepCounter += 1;
151
- toolCallStepNumber = stepCounter; // visible to onToolCallStart
152
- currentReasoning = '';
153
- currentToolCalls = [];
154
- reasoningEchoed = false;
155
- break;
156
- }
157
- case 'text-delta': {
158
- currentReasoning += part.text;
159
- break;
160
- }
161
- case 'text-end': {
162
- if (!reasoningEchoed) {
163
- reasoning.echoReasoning(stepCounter, currentReasoning);
164
- reasoningEchoed = true;
149
+ // Terminal state for the run. The for-await loop transitions us out of
150
+ // 'completed' (the default) into 'cancelled' on Ctrl-C, or 'fatal' on
151
+ // an unrecoverable AWS CLI failure. cli.ts uses endReason to pick the
152
+ // exit code and the user-facing message.
153
+ let endReason = 'completed';
154
+ try {
155
+ for await (const part of result.fullStream) {
156
+ switch (part.type) {
157
+ case 'start-step': {
158
+ stepCounter += 1;
159
+ toolCallStepNumber = stepCounter; // visible to onToolCallStart
160
+ currentReasoning = '';
161
+ currentToolCalls = [];
162
+ reasoningEchoed = false;
163
+ break;
165
164
  }
166
- break;
167
- }
168
- case 'tool-call': {
169
- // Backup echo path: if text-end didn't fire (provider variant or
170
- // text-less step), echo whatever reasoning we have when we see
171
- // tool-call. The tool-call LINE itself is NOT printed here — it's
172
- // printed by experimental_onToolCallStart, which fires
173
- // synchronously before execute() and guarantees ordering above
174
- // any approval prompt.
175
- if (!reasoningEchoed) {
176
- reasoning.echoReasoning(stepCounter, currentReasoning);
177
- reasoningEchoed = true;
165
+ case 'text-delta': {
166
+ currentReasoning += part.text;
167
+ break;
168
+ }
169
+ case 'text-end': {
170
+ if (!reasoningEchoed) {
171
+ reasoning.echoReasoning(stepCounter, currentReasoning);
172
+ reasoningEchoed = true;
173
+ }
174
+ break;
178
175
  }
179
- break;
176
+ case 'tool-call': {
177
+ // Backup echo path: if text-end didn't fire (provider variant or
178
+ // text-less step), echo whatever reasoning we have when we see
179
+ // tool-call. The tool-call LINE itself is NOT printed here — it's
180
+ // printed by experimental_onToolCallStart, which fires
181
+ // synchronously before execute() and guarantees ordering above
182
+ // any approval prompt.
183
+ if (!reasoningEchoed) {
184
+ reasoning.echoReasoning(stepCounter, currentReasoning);
185
+ reasoningEchoed = true;
186
+ }
187
+ break;
188
+ }
189
+ case 'tool-error': {
190
+ // The SDK catches errors thrown from tool.execute() and emits
191
+ // them as tool-error events instead of rejecting the stream. So
192
+ // we inspect every tool-error for our sentinels:
193
+ //
194
+ // - UserCancelledError → throw out of the loop so the outer
195
+ // catch propagates it to cli.ts for "cancelled by user" + exit 130.
196
+ // - FatalAwsCliError → set endReason='fatal' and stop iterating.
197
+ // The failed call has already been recorded in executions[]
198
+ // by the tool (audit + record fire before the throw), so
199
+ // finalError further down will pick up the stderr naturally.
200
+ // - Anything else: ignore. Soft failures shouldn't be thrown
201
+ // (tools return them as results), and any other thrown error
202
+ // is treated as a tool-level failure the model can decide
203
+ // how to handle.
204
+ if (part.error instanceof UserCancelledError) {
205
+ throw part.error;
206
+ }
207
+ if (part.error instanceof FatalAwsCliError) {
208
+ endReason = 'fatal';
209
+ logger.warn(`Run ended on fatal AWS CLI error (exit ${part.error.exitCode}).`);
210
+ // Flush this step's reasoning to the file log; the tool-call
211
+ // event for this step already fired, so currentToolCalls is
212
+ // populated. We need to break out cleanly without waiting
213
+ // for finish-step (the SDK may still emit it, may not).
214
+ reasoning.logStepToFile({
215
+ step: stepCounter,
216
+ reasoning: currentReasoning,
217
+ toolCalls: currentToolCalls,
218
+ finishReason: 'fatal-error',
219
+ });
220
+ // Stop processing the stream. We don't break out of the
221
+ // for-await directly because we want to drain remaining events
222
+ // for the SDK's internal cleanup; but we set a flag so we
223
+ // don't process them.
224
+ // Simplest: just let the loop continue. finish-step / finish
225
+ // events will pass through harmlessly.
226
+ }
227
+ break;
228
+ }
229
+ case 'finish-step': {
230
+ // After a fatal tool-error, finish-step still arrives for the
231
+ // same step. The reasoning was already flushed in the tool-error
232
+ // handler — don't double-flush. For normal steps, this is the
233
+ // path that flushes.
234
+ if (endReason !== 'fatal') {
235
+ reasoning.logStepToFile({
236
+ step: stepCounter,
237
+ reasoning: currentReasoning,
238
+ toolCalls: currentToolCalls,
239
+ finishReason: part.finishReason,
240
+ });
241
+ }
242
+ logger.debug(`Step ${stepCounter} finished (finishReason=${part.finishReason})`);
243
+ break;
244
+ }
245
+ // Other event types (reasoning-delta for thinking-models,
246
+ // tool-input-delta, source, file, raw, etc.) are ignored —
247
+ // fullStream is forward-compatible.
180
248
  }
181
- case 'finish-step': {
249
+ }
250
+ }
251
+ catch (err) {
252
+ // The for-await loop throws when we re-throw UserCancelledError above.
253
+ // It can also throw on genuine SDK / provider failures. We distinguish:
254
+ if (err instanceof UserCancelledError) {
255
+ // No endReason='cancelled' assignment here: we throw immediately
256
+ // and the post-stream code in this function never runs. cli.ts is
257
+ // the one that recognizes UserCancelledError and exits 130 — it
258
+ // doesn't need RunResult.endReason for that.
259
+ if (currentReasoning.trim().length > 0 || currentToolCalls.length > 0) {
182
260
  reasoning.logStepToFile({
183
261
  step: stepCounter,
184
262
  reasoning: currentReasoning,
185
263
  toolCalls: currentToolCalls,
186
- finishReason: part.finishReason,
264
+ finishReason: 'cancelled',
187
265
  });
188
- logger.debug(`Step ${stepCounter} finished (finishReason=${part.finishReason})`);
189
- break;
190
266
  }
191
- // Other event types (reasoning-delta for thinking-models,
192
- // tool-input-delta, source, file, raw, etc.) are ignored —
193
- // fullStream is forward-compatible.
267
+ logger.info('Run cancelled by user.');
268
+ throw err;
194
269
  }
270
+ // Genuine bug or provider failure. Let it bubble.
271
+ throw err;
272
+ }
273
+ // After the stream completes (normally OR via FatalAwsCliError), pull
274
+ // the post-stream promises. Most runs reach here with all three already
275
+ // resolved (the stream completion is the signal). But when we caught a
276
+ // FatalAwsCliError mid-stream, the SDK may have left these in a rejected
277
+ // state — the stream didn't naturally complete. Defensive try/await
278
+ // around each so we degrade gracefully: a partial RunResult with
279
+ // whatever usage we got from steps that did complete is better than
280
+ // crashing on a downstream `await` and losing the failure context.
281
+ let finalText = '';
282
+ let finalSteps = [];
283
+ let totalUsage;
284
+ try {
285
+ finalText = await result.text;
286
+ }
287
+ catch (err) {
288
+ logger.debug('result.text rejected (expected after fatal/cancel)', err);
289
+ }
290
+ try {
291
+ finalSteps = await result.steps;
292
+ }
293
+ catch (err) {
294
+ logger.debug('result.steps rejected (expected after fatal/cancel)', err);
295
+ }
296
+ try {
297
+ totalUsage = await result.totalUsage;
298
+ }
299
+ catch (err) {
300
+ logger.debug('result.totalUsage rejected (expected after fatal/cancel)', err);
195
301
  }
196
- // Wait for all the post-stream promises to resolve. They're already
197
- // ready by the time fullStream finishes (the stream completion is the
198
- // signal), so these awaits are effectively synchronous.
199
- const finalText = await result.text;
200
- const finalSteps = await result.steps;
201
- const totalUsage = await result.totalUsage;
202
302
  logger.info(`Agent finished after ${finalSteps.length} step(s)`);
203
303
  logger.debug('Final text', finalText);
204
304
  // Token usage for this invocation.
@@ -278,6 +378,7 @@ export async function runAgent(opts) {
278
378
  finalOutput,
279
379
  finalError,
280
380
  ranCommand,
381
+ endReason,
281
382
  };
282
383
  }
283
384
  /**
package/dist/cli.js CHANGED
@@ -8,7 +8,8 @@ import { UsageLogger } from './usage.js';
8
8
  import { History } from './history.js';
9
9
  import { runAgent } from './agent.js';
10
10
  import { FILES, PATHS, DEFAULT_SCRIPT_FOLDER } from './paths.js';
11
- const VERSION = '0.4.0';
11
+ import { UserCancelledError } from './errors.js';
12
+ const VERSION = '0.5.0';
12
13
  /**
13
14
  * Apply CLI flags on top of the loaded config. Flags only override; they
14
15
  * never widen or compose with each other implicitly.
@@ -154,7 +155,13 @@ export async function main(argv) {
154
155
  // Footer counts only commands that actually executed. Declined or
155
156
  // cancelled commands appear in `result.commands` for the history
156
157
  // log but don't count as "ran" since no subprocess was started.
157
- if (result.executedCommandCount > 0) {
158
+ //
159
+ // Gated on `cfg.verbose`: the footer is supplementary information
160
+ // ("here's what happened during the run") that's useful while you're
161
+ // watching the agent work, but noisy for scripted/pipeline use. With
162
+ // verbose off, nothing aca generates reaches the terminal — only the
163
+ // AWS CLI's verbatim output does.
164
+ if (cfg.verbose && result.executedCommandCount > 0) {
158
165
  const tag = result.profile ? `[${result.profile}]` : '';
159
166
  const cmds = result.executedCommandCount === 1
160
167
  ? '1 command'
@@ -163,10 +170,19 @@ export async function main(argv) {
163
170
  }
164
171
  }
165
172
  catch (err) {
166
- const msg = err instanceof Error ? err.message : String(err);
167
- logger.error('Agent failed', msg);
168
- process.stderr.write(chalk.red('Error: ') + msg + '\n');
169
- process.exitCode = 1;
173
+ // User cancelled (Ctrl-C at a prompt). Print a calm message,
174
+ // exit 130 (SIGINT convention), no red error, no "ran N" footer,
175
+ // no stack trace.
176
+ if (err instanceof UserCancelledError) {
177
+ process.stderr.write(chalk.dim('cancelled by user\n'));
178
+ process.exitCode = 130;
179
+ }
180
+ else {
181
+ const msg = err instanceof Error ? err.message : String(err);
182
+ logger.error('Agent failed', msg);
183
+ process.stderr.write(chalk.red('Error: ') + msg + '\n');
184
+ process.exitCode = 1;
185
+ }
170
186
  }
171
187
  finally {
172
188
  logger.close();
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Sentinel error: the user pressed Ctrl-C during a prompt. Thrown from
3
+ * inside tool `execute()` functions when Inquirer throws ExitPromptError,
4
+ * propagated up through the agent loop, caught at the cli.ts boundary
5
+ * where it triggers a clean exit with status 130.
6
+ *
7
+ * Using a custom class (not a string match) gives us reliable
8
+ * `instanceof UserCancelledError` checks across all the places that need
9
+ * to handle the cancellation differently from real errors.
10
+ */
11
+ export declare class UserCancelledError extends Error {
12
+ constructor(message?: string);
13
+ }
14
+ /**
15
+ * Sentinel error: the AWS CLI returned an exit code in FATAL_AWS_EXIT_CODES
16
+ * (252-255). These indicate an unrecoverable condition — auth failure,
17
+ * missing credentials, malformed request, AWS service failure — and
18
+ * retrying won't help. The tool throws this instead of returning a result,
19
+ * so the model never gets a chance to retry. The agent loop catches it,
20
+ * propagates the stderr to the user, and exits 1.
21
+ *
22
+ * Carries the original cmd, exitCode, and stderr so cli.ts can surface
23
+ * them to the user.
24
+ */
25
+ export declare class FatalAwsCliError extends Error {
26
+ readonly cmd: string;
27
+ readonly exitCode: number;
28
+ readonly stderr: string;
29
+ constructor(cmd: string, exitCode: number, stderr: string);
30
+ }
31
+ /**
32
+ * AWS CLI exit codes that indicate an unrecoverable condition:
33
+ * 252 — Command-line parsing errors (typically a bug in our agent or
34
+ * the CLI itself; retrying won't help)
35
+ * 253 — Profile/credentials not found in the credential chain
36
+ * 254 — Client-side error (4xx from the service — auth, permission,
37
+ * malformed request)
38
+ * 255 — Server-side error (5xx from the service — internal AWS issues)
39
+ *
40
+ * Anything else non-zero is a soft error (resource not found, etc.) and
41
+ * gets returned to the model normally — it may try a different approach.
42
+ * The model is bounded by `maxSteps` for runaway loops; we deliberately
43
+ * don't impose a separate soft-failure cap.
44
+ *
45
+ * Exit code 130 (SIGINT) in interactive mode is treated as a clean user
46
+ * cancellation, not an error — see aws-cli.ts's `effectivelyOk` rule.
47
+ */
48
+ export declare const FATAL_AWS_EXIT_CODES: Set<number>;
49
+ /**
50
+ * Wrap an Inquirer prompt promise so that Ctrl-C (which Inquirer reports
51
+ * as `ExitPromptError`) becomes our `UserCancelledError` sentinel. The
52
+ * Inquirer error class isn't easily importable, so we detect by `.name`.
53
+ * Re-throws any other error unchanged.
54
+ *
55
+ * const answer = await wrapPrompt(confirm({ message: '...' }));
56
+ */
57
+ export declare function wrapPrompt<T>(p: Promise<T>): Promise<T>;
package/dist/errors.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Sentinel error: the user pressed Ctrl-C during a prompt. Thrown from
3
+ * inside tool `execute()` functions when Inquirer throws ExitPromptError,
4
+ * propagated up through the agent loop, caught at the cli.ts boundary
5
+ * where it triggers a clean exit with status 130.
6
+ *
7
+ * Using a custom class (not a string match) gives us reliable
8
+ * `instanceof UserCancelledError` checks across all the places that need
9
+ * to handle the cancellation differently from real errors.
10
+ */
11
+ export class UserCancelledError extends Error {
12
+ constructor(message = 'User cancelled the operation.') {
13
+ super(message);
14
+ this.name = 'UserCancelledError';
15
+ }
16
+ }
17
+ /**
18
+ * Sentinel error: the AWS CLI returned an exit code in FATAL_AWS_EXIT_CODES
19
+ * (252-255). These indicate an unrecoverable condition — auth failure,
20
+ * missing credentials, malformed request, AWS service failure — and
21
+ * retrying won't help. The tool throws this instead of returning a result,
22
+ * so the model never gets a chance to retry. The agent loop catches it,
23
+ * propagates the stderr to the user, and exits 1.
24
+ *
25
+ * Carries the original cmd, exitCode, and stderr so cli.ts can surface
26
+ * them to the user.
27
+ */
28
+ export class FatalAwsCliError extends Error {
29
+ cmd;
30
+ exitCode;
31
+ stderr;
32
+ constructor(cmd, exitCode, stderr) {
33
+ super(`AWS CLI exited with code ${exitCode} (unrecoverable): ${stderr.trim() || '<no stderr>'}`);
34
+ this.cmd = cmd;
35
+ this.exitCode = exitCode;
36
+ this.stderr = stderr;
37
+ this.name = 'FatalAwsCliError';
38
+ }
39
+ }
40
+ /**
41
+ * AWS CLI exit codes that indicate an unrecoverable condition:
42
+ * 252 — Command-line parsing errors (typically a bug in our agent or
43
+ * the CLI itself; retrying won't help)
44
+ * 253 — Profile/credentials not found in the credential chain
45
+ * 254 — Client-side error (4xx from the service — auth, permission,
46
+ * malformed request)
47
+ * 255 — Server-side error (5xx from the service — internal AWS issues)
48
+ *
49
+ * Anything else non-zero is a soft error (resource not found, etc.) and
50
+ * gets returned to the model normally — it may try a different approach.
51
+ * The model is bounded by `maxSteps` for runaway loops; we deliberately
52
+ * don't impose a separate soft-failure cap.
53
+ *
54
+ * Exit code 130 (SIGINT) in interactive mode is treated as a clean user
55
+ * cancellation, not an error — see aws-cli.ts's `effectivelyOk` rule.
56
+ */
57
+ export const FATAL_AWS_EXIT_CODES = new Set([252, 253, 254, 255]);
58
+ /**
59
+ * Wrap an Inquirer prompt promise so that Ctrl-C (which Inquirer reports
60
+ * as `ExitPromptError`) becomes our `UserCancelledError` sentinel. The
61
+ * Inquirer error class isn't easily importable, so we detect by `.name`.
62
+ * Re-throws any other error unchanged.
63
+ *
64
+ * const answer = await wrapPrompt(confirm({ message: '...' }));
65
+ */
66
+ export async function wrapPrompt(p) {
67
+ try {
68
+ return await p;
69
+ }
70
+ catch (err) {
71
+ if (err instanceof Error && err.name === 'ExitPromptError') {
72
+ throw new UserCancelledError();
73
+ }
74
+ throw err;
75
+ }
76
+ }
@@ -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-/,
@@ -164,7 +165,7 @@ export function awsCliTool(opts) {
164
165
  if (useInteractive) {
165
166
  process.stderr.write(`${chalk.bold(' Mode: ')}${chalk.yellow('interactive')} (your terminal will be connected to the command)\n`);
166
167
  }
167
- const ok = await confirm({ message: 'Execute this command?', default: true });
168
+ const ok = await wrapPrompt(confirm({ message: 'Execute this command?', default: true }));
168
169
  if (!ok) {
169
170
  opts.logger.warn('User declined command');
170
171
  // Record the declined call so the agent's end-of-run logic sees
@@ -208,9 +209,18 @@ export function awsCliTool(opts) {
208
209
  opts.logger.trace('stderr', stderr);
209
210
  }
210
211
  }
211
- else if (code !== 0) {
212
+ else if (code !== 0 && code !== 130) {
213
+ // Exit 130 in interactive mode = user pressed Ctrl-C to end their
214
+ // SSM session, shell, port-forward, etc. That's expected, not a
215
+ // failure. Anything else is genuine — log it.
212
216
  opts.logger.warn(`Interactive AWS CLI exited non-zero (${code})`);
213
217
  }
218
+ // SSM sessions and other interactive AWS CLI commands return 130
219
+ // when the user Ctrl-Cs to end the session. That's the normal way
220
+ // to leave a shell — treat it as success for ok/exit purposes so we
221
+ // don't surface it as an error in cli.ts. The real exit code is
222
+ // still recorded in the audit log for accuracy.
223
+ const effectivelyOk = code === 0 || (useInteractive && code === 130);
214
224
  // Audit captures whatever we have. For interactive runs stdout/stderr
215
225
  // are empty — that's accurate, the bytes went to the terminal — and
216
226
  // the audit entry serves as a record that "an interactive session
@@ -219,7 +229,7 @@ export function awsCliTool(opts) {
219
229
  cmd: display,
220
230
  profile,
221
231
  exitCode: code,
222
- ok: code === 0,
232
+ ok: effectivelyOk,
223
233
  stdout: useInteractive ? '[interactive session — output not captured]' : stdout,
224
234
  stderr: useInteractive ? '' : stderr,
225
235
  });
@@ -234,18 +244,33 @@ export function awsCliTool(opts) {
234
244
  : stdout,
235
245
  stderr: useInteractive ? '' : stderr,
236
246
  exitCode: code,
237
- ok: code === 0,
247
+ ok: effectivelyOk,
238
248
  });
239
249
  // For the agent's context, return a clear signal that interactive
240
250
  // mode ran so it doesn't try to parse fictional stdout.
241
251
  if (useInteractive) {
242
252
  return {
243
- ok: code === 0,
253
+ ok: effectivelyOk,
244
254
  exitCode: code,
245
255
  interactive: true,
246
256
  note: 'Interactive session ran. Output went directly to the user\'s terminal and was not captured. Do not summarize or describe its contents.',
247
257
  };
248
258
  }
259
+ // Classify failures. Fatal exit codes (252-255) indicate the call
260
+ // won't succeed without external intervention — bad credentials,
261
+ // missing resource, malformed request, AWS service failure. We
262
+ // throw FatalAwsCliError (after recording the audit trail above)
263
+ // rather than returning a result to the model: the throw unwinds
264
+ // the agent loop entirely, the user sees the AWS stderr in red,
265
+ // and we exit 1. The model never gets a chance to retry, because
266
+ // these classes of error don't get better with retries.
267
+ //
268
+ // Soft failures (other non-zero exits) ARE returned to the model
269
+ // as ordinary results. The model may retry with a different
270
+ // approach. maxSteps bounds the worst case if that goes nowhere.
271
+ if (code !== 0 && FATAL_AWS_EXIT_CODES.has(code)) {
272
+ throw new FatalAwsCliError(display, code, stderr);
273
+ }
249
274
  return {
250
275
  ok: code === 0,
251
276
  exitCode: code,
@@ -254,6 +279,11 @@ export function awsCliTool(opts) {
254
279
  };
255
280
  }
256
281
  catch (err) {
282
+ // FatalAwsCliError is our own signal — propagate it cleanly.
283
+ // UserCancelledError must propagate too (Ctrl-C at the approval
284
+ // prompt) or it'd get swallowed into a spawn-failure log entry.
285
+ if (err instanceof FatalAwsCliError || err instanceof UserCancelledError)
286
+ throw err;
257
287
  const msg = err instanceof Error ? err.message : String(err);
258
288
  opts.logger.error('Failed to spawn aws CLI', msg);
259
289
  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(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
+ }));
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,28 @@ 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
+ const answer = await wrapPrompt(select({
48
49
  message: q.message,
49
50
  choices: q.choices.map((c) => ({ value: c, name: c })),
50
51
  default: q.defaultValue,
51
- });
52
+ }));
52
53
  return answer;
53
54
  }
54
55
  case 'confirm': {
55
56
  const def = (q.defaultValue ?? 'yes').toLowerCase().startsWith('y');
56
- const answer = await confirm({ message: q.message, default: def });
57
+ const answer = await wrapPrompt(confirm({ message: q.message, default: def }));
57
58
  return answer ? 'yes' : 'no';
58
59
  }
59
60
  case 'secret': {
60
61
  // Inquirer's password prompt masks input. Used for short secrets like
61
62
  // MFA codes; long-lived AWS credentials should always come from the
62
63
  // user's profile, not be typed here.
63
- const answer = await password({ message: q.message, mask: '*' });
64
+ const answer = await wrapPrompt(password({ message: q.message, mask: '*' }));
64
65
  return answer;
65
66
  }
66
67
  case 'text':
67
68
  default: {
68
- const answer = await input({ message: q.message, default: q.defaultValue });
69
+ const answer = await wrapPrompt(input({ message: q.message, default: q.defaultValue }));
69
70
  return answer;
70
71
  }
71
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aws-cli-agent",
3
- "version": "0.4.0",
3
+ "version": "0.5.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": {
@@ -56,7 +56,7 @@
56
56
  "@ai-sdk/google": "^3.0.74",
57
57
  "@ai-sdk/openai": "^3.0.64",
58
58
  "@aws-sdk/credential-providers": "^3.1046.0",
59
- "@inquirer/prompts": "^7.3.0",
59
+ "@inquirer/prompts": "^8.4.3",
60
60
  "ai": "^6.0.183",
61
61
  "chalk": "^5.4.0",
62
62
  "commander": "^13.0.0",