claudeCode() always pipes the prompt on stdin, so a /slash-command prompt never expands
Summary
claudeCode() always passes the prompt on stdin, so a prompt that is a slash command (/code-review, /security-review, a custom command) never expands — Claude Code hands it to the model as ordinary text and the model improvises an answer from the command's name. There is no error, so a degraded run is indistinguishable from a real one.
Asking for an opt-in argv prompt mode, default unchanged.
Environment
@ai-hero/sandcastle0.12.0- Claude Code CLI 2.1.216 (also observed on 2.1.215)
- Node v26.5.0, macOS (darwin 25.5.0)
- Provider
claudeCode(), sandboxnoSandbox()
Symptom
A RunOptions.prompt of /code-review --fix produces a plausible review that was never run by the command. The agent's own narration gives it away:
The
/code-reviewcommand isn't a loaded Skill here, so me run review directly on branch diff vs origin/master.
The improvised result validates against the same output schema as a real one, so nothing downstream can tell them apart.
Root cause
Claude Code expands user-invoked skills and custom commands only for a prompt passed as an argument:
User-invoked skills and custom commands work in
-pmode: include/skill-namein the prompt string and Claude Code expands it before running.
claudeCode()'s buildPrintCommand returns stdin unconditionally (dist/index.js):
return {
command: `claude --print --verbose${permissionFlag} --output-format stream-json --model ${shellEscape(model)}${effortFlag}${resumeFlag}${forkFlag} -p -`,
stdin: prompt
};The orchestrator then passes that stdin straight to sandbox.exec, so for this provider there is no caller-reachable argv path.
Reproduction (outside Sandcastle)
$ claude --print "/model"
Current model: Opus 4.8 (1M context) (default)
Usage: /model <name>. Available: sonnet, opus, haiku, fable, … # the command's own output
$ printf '/model' | claude --print -p -
Model switch UI-only. Type `/model` alone in interactive session — this one
non-interactive, can't open picker.
Current: Opus 4.8 (1M context), ID `claude-opus-4-8[1m]`. # the model answering in proseSame CLI, same text, different transport, different behaviour.
Why this is opt-in rather than a default change
Prompts moved to stdin deliberately, to escape E2BIG past Linux's 128 KB MAX_ARG_STRLEN — #387, fixed by #393. That reasoning still holds, so the default should stay stdin.
Worth noting for anyone implementing it: sandbox.exec runs ["-c", command], so the kernel limit applies to the whole command string, not the prompt alone. Shell-escaping expands each ' to four bytes, so a byte check against the raw prompt undercounts — measuring the assembled command is the correct guard. (copilot already caps at 120 KB, and cursor documents the same ceiling.)
Proposal
An option on claudeCode():
claudeCode(model, { promptMode: "argv" }) // default: "stdin"In "argv" mode buildPrintCommand emits -p <shellEscape(prompt)> and omits stdin from the returned PrintCommand. shellEscape already exists in the module, and the argv path is already exercised by the argv-only copilot and cursor providers, so exec tolerates a missing stdin today.
A caller who opts in accepts the argv-length ceiling knowingly; erroring above it, as copilot does, would match existing behaviour.
Notes
Worked around downstream by wrapping the provider and rewriting the invocation, which works but couples us to the exact -p - suffix — hence the request for a supported option.
Happy to open a PR if the shape looks right.
Source: mattpocock/sandcastle