Skip to content
IT

12 · CLI scripting

Estimated time: 50 minutesPrerequisites: the Foundations section and module 9. You need to be comfortable with bash or PowerShell.

So far you have always conversed with Claude. This module turns it into a cog: a command that starts, does one thing, returns the result and exits — with nobody at the keyboard. It is called headless mode (without a head, that is, without an interface) or non-interactive, and it opens a new category of uses: the automatic check on every change before saving, the report that generates itself every morning, the same fix applied to two hundred files in a row. The whole Automation section of the course rests on this module.

Starting from zeroThe three tools of this module: stdin, stdout, exit code

Every terminal command has an input (stdin), an output (stdout) and a channel for errors (stderr); the pipe | connects the output of one command to the input of the next. And when it finishes, every command leaves an exit code: 0 = all good, anything else = problem. Claude, in this mode, is a command like any other: you feed it data through the pipe, and you check how it went by reading the exit code. If these three concepts are new, The basics shows them in practice.

  • Run Claude Code non-interactively and use its output in a script.
  • Know what claude -p loads by default, and why CI needs --bare.
  • Handle permissions in a context where nobody can click “approve”.
  • Continue and resume conversations across separate invocations.
  • Make a script branch on Claude’s outcome, instead of hoping.

-p turns Claude Code into a Unix command. Add -p (or --print) to any invocation and Claude runs the task, prints the result and exits. It reads from stdin, writes to stdout, returns an exit code: it behaves like grep or jq.

Terminal window
claude -p "Find and fix the bug in auth.py" --allowedTools "Read,Edit,Bash"

Exit code 0 on success, non-zero on failure — so scripts can branch. An invalid flag is reported on stderr before the run starts; a failure inside the run, such as missing authentication, is printed as the result on stdout.

By default -p loads everything an interactive session would load: hooks, skills, plugins, MCP servers, the auto memory from module 4, CLAUDE.md. Locally that is what you want. In CI it is a problem: a hook in a colleague’s ~/.claude or an MCP server in the project’s .mcp.json change the result from machine to machine.

--bare is the answer. It skips the auto-discovery of hooks, skills, plugins, MCP servers, auto memory and CLAUDE.md. Same result on every machine.

In bare mode Claude has access to the Bash, file read and file edit tools. Context must be passed by hand:

To load Use
Additions to the system prompt --append-system-prompt, --append-system-prompt-file
Settings --settings <file-o-json>
MCP servers --mcp-config <file-o-json>
Custom subagents --agents <json>
A plugin --plugin-dir <path>, --plugin-url <url>

A flag is an option of the command, recognizable by the dashes. These ten cover almost all real scripts — learn these, and look up the rest when needed:

Flag What it does
-p, --print Non-interactive mode: run, print, exit
--bare Minimal mode for CI: no automatic loading
--output-format Response format: text (default), json, stream-json — module 13 is all about this
--json-schema Response validated against a schema: data, not prose
--allowedTools Pre-approved tools, which run without asking
--permission-mode The starting permission mode (module 6)
--continue, -c Continues the most recent conversation
--resume, -r Resumes a session by ID or name
--max-turns Cap on turns: the seat belt of unattended runs
--append-system-prompt Adds role instructions (“you are a security reviewer…”)
The full flag reference, for when you need it

Input/output

Flag What it does
--input-format text or stream-json
--verbose Detailed output — required with stream-json
--include-partial-messages Includes partial streaming events

Permissions

Flag What it does
--disallowedTools Deny rules
--tools Limits which built-in tools Claude can use
--permission-prompt-tool MCP tool that handles permission requests on your behalf
--dangerously-skip-permissions Skips every confirmation — only in isolated environments

Sessions

Flag What it does
--session-id Uses a specific session ID (valid UUID)
--fork-session When resuming, creates a new ID instead of reusing the original
--no-session-persistence Does not save the transcript (only with -p)
--name, -n Display name of the session

System prompt and context

Flag What it does
--append-system-prompt-file Like --append-system-prompt, but from a file
--system-prompt / --system-prompt-file Replaces the entire system prompt
--add-dir Additional working directories
--setting-sources Which settings sources to load: user, project, local
--settings Settings JSON file, or inline JSON

Limits, model, diagnostics

