# Shell Heredoc And Multiline Strings

> Pass long/multi-line strings (commit messages, PR bodies) through an agent shell without corruption.

- Skill: `chen3feng/shell-heredoc-and-multiline-strings` (Agent Skill)
- Install (CLI): `npx skillmds@latest add chen3feng/shell-heredoc-and-multiline-strings`
- Raw SKILL.md: https://api.skillmd.com/api/skills/chen3feng/shell-heredoc-and-multiline-strings/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: chen3feng (https://skillmd.com/u/chen3feng)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/chen3feng/shell-heredoc-and-multiline-strings

---


# Multi-line strings through an agent shell

## When to use

You need to supply a multi-line string to a command — a commit
message, a `gh pr create --body`, a SQL snippet — from an AI agent's
terminal tool.

## Problem

Agent terminals often paste multi-line commands with subtle
whitespace/newline mangling. Observed failures:

- Heredocs that silently truncate at the first embedded `'` or `` ` ``.
- `$(cat <<'EOF' … EOF)` producing a stray `cmdsubst heredoc>` prompt
  the agent never escapes, leaving a process wedged in the background.
- In zsh, wrapping a heredoc inside a double-quoted command-substitution —
  `gh pr create --body "$(cat <<'EOF' … EOF)"` — hangs at
  `cmdand dquote>` / `cmdand quote>` prompts as soon as the heredoc body
  contains triple-backtick fences, embedded `$(…)`, or backslashes.
  zsh's parser can no longer tell whether quotes are balanced and waits
  forever for input. Common symptom: the `terminal` tool reports
  "command was running, but the user actively interrupted" with a
  transcript full of `cmdand dquote>` lines.
- Quoted multi-line `-m` strings being split at newlines, so only the
  first line becomes the commit title and the rest is interpreted as
  extra git arguments — sometimes parsed as `-c` config keys (see
  `error: key does not contain a section`).
- **Multiple `-m` flags where one flag's value contains embedded
  newlines** (e.g. `git commit -m 'title' -m 'para1' -m 'line1\nline2\nline3'`).
  The agent terminal → shell → git chain re-interprets the real newlines
  inside the quoted argument: the shell sees an unclosed quote and
  drops into a `quote>` / `dquote>` continuation prompt, waiting
  forever for input. Symptom is identical to the heredoc hang: the
  `terminal` tool reports the command as running but the user has to
  interrupt. This failure mode persists even when each individual `-m`
  value looks "safe" in isolation — it only takes one multi-line value
  anywhere in the argv to wedge the parse.

## Solution

**Iron rule: any `git` / `gh` argument whose value contains a real
newline must be passed via `-F <file>` / `--body-file <file>`, never
via `-m` / `--body`.** No exceptions, no "just this once". Even a
single multi-line value anywhere in argv can wedge the shell.

**General rule: write the string to a file first, then point the
tool at the file.** The file can be produced two ways, both reliable:

### Option A — editor tool writes to a repo-local path

Preferred when the content is long or contains heavy markdown.

**Critical constraint**: many agent editor tools (e.g. Cursor's
`edit_file`, `write_file`) refuse to write outside the current
workspace — system-temp paths like `/tmp/foo.txt` return a
"path not in workspace" error. **Do not target `mktemp`'s output
with an editor tool.** Use a repo-local path instead (Option C
below if `.agent/scratchpad/` exists, otherwise create one or fall
back to Option B / pure `printf`).

```bash
# With .agent/scratchpad/ convention (recommended):
MSG=.agent/scratchpad/commitmsg.$$.txt
# Then use the agent's editor tool to write to "$MSG".
git commit -F "$MSG"
# (No rm — .agent/scratchpad/ is gitignored, sweep occasionally.)
```

If the repo has no `.agent/` layout and you still need editor-tool
rich content, either introduce the layout first (one commit to add
`.agent/scratchpad/.gitkeep` + gitignore entry) or use Option B
(`printf` to `mktemp`, no editor needed).

(Why `/bin/rm` and not `rm` when cleanup *is* needed? See the
*Alias-shadowed commands* pitfall below — in short, many users
alias `rm` to a trash-can wrapper, and the absolute path guarantees
the real binary runs.)

### Option B — `printf '%s\n'` line-by-line

Also reliable in an agent terminal because each line is a separate
*argument* (not an embedded newline inside one argument), so the
shell never sees an unclosed quote:

```bash
MSG=$(mktemp -t commit_msg.XXXXXX.txt)
printf '%s\n' \
  'Subject line' \
  '' \
  'First paragraph of the body.' \
  '' \
  '- bullet one' \
  '- bullet two' \
  > "$MSG"
