From 676b8d916635c38845a180eb411e8f1a29054445 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 27 Apr 2026 10:02:16 -0400 Subject: [PATCH 1/2] feat(invoke): add --prompt-file and stdin support for long prompts Long prompts hit shell argument limits (E2BIG, typically 128KB-2MB) when passed as positional args. This adds two new sources: - --prompt-file : read prompt from a file - piped stdin: when no prompt is given and stdin is not a TTY, read the prompt from stdin Precedence is hybrid and backward-compatible: --prompt > positional > --prompt-file > stdin --prompt-file combined with piped stdin content returns an explicit collision error rather than silently picking one. Closes #686 --- .../invoke/__tests__/resolve-prompt.test.ts | 88 +++++++++++++++++++ src/cli/commands/invoke/command.tsx | 23 ++++- src/cli/commands/invoke/resolve-prompt.ts | 70 +++++++++++++++ src/cli/commands/invoke/types.ts | 2 + src/test-utils/cli-runner.ts | 1 + 5 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 src/cli/commands/invoke/__tests__/resolve-prompt.test.ts create mode 100644 src/cli/commands/invoke/resolve-prompt.ts diff --git a/src/cli/commands/invoke/__tests__/resolve-prompt.test.ts b/src/cli/commands/invoke/__tests__/resolve-prompt.test.ts new file mode 100644 index 000000000..0b2c8daa1 --- /dev/null +++ b/src/cli/commands/invoke/__tests__/resolve-prompt.test.ts @@ -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: '' }); + }); +}); diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 6243d90f2..af13d2b7b 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -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'; @@ -93,6 +94,7 @@ export const registerInvoke = (program: Command) => { .description(COMMAND_DESCRIPTIONS.invoke) .argument('[prompt]', 'Prompt to send to the agent [non-interactive]') .option('--prompt ', 'Prompt to send to the agent [non-interactive]') + .option('--prompt-file ', 'Read the prompt from a file [non-interactive]') .option('--runtime ', 'Select specific runtime [non-interactive]') .option('--target ', 'Select deployment target [non-interactive]') .option('--session-id ', 'Use specific session ID for conversation continuity') @@ -115,6 +117,7 @@ export const registerInvoke = (program: Command) => { positionalPrompt: string | undefined, cliOptions: { prompt?: string; + promptFile?: string; runtime?: string; target?: string; sessionId?: string; @@ -131,8 +134,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 | undefined; @@ -142,7 +159,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 || diff --git a/src/cli/commands/invoke/resolve-prompt.ts b/src/cli/commands/invoke/resolve-prompt.ts new file mode 100644 index 000000000..810395b27 --- /dev/null +++ b/src/cli/commands/invoke/resolve-prompt.ts @@ -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 { + 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 { + 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(); +} + +/** + * 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 { + 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 }; +} diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index 8d8175095..fe02e8225 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -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; diff --git a/src/test-utils/cli-runner.ts b/src/test-utils/cli-runner.ts index 10cf1bbf3..789624364 100644 --- a/src/test-utils/cli-runner.ts +++ b/src/test-utils/cli-runner.ts @@ -36,6 +36,7 @@ export function spawnAndCollect( const proc = spawn(command, args, { cwd, env: cleanSpawnEnv(extraEnv), + stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; From 2d9700b6f081c466cf20a16e96a1214c31b47fef Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 27 Apr 2026 10:08:28 -0400 Subject: [PATCH 2/2] docs(invoke): document --prompt-file and stdin support --- docs/commands.md | 43 ++++++++++++++++++----------- src/cli/commands/invoke/command.tsx | 10 +++++-- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index f2ea60a0e..f6f15b9ae 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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"}' @@ -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 ` | Prompt text (flag, takes precedence over positional) | -| `--runtime ` | Specific runtime | -| `--target ` | Deployment target | -| `--session-id ` | Continue a specific session | -| `--user-id ` | User ID for runtime invocation (default: `default-user`) | -| `--stream` | Stream response in real-time | -| `--tool ` | MCP tool name (use with `call-tool` prompt) | -| `--input ` | MCP tool arguments as JSON (use with `--tool`) | -| `-H, --header ` | Custom header (`"Name: Value"`, repeatable) | -| `--bearer-token ` | Bearer token for CUSTOM_JWT auth | -| `--exec` | Execute a shell command in the runtime container | -| `--timeout ` | 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 ` | Prompt text (flag, takes precedence over positional) | +| `--prompt-file ` | Read the prompt from a file (useful for long / structured input) | +| `--runtime ` | Specific runtime | +| `--target ` | Deployment target | +| `--session-id ` | Continue a specific session | +| `--user-id ` | User ID for runtime invocation (default: `default-user`) | +| `--stream` | Stream response in real-time | +| `--tool ` | MCP tool name (use with `call-tool` prompt) | +| `--input ` | MCP tool arguments as JSON (use with `--tool`) | +| `-H, --header ` | Custom header (`"Name: Value"`, repeatable) | +| `--bearer-token ` | Bearer token for CUSTOM_JWT auth | +| `--exec` | Execute a shell command in the runtime container | +| `--timeout ` | 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. --- diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index af13d2b7b..af058c5f4 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -92,9 +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 ', 'Prompt to send to the agent [non-interactive]') - .option('--prompt-file ', 'Read the prompt from a file [non-interactive]') + .option( + '--prompt-file ', + 'Read the prompt from a file (for long or structured payloads that exceed shell arg limits) [non-interactive]' + ) .option('--runtime ', 'Select specific runtime [non-interactive]') .option('--target ', 'Select deployment target [non-interactive]') .option('--session-id ', 'Use specific session ID for conversation continuity')