Building better bash scripts with Claude Code skills
I often find myself writing scripts to automate small repetitive CLI tasks. The last one that I wrote was a script that creates a docker container and pushes it to Amazon ECR. Afterwards it upgrades a Helm chart with that new container image in a k8s cluster. Running every command becomes annoying after doing it a few times.
Nowadays I don't write the scripts by hand anymore, I just ask an AI to write them form me really quick. Honestly, for small scripts like these, I don't think anyone should waste time writing them by hand anymore. However, I do like that these scripts follow the best practices of writing bash scripts and CLI tools. I'm checking these two websites from time to time when I write scripts:
- https://clig.dev/
- https://tldp.org/LDP/abs/html/ (I don't really check this one by hand but I land on it after clicking a link somewhere).
Claude Code is what the AI that I use the most for software engineering tasks. It has this cool feature called skills that allows you to implement custom behaviour in prompts. This gave me the idea of writing a skill that helps me in implementing bash scripts that follow best practices. So I launched my editor and wrote the following SKILL.md template after skimming through the Claude Code Docs:
1---
2name: bash-scripting
3description: Guideline for writing bash scripts. Use whenever asked to write a bash script.
4---
5
6# Write a bash script for the given task
7
8## Instructions
9
10Write a bash script for $ARGUMENTS. Make sure to:
11- Keep it simple, readable and apply clean code principles.
12- Follow UNIX principles.
13- Make it easy to use.
14- Write a concise usage summary and add a '--help' flag.
15
16Reference the following sources for writing CLI tools:
17- http://clig.dev/
18
19Reference this documentation for writing bash scripts:
20- https://tldp.org/LDP/abs/html/
21
22After you finished writing the script, check it with:
23shellcheck -s bash -C auto <path/to/script>
24Analyze the output of the command and fix the issues reported by shellcheck if any.
It is not at all complete, but I wanted to use Claude to refine it. So I wrote a prompt where I copied the text above and asked Claude to help me improve this SKILL.md.
What was good about this was:
- Clear purpose and trigger condition
- Good external references
- Validation step with
shellcheck
However, in this form, it is not even close to a good skill.
Mistake #1 (and the biggest): the instructions are vague.
The instructions must be specific, i.e. instead of saying 'Follow UNIX principles' you simply list the principles themselves, such as:
- Write output to stdout by default; support
--output <file> - Write errors and diagnostics to stderr only
- Accept stdin when
-is given as filename - Produce one record per line for text output (pipeline-friendly)
- Offer
--jsonfor machine-parseable output when appropriate
Instead of saying 'follow bash scripting best practices', write them out. Showing Claude what good looks like, using actual code, is more effective than describing it.
- Always start the script with:
1#!/usr/bin/env bash
2set -euo pipefail
- Always quote:
"$var"not$var - Defaults:
${var:-default}, required:${var:?error message}
Mistake #2: the skill is not self-contained
Skills have to be self-contained, they should not depend on external references such as links. Unless web search is enabled and explicitly used, Claude cannot browse URLs and will rely on its training data (which might not be up-to-date).
One more reason to avoid links is for context efficiency. Getting a whole website page (or dumping a whole doc) will fill up the context with unnecessary data. Prefer a shorter summary instead with the most essential principles. Skills are meant to reliably shape behaviour, a short summary is enough to guide Claude to output a better response.
Mistake #3: the skill uses $ARGUMENTS
Be clear about the purpose of the skill and the trigger condition in the skill description, and drop the $ARGUMENTS. $ARGUMENTS is only populated when you explicitly invoke the skill with a slash command, e.g. /bash-scripting .... If the skill is triggered automatically based on the description field, the arguments will be missing and the task will be empty: Write a bash script for . Make sure to:. This is confusing and wastes tokens.
Claude can infer what needs to be done from the prompt and will select the skill to shape the response based on the description, so you don't need $ARGUMENTS.
I asked Claude to analyze, extract and summarize the most pragmatic ideas from http://clig.dev/ and https://tldp.org/LDP/abs/html/. I've added them to the skill. Also, I used XML tags to delimit sections instead of using only Markdown headers (Claude also gave me this tip). After a couple more iterations, this is the final SKILL.md:
1---
2name: bash-scripting
3description: Guidelines for writing production-quality bash scripts. Use whenever asked to write a bash script.
4---
5
6<role>
7You are an expert bash developer who writes clean, portable, and robust shell scripts.
8</role>
9
10<cli_design_principles>
11## Human-Friendly Design
12- Provide `--help` and `-h` flags; show usage on invalid input
13- Use full words for long flags (`--output` not `--outp`)
14- Confirm destructive actions unless `--force` is passed
15- Show progress for long operations; support `--quiet` and `--verbose`
16
17## Composability
18- Write output to stdout by default; support `--output <file>`
19- Write errors and diagnostics to stderr only
20- Accept stdin when `-` is given as filename
21- Produce one record per line for text output (pipeline-friendly)
22- Offer `--json` for machine-parseable output when appropriate
23
24## Arguments & Flags
25- Required values → positional arguments
26- Optional values → flags with sensible defaults
27- Support `--` to separate flags from positional arguments
28- Provide short flags for common operations (`-v`, `-h`, `-o`)
29
30## Exit Codes
31- `0` = success
32- `1` = general error
33- `2` = invalid usage
34
35## Error Messages
36- Format: `scriptname: error: what went wrong`
37- Include what happened and how to fix it
38- Suggest `--help` on invalid usage
39
40## Robustness
41- Validate arguments before doing any work
42- Fail fast—don't partially complete then error
43- Clean up temp files on exit (use trap)
44
45</cli_design_principles>
46
47Be idempotent where possible (safe to run twice)
48
49<bash_coding_standards>
50## Script Header
51Always start with:
52```bash
53#!/usr/bin/env bash
54set -euo pipefail
55```
56
57## Variables
58- Lowercase for local: `local filename`
59- UPPERCASE for exported/constants: `readonly VERSION="1.0.0"`
60- Always quote: `"$var"` not `$var`
61- Defaults: `${var:-default}`, required: `${var:?error message}`
62
63## Conditionals
64- Use `[[ ]]` not `[ ]`
65- Use `(( ))` for arithmetic
66- Check command existence: `command -v git &>/dev/null`
67
68## Functions
69- Use `local` for all variables
70- Keep functions short and single-purpose
71- Return status with `return`, output with `echo`
72
73## Error Handling & Cleanup
74```bash
75cleanup() { rm -f "$tmpfile"; }
76trap cleanup EXIT
77
78die() { echo "${0##*/}: error: $*" >&2; exit 1; }
79```
80
81## Argument Parsing Pattern
82```bash
83while [[ $# -gt 0 ]]; do
84 case "$1" in
85 -h|--help) usage; exit 0 ;;
86 -o|--output) output="$2"; shift 2 ;;
87 --) shift; break ;;
88 -*) die "unknown option: $1" ;;
89 *) break ;;
90 esac
91done
92```
93
94## Safe Iteration
95```bash
96# Over lines (handles whitespace)
97while IFS= read -r line; do ...; done < "$file"
98
99# Over globs (handles missing matches)
100for f in *.txt; do [[ -e "$f" ]] || continue; ...; done
101```
102</bash_coding_standards>
103
104<template>
105Use this structure as a starting point:
106
107```bash
108#!/usr/bin/env bash
109set -euo pipefail
110
111readonly SCRIPT_NAME="${0##*/}"
112readonly VERSION="1.0.0"
113
114usage() {
115 cat >&2 <<EOF
116Usage: $SCRIPT_NAME [OPTIONS] <required_arg>
117
118Brief description of what this script does.
119
120Arguments:
121 required_arg Description of required argument
122
123Options:
124 -h, --help Show this help message
125 -v, --verbose Enable verbose output
126 -o, --output Output file (default: stdout)
127
128Examples:
129 $SCRIPT_NAME input.txt
130 $SCRIPT_NAME -o result.txt input.txt
131 echo "data" | $SCRIPT_NAME -
132EOF
133}
134
135die() {
136 echo "$SCRIPT_NAME: error: $*" >&2
137 exit 1
138}
139
140main() {
141 local verbose=false
142 local output="/dev/stdout"
143
144 while [[ $# -gt 0 ]]; do
145 case "$1" in
146 -h|--help) usage; exit 0 ;;
147 -v|--verbose) verbose=true; shift ;;
148 -o|--output) output="$2"; shift 2 ;;
149 --) shift; break ;;
150 -*) die "unknown option: $1 (see --help)" ;;
151 *) break ;;
152 esac
153 done
154
155 [[ $# -ge 1 ]] || { usage; exit 2; }
156
157 local input="$1"
158
159 # Check dependencies
160 # command -v jq &>/dev/null || die "jq is required but not installed"
161
162 # Main logic here
163}
164
165main "$@"
166```
167</template>
168
169<output_format>
170Provide:
1711. The complete bash script
1722. 2-3 sentences explaining key design decisions
1733. Example commands showing typical usage
174</output_format>
175
176<validation>
177Remind the user to validate with:
178```bash
179shellcheck -s bash script.sh
180```
181</validation>
Lessons learned:
- You need to be really specific about the instructions. Do not say 'follow best principles', list the principles themselves instead.
- Showing Claude what good looks like is more effective than describing it.
- Do not provide links to websites in the
SKILL.md. Skills have to be self-contained. Summarize the ideas from those websites and add them to the SKILL.md. - It's up to you to prioritise what's most important for the skill to avoid filling up the context. You know what matters.
- Claude responds better to sections that are delimited by XML tags rather than Markdown headers.
- Be clear about the purpose of the skill and the trigger condition in the skill description, and drop the $ARGUMENTS.