git commit -F "$MSG" && /bin/rm -f "$MSG"
```

The critical detail: every line is its own single-quoted argument.
`printf` emits the real newlines on its own, so the shell parser
never has to reason about balance across lines.

### Option C — write into repo-local `.agent/scratchpad/`

Preferred **when the repo already follows the
[agent-work-artifacts-layout](../agent-work-artifacts-layout/SKILL.md)
convention** (i.e. `.agent/scratchpad/` exists and is gitignored).
This avoids both `mktemp` *and* the trailing `rm` on the same command
line — two shell primitives that agent terminals often flag as
"destructive / chained" and surface as a human-confirmation prompt
before executing. Reducing confirmation prompts matters on repos
where you commit many times per session.

```bash
MSG=.agent/scratchpad/commitmsg.$$.txt
printf '%s\n' \
  'Subject line' \
  '' \
  'First paragraph of the body.' \
  > "$MSG"
git commit -F "$MSG"
# No rm needed — .agent/scratchpad/ is gitignored, sweep occasionally.
```

Why this reduces prompts:

- No `mktemp` call (some agent terminals score `mktemp` as mutating).
- No trailing `&& /bin/rm -f "$MSG"` (chained `rm` is the single
  biggest cause of confirmation prompts in commit flows; see also
  *Alias-shadowed commands* below for why you always spell it
  `/bin/rm`).
- The path is deterministic and local to the repo, so if a command
  gets interrupted the half-written file is obviously visible.

Trade-off: the file lingers in `.agent/scratchpad/` until you sweep
(`/bin/rm -rf .agent/scratchpad/*` when convenient, or let it live —
it's gitignored and stays out of commits). This is a deliberate
choice: leaking a few kilobytes of local drafts is cheaper than a
human-confirmation round-trip on every commit.

Fall back to system temp (`mktemp -t ...`) with **pure-shell
production** (`printf '%s\n' …`, Option B) when the repo does
**not** have an `.agent/` layout, or when the content is sensitive
(auth tokens, user data) — system temp is preferable there because
it gets cleared by the OS. Remember: agent editor tools generally
refuse system-temp paths (see Option A), so editor-tool authoring
implies a repo-local target.

### Commit message

Commit messages follow the same location logic as PR bodies.
Match location to writer:

```bash
# Preferred: repo-local scratchpad (editor tool or printf both work).
MSG=.agent/scratchpad/commitmsg.$$.txt
#    … populate "$MSG" via editor tool or printf …
git commit -F "$MSG"
# No rm needed — .agent/scratchpad/ is gitignored.
```

No `.agent/` layout? Use `mktemp` with **pure-shell** content only
(editor tool will reject the system-temp path):

```bash
MSG=$(mktemp -t commit_msg.XXXXXX.txt)
printf '%s\n' 'Subject' '' 'Body paragraph.' > "$MSG"
git commit -F "$MSG"
/bin/rm -f "$MSG"   # /bin/rm bypasses the common `rm=trash` alias
```

### PR body

PR bodies follow the same location logic as commit messages:

```bash
# Preferred: repo-local scratchpad (editor tool can write here).
BODY=.agent/scratchpad/pr_body.$$.md
#   agent edit tool -> "$BODY"
gh pr create --title "…" --body-file "$BODY"
# No rm needed — .agent/scratchpad/ is gitignored.
```

If the repo has no `.agent/` layout **and** the body is short
enough to express as discrete lines, stay in pure shell:

```bash
BODY=$(mktemp -t pr_body.XXXXXX.md)
printf '%s\n' \
  'Summary line.' \
  '' \
  '- bullet one' \
  '- bullet two' \
  > "$BODY"
gh pr create --title "…" --body-file "$BODY"
/bin/rm -f "$BODY"
```

**Do not** combine `mktemp -t …` with an agent editor tool — the
editor will reject the out-of-workspace path. See Option A above.

### Any other long string

Use `python3 -c 'open("out.txt","w").write(r"<content>")'` or an
editor tool — never a fragile heredoc through the shell.

## Example

Wrong — heredoc inside command substitution (hangs in `cmdsubst heredoc>`):

```bash
MSG=$(cat <<'EOF'
Subject line

Body with some backticks: `foo` and a quote: it's.
EOF
) && git commit -m "$MSG"
```

Wrong — multiple `-m` where one value is multi-line (hangs in `quote>`):

```bash
git commit -m 'Subject' -m 'Paragraph one' -m '- bullet one
- bullet two
- bullet three'      # ← embedded newlines wedge zsh's parser
```

Right — write to a temp file, commit with `-F`:

```bash
MSG=$(mktemp -t commit_msg.XXXXXX.txt)
printf '%s\n' 'Subject' '' 'Paragraph one' '' '- bullet one' '- bullet two' > "$MSG"
git commit -F "$MSG" && /bin/rm -f "$MSG"
```

## Pitfalls

- **Choose the right temp location per repo, and match it to how
  the content is produced.** Two dimensions:
  (1) *Which filesystem* — repo-local `.agent/scratchpad/` (when the
  [agent-work-artifacts-layout](../agent-work-artifacts-layout/SKILL.md)
  convention is in place) avoids `mktemp` + trailing `rm`, which
  together trip agent-terminal confirmation prompts; system temp
  via `mktemp -t ...` is the fallback when there's no `.agent/`
  layout.
  (2) *Which writer* — pure shell (`printf '%s\n' …`) can target
  either location, but **agent editor tools typically refuse paths
  outside the workspace**, so `mktemp -t …` + `edit_file` is a
  broken combination. If you need the editor tool for rich content,
  the target must be repo-local (`.agent/scratchpad/...`).
  Never write temp files to the repo root or a tracked path — a
  stray `git add .` will commit them.
- **Chained `&& rm -f "$MSG"` may trigger a human-confirmation
  prompt.** Many agent terminals rank multi-command lines containing
  `rm` as destructive and require explicit approval each time. If
  the prompts are slowing you down, switch to Option C (scratchpad,
  no `rm` needed) or split the command across two tool calls
  (commit first, `rm` second).
- **Always clean up when using system temp.** With Option A / B,
  put `/bin/rm -f "$MSG"` on the same line as the consumer command
  (`git commit`, `gh pr create`). Defer it and a later session
  inherits orphaned temp files. With Option C you instead rely on
  periodic `.agent/scratchpad/` sweeps.
- **Alias-shadowed commands: prefer absolute paths like `/bin/rm`,
  `/bin/cp`, `/bin/mv`.** Many users turn everyday destructive
  commands into safer interactive wrappers via aliases or shell
  functions in `~/.zshrc` / `~/.bashrc` (e.g. `alias rm='trash'`,
  `alias cp='cp -i'`). In a human's interactive shell that's great;
  in an agent terminal it silently changes behavior — `rm` might
  move the file to a trash can instead of deleting it, or prompt
  for confirmation that the agent will never answer, wedging the
  session. Symptoms: "I ran `rm -f foo` but the file is still
  visible in `ls`," or a command that looks successful but left
  an orphan. Cure: spell out the absolute path of the real binary
  (`/bin/rm`, `/bin/cp`, `/bin/mv`, `/bin/ls`) in any script or
  one-liner the agent emits. `command rm` / `\rm` also bypass
  aliases in bash/zsh, but `/bin/rm` is the most portable and
  self-documenting choice. Applies to every `rm` example in this
  skill — they all use `/bin/rm` for this reason.
- **zsh history expansion eats `!` in glob patterns like `.[!.]*`.**
  The "delete dotfiles but not `.` / `..`" idiom `rm -rf .[!.]*` and
  its cousin `rm -rf dir/* dir/.[!.]*` is standard in POSIX sh and
  bash, but zsh with default `HIST_VERIFY` / history-expansion on
  treats the `!` as a history-event trigger *before* globbing
  happens. Symptoms in an agent terminal:

  ```text
  zsh: event not found: .]
  ```

  (and the `rm` never runs). Single-quoting the pattern does not
  help because zsh performs history expansion even inside most
  quoting contexts when the pattern is emitted by a previous tool.
  Robust cures, in order of preference:

  1. **`find` instead of glob**: `find <dir> -mindepth 1 -delete`
     atomically removes everything inside `<dir>` (files + dotfiles)
     without touching `<dir>` itself. No `!` needed, no shell
     expansion to worry about.
  2. Disable the feature for the one command: `setopt +o banghist`
     (zsh) or prepend `set +H` (bash) — noisy and easy to forget
     to turn back on.
  3. Escape the `!` as `\!` — works but fragile because quoting
     rules differ across wrappers in the agent-terminal → shell
     chain.

  Same caveat applies anywhere an agent emits a shell command
  containing a literal `!` that is not meant as history expansion
  (e.g. `grep '^!'`, `sed 's/!//'`). When in doubt, prefer a tool
  that doesn't require `!` (e.g. `grep -v '^$'` or `find`-driven
  pipelines) over elaborate escaping.
- **Don't try to be clever with `$'…\n…'` or `echo -e`.** ANSI-C
  quoting and `echo -e` don't fix the root cause — the shell still
  sees a multi-line value eventually, and the same hang recurs.
  Only the file-based path is robust.
- **Verify before pushing.** `git log -1 --format=%B | cat` to sanity-
  check the message actually landed with all paragraphs intact
  before you `git push`.

## See also

- [git-commit-author-identity](../git-commit-author-identity/SKILL.md)
- [github-pr-via-gh-cli](../github-pr-via-gh-cli/SKILL.md)
- [workspace-path-constraints](../workspace-path-constraints/SKILL.md)