Flag What it does
--max-budget-usd Spending cap (for those using a pay-as-you-go API key)
--model / --fallback-model Model for the session, with an optional fallback
--effort How much the model “thinks about it”: from low to max
--debug / --debug-file <path> Debug log, optionally to a file
--safe-mode All customizations disabled, for diagnosis
--strict-mcp-config Only the servers passed with --mcp-config, ignores the rest

In a -p session there is nobody who can click “approve”. Three strategies, from the most permissive to the most locked down:

  1. Listing the tools — the granular approach:

    Terminal window
    claude -p "Run the test suite and fix any failures" \
    --allowedTools "Bash,Read,Edit"
  2. Using the permission rule syntax — much more precise:

    Terminal window
    claude -p "Look at my staged changes and create an appropriate commit" \
    --allowedTools "Bash(git diff *),Bash(log *),Bash(git status *),Bash(git commit *)"
  3. Setting a base mode. Two modes serve the two extremes:

    acceptEdits for local tasks where file edits are fine without confirmation:

    Terminal window
    claude -p "Apply the lint fixes" --permission-mode acceptEdits

    (= “apply the linter fixes” — file edits go through, other commands still ask)

    dontAsk for locked-down CI: it denies everything that is not already in your permissions.allow rules or in the set of read-only commands:

    Terminal window
    claude -p "Run the checks" --permission-mode dontAsk

    Note for edge cases: some tools stay denied in dontAsk even with an allow rule — those that by nature require a human, such as AskUserQuestion (asks the user questions) and tools marked as interactive by their MCP server.

Non-interactive mode reads from stdin:

Terminal window
cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt

An example people actually use, as a package.json script:

package.json
{
"scripts": {
"lint:claude": "git diff main | claude -p \"you are a typo linter. for each typo in this diff, report filename:line on one line and the issue on the next. return nothing else.\""
}
}

Piping the diff instead of letting Claude read it has a concrete advantage: Claude does not need the Bash permission to get it.

Terminal window
# First request
claude -p "Review this codebase for performance issues"
# Continue the most recent one
claude -p "Now focus on the database queries" --continue
claude -p "Generate a summary of all issues found" --continue

With several conversations in parallel, capture the ID:

Terminal window
session_id=$(claude -p "Start a review" --output-format json | jq -r '.session_id')
claude -p "Continue that review" --resume "$session_id"

The two commands can run from different directories: Claude Code finds the session by ID in any project on the machine.

Skills and custom user-invocable commands work in -p mode: put /skill-name in the prompt string and Claude Code expands it before running.

Terminal window
claude -p "/pre-commit"

Built-in commands that exist only in the terminal interface, such as /login, are not available. /model, /effort, /fast, /color and /rename accept the value as an argument — /model sonnet — and /mcp without arguments prints a text summary of the servers’ status. To change a setting from a -p invocation, pass key=value to /config, for example /config thinking=false.

  1. The simplest form.

    Terminal window
    cd ~/progetti/il-tuo-progetto
    claude -p "What does the auth module do?"
  2. Check the exit code. It is the basis of every serious script.

    Terminal window
    claude -p "What does the auth module do?" > /dev/null
    echo "exit code: $?"
  3. Piping data. A real case: explaining a build error.

    Terminal window
    npm run build 2>&1 | tail -50 | claude -p \
    'concisely explain the root cause of this build error. one paragraph, no preamble.'
  4. A typo linter on the diff. Note that Claude needs no permission: the diff arrives via stdin.

    Terminal window
    git diff main | claude -p \
    "you are a typo linter. for each typo in this diff, report filename:line on one line and the issue on the next. return nothing else."
  5. A task that edits files. Here permissions are needed:

    Terminal window
    claude -p "Fix all ESLint errors in src/" \
    --allowedTools "Read,Edit,Bash(npx eslint *)" \
    --max-turns 20

    --max-turns is the seat belt: without it, a task that does not converge runs until you run out of tokens.

  6. A reusable security reviewer. Save it as review.sh:

    review.sh
    #!/usr/bin/env bash
    set -euo pipefail
    # gh is the official GitHub CLI (installed separately: cli.github.com);
    # "gh pr diff" downloads the diff of the PR given as the first argument
    gh pr diff "$1" | claude -p \
    --append-system-prompt "You are a security engineer. Review for vulnerabilities. Report only findings that have a concrete exploit path; ignore style." \
    --output-format json
    Terminal window
    chmod +x review.sh
    ./review.sh 123

    "$1" is the first argument: bash review.sh 123 downloads the diff of PR 123.

  7. A chain of invocations that share context.

    audit.sh
    #!/usr/bin/env bash
    set -euo pipefail
    sid=$(claude -p "Map the error handling patterns in src/. Don't propose changes yet." \
    --output-format json | jq -r '.session_id')
    echo "sessione: $sid"
    claude -p "Now list the three places where error handling is inconsistent." \
    --resume "$sid" --output-format json | jq -r '.result'
    claude -p "Write the fix for the first one only. Run the tests." \
    --resume "$sid" \
    --allowedTools "Read,Edit,Bash(npm test *)"
  8. See with your own eyes what -p loads (and what --bare would remove). Ask Claude to list the equipment it started with:

    Terminal window
    # without --bare: your machine's skills, hooks and CLAUDE.md are on board
    claude -p "list the skills and hooks you have available" | head -20

    (= “list the skills and hooks you have available”)

    You will see your skills and your hooks: they started with the session, and on another machine the same invocation would start with different equipment — that is the reproducibility problem --bare solves. The counter-check with --bare needs a token (the subscription login is not read), so it takes the form you will use in CI:

    Terminal window
    # in CI: --bare + token generated once with `claude setup-token`
    CLAUDE_CODE_OAUTH_TOKEN=... claude --bare -p "list the skills and hooks you have available"

    Here the answer would be: no skills, no hooks. Same command, zero equipment, identical result on any machine.

