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 straycmdsubst 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 atcmdand 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: theterminaltool reports "command was running, but the user actively interrupted" with a transcript full ofcmdand dquote>lines. - Quoted multi-line
-mstrings 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-cconfig keys (seeerror: key does not contain a section). - Multiple
-mflags 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 aquote>/dquote>continuation prompt, waiting forever for input. Symptom is identical to the heredoc hang: theterminaltool reports the command as running but the user has to interrupt. This failure mode persists even when each individual-mvalue 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).
# 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:
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
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.
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
mktempcall (some agent terminals scoremktempas mutating). - No trailing
&& /bin/rm -f "$MSG"(chainedrmis 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:
# 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):
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:
# 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:
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>):
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>):
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:
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 convention is in place) avoidsmktemp+ trailingrm, which together trip agent-terminal confirmation prompts; system temp viamktemp -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, somktemp -t …+edit_fileis 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 straygit add .will commit them.Chained
&& rm -f "$MSG"may trigger a human-confirmation prompt. Many agent terminals rank multi-command lines containingrmas destructive and require explicit approval each time. If the prompts are slowing you down, switch to Option C (scratchpad, normneeded) or split the command across two tool calls (commit first,rmsecond).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 —rmmight 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 ranrm -f foobut the file is still visible inls," 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/\rmalso bypass aliases in bash/zsh, but/bin/rmis the most portable and self-documenting choice. Applies to everyrmexample in this skill — they all use/bin/rmfor this reason.zsh history expansion eats
!in glob patterns like.[!.]*. The "delete dotfiles but not./.." idiomrm -rf .[!.]*and its cousinrm -rf dir/* dir/.[!.]*is standard in POSIX sh and bash, but zsh with defaultHIST_VERIFY/ history-expansion on treats the!as a history-event trigger before globbing happens. Symptoms in an agent terminal:zsh: event not found: .](and the
rmnever 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:findinstead of glob:find <dir> -mindepth 1 -deleteatomically removes everything inside<dir>(files + dotfiles) without touching<dir>itself. No!needed, no shell expansion to worry about.- Disable the feature for the one command:
setopt +o banghist(zsh) or prependset +H(bash) — noisy and easy to forget to turn back on. - 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 '^$'orfind-driven pipelines) over elaborate escaping.Don't try to be clever with
$'…\n…'orecho -e. ANSI-C quoting andecho -edon'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 | catto sanity- check the message actually landed with all paragraphs intact before yougit push.