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
43 changes: 27 additions & 16 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,11 @@ agentcore invoke --runtime MyAgent --target staging
agentcore invoke --session-id abc123 # Continue session
agentcore invoke --json # JSON output

# Long prompts: read from a file or pipe from stdin
agentcore invoke --prompt-file prompt.json --json
cat long-prompt.txt | agentcore invoke --json
jq -r '.response' result.json | agentcore invoke --json

# MCP protocol invoke
agentcore invoke call-tool --tool myTool --input '{"key": "value"}'

Expand All @@ -534,22 +539,28 @@ agentcore invoke --exec "python script.py" --timeout 120
agentcore invoke --exec "cat /etc/os-release" --json
```

| Flag | Description |
| --------------------- | -------------------------------------------------------- |
| `[prompt]` | Prompt text (positional argument) |
| `--prompt <text>` | Prompt text (flag, takes precedence over positional) |
| `--runtime <name>` | Specific runtime |
| `--target <name>` | Deployment target |
| `--session-id <id>` | Continue a specific session |
| `--user-id <id>` | User ID for runtime invocation (default: `default-user`) |
| `--stream` | Stream response in real-time |
| `--tool <name>` | MCP tool name (use with `call-tool` prompt) |
| `--input <json>` | MCP tool arguments as JSON (use with `--tool`) |
| `-H, --header <h>` | Custom header (`"Name: Value"`, repeatable) |
| `--bearer-token <t>` | Bearer token for CUSTOM_JWT auth |
| `--exec` | Execute a shell command in the runtime container |
| `--timeout <seconds>` | Timeout in seconds for `--exec` commands |
| `--json` | JSON output |
The prompt can come from four sources, resolved in this precedence order: `--prompt` > positional > `--prompt-file` >
piped stdin. `--prompt-file` combined with piped stdin content returns a collision error — pick one.

| Flag | Description |
| ---------------------- | ---------------------------------------------------------------- |
| `[prompt]` | Prompt text (positional argument) |
| `--prompt <text>` | Prompt text (flag, takes precedence over positional) |
| `--prompt-file <path>` | Read the prompt from a file (useful for long / structured input) |
| `--runtime <name>` | Specific runtime |
| `--target <name>` | Deployment target |
| `--session-id <id>` | Continue a specific session |
| `--user-id <id>` | User ID for runtime invocation (default: `default-user`) |
| `--stream` | Stream response in real-time |
| `--tool <name>` | MCP tool name (use with `call-tool` prompt) |
| `--input <json>` | MCP tool arguments as JSON (use with `--tool`) |
| `-H, --header <h>` | Custom header (`"Name: Value"`, repeatable) |
| `--bearer-token <t>` | Bearer token for CUSTOM_JWT auth |
| `--exec` | Execute a shell command in the runtime container |
| `--timeout <seconds>` | Timeout in seconds for `--exec` commands |
| `--json` | JSON output |

Piped stdin is auto-detected: when no prompt is supplied and stdin is not a TTY, the prompt is read from stdin.

---

Expand Down
88 changes: 88 additions & 0 deletions src/cli/commands/invoke/__tests__/resolve-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { resolvePrompt } from '../resolve-prompt';
import { randomUUID } from 'node:crypto';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

describe('resolvePrompt', () => {
let dir: string;

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), `resolve-prompt-${randomUUID()}-`));
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

it('returns --prompt flag value when provided', async () => {
const result = await resolvePrompt({ flag: 'hello', stdinPiped: false });
expect(result).toEqual({ success: true, prompt: 'hello' });
});

it('prefers --prompt flag over positional, file, and stdin', async () => {
const file = join(dir, 'p.txt');
await writeFile(file, 'from-file');
const result = await resolvePrompt(
{ flag: 'from-flag', positional: 'from-positional', file, stdinPiped: true },
Readable.from(['from-stdin'])
);
expect(result).toEqual({ success: true, prompt: 'from-flag' });
});

it('prefers --prompt over positional', async () => {
const result = await resolvePrompt({ flag: 'from-flag', positional: 'from-positional', stdinPiped: false });
expect(result).toEqual({ success: true, prompt: 'from-flag' });
});

it('falls back to positional when no flag', async () => {
const result = await resolvePrompt({ positional: 'from-positional', stdinPiped: false });
expect(result).toEqual({ success: true, prompt: 'from-positional' });
});

it('reads from --prompt-file when no flag or positional', async () => {
const file = join(dir, 'p.txt');
await writeFile(file, 'content from file\n');
const result = await resolvePrompt({ file, stdinPiped: false });
expect(result).toEqual({ success: true, prompt: 'content from file' });
});

it('strips only one trailing newline from file content', async () => {
const file = join(dir, 'p.txt');
await writeFile(file, 'line1\nline2\n\n');
const result = await resolvePrompt({ file, stdinPiped: false });
expect(result.prompt).toBe('line1\nline2\n');
});

it('reads from stdin when piped and no other source', async () => {
const result = await resolvePrompt({ stdinPiped: true }, Readable.from(['piped input\n']));
expect(result).toEqual({ success: true, prompt: 'piped input' });
});

it('errors when --prompt-file and stdin are both present', async () => {
const file = join(dir, 'p.txt');
await writeFile(file, 'x');
const result = await resolvePrompt({ file, stdinPiped: true }, Readable.from(['y']));
expect(result.success).toBe(false);
expect(result.error).toContain('--prompt-file');
expect(result.error).toContain('stdin');
});

it('returns failure when --prompt-file does not exist', async () => {
const result = await resolvePrompt({ file: join(dir, 'missing.txt'), stdinPiped: false });
expect(result.success).toBe(false);
expect(result.error).toContain('Failed to read --prompt-file');
});

it('returns undefined prompt when no source is provided', async () => {
const result = await resolvePrompt({ stdinPiped: false });
expect(result).toEqual({ success: true, prompt: undefined });
});

it('preserves empty-string flag (does not fall through)', async () => {
const result = await resolvePrompt({ flag: '', positional: 'ignored', stdinPiped: false });
expect(result).toEqual({ success: true, prompt: '' });
});
});
31 changes: 27 additions & 4 deletions src/cli/commands/invoke/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { requireProject } from '../../tui/guards';
import { InvokeScreen } from '../../tui/screens/invoke';
import { parseHeaderFlags } from '../shared/header-utils';
import { handleInvoke, loadInvokeConfig } from './action';
import { resolvePrompt } from './resolve-prompt';
import type { InvokeOptions } from './types';
import { validateInvokeOptions } from './validate';
import type { Command } from '@commander-js/extra-typings';
Expand Down Expand Up @@ -91,8 +92,15 @@ export const registerInvoke = (program: Command) => {
.command('invoke')
.alias('i')
.description(COMMAND_DESCRIPTIONS.invoke)
.argument('[prompt]', 'Prompt to send to the agent [non-interactive]')
.argument(
'[prompt]',
'Prompt to send to the agent. Also accepts piped stdin when no prompt is provided and stdin is not a TTY [non-interactive]'
)
.option('--prompt <text>', 'Prompt to send to the agent [non-interactive]')
.option(
'--prompt-file <path>',
'Read the prompt from a file (for long or structured payloads that exceed shell arg limits) [non-interactive]'
)
.option('--runtime <name>', 'Select specific runtime [non-interactive]')
.option('--target <name>', 'Select deployment target [non-interactive]')
.option('--session-id <id>', 'Use specific session ID for conversation continuity')
Expand All @@ -115,6 +123,7 @@ export const registerInvoke = (program: Command) => {
positionalPrompt: string | undefined,
cliOptions: {
prompt?: string;
promptFile?: string;
runtime?: string;
target?: string;
sessionId?: string;
Expand All @@ -131,8 +140,22 @@ export const registerInvoke = (program: Command) => {
) => {
try {
requireProject();
// --prompt flag takes precedence over positional argument
const prompt = cliOptions.prompt ?? positionalPrompt;
// Resolve prompt from flag / positional / --prompt-file / stdin
const resolved = await resolvePrompt({
flag: cliOptions.prompt,
positional: positionalPrompt,
file: cliOptions.promptFile,
stdinPiped: !process.stdin.isTTY,
});
if (!resolved.success) {
if (cliOptions.json) {
console.log(JSON.stringify({ success: false, error: resolved.error }));
} else {
console.error(resolved.error);
}
process.exit(1);
}
const prompt = resolved.prompt;

// Parse custom headers
let headers: Record<string, string> | undefined;
Expand All @@ -142,7 +165,7 @@ export const registerInvoke = (program: Command) => {

// CLI mode if any CLI-specific options provided (follows deploy command pattern)
if (
prompt ||
prompt !== undefined ||
cliOptions.json ||
cliOptions.target ||
cliOptions.stream ||
Expand Down
70 changes: 70 additions & 0 deletions src/cli/commands/invoke/resolve-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { readFile } from 'node:fs/promises';

export interface PromptSources {
/** Value from --prompt flag */
flag?: string;
/** Value from positional argument */
positional?: string;
/** Path from --prompt-file flag */
file?: string;
/** True when stdin is piped (not a TTY) */
stdinPiped: boolean;
}

export interface ResolvedPrompt {
success: boolean;
prompt?: string;
error?: string;
}

async function readPromptFile(path: string): Promise<ResolvedPrompt> {
try {
const content = await readFile(path, 'utf-8');
return { success: true, prompt: content.replace(/\r?\n$/, '') };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: `Failed to read --prompt-file '${path}': ${message}` };
}
}

async function readStdin(stdin: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of stdin) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString('utf-8').trim();
Comment thread
aidandaly24 marked this conversation as resolved.
}

/**
* Resolves the effective prompt from multiple possible sources.
*
* Precedence (hybrid — backward compatible with existing --prompt/positional behavior):
* 1. --prompt flag
* 2. positional argument
* 3. --prompt-file
* 4. stdin (when piped)
*
* Collision rule: --prompt-file AND piped stdin together is an error, since silent
* precedence between two "bulk" sources would mask user mistakes (e.g. a CI pipeline
* accidentally piping data while also passing --prompt-file).
*/
export async function resolvePrompt(
sources: PromptSources,
stdin: NodeJS.ReadableStream = process.stdin
): Promise<ResolvedPrompt> {
if (sources.flag !== undefined) return { success: true, prompt: sources.flag };
if (sources.positional !== undefined) return { success: true, prompt: sources.positional };

const stdinContent = sources.stdinPiped ? await readStdin(stdin) : '';
const hasStdinContent = stdinContent.length > 0;

if (sources.file !== undefined && hasStdinContent) {
return {
success: false,
error: 'Cannot combine --prompt-file with piped stdin. Provide only one prompt source.',
};
}
if (sources.file !== undefined) return readPromptFile(sources.file);
if (hasStdinContent) return { success: true, prompt: stdinContent };
return { success: true, prompt: undefined };
}
2 changes: 2 additions & 0 deletions src/cli/commands/invoke/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export interface InvokeOptions {
agentName?: string;
targetName?: string;
prompt?: string;
/** Path to a file containing the prompt (alternative to --prompt / positional) */
promptFile?: string;
sessionId?: string;
userId?: string;
json?: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/test-utils/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function spawnAndCollect(
const proc = spawn(command, args, {
cwd,
env: cleanSpawnEnv(extraEnv),
stdio: ['ignore', 'pipe', 'pipe'],
});

let stdout = '';
Expand Down
Loading