Claude Exec — one-shot runtime adapter
Run one caller-supplied prompt through Claude Code print mode and capture the
result, only when the caller selects Claude: the native agent stays the
default and batches belong to agent-native. Flags match claude --help for
2.1.296; recheck it on other versions.
Failure modes of a quick claude -p
- Inherited posture. A plain run loads the user's settings, hooks,
plugins, MCP servers and CLAUDE.md; a settings
bypassPermissionsdefault held even under--safe-mode. Use the clean flags below and set--toolsand--permission-modeto the task's effects. - Renewed time. One deadline, fixed once by the caller:
$SECSis the time left before it, so a retry spends it instead of renewing it. Make one attempt; any other is the caller's decision. - Exit 0 as proof. A denied call still exits 0 with
is_error: falseand "done", and a bare file name once landed in the run's scratchpad, not$WORKDIR. Name targets by path, check effects there, and leave acceptance to a fresh validator. - Silent fallback. With no
claude, login or model, report and stop instead of switching runtimes or passing--fallback-model. - Self-review. Review runs as a separate session, never the author's.
Run
Redirect the prompt from a file, or pass it as the argument with </dev/null,
because print mode reads stdin to EOF: a pipe left open hangs until the bound
kills it. Stock macOS lacks timeout: use GNU timeout or Homebrew's
gtimeout, and launch nothing without one.
bashTO=$(command -v timeout || command -v gtimeout) || { echo "no timeout: not launching" >&2; exit 2; } cd "$WORKDIR" || exit 2 # DEADLINE_EPOCH: absolute Unix time fixed once by the caller; a retry reuses it. SECS=$(( DEADLINE_EPOCH - $(date +%s) )) [ "$SECS" -gt 0 ] || { echo "deadline passed: not launching" >&2; exit 124; } # Read-only: review, research, questions. "$TO" -k 10 "$SECS" claude -p --model "$MODEL" --output-format json \ --setting-sources "" --strict-mcp-config --disable-slash-commands \ --tools "Read,Grep,Glob" --permission-mode dontAsk \ <"$PROMPT_FILE" >"$OUT" 2>"$ERR"; echo "exit=$?" # Edit-capable: authorized file changes under $WORKDIR. "$TO" -k 10 "$SECS" claude -p --model "$MODEL" --output-format json \ --setting-sources "" --strict-mcp-config --disable-slash-commands \ --tools "Read,Grep,Glob,Edit,Write" --permission-mode acceptEdits \ <"$PROMPT_FILE" >"$OUT" 2>"$ERR"; echo "exit=$?"
Print mode never prompts: unapproved calls are denied and listed in
permission_denials. Add Bash to --tools only for authorized commands
named in --allowedTools, e.g. "Bash(go test *)"; with settings hooks
dropped, that list is the guard. --max-budget-usd stops after the call that
crosses it, and --help lists no turn cap, so bound a run by time and budget.
--no-session-persistence keeps no transcript. Add --bare for a clean run
that must not load the caller's hooks, auto-memory or CLAUDE.md; it changes
what the run sees and how it signs in, since it reads only ANTHROPIC_API_KEY
or an apiKeyHelper passed with --settings, never an OAuth login.
A host shell tool may end a call sooner than $SECS, and that kill leaves
$OUT empty because JSON is written only at exit. Before launch, compare the
host tool's cap with $SECS plus the 10 s grace; when it is shorter, start the
command as a host background job and wait for its exit through the host's
completion notice or native wait. A run the host already killed was the one
attempt: report it with $OUT empty. A quiet run is still working; the
timeout bound ends it, not silence.
Result
JSON carries result, is_error, session_id, total_cost_usd,
num_turns, permission_denials, and modelUsage keyed by the models
billed; identity evidence needs stream-json --verbose per the
model-dispatch recipe. Judge
by exit status and is_error: an unknown model exited 1 with
subtype: "success". Exit 1 also covers a spent budget or missing input;
timeout gives 124, or 137 when KILL follows 10 seconds later. Keep at most
the caller's byte cap (10 MiB default), mark a cut file truncated, return
this, and stop:
textcommand: <exact argv> posture: <tools; permission mode; clean flags> model: <requested> -> <modelUsage keys> exit: <status | timeout at N s> session_id: <id | none> output: <path, bytes, truncated: yes|no> permission_denials: <count and tools>