If Claude starts a background Bash task during a -p run — a dev server, a build in watch mode — that shell is terminated about five seconds after Claude has returned the final result and stdin has closed.

Background subagents and workflows are exempt from that grace period, because their result is part of the final output: claude -p waits for them. The wait is however capped at ten minutes by default, so a stuck agent does not keep the process open forever. The cap is set with CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, or 0 to wait with no limit.

If you stop a run with SIGTERM — kill, a process supervisor — Claude Code interrupts the current turn, terminates the process tree of any active Bash command, runs the SessionEnd hooks and exits with code 143.

Write a script that uses Claude as a quality gate, and that behaves well when something goes wrong.

  1. The script takes a branch as input and produces a verdict on a diff.
  2. It must exit with 0 if the diff is clean and 1 if there are blocking problems.
  3. It must not need any Bash permission.
  4. It must degrade cleanly if claude fails.
Solution
gate.sh
#!/usr/bin/env bash
# bash careful mode: stop at the first error, using never-defined
# variables is forbidden, no errors hidden in pipes
set -euo pipefail
# the comparison branch: the script's first argument, or
# "main" if you pass none (that is what ${1:-main} means)
BASE="${1:-main}"
# the differences of your work relative to the point where you branched off
# BASE (the "..." notation of git diff). We capture them in a
# variable: we will pass them to Claude via stdin, so it needs
# no permission to read them.
DIFF=$(git diff "$BASE"...HEAD)
# no differences, no review: we exit with 0 ("all good")
if [ -z "$DIFF" ]; then
echo "Nessuna differenza rispetto a $BASE."
exit 0
fi
# The heart of the script: the diff enters Claude through the pipe, and
# --json-schema forces the response to have EXACTLY the shape
# described below — an object with a "bloccanti" (blocking) list, where
# each entry has file, line, problem and scenario (all mandatory:
# "required"). So the verdict is data the script can read,
# not prose to interpret.
RISPOSTA=$(printf '%s' "$DIFF" | claude -p \
"Review this diff. Report only defects that affect correctness or security and that have a concrete failure scenario. Ignore style, naming and formatting." \
--output-format json \
--json-schema '{
"type": "object",
"properties": {
"bloccanti": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file": { "type": "string" },
"riga": { "type": "integer" },
"problema": { "type": "string" },
"scenario": { "type": "string" }
},
"required": ["file", "riga", "problema", "scenario"]
}
}
},
"required": ["bloccanti"]
}' \
--max-turns 10) || {
# Claude could not run: this is not a review failure.
echo "gate: claude non ha completato il run" >&2
exit 2
}
# With --json-schema, the validated output is not the whole response: it lives in the
# structured_output field of the JSON envelope (details in module 13). From there
# we read the "bloccanti" list defined by the schema above.
N=$(printf '%s' "$RISPOSTA" | jq '.structured_output.bloccanti | length')
if [ "$N" -eq 0 ]; then
echo "✔ Nessun problema bloccante."
exit 0
fi
echo "✘ $N problemi bloccanti:"
printf '%s' "$RISPOSTA" | jq -r '
.structured_output.bloccanti[]
| " \(.file):\(.riga) \(.problema)\n → \(.scenario)"
'
exit 1

