Skip to content

Commit f6a3e99

Browse files
authored
feat(invoke): add --prompt-file and stdin support for long prompts (#974)
* 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 <path>: 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 * docs(invoke): document --prompt-file and stdin support
1 parent acbfb9e commit f6a3e99

6 files changed

Lines changed: 215 additions & 20 deletions

File tree

docs/commands.md

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,11 @@ agentcore invoke --runtime MyAgent --target staging
525525
agentcore invoke --session-id abc123 # Continue session
526526
agentcore invoke --json # JSON output
527527

528+
# Long prompts: read from a file or pipe from stdin
529+
agentcore invoke --prompt-file prompt.json --json
530+
cat long-prompt.txt | agentcore invoke --json
531+
jq -r '.response' result.json | agentcore invoke --json
532+
528533
# MCP protocol invoke
529534
agentcore invoke call-tool --tool myTool --input '{"key": "value"}'
530535

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

537-
| Flag | Description |
538-
| --------------------- | -------------------------------------------------------- |
539-
| `[prompt]` | Prompt text (positional argument) |
540-
| `--prompt <text>` | Prompt text (flag, takes precedence over positional) |
541-
| `--runtime <name>` | Specific runtime |
542-
| `--target <name>` | Deployment target |
543-
| `--session-id <id>` | Continue a specific session |
544-
| `--user-id <id>` | User ID for runtime invocation (default: `default-user`) |
545-
| `--stream` | Stream response in real-time |
546-
| `--tool <name>` | MCP tool name (use with `call-tool` prompt) |
547-
| `--input <json>` | MCP tool arguments as JSON (use with `--tool`) |
548-
| `-H, --header <h>` | Custom header (`"Name: Value"`, repeatable) |
549-
| `--bearer-token <t>` | Bearer token for CUSTOM_JWT auth |
550-
| `--exec` | Execute a shell command in the runtime container |
551-
| `--timeout <seconds>` | Timeout in seconds for `--exec` commands |
552-
| `--json` | JSON output |
542+
The prompt can come from four sources, resolved in this precedence order: `--prompt` > positional > `--prompt-file` >
543+
piped stdin. `--prompt-file` combined with piped stdin content returns a collision error — pick one.
544+
545+
| Flag | Description |
546+
| ---------------------- | ---------------------------------------------------------------- |
547+
| `[prompt]` | Prompt text (positional argument) |
548+
| `--prompt <text>` | Prompt text (flag, takes precedence over positional) |
549+
| `--prompt-file <path>` | Read the prompt from a file (useful for long / structured input) |
550+
| `--runtime <name>` | Specific runtime |
551+
| `--target <name>` | Deployment target |
552+
| `--session-id <id>` | Continue a specific session |
553+
| `--user-id <id>` | User ID for runtime invocation (default: `default-user`) |
554+
| `--stream` | Stream response in real-time |
555+
| `--tool <name>` | MCP tool name (use with `call-tool` prompt) |
556+
| `--input <json>` | MCP tool arguments as JSON (use with `--tool`) |
557+
| `-H, --header <h>` | Custom header (`"Name: Value"`, repeatable) |
558+
| `--bearer-token <t>` | Bearer token for CUSTOM_JWT auth |
559+
| `--exec` | Execute a shell command in the runtime container |
560+
| `--timeout <seconds>` | Timeout in seconds for `--exec` commands |
561+
| `--json` | JSON output |
562+
563+
Piped stdin is auto-detected: when no prompt is supplied and stdin is not a TTY, the prompt is read from stdin.
553564

554565
---
555566

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { resolvePrompt } from '../resolve-prompt';
2+
import { randomUUID } from 'node:crypto';
3+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import { Readable } from 'node:stream';
7+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8+
9+
describe('resolvePrompt', () => {
10+
let dir: string;
11+
12+
beforeEach(async () => {
13+
dir = await mkdtemp(join(tmpdir(), `resolve-prompt-${randomUUID()}-`));
14+
});
15+
16+
afterEach(async () => {
17+
await rm(dir, { recursive: true, force: true });
18+
});
19+
20+
it('returns --prompt flag value when provided', async () => {
21+
const result = await resolvePrompt({ flag: 'hello', stdinPiped: false });
22+
expect(result).toEqual({ success: true, prompt: 'hello' });
23+
});
24+
25+
it('prefers --prompt flag over positional, file, and stdin', async () => {
26+
const file = join(dir, 'p.txt');
27+
await writeFile(file, 'from-file');
28+
const result = await resolvePrompt(
29+
{ flag: 'from-flag', positional: 'from-positional', file, stdinPiped: true },
30+
Readable.from(['from-stdin'])
31+
);
32+
expect(result).toEqual({ success: true, prompt: 'from-flag' });
33+
});
34+
35+
it('prefers --prompt over positional', async () => {
36+
const result = await resolvePrompt({ flag: 'from-flag', positional: 'from-positional', stdinPiped: false });
37+
expect(result).toEqual({ success: true, prompt: 'from-flag' });
38+
});
39+
40+
it('falls back to positional when no flag', async () => {
41+
const result = await resolvePrompt({ positional: 'from-positional', stdinPiped: false });
42+
expect(result).toEqual({ success: true, prompt: 'from-positional' });
43+
});
44+
45+
it('reads from --prompt-file when no flag or positional', async () => {
46+
const file = join(dir, 'p.txt');
47+
await writeFile(file, 'content from file\n');
48+
const result = await resolvePrompt({ file, stdinPiped: false });
49+
expect(result).toEqual({ success: true, prompt: 'content from file' });
50+
});
51+
52+
it('strips only one trailing newline from file content', async () => {
53+
const file = join(dir, 'p.txt');
54+
await writeFile(file, 'line1\nline2\n\n');
55+
const result = await resolvePrompt({ file, stdinPiped: false });
56+
expect(result.prompt).toBe('line1\nline2\n');
57+
});
58+
59+
it('reads from stdin when piped and no other source', async () => {
60+
const result = await resolvePrompt({ stdinPiped: true }, Readable.from(['piped input\n']));
61+
expect(result).toEqual({ success: true, prompt: 'piped input' });
62+
});
63+
64+
it('errors when --prompt-file and stdin are both present', async () => {
65+
const file = join(dir, 'p.txt');
66+
await writeFile(file, 'x');
67+
const result = await resolvePrompt({ file, stdinPiped: true }, Readable.from(['y']));
68+
expect(result.success).toBe(false);
69+
expect(result.error).toContain('--prompt-file');
70+
expect(result.error).toContain('stdin');
71+
});
72+
73+
it('returns failure when --prompt-file does not exist', async () => {
74+
const result = await resolvePrompt({ file: join(dir, 'missing.txt'), stdinPiped: false });
75+
expect(result.success).toBe(false);
76+
expect(result.error).toContain('Failed to read --prompt-file');
77+
});
78+
79+
it('returns undefined prompt when no source is provided', async () => {
80+
const result = await resolvePrompt({ stdinPiped: false });
81+
expect(result).toEqual({ success: true, prompt: undefined });
82+
});
83+
84+
it('preserves empty-string flag (does not fall through)', async () => {
85+
const result = await resolvePrompt({ flag: '', positional: 'ignored', stdinPiped: false });
86+
expect(result).toEqual({ success: true, prompt: '' });
87+
});
88+
});

src/cli/commands/invoke/command.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { requireProject, requireTTY } from '../../tui/guards';
44
import { InvokeScreen } from '../../tui/screens/invoke';
55
import { parseHeaderFlags } from '../shared/header-utils';
66
import { handleInvoke, loadInvokeConfig } from './action';
7+
import { resolvePrompt } from './resolve-prompt';
78
import type { InvokeOptions } from './types';
89
import { validateInvokeOptions } from './validate';
910
import type { Command } from '@commander-js/extra-typings';
@@ -99,8 +100,15 @@ export const registerInvoke = (program: Command) => {
99100
.command('invoke')
100101
.alias('i')
101102
.description(COMMAND_DESCRIPTIONS.invoke)
102-
.argument('[prompt]', 'Prompt to send to the agent [non-interactive]')
103+
.argument(
104+
'[prompt]',
105+
'Prompt to send to the agent. Also accepts piped stdin when no prompt is provided and stdin is not a TTY [non-interactive]'
106+
)
103107
.option('--prompt <text>', 'Prompt to send to the agent [non-interactive]')
108+
.option(
109+
'--prompt-file <path>',
110+
'Read the prompt from a file (for long or structured payloads that exceed shell arg limits) [non-interactive]'
111+
)
104112
.option('--runtime <name>', 'Select specific runtime [non-interactive]')
105113
.option('--target <name>', 'Select deployment target [non-interactive]')
106114
.option('--session-id <id>', 'Use specific session ID for conversation continuity')
@@ -123,6 +131,7 @@ export const registerInvoke = (program: Command) => {
123131
positionalPrompt: string | undefined,
124132
cliOptions: {
125133
prompt?: string;
134+
promptFile?: string;
126135
runtime?: string;
127136
target?: string;
128137
sessionId?: string;
@@ -139,8 +148,22 @@ export const registerInvoke = (program: Command) => {
139148
) => {
140149
try {
141150
requireProject();
142-
// --prompt flag takes precedence over positional argument
143-
const prompt = cliOptions.prompt ?? positionalPrompt;
151+
// Resolve prompt from flag / positional / --prompt-file / stdin
152+
const resolved = await resolvePrompt({
153+
flag: cliOptions.prompt,
154+
positional: positionalPrompt,
155+
file: cliOptions.promptFile,
156+
stdinPiped: !process.stdin.isTTY,
157+
});
158+
if (!resolved.success) {
159+
if (cliOptions.json) {
160+
console.log(JSON.stringify({ success: false, error: resolved.error }));
161+
} else {
162+
console.error(resolved.error);
163+
}
164+
process.exit(1);
165+
}
166+
const prompt = resolved.prompt;
144167

145168
// Parse custom headers
146169
let headers: Record<string, string> | undefined;
@@ -150,7 +173,7 @@ export const registerInvoke = (program: Command) => {
150173

151174
// CLI mode if any CLI-specific options provided (follows deploy command pattern)
152175
if (
153-
prompt ||
176+
prompt !== undefined ||
154177
cliOptions.json ||
155178
cliOptions.target ||
156179
cliOptions.stream ||
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { readFile } from 'node:fs/promises';
2+
3+
export interface PromptSources {
4+
/** Value from --prompt flag */
5+
flag?: string;
6+
/** Value from positional argument */
7+
positional?: string;
8+
/** Path from --prompt-file flag */
9+
file?: string;
10+
/** True when stdin is piped (not a TTY) */
11+
stdinPiped: boolean;
12+
}
13+
14+
export interface ResolvedPrompt {
15+
success: boolean;
16+
prompt?: string;
17+
error?: string;
18+
}
19+
20+
async function readPromptFile(path: string): Promise<ResolvedPrompt> {
21+
try {
22+
const content = await readFile(path, 'utf-8');
23+
return { success: true, prompt: content.replace(/\r?\n$/, '') };
24+
} catch (err) {
25+
const message = err instanceof Error ? err.message : String(err);
26+
return { success: false, error: `Failed to read --prompt-file '${path}': ${message}` };
27+
}
28+
}
29+
30+
async function readStdin(stdin: NodeJS.ReadableStream): Promise<string> {
31+
const chunks: Buffer[] = [];
32+
for await (const chunk of stdin) {
33+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
34+
}
35+
return Buffer.concat(chunks).toString('utf-8').trim();
36+
}
37+
38+
/**
39+
* Resolves the effective prompt from multiple possible sources.
40+
*
41+
* Precedence (hybrid — backward compatible with existing --prompt/positional behavior):
42+
* 1. --prompt flag
43+
* 2. positional argument
44+
* 3. --prompt-file
45+
* 4. stdin (when piped)
46+
*
47+
* Collision rule: --prompt-file AND piped stdin together is an error, since silent
48+
* precedence between two "bulk" sources would mask user mistakes (e.g. a CI pipeline
49+
* accidentally piping data while also passing --prompt-file).
50+
*/
51+
export async function resolvePrompt(
52+
sources: PromptSources,
53+
stdin: NodeJS.ReadableStream = process.stdin
54+
): Promise<ResolvedPrompt> {
55+
if (sources.flag !== undefined) return { success: true, prompt: sources.flag };
56+
if (sources.positional !== undefined) return { success: true, prompt: sources.positional };
57+
58+
const stdinContent = sources.stdinPiped ? await readStdin(stdin) : '';
59+
const hasStdinContent = stdinContent.length > 0;
60+
61+
if (sources.file !== undefined && hasStdinContent) {
62+
return {
63+
success: false,
64+
error: 'Cannot combine --prompt-file with piped stdin. Provide only one prompt source.',
65+
};
66+
}
67+
if (sources.file !== undefined) return readPromptFile(sources.file);
68+
if (hasStdinContent) return { success: true, prompt: stdinContent };
69+
return { success: true, prompt: undefined };
70+
}

src/cli/commands/invoke/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export interface InvokeOptions {
22
agentName?: string;
33
targetName?: string;
44
prompt?: string;
5+
/** Path to a file containing the prompt (alternative to --prompt / positional) */
6+
promptFile?: string;
57
sessionId?: string;
68
userId?: string;
79
json?: boolean;

src/test-utils/cli-runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function spawnAndCollect(
3636
const proc = spawn(command, args, {
3737
cwd,
3838
env: cleanSpawnEnv(extraEnv),
39+
stdio: ['ignore', 'pipe', 'pipe'],
3940
});
4041

4142
let stdout = '';

0 commit comments

Comments
 (0)