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,48 @@
1
+ import type { Logger } from '../logger.js';
2
+ import type { Config } from '../config.js';
3
+ export declare function awsCliTool(opts: {
4
+ logger: Logger;
5
+ config: Config;
6
+ audit: import('../audit.js').AuditLogger;
7
+ record: (entry: import('./index.js').ExecutionRecord) => void;
8
+ }): import("ai").Tool<{
9
+ args: string[];
10
+ purpose: string;
11
+ interactive?: boolean | undefined;
12
+ }, {
13
+ ok: boolean;
14
+ declined: boolean;
15
+ error: string;
16
+ exitCode?: undefined;
17
+ interactive?: undefined;
18
+ note?: undefined;
19
+ stdout?: undefined;
20
+ stderr?: undefined;
21
+ } | {
22
+ ok: boolean;
23
+ exitCode: number;
24
+ interactive: boolean;
25
+ note: string;
26
+ declined?: undefined;
27
+ error?: undefined;
28
+ stdout?: undefined;
29
+ stderr?: undefined;
30
+ } | {
31
+ ok: boolean;
32
+ exitCode: number;
33
+ stdout: string;
34
+ stderr: string;
35
+ declined?: undefined;
36
+ error?: undefined;
37
+ interactive?: undefined;
38
+ note?: undefined;
39
+ } | {
40
+ ok: boolean;
41
+ error: string;
42
+ declined?: undefined;
43
+ exitCode?: undefined;
44
+ interactive?: undefined;
45
+ note?: undefined;
46
+ stdout?: undefined;
47
+ stderr?: undefined;
48
+ }>;
@@ -0,0 +1,279 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { tool } from 'ai';
3
+ import { z } from 'zod';
4
+ import { confirm } from '@inquirer/prompts';
5
+ import chalk from 'chalk';
6
+ const READ_ONLY_VERBS = [
7
+ /^describe-/,
8
+ /^list-/,
9
+ /^get-/,
10
+ /^lookup-/,
11
+ /^batch-get-/,
12
+ /^head-/,
13
+ /^filter-/,
14
+ /^scan$/,
15
+ /^query$/,
16
+ ];
17
+ const READ_ONLY_FULL = [
18
+ /^s3\s+ls(\s|$)/,
19
+ ];
20
+ /**
21
+ * Commands that need direct terminal access — either because they open a TTY
22
+ * (interactive shells), bind a local port and wait for connections, or stream
23
+ * indefinitely until the user interrupts. For these we connect the child's
24
+ * stdio to the parent's terminal instead of capturing stdout/stderr into
25
+ * strings. The agent doesn't get to "see" the output, which is correct:
26
+ * data flow is user ↔ AWS, not user ↔ agent ↔ AWS.
27
+ *
28
+ * If a command should be interactive but isn't listed here, the user can
29
+ * force it with the `--interactive` / `-i` CLI flag or the agent can set
30
+ * `interactive: true` in the tool call.
31
+ */
32
+ const INTERACTIVE_FULL = [
33
+ // SSM Session Manager — opens a shell or a port-forward listener. Both
34
+ // need stdio inheritance: the shell case needs stdin connected, the
35
+ // port-forward case needs to print "Waiting for connections" and survive.
36
+ /^ssm\s+start-session(\s|$)/,
37
+ // CloudShell — interactive shell in AWS console replica.
38
+ /^cloudshell\s+(start|connect)(-.*|\s|$)/,
39
+ // ECS Exec — runs a command inside a container, often a shell.
40
+ /^ecs\s+execute-command(\s|$)/,
41
+ // EKS exec via aws — wraps kubectl exec for cluster pods.
42
+ /^eks\s+(exec|kubeconfig)(\s|$)/,
43
+ // CloudWatch Logs tail with --follow runs until Ctrl-C.
44
+ /^logs\s+tail(\s|$).*--follow/,
45
+ ];
46
+ function isReadOnly(args) {
47
+ const joined = args.join(' ');
48
+ if (READ_ONLY_FULL.some((re) => re.test(joined)))
49
+ return true;
50
+ const verb = args[1];
51
+ if (verb && READ_ONLY_VERBS.some((re) => re.test(verb)))
52
+ return true;
53
+ return false;
54
+ }
55
+ function isInteractive(args) {
56
+ const joined = args.join(' ');
57
+ return INTERACTIVE_FULL.some((re) => re.test(joined));
58
+ }
59
+ function shellQuote(arg) {
60
+ if (/^[a-zA-Z0-9_\-/.=:]+$/.test(arg))
61
+ return arg;
62
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
63
+ }
64
+ function extractProfile(args) {
65
+ const i = args.indexOf('--profile');
66
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
67
+ }
68
+ function hasRegion(args) {
69
+ return args.includes('--region');
70
+ }
71
+ // Default cap is generous because list/describe output for a real AWS account
72
+ // can easily exceed 50 KB (hundreds of buckets, dozens of instances with full
73
+ // describe-instances JSON). The model needs the full data to surface it to the
74
+ // user. If you hit memory pressure, lower this — but the model's own context
75
+ // window is the real limiter.
76
+ function truncate(s, max = 200_000) {
77
+ return s.length <= max ? s : s.slice(0, max) + `\n... [truncated ${s.length - max} bytes]`;
78
+ }
79
+ /**
80
+ * Run aws CLI with stdout/stderr captured into strings. Right for discovery
81
+ * calls where the agent (and the host program) need to read the output.
82
+ */
83
+ function runCaptured(cmd, args) {
84
+ return new Promise((resolve, reject) => {
85
+ const proc = spawn(cmd, args, { env: process.env });
86
+ let stdout = '';
87
+ let stderr = '';
88
+ proc.stdout.on('data', (c) => {
89
+ stdout += c.toString();
90
+ });
91
+ proc.stderr.on('data', (c) => {
92
+ stderr += c.toString();
93
+ });
94
+ proc.on('error', reject);
95
+ proc.on('close', (code) => resolve({ stdout, stderr, code: code ?? 0 }));
96
+ });
97
+ }
98
+ /**
99
+ * Run aws CLI with the child's stdio connected directly to the parent's
100
+ * terminal. Used for interactive sessions (ssm start-session shells),
101
+ * commands that bind local ports and wait (ssm port-forwarding sessions),
102
+ * and long-running streams (logs tail --follow).
103
+ *
104
+ * Returns no stdout/stderr — the bytes went straight to the user's terminal
105
+ * and we never see them. This is correct: data flow is user ↔ AWS, not
106
+ * user ↔ agent ↔ AWS.
107
+ */
108
+ function runInteractive(cmd, args) {
109
+ return new Promise((resolve, reject) => {
110
+ const proc = spawn(cmd, args, {
111
+ env: process.env,
112
+ stdio: 'inherit', // ← the fix: child reuses parent's stdin/stdout/stderr
113
+ });
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
+ });
119
+ }
120
+ export function awsCliTool(opts) {
121
+ return tool({
122
+ description: 'Execute an AWS CLI command. `args` does NOT include the leading "aws" - just the subcommand and parameters, e.g. ["ec2","describe-instances","--profile","my-profile","--output","json"]. ALWAYS use --output json on discovery calls so you can parse results. Read-only commands (describe-/list-/get-/s3 ls) auto-approve if allowed by config; mutating commands always prompt. ' +
123
+ 'Set `interactive: true` for commands that need direct terminal access — interactive shells (ssm start-session, ecs execute-command), port-forwarding sessions, log tails with --follow. Common interactive patterns auto-detect, but you can force it. When interactive, the child process is connected directly to the user\'s terminal; you will not see the output and should not attempt to parse it.',
124
+ inputSchema: z.object({
125
+ args: z.array(z.string()).min(1).describe('Arguments after the "aws" binary.'),
126
+ purpose: z
127
+ .string()
128
+ .describe('Brief explanation of why this call is being made (shown to the user).'),
129
+ interactive: z
130
+ .boolean()
131
+ .optional()
132
+ .describe('Force interactive mode (inherit terminal stdio). Use for shells, port-forwards, and long-running streams. ' +
133
+ 'If unset, the host auto-detects common patterns (ssm start-session, ecs execute-command, logs tail --follow, etc.).'),
134
+ }),
135
+ execute: async ({ args, purpose, interactive }) => {
136
+ // Inject defaultRegion if the agent didn't pass --region.
137
+ let effectiveArgs = args;
138
+ if (!hasRegion(args) && opts.config.defaultRegion) {
139
+ effectiveArgs = [...args, '--region', opts.config.defaultRegion];
140
+ }
141
+ const display = 'aws ' + effectiveArgs.map(shellQuote).join(' ');
142
+ opts.logger.info(`AWS CLI requested: ${purpose}`);
143
+ opts.logger.debug('Command', display);
144
+ // Decide interactive mode. Priority: explicit override on the tool
145
+ // call > CLI flag (via config.forceInteractive) > pattern detection.
146
+ // The CLI flag is the user's escape hatch for cases not in our
147
+ // INTERACTIVE_FULL list.
148
+ const useInteractive = interactive === true ||
149
+ opts.config.forceInteractive === true ||
150
+ isInteractive(effectiveArgs);
151
+ const readOnly = isReadOnly(effectiveArgs);
152
+ // Interactive commands are never auto-approved. The user is about to
153
+ // hand their terminal over to a subprocess — that always warrants a
154
+ // confirmation, regardless of autoApprove settings.
155
+ const autoApprove = !useInteractive &&
156
+ (opts.config.autoApprove.all || (opts.config.autoApprove.readOnly && readOnly));
157
+ // Extract profile up-front so the declined-branch audit/record
158
+ // entries can include it. extractProfile just scans args, no I/O.
159
+ const profile = extractProfile(effectiveArgs);
160
+ if (!autoApprove) {
161
+ process.stderr.write('\n');
162
+ process.stderr.write(`${chalk.bold(' Reason: ')}${purpose}\n`);
163
+ process.stderr.write(`${chalk.bold(' Command: ')}${chalk.green(display)}\n`);
164
+ if (useInteractive) {
165
+ process.stderr.write(`${chalk.bold(' Mode: ')}${chalk.yellow('interactive')} (your terminal will be connected to the command)\n`);
166
+ }
167
+ const ok = await confirm({ message: 'Execute this command?', default: true });
168
+ if (!ok) {
169
+ opts.logger.warn('User declined command');
170
+ // Record the declined call so the agent's end-of-run logic sees
171
+ // that the last action was a refusal, not the most recent
172
+ // successful intermediate call. Without this, cli.ts would print
173
+ // the stdout of an earlier describe/list call as if it were the
174
+ // final answer — confusing the user with scaffolding output.
175
+ // Audit log gets a separate marker for the same reason: clear
176
+ // trail of "user said no" rather than absence-of-record.
177
+ opts.audit.logCommand({
178
+ cmd: display,
179
+ profile,
180
+ exitCode: -1,
181
+ ok: false,
182
+ stdout: '',
183
+ stderr: '[declined by user]',
184
+ });
185
+ opts.record({
186
+ cmd: display,
187
+ profile,
188
+ stdout: '',
189
+ stderr: '[declined by user]',
190
+ exitCode: -1,
191
+ ok: false,
192
+ });
193
+ return { ok: false, declined: true, error: 'User declined to execute this command.' };
194
+ }
195
+ }
196
+ else {
197
+ opts.logger.debug(`Auto-approved (${readOnly ? 'read-only' : 'all'})`);
198
+ }
199
+ try {
200
+ const { stdout, stderr, code } = useInteractive
201
+ ? await runInteractive('aws', effectiveArgs)
202
+ : await runCaptured('aws', effectiveArgs);
203
+ opts.logger.debug('Exit code', code);
204
+ if (!useInteractive) {
205
+ opts.logger.trace('stdout', stdout);
206
+ if (code !== 0) {
207
+ opts.logger.warn(`AWS CLI failed (exit ${code})`);
208
+ opts.logger.trace('stderr', stderr);
209
+ }
210
+ }
211
+ else if (code !== 0) {
212
+ opts.logger.warn(`Interactive AWS CLI exited non-zero (${code})`);
213
+ }
214
+ // Audit captures whatever we have. For interactive runs stdout/stderr
215
+ // are empty — that's accurate, the bytes went to the terminal — and
216
+ // the audit entry serves as a record that "an interactive session
217
+ // ran" rather than a transcript of what happened in it.
218
+ opts.audit.logCommand({
219
+ cmd: display,
220
+ profile,
221
+ exitCode: code,
222
+ ok: code === 0,
223
+ stdout: useInteractive ? '[interactive session — output not captured]' : stdout,
224
+ stderr: useInteractive ? '' : stderr,
225
+ });
226
+ opts.record({
227
+ cmd: display,
228
+ profile,
229
+ // For interactive runs, give the host CLI a one-line summary to
230
+ // emit instead of empty output. Users finishing an SSM session see
231
+ // their shell ouptut as it happens; this just confirms it ended.
232
+ stdout: useInteractive
233
+ ? `[interactive session ended, exit ${code}]\n`
234
+ : stdout,
235
+ stderr: useInteractive ? '' : stderr,
236
+ exitCode: code,
237
+ ok: code === 0,
238
+ });
239
+ // For the agent's context, return a clear signal that interactive
240
+ // mode ran so it doesn't try to parse fictional stdout.
241
+ if (useInteractive) {
242
+ return {
243
+ ok: code === 0,
244
+ exitCode: code,
245
+ interactive: true,
246
+ note: 'Interactive session ran. Output went directly to the user\'s terminal and was not captured. Do not summarize or describe its contents.',
247
+ };
248
+ }
249
+ return {
250
+ ok: code === 0,
251
+ exitCode: code,
252
+ stdout: truncate(stdout),
253
+ stderr: truncate(stderr),
254
+ };
255
+ }
256
+ catch (err) {
257
+ const msg = err instanceof Error ? err.message : String(err);
258
+ opts.logger.error('Failed to spawn aws CLI', msg);
259
+ opts.audit.logCommand({
260
+ cmd: display,
261
+ profile,
262
+ exitCode: -1,
263
+ ok: false,
264
+ stdout: '',
265
+ stderr: msg,
266
+ });
267
+ opts.record({
268
+ cmd: display,
269
+ profile,
270
+ stdout: '',
271
+ stderr: msg,
272
+ exitCode: -1,
273
+ ok: false,
274
+ });
275
+ return { ok: false, error: msg };
276
+ }
277
+ },
278
+ });
279
+ }
@@ -0,0 +1,47 @@
1
+ import type { Logger } from '../logger.js';
2
+ import type { Config } from '../config.js';
3
+ export declare function bashScriptTool(opts: {
4
+ logger: Logger;
5
+ config: Config;
6
+ audit: import('../audit.js').AuditLogger;
7
+ record: (entry: import('./index.js').ExecutionRecord) => void;
8
+ }): import("ai").Tool<{
9
+ script: string;
10
+ purpose: string;
11
+ }, {
12
+ ok: boolean;
13
+ declined: boolean;
14
+ error: string;
15
+ saved?: undefined;
16
+ path?: undefined;
17
+ stdout?: undefined;
18
+ exitCode?: undefined;
19
+ stderr?: undefined;
20
+ } | {
21
+ ok: boolean;
22
+ error: string;
23
+ declined?: undefined;
24
+ saved?: undefined;
25
+ path?: undefined;
26
+ stdout?: undefined;
27
+ exitCode?: undefined;
28
+ stderr?: undefined;
29
+ } | {
30
+ ok: boolean;
31
+ saved: boolean;
32
+ path: string;
33
+ stdout: string;
34
+ declined?: undefined;
35
+ error?: undefined;
36
+ exitCode?: undefined;
37
+ stderr?: undefined;
38
+ } | {
39
+ ok: boolean;
40
+ exitCode: number;
41
+ stdout: string;
42
+ stderr: string;
43
+ declined?: undefined;
44
+ error?: undefined;
45
+ saved?: undefined;
46
+ path?: undefined;
47
+ }>;
@@ -0,0 +1,197 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { tool } from 'ai';
6
+ import { z } from 'zod';
7
+ import { select } from '@inquirer/prompts';
8
+ import chalk from 'chalk';
9
+ import { DEFAULT_SCRIPT_FOLDER } from '../paths.js';
10
+ function runProcess(cmd, args) {
11
+ return new Promise((resolve, reject) => {
12
+ const proc = spawn(cmd, args, { env: process.env });
13
+ let stdout = '';
14
+ let stderr = '';
15
+ proc.stdout.on('data', (c) => {
16
+ stdout += c.toString();
17
+ });
18
+ proc.stderr.on('data', (c) => {
19
+ stderr += c.toString();
20
+ });
21
+ proc.on('error', reject);
22
+ proc.on('close', (code) => resolve({ stdout, stderr, code: code ?? 0 }));
23
+ });
24
+ }
25
+ function truncate(s, max = 200_000) {
26
+ return s.length <= max ? s : s.slice(0, max) + `\n... [truncated]`;
27
+ }
28
+ function indent(s, prefix) {
29
+ return s
30
+ .split('\n')
31
+ .map((l) => prefix + l)
32
+ .join('\n');
33
+ }
34
+ /**
35
+ * Compute a filesystem-friendly filename for a saved script.
36
+ * Combines a timestamp (so files sort chronologically) with a short slug
37
+ * derived from the purpose (so a directory listing is humanly scannable).
38
+ */
39
+ function scriptFileName(purpose) {
40
+ const slug = purpose
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9]+/g, '-')
43
+ .replace(/^-+|-+$/g, '')
44
+ .slice(0, 40) || 'script';
45
+ const ts = new Date()
46
+ .toISOString()
47
+ .replace(/[:.]/g, '-')
48
+ .replace(/T/, '_')
49
+ .slice(0, 19);
50
+ return `${ts}_${slug}.sh`;
51
+ }
52
+ export function bashScriptTool(opts) {
53
+ return tool({
54
+ description: 'Execute a bash script. Use this for multi-step / multi-account workflows that need looping, jq filtering, or composition (e.g. "list all RDS Aurora databases in all accounts of org X"). The user is prompted to (a) execute the script now, (b) save it to disk for later review or scheduled execution, or (c) cancel. Always start scripts with `set -euo pipefail`.',
55
+ inputSchema: z.object({
56
+ script: z.string().min(1).describe('Full bash script source.'),
57
+ purpose: z.string().describe('What this script accomplishes.'),
58
+ }),
59
+ execute: async ({ script, purpose }) => {
60
+ opts.logger.info(`Bash script requested: ${purpose}`);
61
+ opts.logger.debug('Script', script);
62
+ // Show what would run so the user can make an informed choice.
63
+ process.stderr.write('\n');
64
+ process.stderr.write(`${chalk.bold(' Reason: ')}${purpose}\n`);
65
+ process.stderr.write(`${chalk.bold(' Script:')}\n`);
66
+ process.stderr.write(chalk.green(indent(script, ' ')) + '\n');
67
+ // The save-to-disk option respects the configured folder, or falls back
68
+ // to the XDG default. Compute the would-be path *before* prompting so
69
+ // the user can see exactly where it'll land.
70
+ const scriptFolder = opts.config.scriptFolder ?? DEFAULT_SCRIPT_FOLDER;
71
+ const savePath = path.join(scriptFolder, scriptFileName(purpose));
72
+ // Scripts always go through the three-way prompt regardless of
73
+ // autoApprove. Scripts are arbitrary code with shell-level capability
74
+ // — auto-approving them would defeat a primary safety boundary. The
75
+ // autoApprove flag remains in effect for individual aws CLI commands
76
+ // (where read-only is a meaningful and enforceable category).
77
+ const action = await select({
78
+ message: 'What would you like to do with this script?',
79
+ choices: [
80
+ { value: 'execute', name: 'Execute now' },
81
+ { value: 'save', name: `Save to disk (${savePath})` },
82
+ { value: 'cancel', name: 'Cancel' },
83
+ ],
84
+ default: 'execute',
85
+ });
86
+ if (action === 'cancel') {
87
+ opts.logger.warn('User cancelled script');
88
+ // Record the cancelled call so the agent's end-of-run logic sees
89
+ // refusal as the final action, not an earlier successful step.
90
+ // See the parallel comment in aws-cli.ts for the reasoning.
91
+ const cmdLabel = `[bash script: ${purpose}]`;
92
+ opts.audit.logCommand({
93
+ cmd: cmdLabel,
94
+ profile: null,
95
+ exitCode: -1,
96
+ ok: false,
97
+ stdout: '',
98
+ stderr: '[cancelled by user]',
99
+ });
100
+ opts.record({
101
+ cmd: cmdLabel,
102
+ profile: null,
103
+ stdout: '',
104
+ stderr: '[cancelled by user]',
105
+ exitCode: -1,
106
+ ok: false,
107
+ });
108
+ return {
109
+ ok: false,
110
+ declined: true,
111
+ error: 'User cancelled. No script was executed or saved.',
112
+ };
113
+ }
114
+ if (action === 'save') {
115
+ try {
116
+ fs.mkdirSync(scriptFolder, { recursive: true });
117
+ fs.writeFileSync(savePath, script, { mode: 0o700 });
118
+ }
119
+ catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err);
121
+ opts.logger.error('Failed to save script', msg);
122
+ return { ok: false, error: `Failed to save script: ${msg}` };
123
+ }
124
+ opts.logger.info(`Script saved to ${savePath}`);
125
+ // Still audit the save action so the trail is complete. exitCode=0
126
+ // is honest here: the user-visible action succeeded.
127
+ opts.audit.logScript({
128
+ cmd: `saved ${savePath}`,
129
+ profile: null,
130
+ exitCode: 0,
131
+ ok: true,
132
+ stdout: '',
133
+ stderr: '',
134
+ script,
135
+ });
136
+ // Record so the CLI can print a confirmation in place of stdout —
137
+ // the script wasn't run, so there's no real stdout to forward.
138
+ opts.record({
139
+ cmd: `saved ${savePath}`,
140
+ profile: null,
141
+ stdout: `Script saved to ${savePath}\n`,
142
+ stderr: '',
143
+ exitCode: 0,
144
+ ok: true,
145
+ });
146
+ return {
147
+ ok: true,
148
+ saved: true,
149
+ path: savePath,
150
+ stdout: `Script saved to ${savePath}`,
151
+ };
152
+ }
153
+ // action === 'execute' — same path as before.
154
+ const tmp = path.join(os.tmpdir(), `aws-cli-agent-${Date.now()}-${process.pid}.sh`);
155
+ fs.writeFileSync(tmp, script, { mode: 0o700 });
156
+ const cmdLabel = `bash ${tmp}`;
157
+ try {
158
+ const { stdout, stderr, code } = await runProcess('bash', [tmp]);
159
+ opts.logger.debug('Script exit code', code);
160
+ opts.logger.trace('stdout', stdout);
161
+ if (code !== 0)
162
+ opts.logger.trace('stderr', stderr);
163
+ opts.audit.logScript({
164
+ cmd: cmdLabel,
165
+ profile: null,
166
+ exitCode: code,
167
+ ok: code === 0,
168
+ stdout,
169
+ stderr,
170
+ script,
171
+ });
172
+ opts.record({
173
+ cmd: cmdLabel,
174
+ profile: null,
175
+ stdout,
176
+ stderr,
177
+ exitCode: code,
178
+ ok: code === 0,
179
+ });
180
+ return {
181
+ ok: code === 0,
182
+ exitCode: code,
183
+ stdout: truncate(stdout),
184
+ stderr: truncate(stderr),
185
+ };
186
+ }
187
+ finally {
188
+ try {
189
+ fs.unlinkSync(tmp);
190
+ }
191
+ catch {
192
+ /* ignore */
193
+ }
194
+ }
195
+ },
196
+ });
197
+ }
@@ -0,0 +1,18 @@
1
+ import type { History } from '../history.js';
2
+ import type { Logger } from '../logger.js';
3
+ export declare function historyTool(opts: {
4
+ history: History;
5
+ logger: Logger;
6
+ }): import("ai").Tool<{
7
+ query: string;
8
+ limit: number;
9
+ }, {
10
+ count: number;
11
+ entries: {
12
+ timestamp: string;
13
+ input: string;
14
+ commands: string[];
15
+ profile: string | null;
16
+ resources: Record<string, string>;
17
+ }[];
18
+ }>;
@@ -0,0 +1,27 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ export function historyTool(opts) {
4
+ return tool({
5
+ description: 'Search the local history of past requests/commands to recover context — e.g. which AWS profile was used for a given account name, common bucket/instance names, etc. Run this EARLY (typically first) when a request mentions an account, resource, or scope by name.',
6
+ inputSchema: z.object({
7
+ query: z
8
+ .string()
9
+ .describe('Search tokens. Matched against past input, commands, profile, and resources.'),
10
+ limit: z.number().int().min(1).max(20).default(5),
11
+ }),
12
+ execute: async ({ query, limit }) => {
13
+ opts.logger.debug('History search', { query, limit });
14
+ const results = opts.history.search(query, limit);
15
+ return {
16
+ count: results.length,
17
+ entries: results.map((e) => ({
18
+ timestamp: e.timestamp,
19
+ input: e.input,
20
+ commands: e.commands,
21
+ profile: e.profile,
22
+ resources: e.resources,
23
+ })),
24
+ };
25
+ },
26
+ });
27
+ }