# Writing an AGENTS.md for shell-heavy projects

> Source: https://learn-bash.net/ai/agents-md/
> Part of Learn Bash, free to read.

`AGENTS.md` is a Markdown file in your repository root that coding agents read before they start. Claude Code reads `CLAUDE.md`; most other tools read `AGENTS.md`. Write one, symlink the other:

```bash
ln -s AGENTS.md CLAUDE.md
```

For shell, this file has a narrower job than in other languages, because `shellcheck` already covers the syntax-level mistakes better than prose ever could. What is left is three things: **the mandatory header, the destructive-command policy, and when to stop.**

## The header is the whole first section

Generated shell is written in the style of the shell in its training data — tutorials, READMEs, Stack Overflow answers — none of which was expected to run unattended, and all of which omits guards for brevity.

```markdown
## Every script starts with exactly this
    #!/usr/bin/env bash
    set -Eeuo pipefail
    IFS=$'\n\t'
    trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR
```

`set -u` is doing the heaviest lifting there. It is what turns `rm -rf "$BUILD_DIR/"` with an unset variable from a catastrophe into a clean exit — which is the single worst outcome available in this language.

`#!/usr/bin/env bash` rather than `#!/bin/bash` matters on macOS, where `/bin/bash` is version 3.2 and lacks associative arrays and `${var,,}`.

## The template

```markdown AGENTS.md
Bash 5. Scripts live in scripts/ and are checked by shellcheck and shfmt.

## Commands
- Lint:   `shellcheck -S warning scripts/*.sh`  <- must be clean
- Format: `shfmt -w -i 2 -ci -bn scripts/`
- Test:   `bats test/`

## Every script starts with exactly this
    #!/usr/bin/env bash
    set -Eeuo pipefail
    IFS=$'\n\t'
    trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR

## Non-negotiable
- Quote every expansion: "$var", "${arr[@]}", "$(cmd)". No exceptions.
- Use `--` before anything user-controlled: `rm -- "$f"`, `cp -- "$a" "$b"`.
- Never parse `ls`. Glob, and handle the no-match case:
      for f in ./*.txt; do [[ -e "$f" ]] || continue; ...; done
- `[[ ]]` not `[ ]`. `(( ))` for arithmetic comparison.
- `jq` for JSON, `yq` for YAML. Never sed or grep on structured data.
- No `eval` on anything interpolated. Use an array instead.
- Read loops use `IFS= read -r`, and take input via `< <(cmd)` process
  substitution, never `cmd | while` — the pipeline creates a subshell and
  your variables are lost.

## Destructive operations
- Anything that deletes, overwrites, or pushes needs either a `--dry-run`
  flag that prints instead, or an explicit `--i-mean-it`.
- Validate before deleting:
      [[ -n "${dir:-}" && "$dir" != "/" ]] || { echo refusing >&2; exit 1; }
- `cd` with a check. Verify a marker file exists before doing anything:
      cd "$(dirname "$0")/.." && [[ -f go.mod ]] || exit 1

## Portability
- We run on macOS and Linux. Do not use GNU-only flags (`sed -i` without an
  argument, `date -d`). If you need them, use gsed/gdate and check they exist.

## When to stop writing shell
Over ~100 lines, or the moment you need an array of anything other than
strings, real error handling, or a function that returns a value: rewrite it
in Python. Say so rather than writing a 400-line bash script.

## Landmines
- scripts/deploy.sh is run by CI with no TTY. Nothing may prompt.
- scripts/rotate-logs.sh runs as root from cron. Read it twice.
```

## Enforce what you can

Almost everything in the "Non-negotiable" section above is a `shellcheck` rule, which means it does not have to rely on the model's attention:

```bash
shellcheck -S warning scripts/*.sh
```

Wire it into a [post-edit hook](https://codelearningdojo.com/harness-hooks/) and generated shell gets fixed before you ever read it. `shellcheck` has very few false positives and every warning links to an explanation — it is the `ruff` of this ecosystem.

What `shellcheck` cannot tell you is whether a destructive command is pointed at the right path. That is why the destructive-operations section stays in prose, and why it is worth also enforcing at the harness level with a `PreToolUse` guard.

:::warn The rule most worth having
"Anything destructive needs a `--dry-run`." It costs three lines in a script and it makes every generated script safe to run once before you trust it. This is the single most useful line in a shell `AGENTS.md`.
:::

## The "when to stop" line is not filler

Left to itself, a model asked to extend a shell script will extend the shell script. It will not tell you that what you now have is a program written in a language without data structures.

Putting the threshold in the file gives it permission to say so, and it will — usually correctly. That one paragraph has prevented more bad code than the rest of the file combined.

## Common questions

### Is this file worth writing if I only have a few scripts?

The header section is, on its own. Four lines, and it changes the failure behaviour of every script in the repo. The rest can wait until you have enough shell to have opinions about it.

### Should the rules apply to inline commands the agent runs, not just committed scripts?

Different mechanism. `AGENTS.md` shapes what gets written to files; what the agent *runs* is governed by your permission allowlist and hooks. Both matter, and the hook is the one that actually enforces — see [harness hooks](https://codelearningdojo.com/harness-hooks/).

### bash or POSIX sh?

`bash` unless you genuinely must run on Alpine or a BSD without it. Writing POSIX-only by default costs you arrays and `[[ ]]` for portability you probably do not need — but if you do need it, say so in the file and run `shellcheck -s sh` to enforce it.
