aws-cli-agent 0.4.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.
@@ -0,0 +1,157 @@
1
+ import type { Logger } from '../logger.js';
2
+ import type { Config } from '../config.js';
3
+ import type { History } from '../history.js';
4
+ import type { AuditLogger } from '../audit.js';
5
+ export type ExecutionRecord = {
6
+ cmd: string;
7
+ profile: string | null;
8
+ stdout: string;
9
+ stderr: string;
10
+ exitCode: number;
11
+ ok: boolean;
12
+ };
13
+ export type ToolContext = {
14
+ logger: Logger;
15
+ config: Config;
16
+ history: History;
17
+ audit: AuditLogger;
18
+ record: (entry: ExecutionRecord) => void;
19
+ };
20
+ /**
21
+ * Build the agent's tool set.
22
+ *
23
+ * Note on caching: there is no per-tool cache marker here. We previously
24
+ * marked the last tool with `providerOptions.{anthropic.cacheControl,
25
+ * bedrock.cachePoint}` hoping the SDK would translate that into a cache
26
+ * breakpoint after the tools section of the request body. Investigation of
27
+ * `@ai-sdk/amazon-bedrock` v4 showed it only reads `name`, `description`,
28
+ * `strict`, and `inputSchema` when serializing function tools — it ignores
29
+ * `providerOptions`, so the marker had no effect. Confirmed via trace logs:
30
+ * `cacheDetails` always had one entry (the system message), never two.
31
+ *
32
+ * So the prompt cache currently captures only the system message. The
33
+ * tools array is re-sent at full cost on every request. If/when the SDK
34
+ * starts propagating tool-level providerOptions to cachePoints, the right
35
+ * place to add markers is on the last entry in this object — Anthropic
36
+ * recommends a single breakpoint at the end of the tools block.
37
+ */
38
+ export declare function createTools(ctx: ToolContext): {
39
+ query_history: import("ai").Tool<{
40
+ query: string;
41
+ limit: number;
42
+ }, {
43
+ count: number;
44
+ entries: {
45
+ timestamp: string;
46
+ input: string;
47
+ commands: string[];
48
+ profile: string | null;
49
+ resources: Record<string, string>;
50
+ }[];
51
+ }>;
52
+ list_aws_profiles: import("ai").Tool<Record<string, never>, {
53
+ profiles: string[];
54
+ count: number;
55
+ }>;
56
+ prompt_user: import("ai").Tool<{
57
+ kind: "text" | "choice" | "confirm" | "secret";
58
+ message: string;
59
+ key?: string | undefined;
60
+ choices?: string[] | undefined;
61
+ defaultValue?: string | undefined;
62
+ }, {
63
+ answer: string;
64
+ }>;
65
+ prompt_user_multi: import("ai").Tool<{
66
+ questions: {
67
+ kind: "text" | "choice" | "confirm" | "secret";
68
+ message: string;
69
+ key?: string | undefined;
70
+ choices?: string[] | undefined;
71
+ defaultValue?: string | undefined;
72
+ }[];
73
+ }, {
74
+ answers: Record<string, string>;
75
+ }>;
76
+ execute_aws_command: import("ai").Tool<{
77
+ args: string[];
78
+ purpose: string;
79
+ interactive?: boolean | undefined;
80
+ }, {
81
+ ok: boolean;
82
+ declined: boolean;
83
+ error: string;
84
+ exitCode?: undefined;
85
+ interactive?: undefined;
86
+ note?: undefined;
87
+ stdout?: undefined;
88
+ stderr?: undefined;
89
+ } | {
90
+ ok: boolean;
91
+ exitCode: number;
92
+ interactive: boolean;
93
+ note: string;
94
+ declined?: undefined;
95
+ error?: undefined;
96
+ stdout?: undefined;
97
+ stderr?: undefined;
98
+ } | {
99
+ ok: boolean;
100
+ exitCode: number;
101
+ stdout: string;
102
+ stderr: string;
103
+ declined?: undefined;
104
+ error?: undefined;
105
+ interactive?: undefined;
106
+ note?: undefined;
107
+ } | {
108
+ ok: boolean;
109
+ error: string;
110
+ declined?: undefined;
111
+ exitCode?: undefined;
112
+ interactive?: undefined;
113
+ note?: undefined;
114
+ stdout?: undefined;
115
+ stderr?: undefined;
116
+ }>;
117
+ execute_bash_script: import("ai").Tool<{
118
+ script: string;
119
+ purpose: string;
120
+ }, {
121
+ ok: boolean;
122
+ declined: boolean;
123
+ error: string;
124
+ saved?: undefined;
125
+ path?: undefined;
126
+ stdout?: undefined;
127
+ exitCode?: undefined;
128
+ stderr?: undefined;
129
+ } | {
130
+ ok: boolean;
131
+ error: string;
132
+ declined?: undefined;
133
+ saved?: undefined;
134
+ path?: undefined;
135
+ stdout?: undefined;
136
+ exitCode?: undefined;
137
+ stderr?: undefined;
138
+ } | {
139
+ ok: boolean;
140
+ saved: boolean;
141
+ path: string;
142
+ stdout: string;
143
+ declined?: undefined;
144
+ error?: undefined;
145
+ exitCode?: undefined;
146
+ stderr?: undefined;
147
+ } | {
148
+ ok: boolean;
149
+ exitCode: number;
150
+ stdout: string;
151
+ stderr: string;
152
+ declined?: undefined;
153
+ error?: undefined;
154
+ saved?: undefined;
155
+ path?: undefined;
156
+ }>;
157
+ };
@@ -0,0 +1,43 @@
1
+ import { awsCliTool } from './aws-cli.js';
2
+ import { bashScriptTool } from './bash.js';
3
+ import { listProfilesTool } from './profiles.js';
4
+ import { historyTool } from './history.js';
5
+ import { promptUserTool, promptUserMultiTool } from './prompt.js';
6
+ /**
7
+ * Build the agent's tool set.
8
+ *
9
+ * Note on caching: there is no per-tool cache marker here. We previously
10
+ * marked the last tool with `providerOptions.{anthropic.cacheControl,
11
+ * bedrock.cachePoint}` hoping the SDK would translate that into a cache
12
+ * breakpoint after the tools section of the request body. Investigation of
13
+ * `@ai-sdk/amazon-bedrock` v4 showed it only reads `name`, `description`,
14
+ * `strict`, and `inputSchema` when serializing function tools — it ignores
15
+ * `providerOptions`, so the marker had no effect. Confirmed via trace logs:
16
+ * `cacheDetails` always had one entry (the system message), never two.
17
+ *
18
+ * So the prompt cache currently captures only the system message. The
19
+ * tools array is re-sent at full cost on every request. If/when the SDK
20
+ * starts propagating tool-level providerOptions to cachePoints, the right
21
+ * place to add markers is on the last entry in this object — Anthropic
22
+ * recommends a single breakpoint at the end of the tools block.
23
+ */
24
+ export function createTools(ctx) {
25
+ return {
26
+ query_history: historyTool({ history: ctx.history, logger: ctx.logger }),
27
+ list_aws_profiles: listProfilesTool({ logger: ctx.logger }),
28
+ prompt_user: promptUserTool({ logger: ctx.logger }),
29
+ prompt_user_multi: promptUserMultiTool({ logger: ctx.logger }),
30
+ execute_aws_command: awsCliTool({
31
+ logger: ctx.logger,
32
+ config: ctx.config,
33
+ audit: ctx.audit,
34
+ record: ctx.record,
35
+ }),
36
+ execute_bash_script: bashScriptTool({
37
+ logger: ctx.logger,
38
+ config: ctx.config,
39
+ audit: ctx.audit,
40
+ record: ctx.record,
41
+ }),
42
+ };
43
+ }
@@ -0,0 +1,7 @@
1
+ import type { Logger } from '../logger.js';
2
+ export declare function listProfilesTool(opts: {
3
+ logger: Logger;
4
+ }): import("ai").Tool<Record<string, never>, {
5
+ profiles: string[];
6
+ count: number;
7
+ }>;
@@ -0,0 +1,37 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { tool } from 'ai';
5
+ import { z } from 'zod';
6
+ export function listProfilesTool(opts) {
7
+ return tool({
8
+ description: 'List AWS named profiles configured locally in ~/.aws/config and ~/.aws/credentials. Use this when the user references an account by name and history did not resolve it.',
9
+ inputSchema: z.object({}),
10
+ execute: async () => {
11
+ opts.logger.debug('Listing AWS profiles');
12
+ const profiles = new Set();
13
+ const files = [
14
+ path.join(os.homedir(), '.aws', 'config'),
15
+ path.join(os.homedir(), '.aws', 'credentials'),
16
+ ];
17
+ for (const f of files) {
18
+ if (!fs.existsSync(f))
19
+ continue;
20
+ const content = fs.readFileSync(f, 'utf8');
21
+ // [profile foo] in config, [foo] in credentials
22
+ const re = /^\s*\[(?:profile\s+)?([^\]]+)\]/gm;
23
+ let m;
24
+ while ((m = re.exec(content)) !== null) {
25
+ const name = m[1].trim();
26
+ if (name && name !== 'default')
27
+ profiles.add(name);
28
+ if (name === 'default')
29
+ profiles.add('default');
30
+ }
31
+ }
32
+ const result = Array.from(profiles).sort();
33
+ opts.logger.trace('Profiles found', result);
34
+ return { profiles: result, count: result.length };
35
+ },
36
+ });
37
+ }
@@ -0,0 +1,37 @@
1
+ import type { Logger } from '../logger.js';
2
+ /**
3
+ * Single-question prompt. The agent calls this whenever a required parameter
4
+ * cannot be inferred from history or discovered via the AWS CLI. Strong
5
+ * preference for kind="choice" when the candidate set is enumerable —
6
+ * picking from a list is faster and less error-prone than typing.
7
+ */
8
+ export declare function promptUserTool(opts: {
9
+ logger: Logger;
10
+ }): import("ai").Tool<{
11
+ kind: "text" | "choice" | "confirm" | "secret";
12
+ message: string;
13
+ key?: string | undefined;
14
+ choices?: string[] | undefined;
15
+ defaultValue?: string | undefined;
16
+ }, {
17
+ answer: string;
18
+ }>;
19
+ /**
20
+ * Multi-question prompt. Ask several related questions in one tool call —
21
+ * cuts model round-trips when the agent already knows it needs N pieces of
22
+ * info (e.g. "I need a source bucket, a destination bucket, and a region").
23
+ * Each question's `key` becomes the field name in the returned object.
24
+ */
25
+ export declare function promptUserMultiTool(opts: {
26
+ logger: Logger;
27
+ }): import("ai").Tool<{
28
+ questions: {
29
+ kind: "text" | "choice" | "confirm" | "secret";
30
+ message: string;
31
+ key?: string | undefined;
32
+ choices?: string[] | undefined;
33
+ defaultValue?: string | undefined;
34
+ }[];
35
+ }, {
36
+ answers: Record<string, string>;
37
+ }>;
@@ -0,0 +1,145 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { confirm, input, password, select } from '@inquirer/prompts';
4
+ import chalk from 'chalk';
5
+ /**
6
+ * Schema for a single question. Used both by `prompt_user` directly (single
7
+ * question per call) and `prompt_user_multi` (batch of questions in one call).
8
+ *
9
+ * `kind` is explicit so the model picks the right UI control instead of
10
+ * inferring it from prose. The default `text` keeps simple uses simple.
11
+ */
12
+ const QuestionSchema = z.object({
13
+ /** Optional key — only used by prompt_user_multi to label answers. */
14
+ key: z
15
+ .string()
16
+ .optional()
17
+ .describe('Identifier for this question in the returned answers object. Required for prompt_user_multi.'),
18
+ kind: z
19
+ .enum(['text', 'choice', 'confirm', 'secret'])
20
+ .default('text')
21
+ .describe('choice = pick one of `choices` (best for finite sets like profiles or buckets). ' +
22
+ 'confirm = yes/no decision. ' +
23
+ 'secret = same as text but input is hidden (use for tokens, MFA codes, never for AWS creds — those come from the profile). ' +
24
+ 'text = free-form input.'),
25
+ message: z.string().describe('Question shown to the user.'),
26
+ choices: z
27
+ .array(z.string())
28
+ .optional()
29
+ .describe('Required when kind = "choice". Ignored otherwise.'),
30
+ defaultValue: z
31
+ .string()
32
+ .optional()
33
+ .describe('Default for kind=text/secret (typed-in default), or kind=choice (pre-selected option). ' +
34
+ 'For kind=confirm, use "yes" or "no".'),
35
+ });
36
+ async function askOne(q, logger) {
37
+ logger.debug('Prompt', { kind: q.kind, message: q.message });
38
+ // Render the question header on stderr first so the user sees a clear
39
+ // visual break between agent reasoning and a question that wants input.
40
+ // Inquirer renders its own prompt line; the header is a visual anchor.
41
+ process.stderr.write('\n' + chalk.bold.cyan('? Agent needs input:') + '\n');
42
+ switch (q.kind) {
43
+ case 'choice': {
44
+ if (!q.choices || q.choices.length === 0) {
45
+ throw new Error('kind="choice" requires non-empty `choices`.');
46
+ }
47
+ const answer = await select({
48
+ message: q.message,
49
+ choices: q.choices.map((c) => ({ value: c, name: c })),
50
+ default: q.defaultValue,
51
+ });
52
+ return answer;
53
+ }
54
+ case 'confirm': {
55
+ const def = (q.defaultValue ?? 'yes').toLowerCase().startsWith('y');
56
+ const answer = await confirm({ message: q.message, default: def });
57
+ return answer ? 'yes' : 'no';
58
+ }
59
+ case 'secret': {
60
+ // Inquirer's password prompt masks input. Used for short secrets like
61
+ // MFA codes; long-lived AWS credentials should always come from the
62
+ // user's profile, not be typed here.
63
+ const answer = await password({ message: q.message, mask: '*' });
64
+ return answer;
65
+ }
66
+ case 'text':
67
+ default: {
68
+ const answer = await input({ message: q.message, default: q.defaultValue });
69
+ return answer;
70
+ }
71
+ }
72
+ }
73
+ /**
74
+ * Single-question prompt. The agent calls this whenever a required parameter
75
+ * cannot be inferred from history or discovered via the AWS CLI. Strong
76
+ * preference for kind="choice" when the candidate set is enumerable —
77
+ * picking from a list is faster and less error-prone than typing.
78
+ */
79
+ export function promptUserTool(opts) {
80
+ return tool({
81
+ description: `Ask the user ONE question to gather missing information mid-reasoning. ` +
82
+ `Strongly prefer kind="choice" with explicit options when the set of valid answers is finite (e.g. matching profiles, bucket names, AZ ids). ` +
83
+ `Use kind="confirm" for yes/no decisions before risky actions. ` +
84
+ `Use kind="secret" only for short secrets typed at the moment of use (e.g. MFA codes); never solicit long-lived AWS credentials this way — they come from the user's profile. ` +
85
+ `Use kind="text" only when free-form input is genuinely required (e.g. a new tag value the user is inventing). ` +
86
+ `Whenever you are about to guess a value, call this tool instead.`,
87
+ inputSchema: QuestionSchema,
88
+ execute: async (q) => {
89
+ const answer = await askOne(q, opts.logger);
90
+ opts.logger.debug('Got answer', { answer: q.kind === 'secret' ? '***' : answer });
91
+ return { answer };
92
+ },
93
+ });
94
+ }
95
+ /**
96
+ * Multi-question prompt. Ask several related questions in one tool call —
97
+ * cuts model round-trips when the agent already knows it needs N pieces of
98
+ * info (e.g. "I need a source bucket, a destination bucket, and a region").
99
+ * Each question's `key` becomes the field name in the returned object.
100
+ */
101
+ export function promptUserMultiTool(opts) {
102
+ return tool({
103
+ description: `Ask the user MULTIPLE related questions in one round, returning a map of key → answer. ` +
104
+ `Use this when the agent knows up front that several values are missing and asking them together is less disruptive than one-by-one. ` +
105
+ `Each question MUST have a unique \`key\` — that becomes the field in the returned \`answers\` object. ` +
106
+ `Same kind options as prompt_user: text, choice, confirm, secret. ` +
107
+ `For unrelated questions or when the answer to question A determines what to ask in question B, use prompt_user (single) instead.`,
108
+ inputSchema: z.object({
109
+ questions: z
110
+ .array(QuestionSchema)
111
+ .min(1)
112
+ .max(8)
113
+ .describe('1–8 related questions. Each must have a unique `key`.'),
114
+ }),
115
+ execute: async ({ questions }) => {
116
+ // Surface duplicate keys early — the model occasionally re-uses keys
117
+ // and the answers map would silently overwrite.
118
+ const seen = new Set();
119
+ for (const q of questions) {
120
+ if (!q.key)
121
+ throw new Error('Every question in prompt_user_multi requires a `key`.');
122
+ if (seen.has(q.key))
123
+ throw new Error(`Duplicate question key: ${q.key}`);
124
+ seen.add(q.key);
125
+ }
126
+ // Tell the user how many questions are coming up front. Less jarring
127
+ // than a surprise series of prompts.
128
+ process.stderr.write('\n' + chalk.dim(`(agent has ${questions.length} questions)`) + '\n');
129
+ const answers = {};
130
+ for (const q of questions) {
131
+ // q.key is guaranteed non-undefined here (checked above) but
132
+ // narrowing through Set membership isn't enough for the type system.
133
+ const key = q.key;
134
+ answers[key] = await askOne(q, opts.logger);
135
+ }
136
+ // Don't log secret values, but do confirm the keys we got.
137
+ const safeForLog = Object.fromEntries(Object.entries(answers).map(([k, v]) => {
138
+ const q = questions.find((qq) => qq.key === k);
139
+ return [k, q?.kind === 'secret' ? '***' : v];
140
+ }));
141
+ opts.logger.debug('Got multi answers', safeForLog);
142
+ return { answers };
143
+ },
144
+ });
145
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Usage log: append-only JSONL of token totals per `aca` invocation. One line
3
+ * per run. Totals only — per-step breakdown is intentionally omitted to keep
4
+ * entries small and forward-compatible across providers.
5
+ *
6
+ * Disable via `logging.usageLog = false` in config; the writer becomes a no-op.
7
+ *
8
+ * Analytical use: this file is grep/jq-friendly. Sum tokens for the day:
9
+ * cat ~/.local/state/aws-cli-agent/usage.log | jq -s 'map(.totalTokens) | add'
10
+ */
11
+ export type UsageEntry = {
12
+ timestamp: string;
13
+ input: string;
14
+ provider: string;
15
+ model: string;
16
+ steps: number;
17
+ promptTokens: number;
18
+ completionTokens: number;
19
+ totalTokens: number;
20
+ /**
21
+ * Tokens served from prompt cache (cache hit). Available on Anthropic and
22
+ * Bedrock when caching was enabled and the provider returned the count.
23
+ * 0 when caching was disabled, the provider didn't report it, or this
24
+ * was a first-time request with no cache to hit.
25
+ */
26
+ cacheReadTokens: number;
27
+ /**
28
+ * Tokens written to prompt cache (cache miss + store). Counts the prefix
29
+ * length on cache-write events. 0 when caching was disabled or the
30
+ * provider didn't write a cache entry on this call.
31
+ */
32
+ cacheWriteTokens: number;
33
+ };
34
+ export declare class UsageLogger {
35
+ private readonly stream;
36
+ constructor(enabled: boolean);
37
+ log(entry: Omit<UsageEntry, 'timestamp'>): void;
38
+ close(): void;
39
+ }
package/dist/usage.js ADDED
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs';
2
+ import { FILES, PATHS } from './paths.js';
3
+ export class UsageLogger {
4
+ stream;
5
+ constructor(enabled) {
6
+ if (!enabled) {
7
+ this.stream = null;
8
+ return;
9
+ }
10
+ fs.mkdirSync(PATHS.state, { recursive: true });
11
+ this.stream = fs.createWriteStream(FILES.usage, { flags: 'a' });
12
+ }
13
+ log(entry) {
14
+ if (!this.stream)
15
+ return;
16
+ try {
17
+ const full = { timestamp: new Date().toISOString(), ...entry };
18
+ this.stream.write(JSON.stringify(full) + '\n');
19
+ }
20
+ catch {
21
+ // Same philosophy as the other loggers: never crash the agent on
22
+ // log failures. Usage tracking is observability, not load-bearing.
23
+ }
24
+ }
25
+ close() {
26
+ this.stream?.end();
27
+ }
28
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "aws-cli-agent",
3
+ "version": "0.4.0",
4
+ "description": "Agentic AI assistant that turns natural-language requests into AWS CLI commands and runs them locally.",
5
+ "type": "module",
6
+ "bin": {
7
+ "aws-cli-agent": "dist/index.js",
8
+ "aca": "dist/index.js"
9
+ },
10
+ "main": "dist/index.js",
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "CHANGELOG.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "dev": "tsx src/index.ts",
23
+ "typecheck": "tsc --noEmit",
24
+ "lint": "eslint src",
25
+ "lint:fix": "eslint src --fix",
26
+ "test": "node scripts/smoke-test.mjs",
27
+ "ci": "npm run lint && npm run typecheck && npm run build && npm test",
28
+ "prepublishOnly": "npm run ci"
29
+ },
30
+ "keywords": [
31
+ "aws",
32
+ "aws-cli",
33
+ "aws-cli-agent",
34
+ "aca",
35
+ "ai",
36
+ "agent",
37
+ "llm",
38
+ "natural-language",
39
+ "cli",
40
+ "anthropic",
41
+ "openai",
42
+ "bedrock"
43
+ ],
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/trstnk/aws-cli-agent.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/trstnk/aws-cli-agent/issues"
51
+ },
52
+ "homepage": "https://github.com/trstnk/aws-cli-agent#readme",
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",
63
+ "zod": "^4.4.3"
64
+ },
65
+ "devDependencies": {
66
+ "@eslint/js": "^10.0.1",
67
+ "@types/node": "^25.8.0",
68
+ "eslint": "^10.4.0",
69
+ "tsx": "^4.22.0",
70
+ "typescript": "^6.0.3",
71
+ "typescript-eslint": "^8.59.3"
72
+ }
73
+ }