/scaffold
Scaffold a new shell script or shell project with production-ready defaults
From ccfg-shellnpx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-shell<name> [--type=script|project] [--shell=bash|sh]scaffold
Create a new shell script or shell project with production-ready structure, safety defaults, and consistent conventions. Scripts are immediately executable and pass shellcheck and shfmt validation.
Usage
ccfg shell scaffold deploy.sh # Single bash script (default)
ccfg shell scaffold install.sh --shell=sh # Single POSIX sh script
ccfg shell scaffold mytools --type=project # Full project layout
ccfg shell scaffold mytools --type=project --shell=sh # POSIX sh project
Overview
The scaffold command creates shell scripts or projects with:
- Safety preamble:
set -euo pipefail(bash) orset -eu(POSIX sh) - main() pattern: Structured code organization with functions
- Trap cleanup: Automatic resource cleanup on exit
- Argument parsing: Template for handling flags and arguments
- Executable permissions:
chmod +xapplied automatically - shellcheck compliance: Generated code passes shellcheck without warnings
All generated code follows shell best practices and is immediately usable.
Script Types
Single Script (Default)
A standalone executable shell script with full safety features.
Generated file (bash):
#!/usr/bin/env bash
#
# deploy.sh - [Brief description]
#
# Usage: deploy.sh [OPTIONS] <args>
#
set -euo pipefail
# --- Script directory ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- Logging ---
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
warn() { printf '[%s] WARN: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
err() { printf '[%s] ERROR: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
die() { err "$@"; exit 1; }
# --- Cleanup ---
cleanup() {
# Remove temporary files, kill background processes, etc.
:
}
trap cleanup EXIT
# --- Usage ---
usage() {
cat <<'EOF'
Usage: deploy.sh [OPTIONS] <args>
[Description of what this script does]
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
Arguments:
args [Description of required arguments]
Examples:
deploy.sh -v target
deploy.sh --help
EOF
}
# --- Argument parsing ---
parse_args() {
VERBOSE=false
while (( $# > 0 )); do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--verbose) VERBOSE=true; shift ;;
--) shift; break ;;
-*) die "Unknown option: $1" ;;
*) break ;;
esac
done
# Validate required arguments
if (( $# < 1 )); then
die "Missing required argument. Run with --help for usage."
fi
}
# --- Main ---
main() {
parse_args "$@"
log "Starting..."
# Script logic here
log "Done"
}
main "$@"
Generated file (POSIX sh):
#!/bin/sh
#
# install.sh - [Brief description]
#
# Usage: install.sh [OPTIONS] <args>
#
set -eu
# --- Logging ---
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
warn() { printf '[%s] WARN: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
err() { printf '[%s] ERROR: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
die() { err "$@"; exit 1; }
# --- Cleanup ---
cleanup() {
:
}
trap cleanup EXIT
# --- Usage ---
usage() {
cat <<'EOF'
Usage: install.sh [OPTIONS] <args>
[Description of what this script does]
Options:
-h Show this help message
-v Enable verbose output
Arguments:
args [Description of required arguments]
EOF
}
# --- Argument parsing ---
VERBOSE=false
while getopts ":hv" opt; do
case $opt in
h) usage; exit 0 ;;
v) VERBOSE=true ;;
*) die "Unknown option: -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
# --- Main ---
main() {
if [ $# -lt 1 ]; then
die "Missing required argument. Run with -h for usage."
fi
log "Starting..."
# Script logic here
log "Done"
}
main "$@"
Key differences between bash and POSIX sh scripts:
| Feature | Bash | POSIX sh |
|---|---|---|
| Preamble | set -euo pipefail | set -eu |
| Test syntax | [[ ]] | [ ] |
| Arrays | Supported | Not available |
| Local variables | local var= | Use function-scoped naming |
| Pattern matching | [[ $x == *.txt ]] | case $x in *.txt) |
| Arg parsing | while+case with long opts | getopts with short opts |
| Script dir | ${BASH_SOURCE[0]} | $0 (less reliable) |
Project Layout
A multi-file shell project with organized directory structure.
Generated structure:
mytools/
bin/
mytool # Main entry point (executable, no extension)
lib/
logging.sh # Logging functions
utils.sh # Utility functions
test/
mytool.bats # bats test file
Makefile # Build and validation targets
.shellcheckrc # shellcheck configuration
.editorconfig # Editor and shfmt configuration
README.md # Project documentation
Step-by-Step Process
1. Validate Arguments
Before creating any files, validate the input:
For single script:
- Name must end in
.shor.bash, or have no extension (forbin/scripts) - Name must not contain path traversal (
..) - File must not already exist (refuse to overwrite without confirmation)
For project:
- Name must be a valid directory name (alphanumeric, hyphens, underscores)
- Directory must not already exist
- Name should follow naming conventions (lowercase with hyphens)
Validation:
validate_script_name() {
local name="$1"
if [[ "$name" == *".."* ]]; then
die "Invalid name: contains path traversal"
fi
if [[ -e "$name" ]]; then
die "File already exists: $name"
fi
}
validate_project_name() {
local name="$1"
if [[ ! "$name" =~ ^[a-z][a-z0-9_-]*$ ]]; then
die "Invalid project name: must be lowercase, start with letter, contain only letters/numbers/hyphens/underscores"
fi
if [[ -d "$name" ]]; then
die "Directory already exists: $name"
fi
}
2. Determine Shell Variant
The --shell flag controls which shell dialect to generate:
--shell=bash(default): Bash 4+ with full features--shell=sh: POSIX sh compatible (no bashisms)
POSIX sh restrictions (enforced in generated code):
- No
[[ ]](use[ ]with proper quoting) - No arrays (use positional parameters or files)
- No
localkeyword (widely supported but not strictly POSIX) - No
pipefailoption - No
${BASH_SOURCE[0]}(use$0) - No pattern matching in
[[ ]](usecasestatements) - No
<<<here-strings - No process substitution
<()or>() - No
{1..10}brace expansion
3. Create Single Script
For --type=script (the default):
Write the script file:
Generate the appropriate template based on --shell (see templates above in Script Types section).
Set executable permissions:
chmod +x "$script_name"
Display success message:
Created: deploy.sh (bash script)
Next steps:
1. Edit deploy.sh to add your logic
2. Run: shellcheck deploy.sh
3. Run: ./deploy.sh --help
4. Create Project Layout
For --type=project:
Create directory structure:
mkdir -p "$name"/{bin,lib,test}
Generate bin/mytool (main entry point):
The main entry point in bin/ has no file extension. It sources libraries from lib/ and
implements the main logic.
#!/usr/bin/env bash
#
# mytool - [Brief description]
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$SCRIPT_DIR/../lib"
# shellcheck source=../lib/logging.sh
source "$LIB_DIR/logging.sh"
# shellcheck source=../lib/utils.sh
source "$LIB_DIR/utils.sh"
usage() {
cat <<'EOF'
Usage: mytool [OPTIONS] <command> [args...]
Commands:
run Execute the main operation
check Verify prerequisites
help Show this help message
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
--version Show version
EOF
}
VERSION="0.1.0"
main() {
local verbose=false
while (( $# > 0 )); do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--verbose) verbose=true; shift ;;
--version) echo "mytool $VERSION"; exit 0 ;;
--) shift; break ;;
-*) die "Unknown option: $1" ;;
*) break ;;
esac
done
local command="${1:-help}"
shift || true
case "$command" in
run) cmd_run "$@" ;;
check) cmd_check "$@" ;;
help) usage ;;
*) die "Unknown command: $command. Run 'mytool help' for usage." ;;
esac
}
cmd_run() {
log "Running main operation..."
# Implementation here
log "Done"
}
cmd_check() {
log "Checking prerequisites..."
require_cmd "curl"
require_cmd "jq"
log "All prerequisites met"
}
main "$@"
Generate lib/logging.sh:
#!/usr/bin/env bash
# logging.sh - Logging utility functions
#
# Source this file to get structured logging functions.
# All log output goes to stderr so stdout remains clean for data.
[[ -n "${_LOGGING_SH_LOADED:-}" ]] && return 0
_LOGGING_SH_LOADED=1
# Detect color support
if [[ -t 2 ]]; then
_LOG_RED='\033[0;31m'
_LOG_GREEN='\033[0;32m'
_LOG_YELLOW='\033[0;33m'
_LOG_BLUE='\033[0;34m'
_LOG_RESET='\033[0m'
else
_LOG_RED='' _LOG_GREEN='' _LOG_YELLOW='' _LOG_BLUE='' _LOG_RESET=''
fi
log() {
printf "${_LOG_BLUE}[%s]${_LOG_RESET} %s\n" "$(date '+%H:%M:%S')" "$*" >&2
}
warn() {
printf "${_LOG_YELLOW}[%s] WARN:${_LOG_RESET} %s\n" "$(date '+%H:%M:%S')" "$*" >&2
}
err() {
printf "${_LOG_RED}[%s] ERROR:${_LOG_RESET} %s\n" "$(date '+%H:%M:%S')" "$*" >&2
}
die() {
err "$@"
exit 1
}
success() {
printf "${_LOG_GREEN}[%s] OK:${_LOG_RESET} %s\n" "$(date '+%H:%M:%S')" "$*" >&2
}
Generate lib/utils.sh:
#!/usr/bin/env bash
# utils.sh - Common utility functions
#
# Source this file to get common helpers.
[[ -n "${_UTILS_SH_LOADED:-}" ]] && return 0
_UTILS_SH_LOADED=1
# Check if a command exists
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" &>/dev/null; then
die "Required command not found: $cmd"
fi
}
# Confirm action with user
confirm() {
local prompt="${1:-Continue?}"
local default="${2:-n}"
if [[ "$default" == "y" ]]; then
printf '%s [Y/n] ' "$prompt" >&2
else
printf '%s [y/N] ' "$prompt" >&2
fi
local reply
read -r reply
reply="${reply:-$default}"
[[ "$reply" =~ ^[Yy]$ ]]
}
# Create a temporary directory with automatic cleanup
make_temp_dir() {
local prefix="${1:-tmp}"
local tmpdir
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/${prefix}.XXXXXX")
# Note: caller should set up trap for cleanup
echo "$tmpdir"
}
# Retry a command with backoff
retry() {
local max_attempts="${1:?max_attempts required}"
local delay="${2:?delay required}"
shift 2
local attempt=1
until "$@"; do
if (( attempt >= max_attempts )); then
err "Command failed after $max_attempts attempts: $*"
return 1
fi
warn "Attempt $attempt/$max_attempts failed, retrying in ${delay}s..."
sleep "$delay"
(( attempt++ ))
(( delay *= 2 ))
done
}
Generate test/mytool.bats:
#!/usr/bin/env bats
# Test suite for mytool
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
PATH="$SCRIPT_DIR/bin:$PATH"
}
@test "mytool --help exits 0" {
run mytool --help
[ "$status" -eq 0 ]
}
@test "mytool --help shows usage" {
run mytool --help
[ "$status" -eq 0 ]
[[ "${output}" == *"Usage:"* ]]
}
@test "mytool --version shows version" {
run mytool --version
[ "$status" -eq 0 ]
[[ "${output}" == *"0.1.0"* ]]
}
@test "mytool unknown command fails" {
run mytool nonexistent
[ "$status" -eq 1 ]
[[ "${output}" == *"Unknown command"* ]]
}
@test "mytool unknown option fails" {
run mytool --nonexistent
[ "$status" -eq 1 ]
[[ "${output}" == *"Unknown option"* ]]
}
Generate Makefile:
SHELL := /bin/bash
.DEFAULT_GOAL := help
SCRIPTS := $(shell find bin lib -type f -name '*.sh' -o -type f -executable 2>/dev/null)
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
.PHONY: lint
lint: ## Run shellcheck
@shellcheck -x $(SCRIPTS)
.PHONY: format
format: ## Format with shfmt
@shfmt -w $(SCRIPTS)
.PHONY: format-check
format-check: ## Check formatting
@shfmt -d $(SCRIPTS)
.PHONY: test
test: ## Run bats tests
@bats test/
.PHONY: validate
validate: lint format-check test ## Run all checks
Generate .shellcheckrc:
# ShellCheck configuration
# https://www.shellcheck.net/wiki/
# Follow source directives
external-sources=true
# Default shell (can be overridden by shebang)
shell=bash
Generate .editorconfig:
# EditorConfig for shell scripts
# https://editorconfig.org/
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
[*.{sh,bash}]
indent_style = space
indent_size = 4
[Makefile]
indent_style = tab
[*.bats]
indent_style = space
indent_size = 4
Set executable permissions:
chmod +x "$name/bin/mytool"
5. POSIX sh Project Variant
When --shell=sh is used with --type=project, the following changes apply:
- Shebang is
#!/bin/shinstead of#!/usr/bin/env bash - No
[[ ]], use[ ]with proper quoting - No arrays, no local, no pipefail
- No
${BASH_SOURCE[0]}, use$0 - Library guard uses a different pattern:
# POSIX sh guard against double-source
if [ "${_LOGGING_SH_LOADED:-}" = "1" ]; then
return 0 2>/dev/null || true
fi
_LOGGING_SH_LOADED=1
.shellcheckrcsetsshell=sh- Argument parsing uses
getoptsinstead ofwhile+casewith long options
6. Display Success Message
Single script success:
Created: deploy.sh (bash script with main() pattern)
chmod +x deploy.sh (already done)
Next steps:
1. Edit deploy.sh to add your script logic
2. Validate: shellcheck deploy.sh && shfmt -d deploy.sh
3. Run: ./deploy.sh --help
Project success:
Created project: mytools/
mytools/
bin/mytool Main entry point
lib/logging.sh Logging functions
lib/utils.sh Utility functions
test/mytool.bats Test suite
Makefile Build targets
.shellcheckrc shellcheck config
.editorconfig Editor config
Next steps:
1. cd mytools
2. Edit bin/mytool to add commands
3. Run tests: make test
4. Validate: make validate
5. Try it: bin/mytool --help
Key Rules and Requirements
Safety Defaults Are Mandatory
Every generated script must include the appropriate safety preamble:
- Bash:
set -euo pipefail(always, no exceptions) - POSIX sh:
set -eu(always, no exceptions)
Never generate scripts without these safety settings.
The main() Pattern Is Required
Every generated script must use the main() pattern:
- Define functions above main
- Call
main "$@"at the bottom of the file - This prevents accidental execution of code during sourcing
Trap Cleanup Is Required
Every generated script must include a cleanup function and EXIT trap, even if the cleanup function is initially empty. This establishes the pattern for developers to fill in.
chmod +x Is Automatic
Always set executable permissions on generated scripts. The user should never have to manually
chmod +x a scaffolded script.
No Overwriting Existing Files
Never overwrite existing files without explicit confirmation. If the target file or directory already exists, report the conflict and exit.
POSIX sh Must Be Truly Portable
When --shell=sh is selected, the generated code must not contain ANY bashisms:
- No
[[ ]]conditionals - No arrays (indexed or associative)
- No
localkeyword (though widely supported, avoid for strict POSIX) - No
pipefail - No
${BASH_SOURCE[0]} - No
<<<here-strings - No
<()or>()process substitution - No
{1..10}brace expansion - No
$(( ))with++or--operators - No
==in[ ](use=instead)
Generated Code Must Pass Validation
All generated code must pass shellcheck -x and shfmt -d immediately after creation. This is a
hard requirement. Never generate code with known shellcheck warnings.
Common Scenarios
Scenario 1: Quick Utility Script
Developer needs a one-off script for a deployment task.
ccfg shell scaffold deploy-staging.sh
Creates a single bash script with argument parsing, logging, and cleanup. Developer fills in the logic.
Scenario 2: POSIX Installer Script
Project needs a portable installer that works on any Unix system.
ccfg shell scaffold install.sh --shell=sh
Creates a POSIX sh script that avoids all bashisms. Safe to run on Alpine, BusyBox, dash, and other minimal shells.
Scenario 3: CLI Tool Project
Team is building a multi-command shell tool.
ccfg shell scaffold myctl --type=project
Creates a full project with bin/, lib/, test/, Makefile, and configuration files. The main
entry point supports subcommands.
Scenario 4: Adding a Script to Existing Project
Developer wants to add a script to an existing project.
ccfg shell scaffold scripts/backup.sh
Creates the script in the specified path (creating parent directories if needed) without generating a full project structure.
Troubleshooting
"File already exists"
Choose a different name or remove the existing file first. The scaffold command will never silently overwrite existing files.
"Invalid project name"
Project names must start with a lowercase letter and contain only lowercase letters, numbers,
hyphens, and underscores. Examples: my-tool, deploy_utils, backup2.
"shellcheck reports warnings in generated code"
This should never happen. Generated code must pass shellcheck. If it does, this is a bug in the scaffold command template.
Scripts not executable after scaffolding
The scaffold command runs chmod +x automatically. If permissions are wrong, check filesystem
restrictions (e.g., FAT32/exFAT filesystems do not support Unix permissions).
Summary
The scaffold command creates production-ready shell scripts and projects with safety defaults,
structured organization, and immediate shellcheck compliance. By enforcing the main() pattern, trap
cleanup, and strict mode, scaffolded scripts establish good habits from the first line of code.
Choose --shell=sh when portability across minimal Unix environments is required, and
--shell=bash (the default) when Bash 4+ features are needed.