The choices that matter:

Choice Why
The diff via stdin Claude needs no permission: no --allowedTools
--json-schema The verdict is structured data, not prose to parse with regex
scenario mandatory in the schema Forces the model to justify every finding, and cuts false positives
--max-turns 10 The task must not iterate forever
Exit 2 distinct from 1 1 = review failed, 2 = the tool did not work. A CI must be able to tell them apart
The `

Try it:

Terminal window
chmod +x gate.sh
./gate.sh main; echo "exit: $?"

In a GitHub Action it becomes:

.github/workflows/gate.yml
- name: Claude gate
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
run: ./gate.sh origin/main

The token is generated once with claude setup-token and goes into the repository secrets — see module 14.

Symptom Cause Solution
The script hangs waiting for a permission In -p nobody can approve --allowedTools or --permission-mode
The result changes from machine to machine -p loads the host’s hooks, skills, MCP and CLAUDE.md --bare in CI
--bare fails with an authentication error Bare mode does not read OAuth credentials ANTHROPIC_API_KEY, or apiKeyHelper in --settings
Bash(git diff*) allows more than expected The space before the asterisk is missing Bash(git diff *)
Error on stdin too large The limit is 10 MB Write to a file and mention the path in the prompt
The run never ends A background Bash task, or a stuck subagent Bash tasks die after ~5 s; for subagents set CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS
The script runs forever consuming tokens No turn limit --max-turns
You cannot tell a review failure from a tool error Same exit code Catch the error and use distinct codes
A project MCP server is loaded without you wanting it -p sessions do not ask for approval --bare, or --strict-mcp-config, or disabledMcpjsonServers
A repo hook runs in an untrusted folder -p sessions do not show the trust dialog --bare on code that is not yours
/login does not work in -p Terminal-only commands are not available Authenticate first, or use CLAUDE_CODE_OAUTH_TOKEN
Unexpected exit code 143 The process received SIGTERM It is the expected behavior: turn interrupted and SessionEnd run

The basic forms

Terminal window
claude -p "prompt" # runs and prints
cat file | claude -p "prompt" # reads from stdin
claude -p "prompt" --output-format json # structured output
claude -p "prompt" --continue # continues the last conversation
claude -p "prompt" --resume "$sid" # resumes a session by ID
claude --bare -p "prompt" --allowedTools "Read" # reproducible, for CI

The flags to remember

Flag
-p, --print Non-interactive mode
--bare No auto-discovery — for CI
--output-format json Structured output with metadata
--json-schema '<schema>' Output validated against a schema
--allowedTools "A,B(pattern *)" Granular pre-approval
--permission-mode dontAsk Only what is already in allow
--max-turns N Seat belt
--max-budget-usd N Spending cap
--append-system-prompt "..." Adds instructions to the system prompt
--strict-mcp-config Only the servers passed with --mcp-config
--safe-mode Everything disabled, for diagnosis

Exit code

0 → success
≠0 → run failure
143 → interrupted by SIGTERM

Useful CLI commands in scripts

Command
claude setup-token Generates a long-lived OAuth token for CI and scripts
claude auth status Authentication status in JSON
claude doctor Installation and settings diagnostics
claude project purge <path> Deletes a project’s local state

Official pages consulted, checked on 16 August 2026:

A course by Giuseppe Bosi · © 2026 · textCC BY 4.0, code MIT · last updated 29 October 2026 · contact: info@giuseppebosi.com

Author: Prof. Giuseppe Bosi (LinkedIn) · More courses (in Italian):JSON & API ·llama.cpp ·Build your own AI memory (RAG)· Source code on GitHub

Independent course, not affiliated with or endorsed by Anthropic or Google. Claude and Claude Code are trademarks of Anthropic; Antigravity and Gemini are trademarks of Google.

Every module lists its official sources with the date they were consulted. This site uses no cookies and collects no personal data.