Manage persistent state directories for bash scripts
76
56%
Does it follow best practices?
Impact
89%
2.96xAverage score across 6 eval scenarios
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./bash/state-directory-manager-vamseeachanta-workspace-hub/SKILL.mdThe canonical home for this skill is state-directory-manager in majiayu000/claude-skill-registry
Patterns for managing persistent state, configuration, and cache directories in bash scripts following XDG Base Directory specification.
✅ Use when:
❌ Avoid when:
Follow the XDG specification for directory locations:
#!/bin/bash
# ABOUTME: XDG Base Directory compliant state management
# ABOUTME: Cross-platform directory locations
# XDG Base Directories with fallbacks
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
# Application-specific directories
APP_NAME="my-tool"
CONFIG_DIR="$XDG_CONFIG_HOME/$APP_NAME"
DATA_DIR="$XDG_DATA_HOME/$APP_NAME"
STATE_DIR="$XDG_STATE_HOME/$APP_NAME"
CACHE_DIR="$XDG_CACHE_HOME/$APP_NAME"
LOG_DIR="$STATE_DIR/logs"
# Initialize directories
init_directories() {
mkdir -p "$CONFIG_DIR"
mkdir -p "$DATA_DIR"
mkdir -p "$STATE_DIR"
mkdir -p "$CACHE_DIR"
mkdir -p "$LOG_DIR"
}Alternative using home directory (from workspace-hub scripts):
#!/bin/bash
# ABOUTME: Workspace-hub style state directory management
# ABOUTME: Simple $HOME/.app-name pattern
APP_NAME="workspace-hub"
APP_DIR="${HOME}/.${APP_NAME}"
# Directory structure
CONFIG_DIR="$APP_DIR/config"
DATA_DIR="$APP_DIR/data"
LOGS_DIR="$APP_DIR/logs"
CACHE_DIR="$APP_DIR/cache"
TEMP_DIR="$APP_DIR/tmp"
# Initialize with proper permissions
init_app_dirs() {
local dirs=("$CONFIG_DIR" "$DATA_DIR" "$LOGS_DIR" "$CACHE_DIR" "$TEMP_DIR")
for dir in "${dirs[@]}"; do
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir"
chmod 700 "$dir" # Private by default
fi
done
}
# Clean old temp files
clean_temp() {
find "$TEMP_DIR" -type f -mtime +1 -delete 2>/dev/null || true
}Read and write configuration files:
#!/bin/bash
# ABOUTME: Configuration file management
# ABOUTME: Key-value pairs with defaults
CONFIG_FILE="$CONFIG_DIR/config"
# Default configuration
declare -A DEFAULT_CONFIG=(
["parallel_workers"]="5"
["log_level"]="INFO"
["auto_sync"]="true"
["timeout"]="30"
)
# Initialize config with defaults
init_config() {
if [[ ! -f "$CONFIG_FILE" ]]; then
{
echo "# Configuration for $APP_NAME"
echo "# Generated: $(date)"
echo ""
for key in "${!DEFAULT_CONFIG[@]}"; do
echo "${key}=${DEFAULT_CONFIG[$key]}"
done
} > "$CONFIG_FILE"
fi
}
# Read config value
get_config() {
local key="$1"
local default="${2:-${DEFAULT_CONFIG[$key]:-}}"
if [[ -f "$CONFIG_FILE" ]]; then
local value
value=$(grep "^${key}=" "$CONFIG_FILE" 2>/dev/null | cut -d'=' -f2-)
echo "${value:-$default}"
else
echo "$default"
fi
}
# Write config value
set_config() {
local key="$1"
local value="$2"
init_config
if grep -q "^${key}=" "$CONFIG_FILE" 2>/dev/null; then
# Update existing
sed -i "s|^${key}=.*|${key}=${value}|" "$CONFIG_FILE"
else
# Add new
echo "${key}=${value}" >> "$CONFIG_FILE"
fi
}
# Load all config into associative array
load_config() {
declare -gA CONFIG
# Start with defaults
for key in "${!DEFAULT_CONFIG[@]}"; do
CONFIG[$key]="${DEFAULT_CONFIG[$key]}"
done
# Override with file values
if [[ -f "$CONFIG_FILE" ]]; then
while IFS='=' read -r key value; do
[[ "$key" =~ ^#.*$ || -z "$key" ]] && continue
CONFIG[$key]="$value"
done < "$CONFIG_FILE"
fi
}
# Usage
init_config
load_config
echo "Parallel workers: ${CONFIG[parallel_workers]}"
set_config "parallel_workers" "10"Track persistent state between runs:
#!/bin/bash
# ABOUTME: State file operations
# ABOUTME: Track last run, progress, etc.
STATE_FILE="$STATE_DIR/state.json"
# Initialize state
init_state() {
if [[ ! -f "$STATE_FILE" ]]; then
cat > "$STATE_FILE" << EOF
{
"version": "1.0.0",
"created": "$(date -Iseconds)",
"last_run": null,
"run_count": 0,
"last_status": null
}
EOF
fi
}
# Get state value (requires jq)
get_state() {
local key="$1"
local default="${2:-null}"
if [[ -f "$STATE_FILE" ]] && command -v jq &>/dev/null; then
jq -r ".$key // $default" "$STATE_FILE"
else
echo "$default"
fi
}
# Update state value (requires jq)
set_state() {
local key="$1"
local value="$2"
init_state
if command -v jq &>/dev/null; then
local temp=$(mktemp)
jq ".$key = $value" "$STATE_FILE" > "$temp" && mv "$temp" "$STATE_FILE"
fi
}
# Record run
record_run() {
local status="$1"
set_state "last_run" "\"$(date -Iseconds)\""
set_state "last_status" "\"$status\""
set_state "run_count" "$(($(get_state run_count 0) + 1))"
}
# Simple key-value state (no jq required)
STATE_KV_FILE="$STATE_DIR/state.kv"
get_state_kv() {
local key="$1"
local default="$2"
if [[ -f "$STATE_KV_FILE" ]]; then
grep "^${key}=" "$STATE_KV_FILE" 2>/dev/null | cut -d'=' -f2- || echo "$default"
else
echo "$default"
fi
}
set_state_kv() {
local key="$1"
local value="$2"
mkdir -p "$(dirname "$STATE_KV_FILE")"
if [[ -f "$STATE_KV_FILE" ]] && grep -q "^${key}=" "$STATE_KV_FILE"; then
sed -i "s|^${key}=.*|${key}=${value}|" "$STATE_KV_FILE"
else
echo "${key}=${value}" >> "$STATE_KV_FILE"
fi
}Implement caching with expiration:
#!/bin/bash
# ABOUTME: Cache management with TTL
# ABOUTME: Store and retrieve cached data
CACHE_TTL="${CACHE_TTL:-3600}" # 1 hour default
# Get cache file path
cache_path() {
local key="$1"
local hash=$(echo -n "$key" | md5sum | cut -c1-16)
echo "$CACHE_DIR/${hash}"
}
# Check if cache is valid
cache_valid() {
local key="$1"
local ttl="${2:-$CACHE_TTL}"
local path=$(cache_path "$key")
if [[ -f "$path" ]]; then
local age=$(($(date +%s) - $(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path")))
[[ $age -lt $ttl ]]
else
return 1
fi
}
# Get from cache
cache_get() {
local key="$1"
local ttl="${2:-$CACHE_TTL}"
local path=$(cache_path "$key")
if cache_valid "$key" "$ttl"; then
cat "$path"
return 0
fi
return 1
}
# Set cache
cache_set() {
local key="$1"
local value="$2"
local path=$(cache_path "$key")
mkdir -p "$CACHE_DIR"
echo "$value" > "$path"
}
# Delete cache
cache_delete() {
local key="$1"
local path=$(cache_path "$key")
rm -f "$path"
}
# Clear all cache
cache_clear() {
rm -rf "$CACHE_DIR"/*
}
# Clean expired cache entries
cache_clean() {
local ttl="${1:-$CACHE_TTL}"
find "$CACHE_DIR" -type f -mmin "+$((ttl / 60))" -delete 2>/dev/null || true
}
# Usage with automatic caching
get_with_cache() {
local key="$1"
local command="$2"
local ttl="${3:-$CACHE_TTL}"
if cache_valid "$key" "$ttl"; then
cache_get "$key"
else
local result
result=$(eval "$command")
cache_set "$key" "$result"
echo "$result"
fi
}
# Example
result=$(get_with_cache "api_response" "curl -s https://api.example.com/data" 300)Manage logs with rotation:
#!/bin/bash
# ABOUTME: Log file management with rotation
# ABOUTME: Automatic cleanup of old logs
LOG_FILE="$LOG_DIR/app.log"
LOG_MAX_SIZE=$((10 * 1024 * 1024)) # 10MB
LOG_MAX_FILES=5
# Initialize logging
init_logging() {
mkdir -p "$LOG_DIR"
touch "$LOG_FILE"
}
# Write to log
log_to_file() {
local level="$1"
shift
local message="$*"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] $level: $message" >> "$LOG_FILE"
# Check if rotation needed
maybe_rotate_logs
}
# Rotate logs if needed
maybe_rotate_logs() {
if [[ -f "$LOG_FILE" ]]; then
local size=$(stat -c %s "$LOG_FILE" 2>/dev/null || stat -f %z "$LOG_FILE")
if [[ $size -gt $LOG_MAX_SIZE ]]; then
rotate_logs
fi
fi
}
# Perform log rotation
rotate_logs() {
# Remove oldest
rm -f "${LOG_FILE}.${LOG_MAX_FILES}"
# Shift existing
for ((i=LOG_MAX_FILES-1; i>=1; i--)); do
if [[ -f "${LOG_FILE}.$i" ]]; then
mv "${LOG_FILE}.$i" "${LOG_FILE}.$((i+1))"
fi
done
# Rotate current
if [[ -f "$LOG_FILE" ]]; then
mv "$LOG_FILE" "${LOG_FILE}.1"
touch "$LOG_FILE"
fi
}
# Clean old logs
clean_old_logs() {
local days="${1:-30}"
find "$LOG_DIR" -name "*.log*" -mtime "+$days" -delete 2>/dev/null || true
}
# View recent logs
tail_logs() {
local lines="${1:-50}"
tail -n "$lines" "$LOG_FILE"
}
# Search logs
search_logs() {
local pattern="$1"
grep -h "$pattern" "$LOG_DIR"/*.log* 2>/dev/null | tail -100
}#!/bin/bash
# ABOUTME: Complete state directory manager
# ABOUTME: Reusable module for bash scripts
# ─────────────────────────────────────────────────────────────────
# State Directory Manager v1.0.0
# ─────────────────────────────────────────────────────────────────
# Application identity (override in your script)
: "${STATE_APP_NAME:=my-app}"
# Directory setup
STATE_BASE_DIR="${HOME}/.${STATE_APP_NAME}"
STATE_CONFIG_DIR="$STATE_BASE_DIR/config"
STATE_DATA_DIR="$STATE_BASE_DIR/data"
STATE_CACHE_DIR="$STATE_BASE_DIR/cache"
STATE_LOG_DIR="$STATE_BASE_DIR/logs"
STATE_TMP_DIR="$STATE_BASE_DIR/tmp"
# File paths
STATE_CONFIG_FILE="$STATE_CONFIG_DIR/config"
STATE_STATE_FILE="$STATE_DATA_DIR/state"
STATE_LOG_FILE="$STATE_LOG_DIR/app.log"
# Settings
STATE_CACHE_TTL="${STATE_CACHE_TTL:-3600}"
STATE_LOG_MAX_SIZE="${STATE_LOG_MAX_SIZE:-10485760}"
STATE_LOG_MAX_FILES="${STATE_LOG_MAX_FILES:-5}"
# ─────────────────────────────────────────────────────────────────
# Initialization
# ─────────────────────────────────────────────────────────────────
state_init() {
local dirs=(
"$STATE_CONFIG_DIR"
"$STATE_DATA_DIR"
"$STATE_CACHE_DIR"
"$STATE_LOG_DIR"
"$STATE_TMP_DIR"
)
for dir in "${dirs[@]}"; do
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir"
chmod 700 "$dir"
fi
done
# Initialize files
[[ -f "$STATE_CONFIG_FILE" ]] || touch "$STATE_CONFIG_FILE"
[[ -f "$STATE_STATE_FILE" ]] || touch "$STATE_STATE_FILE"
[[ -f "$STATE_LOG_FILE" ]] || touch "$STATE_LOG_FILE"
}
# ─────────────────────────────────────────────────────────────────
# Config Functions
# ─────────────────────────────────────────────────────────────────
state_config_get() {
local key="$1"
local default="$2"
grep "^${key}=" "$STATE_CONFIG_FILE" 2>/dev/null | cut -d'=' -f2- || echo "$default"
}
state_config_set() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "$STATE_CONFIG_FILE" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${value}|" "$STATE_CONFIG_FILE"
else
echo "${key}=${value}" >> "$STATE_CONFIG_FILE"
fi
}
state_config_list() {
cat "$STATE_CONFIG_FILE" 2>/dev/null | grep -v '^#' | grep -v '^$'
}
# ─────────────────────────────────────────────────────────────────
# State Functions
# ─────────────────────────────────────────────────────────────────
state_get() {
local key="$1"
local default="$2"
grep "^${key}=" "$STATE_STATE_FILE" 2>/dev/null | cut -d'=' -f2- || echo "$default"
}
state_set() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "$STATE_STATE_FILE" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${value}|" "$STATE_STATE_FILE"
else
echo "${key}=${value}" >> "$STATE_STATE_FILE"
fi
}
# ─────────────────────────────────────────────────────────────────
# Cache Functions
# ─────────────────────────────────────────────────────────────────
state_cache_key() {
echo -n "$1" | md5sum | cut -c1-16
}
state_cache_get() {
local key="$1"
local ttl="${2:-$STATE_CACHE_TTL}"
local path="$STATE_CACHE_DIR/$(state_cache_key "$key")"
if [[ -f "$path" ]]; then
local age=$(($(date +%s) - $(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path")))
if [[ $age -lt $ttl ]]; then
cat "$path"
return 0
fi
fi
return 1
}
state_cache_set() {
local key="$1"
local value="$2"
local path="$STATE_CACHE_DIR/$(state_cache_key "$key")"
echo "$value" > "$path"
}
state_cache_clear() {
rm -rf "$STATE_CACHE_DIR"/*
}
# ─────────────────────────────────────────────────────────────────
# Log Functions
# ─────────────────────────────────────────────────────────────────
state_log() {
local level="$1"
shift
local message="$*"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $level: $message" >> "$STATE_LOG_FILE"
# Auto-rotate
local size=$(stat -c %s "$STATE_LOG_FILE" 2>/dev/null || echo 0)
if [[ $size -gt $STATE_LOG_MAX_SIZE ]]; then
state_log_rotate
fi
}
state_log_rotate() {
rm -f "${STATE_LOG_FILE}.${STATE_LOG_MAX_FILES}"
for ((i=STATE_LOG_MAX_FILES-1; i>=1; i--)); do
[[ -f "${STATE_LOG_FILE}.$i" ]] && mv "${STATE_LOG_FILE}.$i" "${STATE_LOG_FILE}.$((i+1))"
done
mv "$STATE_LOG_FILE" "${STATE_LOG_FILE}.1"
touch "$STATE_LOG_FILE"
}
state_log_tail() {
tail -n "${1:-50}" "$STATE_LOG_FILE"
}
# ─────────────────────────────────────────────────────────────────
# Cleanup Functions
# ─────────────────────────────────────────────────────────────────
state_cleanup() {
# Clean temp files older than 1 day
find "$STATE_TMP_DIR" -type f -mtime +1 -delete 2>/dev/null || true
# Clean expired cache
find "$STATE_CACHE_DIR" -type f -mmin "+$((STATE_CACHE_TTL / 60))" -delete 2>/dev/null || true
# Clean old logs
find "$STATE_LOG_DIR" -name "*.log.*" -mtime +30 -delete 2>/dev/null || true
}
state_reset() {
rm -rf "$STATE_BASE_DIR"
state_init
}
# ─────────────────────────────────────────────────────────────────
# Auto-initialize
# ─────────────────────────────────────────────────────────────────
state_init#!/bin/bash
# Your script that uses the state manager
# Set app name before sourcing
STATE_APP_NAME="my-tool"
# Source the state manager
source /path/to/state-manager.sh
# Now use it
state_config_set "api_key" "abc123"
api_key=$(state_config_get "api_key")
state_set "last_run" "$(date -Iseconds)"
state_log "INFO" "Script started"
# Use cache
if ! result=$(state_cache_get "api_response"); then
result=$(curl -s https://api.example.com/data)
state_cache_set "api_response" "$result"
fi$HOME/.app-namea1e9f44
Canonical home
since Feb 24, 2026
Also appears in
since Feb 24, 2026
since Feb 24, 2026
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.