Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,12 +135,13 @@ agentcore invoke

### Utilities

| Command | Description |
| -------------- | ----------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `update` | Check for and install CLI updates |
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `feedback` | Send feedback to the AgentCore team (with screenshot) |
| `update` | Check for and install CLI updates |

## Project Structure

Expand DownExpand Up@@ -191,10 +192,21 @@ Projects use JSON schema files in the `agentcore/` directory:
- [Gateway](docs/gateway.md) - Gateway setup, targets, and authentication
- [Memory](docs/memory.md) - Memory strategies and sharing
- [Local Development](docs/local-development.md) - Dev server and debugging
- [Feedback](docs/feedback.md) - Submit feedback from your terminal

## Feedback & Issues

Found a bug or have a feature request? [Open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub.
Have a quick comment or suggestion? Send it from your terminal:

```bash
agentcore feedback "your message" --screenshot path/to/screenshot.png
```

The CLI will display the AWS Customer Agreement and prompt for consent before submitting. See
[docs/feedback.md](docs/feedback.md) for usage details.

For bugs, regressions, or feature requests that need discussion,
[open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub instead.

## Security

Expand Down
19 changes: 19 additions & 0 deletions docs/commands.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -955,6 +955,25 @@ agentcore package -d ./my-project
| `-d, --directory <path>` | Project directory |
| `-r, --runtime <name>` | Package specific runtime |

### feedback

Send feedback about the AgentCore CLI. The CLI displays the AWS Customer Agreement and prompts for consent before
submitting; consent must be confirmed in an interactive terminal.

```bash
agentcore feedback "the dev server is slow on Linux"
agentcore feedback "broken icon" --screenshot ~/Desktop/bug.png
agentcore feedback "automation works" --json
agentcore feedback # launches the wizard
```

| Flag | Description |
| --------------------- | ------------------------------------------------------------ |
| `--screenshot <path>` | Path to a `.png`, `.jpg`, or `.jpeg` file (max 100MB) |
| `--json` | Print result as JSON (`{success, id, timestamp, reference}`) |

See [docs/feedback.md](feedback.md) for usage details.

### update

Check for and install CLI updates. Equivalent to `agentcore update cli`.
Expand Down
55 changes: 55 additions & 0 deletions docs/feedback.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
# Feedback

Send feedback about the AgentCore CLI directly from your terminal.

## When to use
Comment thread
Hweinstock marked this conversation as resolved.

| Use `agentcore feedback` | Use [GitHub Issues](https://github.com/aws/agentcore-cli/issues) instead |
| -------------------------------------- | ------------------------------------------------------------------------ |
| Quick comments, suggestions, papercuts | Bugs that need a conversation or repro steps |
| First impressions, onboarding friction | Regressions you want to track |
| Sharing a screenshot of confusing UX | Feature requests you want to discuss publicly |

## Syntax

```bash
# One-shot
agentcore feedback "your message" [--screenshot path/to/file.png] [--json]

# Multi-step wizard
agentcore feedback
```

The wizard walks through: message → optional screenshot → consent → submit. Press `Esc` to step back one phase.

## Screenshots

- Allowed types: `.png`, `.jpg`, `.jpeg`
- Maximum size: 100 MB

## Consent

Every submission requires interactive consent. The CLI displays:

> All feedback submissions, including any uploaded text and images, are subject to the AWS Customer Agreement
> (https://aws.amazon.com/agreement/). By submitting feedback, you agree that your submissions constitute "Suggestions"
> as defined in the AWS Customer Agreement.

Bare `Enter` defaults to **No**. The command refuses to submit when stdin is not a TTY (e.g. piped input, CI). There is
no flag that bypasses the prompt.

## What not to include

Do not paste credentials, secrets, account IDs, or customer data into the message, and do not attach screenshots that
show those values.

## Output

Plain mode prints a confirmation line on success. JSON mode (`--json`) prints a single line:

```json
{ "success": true, "id": "<uuid>", "timestamp": "<iso8601>", "reference": "<reference>" }
```

On failure the CLI exits with code 1 and prints a human-readable error, or `{"success": false, "error": "..."}` when
`--json` is set.
2 changes: 2 additions & 0 deletions src/cli/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { registerCreate } from './commands/create';
import { registerDeploy } from './commands/deploy';
import { registerDev } from './commands/dev';
import { registerEval } from './commands/eval';
import { registerFeedback } from './commands/feedback';
import { registerFetch } from './commands/fetch';
import { registerHelp } from './commands/help';
import { registerImport } from './commands/import';
Expand DownExpand Up@@ -180,6 +181,7 @@ export function registerCommands(program: Command) {
registerDeploy(program);
registerCreate(program);
registerEval(program);
registerFeedback(program);
registerFetch(program);
registerHelp(program);
registerImport(program);
Expand Down
163 changes: 163 additions & 0 deletions src/cli/commands/feedback/__tests__/command.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
import { TelemetryClient } from '../../../telemetry/client';
import { TelemetryClientAccessor } from '../../../telemetry/client-accessor';
import { InMemorySink } from '../../../telemetry/sinks/in-memory-sink';
import { registerFeedback } from '../command';
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockHandleFeedback = vi.fn();
const mockRender = vi.fn();
const mockRequireTTY = vi.fn();

vi.mock('../action', () => ({
handleFeedback: (...args: unknown[]) => mockHandleFeedback(...args),
}));

vi.mock('../../../tui/guards/tty', () => ({
requireTTY: () => mockRequireTTY(),
}));

vi.mock('../../../tui/screens/feedback', () => ({
Comment thread
Hweinstock marked this conversation as resolved.
FeedbackScreen: () => null,
}));

vi.mock('ink', () => ({
render: (...args: unknown[]) => {
mockRender(...args);
return {
clear: vi.fn(),
unmount: vi.fn(),
waitUntilExit: () => Promise.resolve(),
};
},
Text: 'Text',
Box: 'Box',
}));

const submittedOutcome = {
kind: 'submitted' as const,
result: { id: 'sub-1', timestamp: '2026-05-13T18:00:00Z', reference: 'S3' },
};

describe('registerFeedback', () => {
let program: Command;
let sink: InMemorySink;
let mockExit: ReturnType<typeof vi.spyOn>;
let mockLog: ReturnType<typeof vi.spyOn>;
let mockError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
program = new Command();
program.exitOverride();
registerFeedback(program);

sink = new InMemorySink();
vi.spyOn(TelemetryClientAccessor, 'get').mockResolvedValue(new TelemetryClient(sink));

mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
mockError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it('registers a top-level feedback command', () => {
const cmd = program.commands.find(c => c.name() === 'feedback');
expect(cmd).toBeDefined();
});

it('emits success JSON when --json is supplied with a message', async () => {
mockHandleFeedback.mockResolvedValue(submittedOutcome);

await expect(program.parseAsync(['feedback', 'looks good', '--json'], { from: 'user' })).rejects.toThrow(
'process.exit'
);

expect(mockHandleFeedback).toHaveBeenCalledWith('looks good', expect.objectContaining({ json: true }));
expect(mockExit).toHaveBeenCalledWith(0);
const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({
success: true,
id: 'sub-1',
timestamp: '2026-05-13T18:00:00Z',
reference: 'S3',
});

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'success',
mode: 'cli',
has_screenshot: 'false',
});
});

it('reports a TTY error when consent cannot be confirmed and exits 1', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'no-tty' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('consent must be confirmed interactively'));
expect(mockExit).toHaveBeenCalledWith(1);
});

it('prints a friendly cancellation message when the user declines consent', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'declined' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Feedback cancelled.'));
expect(mockExit).toHaveBeenCalledWith(0);
});

it('reports submission errors with exit 1 in plain mode', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockExit).toHaveBeenCalledWith(1);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining('HTTP 500'));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'failure',
mode: 'cli',
has_screenshot: 'false',
});
});

it('emits a JSON error envelope on submission failure when --json is set', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({ success: false, error: 'HTTP 500' });
expect(mockExit).toHaveBeenCalledWith(1);
});

