claudeCode() always pipes the prompt on stdin, so a /slash-command prompt never expands

Author: denissajnar-qbitCreated Jul 21, 2026Updated Sep 20, 2026

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/sandcastle 0.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(), sandbox noSandbox()

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-review command 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 -p mode: include /skill-name in the prompt string and Claude Code expands it before running.

claudeCode()'s buildPrintCommand returns stdin unconditionally (dist/index.js):

javascript
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 prose

Same 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():

typescript
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.