it('refuses --json when no message is supplied', async () => {
await expect(program.parseAsync(['feedback', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('--json requires a feedback message'));
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockHandleFeedback).not.toHaveBeenCalled();
});

it('hands off to the TUI when no message argument is provided, then exits cleanly', async () => {
await expect(program.parseAsync(['feedback'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockRequireTTY).toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
expect(mockHandleFeedback).not.toHaveBeenCalled();
// After the wizard unmounts we must terminate the Node process; otherwise
// Ink's stdin raw-mode listeners keep the process alive.
expect(mockExit).toHaveBeenCalledWith(0);
});
});
52 changes: 52 additions & 0 deletions src/cli/commands/feedback/__tests__/consent-prompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
import { promptForConsent } from '../consent-prompt';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';

function makeTtyStdin(input: string): NodeJS.ReadableStream & { isTTY?: boolean } {
const stream = new PassThrough() as PassThrough & { isTTY?: boolean };
stream.isTTY = true;
stream.end(input);
return stream;
}

describe('promptForConsent', () => {
Comment thread
tejaskash marked this conversation as resolved.
it('returns no-tty when stdin is not a TTY', async () => {
const stdin = new PassThrough() as PassThrough & { isTTY?: boolean };
stdin.isTTY = false;
const stdout = new PassThrough();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'no-tty' });
});

it('accepts when the user types y', async () => {
const stdin = makeTtyStdin('y\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result.accepted).toBe(true);
});

it('declines on bare Enter (defaults to No)', async () => {
const stdin = makeTtyStdin('\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});

it('declines when the user types n', async () => {
const stdin = makeTtyStdin('n\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});
});
29 changes: 29 additions & 0 deletions src/cli/commands/feedback/action.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
import { toError } from '../../../lib/errors/types';
import { submitFeedback } from '../../operations/feedback';
import type { FeedbackSubmissionResult } from '../../operations/feedback';
import { promptForConsent } from './consent-prompt';
import type { FeedbackOptions } from './types';

export type FeedbackOutcome =
| { kind: 'submitted'; result: FeedbackSubmissionResult }
| { kind: 'declined' }
| { kind: 'no-tty' }
| { kind: 'error'; error: Error };

export async function handleFeedback(message: string, options: FeedbackOptions): Promise<FeedbackOutcome> {
const consent = await promptForConsent();
if (!consent.accepted) {
return consent.reason === 'no-tty' ? { kind: 'no-tty' } : { kind: 'declined' };
}

try {
const result = await submitFeedback({
message,
screenshot: options.screenshot ? { path: options.screenshot } : undefined,
mode: 'cli',
});
return { kind: 'submitted', result };
} catch (err) {
return { kind: 'error', error: toError(err) };
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,12 +135,13 @@ agentcore invoke

### Utilities

| Command | Description |
| -------------- | ----------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `update` | Check for and install CLI updates |
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `feedback` | Send feedback to the AgentCore team (with screenshot) |
| `update` | Check for and install CLI updates |

## Project Structure

Expand DownExpand Up@@ -191,10 +192,21 @@ Projects use JSON schema files in the `agentcore/` directory:
- [Gateway](docs/gateway.md) - Gateway setup, targets, and authentication
- [Memory](docs/memory.md) - Memory strategies and sharing
- [Local Development](docs/local-development.md) - Dev server and debugging
- [Feedback](docs/feedback.md) - Submit feedback from your terminal

## Feedback & Issues

Found a bug or have a feature request? [Open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub.
Have a quick comment or suggestion? Send it from your terminal:

```bash
agentcore feedback "your message" --screenshot path/to/screenshot.png
```

The CLI will display the AWS Customer Agreement and prompt for consent before submitting. See
[docs/feedback.md](docs/feedback.md) for usage details.

For bugs, regressions, or feature requests that need discussion,
[open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub instead.

## Security

Expand Down
19 changes: 19 additions & 0 deletions docs/commands.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -955,6 +955,25 @@ agentcore package -d ./my-project
| `-d, --directory <path>` | Project directory |
| `-r, --runtime <name>` | Package specific runtime |

### feedback

Send feedback about the AgentCore CLI. The CLI displays the AWS Customer Agreement and prompts for consent before
submitting; consent must be confirmed in an interactive terminal.

```bash
agentcore feedback "the dev server is slow on Linux"
agentcore feedback "broken icon" --screenshot ~/Desktop/bug.png
agentcore feedback "automation works" --json
agentcore feedback # launches the wizard
```

| Flag | Description |
| --------------------- | ------------------------------------------------------------ |
| `--screenshot <path>` | Path to a `.png`, `.jpg`, or `.jpeg` file (max 100MB) |
| `--json` | Print result as JSON (`{success, id, timestamp, reference}`) |

See [docs/feedback.md](feedback.md) for usage details.

### update

Check for and install CLI updates. Equivalent to `agentcore update cli`.
Expand Down
55 changes: 55 additions & 0 deletions docs/feedback.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
# Feedback

Send feedback about the AgentCore CLI directly from your terminal.

## When to use
Comment thread
Hweinstock marked this conversation as resolved.

| Use `agentcore feedback` | Use [GitHub Issues](https://github.com/aws/agentcore-cli/issues) instead |
| -------------------------------------- | ------------------------------------------------------------------------ |
| Quick comments, suggestions, papercuts | Bugs that need a conversation or repro steps |
| First impressions, onboarding friction | Regressions you want to track |
| Sharing a screenshot of confusing UX | Feature requests you want to discuss publicly |

## Syntax

```bash
# One-shot
agentcore feedback "your message" [--screenshot path/to/file.png] [--json]

# Multi-step wizard
agentcore feedback
```

The wizard walks through: message → optional screenshot → consent → submit. Press `Esc` to step back one phase.

## Screenshots

- Allowed types: `.png`, `.jpg`, `.jpeg`
- Maximum size: 100 MB

## Consent

Every submission requires interactive consent. The CLI displays:

> All feedback submissions, including any uploaded text and images, are subject to the AWS Customer Agreement
> (https://aws.amazon.com/agreement/). By submitting feedback, you agree that your submissions constitute "Suggestions"
> as defined in the AWS Customer Agreement.

Bare `Enter` defaults to **No**. The command refuses to submit when stdin is not a TTY (e.g. piped input, CI). There is
no flag that bypasses the prompt.

## What not to include

Do not paste credentials, secrets, account IDs, or customer data into the message, and do not attach screenshots that
show those values.

## Output

Plain mode prints a confirmation line on success. JSON mode (`--json`) prints a single line:

```json
{ "success": true, "id": "<uuid>", "timestamp": "<iso8601>", "reference": "<reference>" }
```

On failure the CLI exits with code 1 and prints a human-readable error, or `{"success": false, "error": "..."}` when
`--json` is set.
2 changes: 2 additions & 0 deletions src/cli/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { registerCreate } from './commands/create';
import { registerDeploy } from './commands/deploy';
import { registerDev } from './commands/dev';
import { registerEval } from './commands/eval';
import { registerFeedback } from './commands/feedback';
import { registerFetch } from './commands/fetch';
import { registerHelp } from './commands/help';
import { registerImport } from './commands/import';
Expand DownExpand Up@@ -180,6 +181,7 @@ export function registerCommands(program: Command) {
registerDeploy(program);
registerCreate(program);
registerEval(program);
registerFeedback(program);
registerFetch(program);
registerHelp(program);
registerImport(program);
Expand Down
163 changes: 163 additions & 0 deletions src/cli/commands/feedback/__tests__/command.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
import { TelemetryClient } from '../../../telemetry/client';
import { TelemetryClientAccessor } from '../../../telemetry/client-accessor';
import { InMemorySink } from '../../../telemetry/sinks/in-memory-sink';
import { registerFeedback } from '../command';
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockHandleFeedback = vi.fn();
const mockRender = vi.fn();
const mockRequireTTY = vi.fn();

vi.mock('../action', () => ({
handleFeedback: (...args: unknown[]) => mockHandleFeedback(...args),
}));

vi.mock('../../../tui/guards/tty', () => ({
requireTTY: () => mockRequireTTY(),
}));

vi.mock('../../../tui/screens/feedback', () => ({
Comment thread
Hweinstock marked this conversation as resolved.
FeedbackScreen: () => null,
}));

vi.mock('ink', () => ({
render: (...args: unknown[]) => {
mockRender(...args);
return {
clear: vi.fn(),
unmount: vi.fn(),
waitUntilExit: () => Promise.resolve(),
};
},
Text: 'Text',
Box: 'Box',
}));

const submittedOutcome = {
kind: 'submitted' as const,
result: { id: 'sub-1', timestamp: '2026-05-13T18:00:00Z', reference: 'S3' },
};

describe('registerFeedback', () => {
let program: Command;
let sink: InMemorySink;
let mockExit: ReturnType<typeof vi.spyOn>;
let mockLog: ReturnType<typeof vi.spyOn>;
let mockError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
program = new Command();
program.exitOverride();
registerFeedback(program);

sink = new InMemorySink();
vi.spyOn(TelemetryClientAccessor, 'get').mockResolvedValue(new TelemetryClient(sink));

mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
mockError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it('registers a top-level feedback command', () => {
const cmd = program.commands.find(c => c.name() === 'feedback');
expect(cmd).toBeDefined();
});

it('emits success JSON when --json is supplied with a message', async () => {
mockHandleFeedback.mockResolvedValue(submittedOutcome);

await expect(program.parseAsync(['feedback', 'looks good', '--json'], { from: 'user' })).rejects.toThrow(
'process.exit'
);

expect(mockHandleFeedback).toHaveBeenCalledWith('looks good', expect.objectContaining({ json: true }));
expect(mockExit).toHaveBeenCalledWith(0);
const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({
success: true,
id: 'sub-1',
timestamp: '2026-05-13T18:00:00Z',
reference: 'S3',
});

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'success',
mode: 'cli',
has_screenshot: 'false',
});
});

it('reports a TTY error when consent cannot be confirmed and exits 1', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'no-tty' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('consent must be confirmed interactively'));
expect(mockExit).toHaveBeenCalledWith(1);
});

it('prints a friendly cancellation message when the user declines consent', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'declined' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Feedback cancelled.'));
expect(mockExit).toHaveBeenCalledWith(0);
});

it('reports submission errors with exit 1 in plain mode', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockExit).toHaveBeenCalledWith(1);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining('HTTP 500'));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'failure',
mode: 'cli',
has_screenshot: 'false',
});
});

it('emits a JSON error envelope on submission failure when --json is set', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({ success: false, error: 'HTTP 500' });
expect(mockExit).toHaveBeenCalledWith(1);
});

it('refuses --json when no message is supplied', async () => {
await expect(program.parseAsync(['feedback', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('--json requires a feedback message'));
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockHandleFeedback).not.toHaveBeenCalled();
});

it('hands off to the TUI when no message argument is provided, then exits cleanly', async () => {
await expect(program.parseAsync(['feedback'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockRequireTTY).toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
expect(mockHandleFeedback).not.toHaveBeenCalled();
// After the wizard unmounts we must terminate the Node process; otherwise
// Ink's stdin raw-mode listeners keep the process alive.
expect(mockExit).toHaveBeenCalledWith(0);
});
});
52 changes: 52 additions & 0 deletions src/cli/commands/feedback/__tests__/consent-prompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
import { promptForConsent } from '../consent-prompt';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';

function makeTtyStdin(input: string): NodeJS.ReadableStream & { isTTY?: boolean } {
const stream = new PassThrough() as PassThrough & { isTTY?: boolean };
stream.isTTY = true;
stream.end(input);
return stream;
}

describe('promptForConsent', () => {
Comment thread
tejaskash marked this conversation as resolved.
it('returns no-tty when stdin is not a TTY', async () => {
const stdin = new PassThrough() as PassThrough & { isTTY?: boolean };
stdin.isTTY = false;
const stdout = new PassThrough();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'no-tty' });
});

it('accepts when the user types y', async () => {
const stdin = makeTtyStdin('y\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result.accepted).toBe(true);
});

it('declines on bare Enter (defaults to No)', async () => {
const stdin = makeTtyStdin('\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});

it('declines when the user types n', async () => {
const stdin = makeTtyStdin('n\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});
});
29 changes: 29 additions & 0 deletions src/cli/commands/feedback/action.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
import { toError } from '../../../lib/errors/types';
import { submitFeedback } from '../../operations/feedback';
import type { FeedbackSubmissionResult } from '../../operations/feedback';
import { promptForConsent } from './consent-prompt';
import type { FeedbackOptions } from './types';

export type FeedbackOutcome =
| { kind: 'submitted'; result: FeedbackSubmissionResult }
| { kind: 'declined' }
| { kind: 'no-tty' }
| { kind: 'error'; error: Error };

export async function handleFeedback(message: string, options: FeedbackOptions): Promise<FeedbackOutcome> {
const consent = await promptForConsent();
if (!consent.accepted) {
return consent.reason === 'no-tty' ? { kind: 'no-tty' } : { kind: 'declined' };
}

try {
const result = await submitFeedback({
message,
screenshot: options.screenshot ? { path: options.screenshot } : undefined,
mode: 'cli',
});
return { kind: 'submitted', result };
} catch (err) {
return { kind: 'error', error: toError(err) };
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,12 +135,13 @@ agentcore invoke

### Utilities

| Command | Description |
| -------------- | ----------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `update` | Check for and install CLI updates |
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `feedback` | Send feedback to the AgentCore team (with screenshot) |
| `update` | Check for and install CLI updates |

## Project Structure

Expand DownExpand Up@@ -191,10 +192,21 @@ Projects use JSON schema files in the `agentcore/` directory:
- [Gateway](docs/gateway.md) - Gateway setup, targets, and authentication
- [Memory](docs/memory.md) - Memory strategies and sharing
- [Local Development](docs/local-development.md) - Dev server and debugging
- [Feedback](docs/feedback.md) - Submit feedback from your terminal

## Feedback & Issues

Found a bug or have a feature request? [Open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub.
Have a quick comment or suggestion? Send it from your terminal:

```bash
agentcore feedback "your message" --screenshot path/to/screenshot.png
```

The CLI will display the AWS Customer Agreement and prompt for consent before submitting. See
[docs/feedback.md](docs/feedback.md) for usage details.

For bugs, regressions, or feature requests that need discussion,
[open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub instead.

## Security

Expand Down
19 changes: 19 additions & 0 deletions docs/commands.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -955,6 +955,25 @@ agentcore package -d ./my-project
| `-d, --directory <path>` | Project directory |
| `-r, --runtime <name>` | Package specific runtime |

### feedback

Send feedback about the AgentCore CLI. The CLI displays the AWS Customer Agreement and prompts for consent before
submitting; consent must be confirmed in an interactive terminal.

```bash
agentcore feedback "the dev server is slow on Linux"
agentcore feedback "broken icon" --screenshot ~/Desktop/bug.png
agentcore feedback "automation works" --json
agentcore feedback # launches the wizard
```

| Flag | Description |
| --------------------- | ------------------------------------------------------------ |
| `--screenshot <path>` | Path to a `.png`, `.jpg`, or `.jpeg` file (max 100MB) |
| `--json` | Print result as JSON (`{success, id, timestamp, reference}`) |

See [docs/feedback.md](feedback.md) for usage details.

### update

Check for and install CLI updates. Equivalent to `agentcore update cli`.
Expand Down
55 changes: 55 additions & 0 deletions docs/feedback.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
# Feedback

Send feedback about the AgentCore CLI directly from your terminal.

## When to use
Comment thread
Hweinstock marked this conversation as resolved.

| Use `agentcore feedback` | Use [GitHub Issues](https://github.com/aws/agentcore-cli/issues) instead |
| -------------------------------------- | ------------------------------------------------------------------------ |
| Quick comments, suggestions, papercuts | Bugs that need a conversation or repro steps |
| First impressions, onboarding friction | Regressions you want to track |
| Sharing a screenshot of confusing UX | Feature requests you want to discuss publicly |

## Syntax

```bash
# One-shot
agentcore feedback "your message" [--screenshot path/to/file.png] [--json]

# Multi-step wizard
agentcore feedback
```

The wizard walks through: message → optional screenshot → consent → submit. Press `Esc` to step back one phase.

## Screenshots

- Allowed types: `.png`, `.jpg`, `.jpeg`
- Maximum size: 100 MB

## Consent

Every submission requires interactive consent. The CLI displays:

> All feedback submissions, including any uploaded text and images, are subject to the AWS Customer Agreement
> (https://aws.amazon.com/agreement/). By submitting feedback, you agree that your submissions constitute "Suggestions"
> as defined in the AWS Customer Agreement.

Bare `Enter` defaults to **No**. The command refuses to submit when stdin is not a TTY (e.g. piped input, CI). There is
no flag that bypasses the prompt.

## What not to include

Do not paste credentials, secrets, account IDs, or customer data into the message, and do not attach screenshots that
show those values.

## Output

Plain mode prints a confirmation line on success. JSON mode (`--json`) prints a single line:

```json
{ "success": true, "id": "<uuid>", "timestamp": "<iso8601>", "reference": "<reference>" }
```

On failure the CLI exits with code 1 and prints a human-readable error, or `{"success": false, "error": "..."}` when
`--json` is set.
2 changes: 2 additions & 0 deletions src/cli/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { registerCreate } from './commands/create';
import { registerDeploy } from './commands/deploy';
import { registerDev } from './commands/dev';
import { registerEval } from './commands/eval';
import { registerFeedback } from './commands/feedback';
import { registerFetch } from './commands/fetch';
import { registerHelp } from './commands/help';
import { registerImport } from './commands/import';
Expand DownExpand Up@@ -180,6 +181,7 @@ export function registerCommands(program: Command) {
registerDeploy(program);
registerCreate(program);
registerEval(program);
registerFeedback(program);
registerFetch(program);
registerHelp(program);
registerImport(program);
Expand Down
163 changes: 163 additions & 0 deletions src/cli/commands/feedback/__tests__/command.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
import { TelemetryClient } from '../../../telemetry/client';
import { TelemetryClientAccessor } from '../../../telemetry/client-accessor';
import { InMemorySink } from '../../../telemetry/sinks/in-memory-sink';
import { registerFeedback } from '../command';
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockHandleFeedback = vi.fn();
const mockRender = vi.fn();
const mockRequireTTY = vi.fn();

vi.mock('../action', () => ({
handleFeedback: (...args: unknown[]) => mockHandleFeedback(...args),
}));

vi.mock('../../../tui/guards/tty', () => ({
requireTTY: () => mockRequireTTY(),
}));

vi.mock('../../../tui/screens/feedback', () => ({
Comment thread
Hweinstock marked this conversation as resolved.
FeedbackScreen: () => null,
}));

vi.mock('ink', () => ({
render: (...args: unknown[]) => {
mockRender(...args);
return {
clear: vi.fn(),
unmount: vi.fn(),
waitUntilExit: () => Promise.resolve(),
};
},
Text: 'Text',
Box: 'Box',
}));

const submittedOutcome = {
kind: 'submitted' as const,
result: { id: 'sub-1', timestamp: '2026-05-13T18:00:00Z', reference: 'S3' },
};

describe('registerFeedback', () => {
let program: Command;
let sink: InMemorySink;
let mockExit: ReturnType<typeof vi.spyOn>;
let mockLog: ReturnType<typeof vi.spyOn>;
let mockError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
program = new Command();
program.exitOverride();
registerFeedback(program);

sink = new InMemorySink();
vi.spyOn(TelemetryClientAccessor, 'get').mockResolvedValue(new TelemetryClient(sink));

mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
mockError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it('registers a top-level feedback command', () => {
const cmd = program.commands.find(c => c.name() === 'feedback');
expect(cmd).toBeDefined();
});

it('emits success JSON when --json is supplied with a message', async () => {
mockHandleFeedback.mockResolvedValue(submittedOutcome);

await expect(program.parseAsync(['feedback', 'looks good', '--json'], { from: 'user' })).rejects.toThrow(
'process.exit'
);

expect(mockHandleFeedback).toHaveBeenCalledWith('looks good', expect.objectContaining({ json: true }));
expect(mockExit).toHaveBeenCalledWith(0);
const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({
success: true,
id: 'sub-1',
timestamp: '2026-05-13T18:00:00Z',
reference: 'S3',
});

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'success',
mode: 'cli',
has_screenshot: 'false',
});
});

it('reports a TTY error when consent cannot be confirmed and exits 1', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'no-tty' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('consent must be confirmed interactively'));
expect(mockExit).toHaveBeenCalledWith(1);
});

it('prints a friendly cancellation message when the user declines consent', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'declined' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Feedback cancelled.'));
expect(mockExit).toHaveBeenCalledWith(0);
});

it('reports submission errors with exit 1 in plain mode', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockExit).toHaveBeenCalledWith(1);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining('HTTP 500'));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'failure',
mode: 'cli',
has_screenshot: 'false',
});
});

it('emits a JSON error envelope on submission failure when --json is set', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({ success: false, error: 'HTTP 500' });
expect(mockExit).toHaveBeenCalledWith(1);
});

it('refuses --json when no message is supplied', async () => {
await expect(program.parseAsync(['feedback', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('--json requires a feedback message'));
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockHandleFeedback).not.toHaveBeenCalled();
});

it('hands off to the TUI when no message argument is provided, then exits cleanly', async () => {
await expect(program.parseAsync(['feedback'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockRequireTTY).toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
expect(mockHandleFeedback).not.toHaveBeenCalled();
// After the wizard unmounts we must terminate the Node process; otherwise
// Ink's stdin raw-mode listeners keep the process alive.
expect(mockExit).toHaveBeenCalledWith(0);
});
});
52 changes: 52 additions & 0 deletions src/cli/commands/feedback/__tests__/consent-prompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
import { promptForConsent } from '../consent-prompt';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';

function makeTtyStdin(input: string): NodeJS.ReadableStream & { isTTY?: boolean } {
const stream = new PassThrough() as PassThrough & { isTTY?: boolean };
stream.isTTY = true;
stream.end(input);
return stream;
}

describe('promptForConsent', () => {
Comment thread
tejaskash marked this conversation as resolved.
it('returns no-tty when stdin is not a TTY', async () => {
const stdin = new PassThrough() as PassThrough & { isTTY?: boolean };
stdin.isTTY = false;
const stdout = new PassThrough();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'no-tty' });
});

it('accepts when the user types y', async () => {
const stdin = makeTtyStdin('y\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result.accepted).toBe(true);
});

it('declines on bare Enter (defaults to No)', async () => {
const stdin = makeTtyStdin('\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});

it('declines when the user types n', async () => {
const stdin = makeTtyStdin('n\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});
});
29 changes: 29 additions & 0 deletions src/cli/commands/feedback/action.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
import { toError } from '../../../lib/errors/types';
import { submitFeedback } from '../../operations/feedback';
import type { FeedbackSubmissionResult } from '../../operations/feedback';
import { promptForConsent } from './consent-prompt';
import type { FeedbackOptions } from './types';

export type FeedbackOutcome =
| { kind: 'submitted'; result: FeedbackSubmissionResult }
| { kind: 'declined' }
| { kind: 'no-tty' }
| { kind: 'error'; error: Error };

export async function handleFeedback(message: string, options: FeedbackOptions): Promise<FeedbackOutcome> {
const consent = await promptForConsent();
if (!consent.accepted) {
return consent.reason === 'no-tty' ? { kind: 'no-tty' } : { kind: 'declined' };
}

try {
const result = await submitFeedback({
message,
screenshot: options.screenshot ? { path: options.screenshot } : undefined,
mode: 'cli',
});
return { kind: 'submitted', result };
} catch (err) {
return { kind: 'error', error: toError(err) };
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,12 +135,13 @@ agentcore invoke

### Utilities

| Command | Description |
| -------------- | ----------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `update` | Check for and install CLI updates |
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `feedback` | Send feedback to the AgentCore team (with screenshot) |
| `update` | Check for and install CLI updates |

## Project Structure

Expand DownExpand Up@@ -191,10 +192,21 @@ Projects use JSON schema files in the `agentcore/` directory:
- [Gateway](docs/gateway.md) - Gateway setup, targets, and authentication
- [Memory](docs/memory.md) - Memory strategies and sharing
- [Local Development](docs/local-development.md) - Dev server and debugging
- [Feedback](docs/feedback.md) - Submit feedback from your terminal

## Feedback & Issues

Found a bug or have a feature request? [Open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub.
Have a quick comment or suggestion? Send it from your terminal:

```bash
agentcore feedback "your message" --screenshot path/to/screenshot.png
```

The CLI will display the AWS Customer Agreement and prompt for consent before submitting. See
[docs/feedback.md](docs/feedback.md) for usage details.

For bugs, regressions, or feature requests that need discussion,
[open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub instead.

## Security

Expand Down
19 changes: 19 additions & 0 deletions docs/commands.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -955,6 +955,25 @@ agentcore package -d ./my-project
| `-d, --directory <path>` | Project directory |
| `-r, --runtime <name>` | Package specific runtime |

### feedback

Send feedback about the AgentCore CLI. The CLI displays the AWS Customer Agreement and prompts for consent before
submitting; consent must be confirmed in an interactive terminal.

```bash
agentcore feedback "the dev server is slow on Linux"
agentcore feedback "broken icon" --screenshot ~/Desktop/bug.png
agentcore feedback "automation works" --json
agentcore feedback # launches the wizard
```

| Flag | Description |
| --------------------- | ------------------------------------------------------------ |
| `--screenshot <path>` | Path to a `.png`, `.jpg`, or `.jpeg` file (max 100MB) |
| `--json` | Print result as JSON (`{success, id, timestamp, reference}`) |

See [docs/feedback.md](feedback.md) for usage details.

### update

Check for and install CLI updates. Equivalent to `agentcore update cli`.
Expand Down
55 changes: 55 additions & 0 deletions docs/feedback.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
# Feedback

Send feedback about the AgentCore CLI directly from your terminal.

## When to use
Comment thread
Hweinstock marked this conversation as resolved.

| Use `agentcore feedback` | Use [GitHub Issues](https://github.com/aws/agentcore-cli/issues) instead |
| -------------------------------------- | ------------------------------------------------------------------------ |
| Quick comments, suggestions, papercuts | Bugs that need a conversation or repro steps |
| First impressions, onboarding friction | Regressions you want to track |
| Sharing a screenshot of confusing UX | Feature requests you want to discuss publicly |

## Syntax

```bash
# One-shot
agentcore feedback "your message" [--screenshot path/to/file.png] [--json]

# Multi-step wizard
agentcore feedback
```

The wizard walks through: message → optional screenshot → consent → submit. Press `Esc` to step back one phase.

## Screenshots

- Allowed types: `.png`, `.jpg`, `.jpeg`
- Maximum size: 100 MB

## Consent

Every submission requires interactive consent. The CLI displays:

> All feedback submissions, including any uploaded text and images, are subject to the AWS Customer Agreement
> (https://aws.amazon.com/agreement/). By submitting feedback, you agree that your submissions constitute "Suggestions"
> as defined in the AWS Customer Agreement.

Bare `Enter` defaults to **No**. The command refuses to submit when stdin is not a TTY (e.g. piped input, CI). There is
no flag that bypasses the prompt.

## What not to include

Do not paste credentials, secrets, account IDs, or customer data into the message, and do not attach screenshots that
show those values.

## Output

Plain mode prints a confirmation line on success. JSON mode (`--json`) prints a single line:

```json
{ "success": true, "id": "<uuid>", "timestamp": "<iso8601>", "reference": "<reference>" }
```

On failure the CLI exits with code 1 and prints a human-readable error, or `{"success": false, "error": "..."}` when
`--json` is set.
2 changes: 2 additions & 0 deletions src/cli/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { registerCreate } from './commands/create';
import { registerDeploy } from './commands/deploy';
import { registerDev } from './commands/dev';
import { registerEval } from './commands/eval';
import { registerFeedback } from './commands/feedback';
import { registerFetch } from './commands/fetch';
import { registerHelp } from './commands/help';
import { registerImport } from './commands/import';
Expand DownExpand Up@@ -180,6 +181,7 @@ export function registerCommands(program: Command) {
registerDeploy(program);
registerCreate(program);
registerEval(program);
registerFeedback(program);
registerFetch(program);
registerHelp(program);
registerImport(program);
Expand Down
163 changes: 163 additions & 0 deletions src/cli/commands/feedback/__tests__/command.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
import { TelemetryClient } from '../../../telemetry/client';
import { TelemetryClientAccessor } from '../../../telemetry/client-accessor';
import { InMemorySink } from '../../../telemetry/sinks/in-memory-sink';
import { registerFeedback } from '../command';
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockHandleFeedback = vi.fn();
const mockRender = vi.fn();
const mockRequireTTY = vi.fn();

vi.mock('../action', () => ({
handleFeedback: (...args: unknown[]) => mockHandleFeedback(...args),
}));

vi.mock('../../../tui/guards/tty', () => ({
requireTTY: () => mockRequireTTY(),
}));

vi.mock('../../../tui/screens/feedback', () => ({
Comment thread
Hweinstock marked this conversation as resolved.
FeedbackScreen: () => null,
}));

vi.mock('ink', () => ({
render: (...args: unknown[]) => {
mockRender(...args);
return {
clear: vi.fn(),
unmount: vi.fn(),
waitUntilExit: () => Promise.resolve(),
};
},
Text: 'Text',
Box: 'Box',
}));

const submittedOutcome = {
kind: 'submitted' as const,
result: { id: 'sub-1', timestamp: '2026-05-13T18:00:00Z', reference: 'S3' },
};

describe('registerFeedback', () => {
let program: Command;
let sink: InMemorySink;
let mockExit: ReturnType<typeof vi.spyOn>;
let mockLog: ReturnType<typeof vi.spyOn>;
let mockError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
program = new Command();
program.exitOverride();
registerFeedback(program);

sink = new InMemorySink();
vi.spyOn(TelemetryClientAccessor, 'get').mockResolvedValue(new TelemetryClient(sink));

mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
mockError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it('registers a top-level feedback command', () => {
const cmd = program.commands.find(c => c.name() === 'feedback');
expect(cmd).toBeDefined();
});

it('emits success JSON when --json is supplied with a message', async () => {
mockHandleFeedback.mockResolvedValue(submittedOutcome);

await expect(program.parseAsync(['feedback', 'looks good', '--json'], { from: 'user' })).rejects.toThrow(
'process.exit'
);

expect(mockHandleFeedback).toHaveBeenCalledWith('looks good', expect.objectContaining({ json: true }));
expect(mockExit).toHaveBeenCalledWith(0);
const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({
success: true,
id: 'sub-1',
timestamp: '2026-05-13T18:00:00Z',
reference: 'S3',
});

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'success',
mode: 'cli',
has_screenshot: 'false',
});
});

it('reports a TTY error when consent cannot be confirmed and exits 1', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'no-tty' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('consent must be confirmed interactively'));
expect(mockExit).toHaveBeenCalledWith(1);
});

it('prints a friendly cancellation message when the user declines consent', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'declined' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Feedback cancelled.'));
expect(mockExit).toHaveBeenCalledWith(0);
});

it('reports submission errors with exit 1 in plain mode', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockExit).toHaveBeenCalledWith(1);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining('HTTP 500'));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'failure',
mode: 'cli',
has_screenshot: 'false',
});
});

it('emits a JSON error envelope on submission failure when --json is set', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({ success: false, error: 'HTTP 500' });
expect(mockExit).toHaveBeenCalledWith(1);
});

it('refuses --json when no message is supplied', async () => {
await expect(program.parseAsync(['feedback', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('--json requires a feedback message'));
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockHandleFeedback).not.toHaveBeenCalled();
});

it('hands off to the TUI when no message argument is provided, then exits cleanly', async () => {
await expect(program.parseAsync(['feedback'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockRequireTTY).toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
expect(mockHandleFeedback).not.toHaveBeenCalled();
// After the wizard unmounts we must terminate the Node process; otherwise
// Ink's stdin raw-mode listeners keep the process alive.
expect(mockExit).toHaveBeenCalledWith(0);
});
});
52 changes: 52 additions & 0 deletions src/cli/commands/feedback/__tests__/consent-prompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
import { promptForConsent } from '../consent-prompt';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';

function makeTtyStdin(input: string): NodeJS.ReadableStream & { isTTY?: boolean } {
const stream = new PassThrough() as PassThrough & { isTTY?: boolean };
stream.isTTY = true;
stream.end(input);
return stream;
}

describe('promptForConsent', () => {
Comment thread
tejaskash marked this conversation as resolved.
it('returns no-tty when stdin is not a TTY', async () => {
const stdin = new PassThrough() as PassThrough & { isTTY?: boolean };
stdin.isTTY = false;
const stdout = new PassThrough();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'no-tty' });
});

it('accepts when the user types y', async () => {
const stdin = makeTtyStdin('y\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result.accepted).toBe(true);
});

it('declines on bare Enter (defaults to No)', async () => {
const stdin = makeTtyStdin('\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});

it('declines when the user types n', async () => {
const stdin = makeTtyStdin('n\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});
});
29 changes: 29 additions & 0 deletions src/cli/commands/feedback/action.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
import { toError } from '../../../lib/errors/types';
import { submitFeedback } from '../../operations/feedback';
import type { FeedbackSubmissionResult } from '../../operations/feedback';
import { promptForConsent } from './consent-prompt';
import type { FeedbackOptions } from './types';

export type FeedbackOutcome =
| { kind: 'submitted'; result: FeedbackSubmissionResult }
| { kind: 'declined' }
| { kind: 'no-tty' }
| { kind: 'error'; error: Error };

export async function handleFeedback(message: string, options: FeedbackOptions): Promise<FeedbackOutcome> {
const consent = await promptForConsent();
if (!consent.accepted) {
return consent.reason === 'no-tty' ? { kind: 'no-tty' } : { kind: 'declined' };
}

try {
const result = await submitFeedback({
message,
screenshot: options.screenshot ? { path: options.screenshot } : undefined,
mode: 'cli',
});
return { kind: 'submitted', result };
} catch (err) {
return { kind: 'error', error: toError(err) };
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,12 +135,13 @@ agentcore invoke

### Utilities

| Command | Description |
| -------------- | ----------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `update` | Check for and install CLI updates |
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `feedback` | Send feedback to the AgentCore team (with screenshot) |
| `update` | Check for and install CLI updates |

## Project Structure

Expand DownExpand Up@@ -191,10 +192,21 @@ Projects use JSON schema files in the `agentcore/` directory:
- [Gateway](docs/gateway.md) - Gateway setup, targets, and authentication
- [Memory](docs/memory.md) - Memory strategies and sharing
- [Local Development](docs/local-development.md) - Dev server and debugging
- [Feedback](docs/feedback.md) - Submit feedback from your terminal

## Feedback & Issues

Found a bug or have a feature request? [Open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub.
Have a quick comment or suggestion? Send it from your terminal:

```bash
agentcore feedback "your message" --screenshot path/to/screenshot.png
```

The CLI will display the AWS Customer Agreement and prompt for consent before submitting. See
[docs/feedback.md](docs/feedback.md) for usage details.

For bugs, regressions, or feature requests that need discussion,
[open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub instead.

## Security

Expand Down
19 changes: 19 additions & 0 deletions docs/commands.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -955,6 +955,25 @@ agentcore package -d ./my-project
| `-d, --directory <path>` | Project directory |
| `-r, --runtime <name>` | Package specific runtime |

### feedback

Send feedback about the AgentCore CLI. The CLI displays the AWS Customer Agreement and prompts for consent before
submitting; consent must be confirmed in an interactive terminal.

```bash
agentcore feedback "the dev server is slow on Linux"
agentcore feedback "broken icon" --screenshot ~/Desktop/bug.png
agentcore feedback "automation works" --json
agentcore feedback # launches the wizard
```

| Flag | Description |
| --------------------- | ------------------------------------------------------------ |
| `--screenshot <path>` | Path to a `.png`, `.jpg`, or `.jpeg` file (max 100MB) |
| `--json` | Print result as JSON (`{success, id, timestamp, reference}`) |

See [docs/feedback.md](feedback.md) for usage details.

### update

Check for and install CLI updates. Equivalent to `agentcore update cli`.
Expand Down
55 changes: 55 additions & 0 deletions docs/feedback.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
# Feedback

Send feedback about the AgentCore CLI directly from your terminal.

## When to use
Comment thread
Hweinstock marked this conversation as resolved.

| Use `agentcore feedback` | Use [GitHub Issues](https://github.com/aws/agentcore-cli/issues) instead |
| -------------------------------------- | ------------------------------------------------------------------------ |
| Quick comments, suggestions, papercuts | Bugs that need a conversation or repro steps |
| First impressions, onboarding friction | Regressions you want to track |
| Sharing a screenshot of confusing UX | Feature requests you want to discuss publicly |

## Syntax

```bash
# One-shot
agentcore feedback "your message" [--screenshot path/to/file.png] [--json]

# Multi-step wizard
agentcore feedback
```

The wizard walks through: message → optional screenshot → consent → submit. Press `Esc` to step back one phase.

## Screenshots

- Allowed types: `.png`, `.jpg`, `.jpeg`
- Maximum size: 100 MB

## Consent

Every submission requires interactive consent. The CLI displays:

> All feedback submissions, including any uploaded text and images, are subject to the AWS Customer Agreement
> (https://aws.amazon.com/agreement/). By submitting feedback, you agree that your submissions constitute "Suggestions"
> as defined in the AWS Customer Agreement.

Bare `Enter` defaults to **No**. The command refuses to submit when stdin is not a TTY (e.g. piped input, CI). There is
no flag that bypasses the prompt.

## What not to include

Do not paste credentials, secrets, account IDs, or customer data into the message, and do not attach screenshots that
show those values.

## Output

Plain mode prints a confirmation line on success. JSON mode (`--json`) prints a single line:

```json
{ "success": true, "id": "<uuid>", "timestamp": "<iso8601>", "reference": "<reference>" }
```

On failure the CLI exits with code 1 and prints a human-readable error, or `{"success": false, "error": "..."}` when
`--json` is set.
2 changes: 2 additions & 0 deletions src/cli/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { registerCreate } from './commands/create';
import { registerDeploy } from './commands/deploy';
import { registerDev } from './commands/dev';
import { registerEval } from './commands/eval';
import { registerFeedback } from './commands/feedback';
import { registerFetch } from './commands/fetch';
import { registerHelp } from './commands/help';
import { registerImport } from './commands/import';
Expand DownExpand Up@@ -180,6 +181,7 @@ export function registerCommands(program: Command) {
registerDeploy(program);
registerCreate(program);
registerEval(program);
registerFeedback(program);
registerFetch(program);
registerHelp(program);
registerImport(program);
Expand Down
163 changes: 163 additions & 0 deletions src/cli/commands/feedback/__tests__/command.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
import { TelemetryClient } from '../../../telemetry/client';
import { TelemetryClientAccessor } from '../../../telemetry/client-accessor';
import { InMemorySink } from '../../../telemetry/sinks/in-memory-sink';
import { registerFeedback } from '../command';
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockHandleFeedback = vi.fn();
const mockRender = vi.fn();
const mockRequireTTY = vi.fn();

vi.mock('../action', () => ({
handleFeedback: (...args: unknown[]) => mockHandleFeedback(...args),
}));

vi.mock('../../../tui/guards/tty', () => ({
requireTTY: () => mockRequireTTY(),
}));

vi.mock('../../../tui/screens/feedback', () => ({
Comment thread
Hweinstock marked this conversation as resolved.
FeedbackScreen: () => null,
}));

vi.mock('ink', () => ({
render: (...args: unknown[]) => {
mockRender(...args);
return {
clear: vi.fn(),
unmount: vi.fn(),
waitUntilExit: () => Promise.resolve(),
};
},
Text: 'Text',
Box: 'Box',
}));

const submittedOutcome = {
kind: 'submitted' as const,
result: { id: 'sub-1', timestamp: '2026-05-13T18:00:00Z', reference: 'S3' },
};

describe('registerFeedback', () => {
let program: Command;
let sink: InMemorySink;
let mockExit: ReturnType<typeof vi.spyOn>;
let mockLog: ReturnType<typeof vi.spyOn>;
let mockError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
program = new Command();
program.exitOverride();
registerFeedback(program);

sink = new InMemorySink();
vi.spyOn(TelemetryClientAccessor, 'get').mockResolvedValue(new TelemetryClient(sink));

mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
mockError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it('registers a top-level feedback command', () => {
const cmd = program.commands.find(c => c.name() === 'feedback');
expect(cmd).toBeDefined();
});

it('emits success JSON when --json is supplied with a message', async () => {
mockHandleFeedback.mockResolvedValue(submittedOutcome);

await expect(program.parseAsync(['feedback', 'looks good', '--json'], { from: 'user' })).rejects.toThrow(
'process.exit'
);

expect(mockHandleFeedback).toHaveBeenCalledWith('looks good', expect.objectContaining({ json: true }));
expect(mockExit).toHaveBeenCalledWith(0);
const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({
success: true,
id: 'sub-1',
timestamp: '2026-05-13T18:00:00Z',
reference: 'S3',
});

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'success',
mode: 'cli',
has_screenshot: 'false',
});
});

it('reports a TTY error when consent cannot be confirmed and exits 1', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'no-tty' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('consent must be confirmed interactively'));
expect(mockExit).toHaveBeenCalledWith(1);
});

it('prints a friendly cancellation message when the user declines consent', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'declined' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Feedback cancelled.'));
expect(mockExit).toHaveBeenCalledWith(0);
});

it('reports submission errors with exit 1 in plain mode', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockExit).toHaveBeenCalledWith(1);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining('HTTP 500'));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'failure',
mode: 'cli',
has_screenshot: 'false',
});
});

it('emits a JSON error envelope on submission failure when --json is set', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({ success: false, error: 'HTTP 500' });
expect(mockExit).toHaveBeenCalledWith(1);
});

it('refuses --json when no message is supplied', async () => {
await expect(program.parseAsync(['feedback', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('--json requires a feedback message'));
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockHandleFeedback).not.toHaveBeenCalled();
});

it('hands off to the TUI when no message argument is provided, then exits cleanly', async () => {
await expect(program.parseAsync(['feedback'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockRequireTTY).toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
expect(mockHandleFeedback).not.toHaveBeenCalled();
// After the wizard unmounts we must terminate the Node process; otherwise
// Ink's stdin raw-mode listeners keep the process alive.
expect(mockExit).toHaveBeenCalledWith(0);
});
});
52 changes: 52 additions & 0 deletions src/cli/commands/feedback/__tests__/consent-prompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
import { promptForConsent } from '../consent-prompt';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';

function makeTtyStdin(input: string): NodeJS.ReadableStream & { isTTY?: boolean } {
const stream = new PassThrough() as PassThrough & { isTTY?: boolean };
stream.isTTY = true;
stream.end(input);
return stream;
}

describe('promptForConsent', () => {
Comment thread
tejaskash marked this conversation as resolved.
it('returns no-tty when stdin is not a TTY', async () => {
const stdin = new PassThrough() as PassThrough & { isTTY?: boolean };
stdin.isTTY = false;
const stdout = new PassThrough();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'no-tty' });
});

it('accepts when the user types y', async () => {
const stdin = makeTtyStdin('y\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result.accepted).toBe(true);
});

it('declines on bare Enter (defaults to No)', async () => {
const stdin = makeTtyStdin('\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});

it('declines when the user types n', async () => {
const stdin = makeTtyStdin('n\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});
});
29 changes: 29 additions & 0 deletions src/cli/commands/feedback/action.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
import { toError } from '../../../lib/errors/types';
import { submitFeedback } from '../../operations/feedback';
import type { FeedbackSubmissionResult } from '../../operations/feedback';
import { promptForConsent } from './consent-prompt';
import type { FeedbackOptions } from './types';

export type FeedbackOutcome =
| { kind: 'submitted'; result: FeedbackSubmissionResult }
| { kind: 'declined' }
| { kind: 'no-tty' }
| { kind: 'error'; error: Error };

export async function handleFeedback(message: string, options: FeedbackOptions): Promise<FeedbackOutcome> {
const consent = await promptForConsent();
if (!consent.accepted) {
return consent.reason === 'no-tty' ? { kind: 'no-tty' } : { kind: 'declined' };
}

try {
const result = await submitFeedback({
message,
screenshot: options.screenshot ? { path: options.screenshot } : undefined,
mode: 'cli',
});
return { kind: 'submitted', result };
} catch (err) {
return { kind: 'error', error: toError(err) };
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,12 +135,13 @@ agentcore invoke

### Utilities

| Command | Description |
| -------------- | ----------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `update` | Check for and install CLI updates |
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `feedback` | Send feedback to the AgentCore team (with screenshot) |
| `update` | Check for and install CLI updates |

## Project Structure

Expand DownExpand Up@@ -191,10 +192,21 @@ Projects use JSON schema files in the `agentcore/` directory:
- [Gateway](docs/gateway.md) - Gateway setup, targets, and authentication
- [Memory](docs/memory.md) - Memory strategies and sharing
- [Local Development](docs/local-development.md) - Dev server and debugging
- [Feedback](docs/feedback.md) - Submit feedback from your terminal

## Feedback & Issues

Found a bug or have a feature request? [Open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub.
Have a quick comment or suggestion? Send it from your terminal:

```bash
agentcore feedback "your message" --screenshot path/to/screenshot.png
```

The CLI will display the AWS Customer Agreement and prompt for consent before submitting. See
[docs/feedback.md](docs/feedback.md) for usage details.

For bugs, regressions, or feature requests that need discussion,
[open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub instead.

## Security

Expand Down
19 changes: 19 additions & 0 deletions docs/commands.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -955,6 +955,25 @@ agentcore package -d ./my-project
| `-d, --directory <path>` | Project directory |
| `-r, --runtime <name>` | Package specific runtime |

### feedback

Send feedback about the AgentCore CLI. The CLI displays the AWS Customer Agreement and prompts for consent before
submitting; consent must be confirmed in an interactive terminal.

```bash
agentcore feedback "the dev server is slow on Linux"
agentcore feedback "broken icon" --screenshot ~/Desktop/bug.png
agentcore feedback "automation works" --json
agentcore feedback # launches the wizard
```

| Flag | Description |
| --------------------- | ------------------------------------------------------------ |
| `--screenshot <path>` | Path to a `.png`, `.jpg`, or `.jpeg` file (max 100MB) |
| `--json` | Print result as JSON (`{success, id, timestamp, reference}`) |

See [docs/feedback.md](feedback.md) for usage details.

### update

Check for and install CLI updates. Equivalent to `agentcore update cli`.
Expand Down
55 changes: 55 additions & 0 deletions docs/feedback.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
# Feedback

Send feedback about the AgentCore CLI directly from your terminal.

## When to use
Comment thread
Hweinstock marked this conversation as resolved.

| Use `agentcore feedback` | Use [GitHub Issues](https://github.com/aws/agentcore-cli/issues) instead |
| -------------------------------------- | ------------------------------------------------------------------------ |
| Quick comments, suggestions, papercuts | Bugs that need a conversation or repro steps |
| First impressions, onboarding friction | Regressions you want to track |
| Sharing a screenshot of confusing UX | Feature requests you want to discuss publicly |

## Syntax

```bash
# One-shot
agentcore feedback "your message" [--screenshot path/to/file.png] [--json]

# Multi-step wizard
agentcore feedback
```

The wizard walks through: message → optional screenshot → consent → submit. Press `Esc` to step back one phase.

## Screenshots

- Allowed types: `.png`, `.jpg`, `.jpeg`
- Maximum size: 100 MB

## Consent

Every submission requires interactive consent. The CLI displays:

> All feedback submissions, including any uploaded text and images, are subject to the AWS Customer Agreement
> (https://aws.amazon.com/agreement/). By submitting feedback, you agree that your submissions constitute "Suggestions"
> as defined in the AWS Customer Agreement.

Bare `Enter` defaults to **No**. The command refuses to submit when stdin is not a TTY (e.g. piped input, CI). There is
no flag that bypasses the prompt.

## What not to include

Do not paste credentials, secrets, account IDs, or customer data into the message, and do not attach screenshots that
show those values.

## Output

Plain mode prints a confirmation line on success. JSON mode (`--json`) prints a single line:

```json
{ "success": true, "id": "<uuid>", "timestamp": "<iso8601>", "reference": "<reference>" }
```

On failure the CLI exits with code 1 and prints a human-readable error, or `{"success": false, "error": "..."}` when
`--json` is set.
2 changes: 2 additions & 0 deletions src/cli/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { registerCreate } from './commands/create';
import { registerDeploy } from './commands/deploy';
import { registerDev } from './commands/dev';
import { registerEval } from './commands/eval';
import { registerFeedback } from './commands/feedback';
import { registerFetch } from './commands/fetch';
import { registerHelp } from './commands/help';
import { registerImport } from './commands/import';
Expand DownExpand Up@@ -180,6 +181,7 @@ export function registerCommands(program: Command) {
registerDeploy(program);
registerCreate(program);
registerEval(program);
registerFeedback(program);
registerFetch(program);
registerHelp(program);
registerImport(program);
Expand Down
163 changes: 163 additions & 0 deletions src/cli/commands/feedback/__tests__/command.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
import { TelemetryClient } from '../../../telemetry/client';
import { TelemetryClientAccessor } from '../../../telemetry/client-accessor';
import { InMemorySink } from '../../../telemetry/sinks/in-memory-sink';
import { registerFeedback } from '../command';
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockHandleFeedback = vi.fn();
const mockRender = vi.fn();
const mockRequireTTY = vi.fn();

vi.mock('../action', () => ({
handleFeedback: (...args: unknown[]) => mockHandleFeedback(...args),
}));

vi.mock('../../../tui/guards/tty', () => ({
requireTTY: () => mockRequireTTY(),
}));

vi.mock('../../../tui/screens/feedback', () => ({
Comment thread
Hweinstock marked this conversation as resolved.
FeedbackScreen: () => null,
}));

vi.mock('ink', () => ({
render: (...args: unknown[]) => {
mockRender(...args);
return {
clear: vi.fn(),
unmount: vi.fn(),
waitUntilExit: () => Promise.resolve(),
};
},
Text: 'Text',
Box: 'Box',
}));

const submittedOutcome = {
kind: 'submitted' as const,
result: { id: 'sub-1', timestamp: '2026-05-13T18:00:00Z', reference: 'S3' },
};

describe('registerFeedback', () => {
let program: Command;
let sink: InMemorySink;
let mockExit: ReturnType<typeof vi.spyOn>;
let mockLog: ReturnType<typeof vi.spyOn>;
let mockError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
program = new Command();
program.exitOverride();
registerFeedback(program);

sink = new InMemorySink();
vi.spyOn(TelemetryClientAccessor, 'get').mockResolvedValue(new TelemetryClient(sink));

mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
mockError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it('registers a top-level feedback command', () => {
const cmd = program.commands.find(c => c.name() === 'feedback');
expect(cmd).toBeDefined();
});

it('emits success JSON when --json is supplied with a message', async () => {
mockHandleFeedback.mockResolvedValue(submittedOutcome);

await expect(program.parseAsync(['feedback', 'looks good', '--json'], { from: 'user' })).rejects.toThrow(
'process.exit'
);

expect(mockHandleFeedback).toHaveBeenCalledWith('looks good', expect.objectContaining({ json: true }));
expect(mockExit).toHaveBeenCalledWith(0);
const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({
success: true,
id: 'sub-1',
timestamp: '2026-05-13T18:00:00Z',
reference: 'S3',
});

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'success',
mode: 'cli',
has_screenshot: 'false',
});
});

it('reports a TTY error when consent cannot be confirmed and exits 1', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'no-tty' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('consent must be confirmed interactively'));
expect(mockExit).toHaveBeenCalledWith(1);
});

it('prints a friendly cancellation message when the user declines consent', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'declined' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Feedback cancelled.'));
expect(mockExit).toHaveBeenCalledWith(0);
});

it('reports submission errors with exit 1 in plain mode', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockExit).toHaveBeenCalledWith(1);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining('HTTP 500'));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'failure',
mode: 'cli',
has_screenshot: 'false',
});
});

it('emits a JSON error envelope on submission failure when --json is set', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({ success: false, error: 'HTTP 500' });
expect(mockExit).toHaveBeenCalledWith(1);
});

it('refuses --json when no message is supplied', async () => {
await expect(program.parseAsync(['feedback', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('--json requires a feedback message'));
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockHandleFeedback).not.toHaveBeenCalled();
});

it('hands off to the TUI when no message argument is provided, then exits cleanly', async () => {
await expect(program.parseAsync(['feedback'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockRequireTTY).toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
expect(mockHandleFeedback).not.toHaveBeenCalled();
// After the wizard unmounts we must terminate the Node process; otherwise
// Ink's stdin raw-mode listeners keep the process alive.
expect(mockExit).toHaveBeenCalledWith(0);
});
});
52 changes: 52 additions & 0 deletions src/cli/commands/feedback/__tests__/consent-prompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
import { promptForConsent } from '../consent-prompt';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';

function makeTtyStdin(input: string): NodeJS.ReadableStream & { isTTY?: boolean } {
const stream = new PassThrough() as PassThrough & { isTTY?: boolean };
stream.isTTY = true;
stream.end(input);
return stream;
}

describe('promptForConsent', () => {
Comment thread
tejaskash marked this conversation as resolved.
it('returns no-tty when stdin is not a TTY', async () => {
const stdin = new PassThrough() as PassThrough & { isTTY?: boolean };
stdin.isTTY = false;
const stdout = new PassThrough();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'no-tty' });
});

it('accepts when the user types y', async () => {
const stdin = makeTtyStdin('y\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result.accepted).toBe(true);
});

it('declines on bare Enter (defaults to No)', async () => {
const stdin = makeTtyStdin('\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});

it('declines when the user types n', async () => {
const stdin = makeTtyStdin('n\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});
});
29 changes: 29 additions & 0 deletions src/cli/commands/feedback/action.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
import { toError } from '../../../lib/errors/types';
import { submitFeedback } from '../../operations/feedback';
import type { FeedbackSubmissionResult } from '../../operations/feedback';
import { promptForConsent } from './consent-prompt';
import type { FeedbackOptions } from './types';

export type FeedbackOutcome =
| { kind: 'submitted'; result: FeedbackSubmissionResult }
| { kind: 'declined' }
| { kind: 'no-tty' }
| { kind: 'error'; error: Error };

export async function handleFeedback(message: string, options: FeedbackOptions): Promise<FeedbackOutcome> {
const consent = await promptForConsent();
if (!consent.accepted) {
return consent.reason === 'no-tty' ? { kind: 'no-tty' } : { kind: 'declined' };
}

try {
const result = await submitFeedback({
message,
screenshot: options.screenshot ? { path: options.screenshot } : undefined,
mode: 'cli',
});
return { kind: 'submitted', result };
} catch (err) {
return { kind: 'error', error: toError(err) };
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,12 +135,13 @@ agentcore invoke

### Utilities

| Command | Description |
| -------------- | ----------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `update` | Check for and install CLI updates |
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `feedback` | Send feedback to the AgentCore team (with screenshot) |
| `update` | Check for and install CLI updates |

## Project Structure

Expand DownExpand Up@@ -191,10 +192,21 @@ Projects use JSON schema files in the `agentcore/` directory:
- [Gateway](docs/gateway.md) - Gateway setup, targets, and authentication
- [Memory](docs/memory.md) - Memory strategies and sharing
- [Local Development](docs/local-development.md) - Dev server and debugging
- [Feedback](docs/feedback.md) - Submit feedback from your terminal

## Feedback & Issues

Found a bug or have a feature request? [Open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub.
Have a quick comment or suggestion? Send it from your terminal:

```bash
agentcore feedback "your message" --screenshot path/to/screenshot.png
```

The CLI will display the AWS Customer Agreement and prompt for consent before submitting. See
[docs/feedback.md](docs/feedback.md) for usage details.

For bugs, regressions, or feature requests that need discussion,
[open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub instead.

## Security

Expand Down
19 changes: 19 additions & 0 deletions docs/commands.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -955,6 +955,25 @@ agentcore package -d ./my-project
| `-d, --directory <path>` | Project directory |
| `-r, --runtime <name>` | Package specific runtime |

### feedback

Send feedback about the AgentCore CLI. The CLI displays the AWS Customer Agreement and prompts for consent before
submitting; consent must be confirmed in an interactive terminal.

```bash
agentcore feedback "the dev server is slow on Linux"
agentcore feedback "broken icon" --screenshot ~/Desktop/bug.png
agentcore feedback "automation works" --json
agentcore feedback # launches the wizard
```

| Flag | Description |
| --------------------- | ------------------------------------------------------------ |
| `--screenshot <path>` | Path to a `.png`, `.jpg`, or `.jpeg` file (max 100MB) |
| `--json` | Print result as JSON (`{success, id, timestamp, reference}`) |

See [docs/feedback.md](feedback.md) for usage details.

### update

Check for and install CLI updates. Equivalent to `agentcore update cli`.
Expand Down
55 changes: 55 additions & 0 deletions docs/feedback.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
# Feedback

Send feedback about the AgentCore CLI directly from your terminal.

## When to use
Comment thread
Hweinstock marked this conversation as resolved.

| Use `agentcore feedback` | Use [GitHub Issues](https://github.com/aws/agentcore-cli/issues) instead |
| -------------------------------------- | ------------------------------------------------------------------------ |
| Quick comments, suggestions, papercuts | Bugs that need a conversation or repro steps |
| First impressions, onboarding friction | Regressions you want to track |
| Sharing a screenshot of confusing UX | Feature requests you want to discuss publicly |

## Syntax

```bash
# One-shot
agentcore feedback "your message" [--screenshot path/to/file.png] [--json]

# Multi-step wizard
agentcore feedback
```

The wizard walks through: message → optional screenshot → consent → submit. Press `Esc` to step back one phase.

## Screenshots

- Allowed types: `.png`, `.jpg`, `.jpeg`
- Maximum size: 100 MB

## Consent

Every submission requires interactive consent. The CLI displays:

> All feedback submissions, including any uploaded text and images, are subject to the AWS Customer Agreement
> (https://aws.amazon.com/agreement/). By submitting feedback, you agree that your submissions constitute "Suggestions"
> as defined in the AWS Customer Agreement.

Bare `Enter` defaults to **No**. The command refuses to submit when stdin is not a TTY (e.g. piped input, CI). There is
no flag that bypasses the prompt.

## What not to include

Do not paste credentials, secrets, account IDs, or customer data into the message, and do not attach screenshots that
show those values.

## Output

Plain mode prints a confirmation line on success. JSON mode (`--json`) prints a single line:

```json
{ "success": true, "id": "<uuid>", "timestamp": "<iso8601>", "reference": "<reference>" }
```

On failure the CLI exits with code 1 and prints a human-readable error, or `{"success": false, "error": "..."}` when
`--json` is set.
2 changes: 2 additions & 0 deletions src/cli/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { registerCreate } from './commands/create';
import { registerDeploy } from './commands/deploy';
import { registerDev } from './commands/dev';
import { registerEval } from './commands/eval';
import { registerFeedback } from './commands/feedback';
import { registerFetch } from './commands/fetch';
import { registerHelp } from './commands/help';
import { registerImport } from './commands/import';
Expand DownExpand Up@@ -180,6 +181,7 @@ export function registerCommands(program: Command) {
registerDeploy(program);
registerCreate(program);
registerEval(program);
registerFeedback(program);
registerFetch(program);
registerHelp(program);
registerImport(program);
Expand Down
163 changes: 163 additions & 0 deletions src/cli/commands/feedback/__tests__/command.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
import { TelemetryClient } from '../../../telemetry/client';
import { TelemetryClientAccessor } from '../../../telemetry/client-accessor';
import { InMemorySink } from '../../../telemetry/sinks/in-memory-sink';
import { registerFeedback } from '../command';
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockHandleFeedback = vi.fn();
const mockRender = vi.fn();
const mockRequireTTY = vi.fn();

vi.mock('../action', () => ({
handleFeedback: (...args: unknown[]) => mockHandleFeedback(...args),
}));

vi.mock('../../../tui/guards/tty', () => ({
requireTTY: () => mockRequireTTY(),
}));

vi.mock('../../../tui/screens/feedback', () => ({
Comment thread
Hweinstock marked this conversation as resolved.
FeedbackScreen: () => null,
}));

vi.mock('ink', () => ({
render: (...args: unknown[]) => {
mockRender(...args);
return {
clear: vi.fn(),
unmount: vi.fn(),
waitUntilExit: () => Promise.resolve(),
};
},
Text: 'Text',
Box: 'Box',
}));

const submittedOutcome = {
kind: 'submitted' as const,
result: { id: 'sub-1', timestamp: '2026-05-13T18:00:00Z', reference: 'S3' },
};

describe('registerFeedback', () => {
let program: Command;
let sink: InMemorySink;
let mockExit: ReturnType<typeof vi.spyOn>;
let mockLog: ReturnType<typeof vi.spyOn>;
let mockError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
program = new Command();
program.exitOverride();
registerFeedback(program);

sink = new InMemorySink();
vi.spyOn(TelemetryClientAccessor, 'get').mockResolvedValue(new TelemetryClient(sink));

mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
mockError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it('registers a top-level feedback command', () => {
const cmd = program.commands.find(c => c.name() === 'feedback');
expect(cmd).toBeDefined();
});

it('emits success JSON when --json is supplied with a message', async () => {
mockHandleFeedback.mockResolvedValue(submittedOutcome);

await expect(program.parseAsync(['feedback', 'looks good', '--json'], { from: 'user' })).rejects.toThrow(
'process.exit'
);

expect(mockHandleFeedback).toHaveBeenCalledWith('looks good', expect.objectContaining({ json: true }));
expect(mockExit).toHaveBeenCalledWith(0);
const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({
success: true,
id: 'sub-1',
timestamp: '2026-05-13T18:00:00Z',
reference: 'S3',
});

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'success',
mode: 'cli',
has_screenshot: 'false',
});
});

it('reports a TTY error when consent cannot be confirmed and exits 1', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'no-tty' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('consent must be confirmed interactively'));
expect(mockExit).toHaveBeenCalledWith(1);
});

it('prints a friendly cancellation message when the user declines consent', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'declined' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Feedback cancelled.'));
expect(mockExit).toHaveBeenCalledWith(0);
});

it('reports submission errors with exit 1 in plain mode', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockExit).toHaveBeenCalledWith(1);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining('HTTP 500'));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'failure',
mode: 'cli',
has_screenshot: 'false',
});
});

it('emits a JSON error envelope on submission failure when --json is set', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({ success: false, error: 'HTTP 500' });
expect(mockExit).toHaveBeenCalledWith(1);
});

it('refuses --json when no message is supplied', async () => {
await expect(program.parseAsync(['feedback', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('--json requires a feedback message'));
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockHandleFeedback).not.toHaveBeenCalled();
});

it('hands off to the TUI when no message argument is provided, then exits cleanly', async () => {
await expect(program.parseAsync(['feedback'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockRequireTTY).toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
expect(mockHandleFeedback).not.toHaveBeenCalled();
// After the wizard unmounts we must terminate the Node process; otherwise
// Ink's stdin raw-mode listeners keep the process alive.
expect(mockExit).toHaveBeenCalledWith(0);
});
});
52 changes: 52 additions & 0 deletions src/cli/commands/feedback/__tests__/consent-prompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
import { promptForConsent } from '../consent-prompt';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';

function makeTtyStdin(input: string): NodeJS.ReadableStream & { isTTY?: boolean } {
const stream = new PassThrough() as PassThrough & { isTTY?: boolean };
stream.isTTY = true;
stream.end(input);
return stream;
}

describe('promptForConsent', () => {
Comment thread
tejaskash marked this conversation as resolved.
it('returns no-tty when stdin is not a TTY', async () => {
const stdin = new PassThrough() as PassThrough & { isTTY?: boolean };
stdin.isTTY = false;
const stdout = new PassThrough();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'no-tty' });
});

it('accepts when the user types y', async () => {
const stdin = makeTtyStdin('y\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result.accepted).toBe(true);
});

it('declines on bare Enter (defaults to No)', async () => {
const stdin = makeTtyStdin('\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});

it('declines when the user types n', async () => {
const stdin = makeTtyStdin('n\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});
});
29 changes: 29 additions & 0 deletions src/cli/commands/feedback/action.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
import { toError } from '../../../lib/errors/types';
import { submitFeedback } from '../../operations/feedback';
import type { FeedbackSubmissionResult } from '../../operations/feedback';
import { promptForConsent } from './consent-prompt';
import type { FeedbackOptions } from './types';

export type FeedbackOutcome =
| { kind: 'submitted'; result: FeedbackSubmissionResult }
| { kind: 'declined' }
| { kind: 'no-tty' }
| { kind: 'error'; error: Error };

export async function handleFeedback(message: string, options: FeedbackOptions): Promise<FeedbackOutcome> {
const consent = await promptForConsent();
if (!consent.accepted) {
return consent.reason === 'no-tty' ? { kind: 'no-tty' } : { kind: 'declined' };
}

try {
const result = await submitFeedback({
message,
screenshot: options.screenshot ? { path: options.screenshot } : undefined,
mode: 'cli',
});
return { kind: 'submitted', result };
} catch (err) {
return { kind: 'error', error: toError(err) };
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,12 +135,13 @@ agentcore invoke

### Utilities

| Command | Description |
| -------------- | ----------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `update` | Check for and install CLI updates |
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `validate` | Validate configuration files |
| `package` | Package agent artifacts without deploying |
| `fetch access` | Fetch access info for deployed resources |
| `feedback` | Send feedback to the AgentCore team (with screenshot) |
| `update` | Check for and install CLI updates |

## Project Structure

Expand DownExpand Up@@ -191,10 +192,21 @@ Projects use JSON schema files in the `agentcore/` directory:
- [Gateway](docs/gateway.md) - Gateway setup, targets, and authentication
- [Memory](docs/memory.md) - Memory strategies and sharing
- [Local Development](docs/local-development.md) - Dev server and debugging
- [Feedback](docs/feedback.md) - Submit feedback from your terminal

## Feedback & Issues

Found a bug or have a feature request? [Open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub.
Have a quick comment or suggestion? Send it from your terminal:

```bash
agentcore feedback "your message" --screenshot path/to/screenshot.png
```

The CLI will display the AWS Customer Agreement and prompt for consent before submitting. See
[docs/feedback.md](docs/feedback.md) for usage details.

For bugs, regressions, or feature requests that need discussion,
[open an issue](https://github.com/aws/agentcore-cli/issues/new) on GitHub instead.

## Security

Expand Down
19 changes: 19 additions & 0 deletions docs/commands.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -955,6 +955,25 @@ agentcore package -d ./my-project
| `-d, --directory <path>` | Project directory |
| `-r, --runtime <name>` | Package specific runtime |

### feedback

Send feedback about the AgentCore CLI. The CLI displays the AWS Customer Agreement and prompts for consent before
submitting; consent must be confirmed in an interactive terminal.

```bash
agentcore feedback "the dev server is slow on Linux"
agentcore feedback "broken icon" --screenshot ~/Desktop/bug.png
agentcore feedback "automation works" --json
agentcore feedback # launches the wizard
```

| Flag | Description |
| --------------------- | ------------------------------------------------------------ |
| `--screenshot <path>` | Path to a `.png`, `.jpg`, or `.jpeg` file (max 100MB) |
| `--json` | Print result as JSON (`{success, id, timestamp, reference}`) |

See [docs/feedback.md](feedback.md) for usage details.

### update

Check for and install CLI updates. Equivalent to `agentcore update cli`.
Expand Down
55 changes: 55 additions & 0 deletions docs/feedback.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
# Feedback

Send feedback about the AgentCore CLI directly from your terminal.

## When to use
Comment thread
Hweinstock marked this conversation as resolved.

| Use `agentcore feedback` | Use [GitHub Issues](https://github.com/aws/agentcore-cli/issues) instead |
| -------------------------------------- | ------------------------------------------------------------------------ |
| Quick comments, suggestions, papercuts | Bugs that need a conversation or repro steps |
| First impressions, onboarding friction | Regressions you want to track |
| Sharing a screenshot of confusing UX | Feature requests you want to discuss publicly |

## Syntax

```bash
# One-shot
agentcore feedback "your message" [--screenshot path/to/file.png] [--json]

# Multi-step wizard
agentcore feedback
```

The wizard walks through: message → optional screenshot → consent → submit. Press `Esc` to step back one phase.

## Screenshots

- Allowed types: `.png`, `.jpg`, `.jpeg`
- Maximum size: 100 MB

## Consent

Every submission requires interactive consent. The CLI displays:

> All feedback submissions, including any uploaded text and images, are subject to the AWS Customer Agreement
> (https://aws.amazon.com/agreement/). By submitting feedback, you agree that your submissions constitute "Suggestions"
> as defined in the AWS Customer Agreement.

Bare `Enter` defaults to **No**. The command refuses to submit when stdin is not a TTY (e.g. piped input, CI). There is
no flag that bypasses the prompt.

## What not to include

Do not paste credentials, secrets, account IDs, or customer data into the message, and do not attach screenshots that
show those values.

## Output

Plain mode prints a confirmation line on success. JSON mode (`--json`) prints a single line:

```json
{ "success": true, "id": "<uuid>", "timestamp": "<iso8601>", "reference": "<reference>" }
```

On failure the CLI exits with code 1 and prints a human-readable error, or `{"success": false, "error": "..."}` when
`--json` is set.
2 changes: 2 additions & 0 deletions src/cli/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { registerCreate } from './commands/create';
import { registerDeploy } from './commands/deploy';
import { registerDev } from './commands/dev';
import { registerEval } from './commands/eval';
import { registerFeedback } from './commands/feedback';
import { registerFetch } from './commands/fetch';
import { registerHelp } from './commands/help';
import { registerImport } from './commands/import';
Expand DownExpand Up@@ -180,6 +181,7 @@ export function registerCommands(program: Command) {
registerDeploy(program);
registerCreate(program);
registerEval(program);
registerFeedback(program);
registerFetch(program);
registerHelp(program);
registerImport(program);
Expand Down
163 changes: 163 additions & 0 deletions src/cli/commands/feedback/__tests__/command.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
import { TelemetryClient } from '../../../telemetry/client';
import { TelemetryClientAccessor } from '../../../telemetry/client-accessor';
import { InMemorySink } from '../../../telemetry/sinks/in-memory-sink';
import { registerFeedback } from '../command';
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockHandleFeedback = vi.fn();
const mockRender = vi.fn();
const mockRequireTTY = vi.fn();

vi.mock('../action', () => ({
handleFeedback: (...args: unknown[]) => mockHandleFeedback(...args),
}));

vi.mock('../../../tui/guards/tty', () => ({
requireTTY: () => mockRequireTTY(),
}));

vi.mock('../../../tui/screens/feedback', () => ({
Comment thread
Hweinstock marked this conversation as resolved.
FeedbackScreen: () => null,
}));

vi.mock('ink', () => ({
render: (...args: unknown[]) => {
mockRender(...args);
return {
clear: vi.fn(),
unmount: vi.fn(),
waitUntilExit: () => Promise.resolve(),
};
},
Text: 'Text',
Box: 'Box',
}));

const submittedOutcome = {
kind: 'submitted' as const,
result: { id: 'sub-1', timestamp: '2026-05-13T18:00:00Z', reference: 'S3' },
};

describe('registerFeedback', () => {
let program: Command;
let sink: InMemorySink;
let mockExit: ReturnType<typeof vi.spyOn>;
let mockLog: ReturnType<typeof vi.spyOn>;
let mockError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
program = new Command();
program.exitOverride();
registerFeedback(program);

sink = new InMemorySink();
vi.spyOn(TelemetryClientAccessor, 'get').mockResolvedValue(new TelemetryClient(sink));

mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
mockError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it('registers a top-level feedback command', () => {
const cmd = program.commands.find(c => c.name() === 'feedback');
expect(cmd).toBeDefined();
});

it('emits success JSON when --json is supplied with a message', async () => {
mockHandleFeedback.mockResolvedValue(submittedOutcome);

await expect(program.parseAsync(['feedback', 'looks good', '--json'], { from: 'user' })).rejects.toThrow(
'process.exit'
);

expect(mockHandleFeedback).toHaveBeenCalledWith('looks good', expect.objectContaining({ json: true }));
expect(mockExit).toHaveBeenCalledWith(0);
const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({
success: true,
id: 'sub-1',
timestamp: '2026-05-13T18:00:00Z',
reference: 'S3',
});

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'success',
mode: 'cli',
has_screenshot: 'false',
});
});

it('reports a TTY error when consent cannot be confirmed and exits 1', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'no-tty' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('consent must be confirmed interactively'));
expect(mockExit).toHaveBeenCalledWith(1);
});

it('prints a friendly cancellation message when the user declines consent', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'declined' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Feedback cancelled.'));
expect(mockExit).toHaveBeenCalledWith(0);
});

it('reports submission errors with exit 1 in plain mode', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockExit).toHaveBeenCalledWith(1);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining('HTTP 500'));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command: 'feedback',
exit_reason: 'failure',
mode: 'cli',
has_screenshot: 'false',
});
});

it('emits a JSON error envelope on submission failure when --json is set', async () => {
mockHandleFeedback.mockResolvedValue({ kind: 'error', error: 'HTTP 500' });

await expect(program.parseAsync(['feedback', 'msg', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

const output = JSON.parse(mockLog.mock.calls[0]?.[0] as string);
expect(output).toEqual({ success: false, error: 'HTTP 500' });
expect(mockExit).toHaveBeenCalledWith(1);
});

it('refuses --json when no message is supplied', async () => {
await expect(program.parseAsync(['feedback', '--json'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockError).toHaveBeenCalledWith(expect.stringContaining('--json requires a feedback message'));
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockHandleFeedback).not.toHaveBeenCalled();
});

it('hands off to the TUI when no message argument is provided, then exits cleanly', async () => {
await expect(program.parseAsync(['feedback'], { from: 'user' })).rejects.toThrow('process.exit');

expect(mockRequireTTY).toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
expect(mockHandleFeedback).not.toHaveBeenCalled();
// After the wizard unmounts we must terminate the Node process; otherwise
// Ink's stdin raw-mode listeners keep the process alive.
expect(mockExit).toHaveBeenCalledWith(0);
});
});
52 changes: 52 additions & 0 deletions src/cli/commands/feedback/__tests__/consent-prompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
import { promptForConsent } from '../consent-prompt';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';

function makeTtyStdin(input: string): NodeJS.ReadableStream & { isTTY?: boolean } {
const stream = new PassThrough() as PassThrough & { isTTY?: boolean };
stream.isTTY = true;
stream.end(input);
return stream;
}

describe('promptForConsent', () => {
Comment thread
tejaskash marked this conversation as resolved.
it('returns no-tty when stdin is not a TTY', async () => {
const stdin = new PassThrough() as PassThrough & { isTTY?: boolean };
stdin.isTTY = false;
const stdout = new PassThrough();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'no-tty' });
});

it('accepts when the user types y', async () => {
const stdin = makeTtyStdin('y\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result.accepted).toBe(true);
});

it('declines on bare Enter (defaults to No)', async () => {
const stdin = makeTtyStdin('\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});

it('declines when the user types n', async () => {
const stdin = makeTtyStdin('n\n');
const stdout = new PassThrough();
stdout.resume();

const result = await promptForConsent({ stdin, stdout });

expect(result).toEqual({ accepted: false, reason: 'declined' });
});
});
29 changes: 29 additions & 0 deletions src/cli/commands/feedback/action.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
import { toError } from '../../../lib/errors/types';
import { submitFeedback } from '../../operations/feedback';
import type { FeedbackSubmissionResult } from '../../operations/feedback';
import { promptForConsent } from './consent-prompt';
import type { FeedbackOptions } from './types';

export type FeedbackOutcome =
| { kind: 'submitted'; result: FeedbackSubmissionResult }
| { kind: 'declined' }
| { kind: 'no-tty' }
| { kind: 'error'; error: Error };

export async function handleFeedback(message: string, options: FeedbackOptions): Promise<FeedbackOutcome> {
const consent = await promptForConsent();
if (!consent.accepted) {
return consent.reason === 'no-tty' ? { kind: 'no-tty' } : { kind: 'declined' };
}

try {
const result = await submitFeedback({
message,
screenshot: options.screenshot ? { path: options.screenshot } : undefined,
mode: 'cli',
});
return { kind: 'submitted', result };
} catch (err) {
return { kind: 'error', error: toError(err) };
}
}
Loading
Loading