State Directory Manager
Patterns for managing persistent state, configuration, and cache directories in bash scripts following XDG Base Directory specification.
When to Use This Skill
✅ Use when:
-
Scripts need to persist data between runs
-
Storing user preferences or configuration
-
Caching results for performance
-
Managing log files with rotation
-
Creating portable CLI tools
❌ Avoid when:
-
One-time scripts that don't need state
-
Scripts that should be purely stateless
-
When environment variables are sufficient
Core Capabilities
- XDG Base Directory Standard
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" }
- Workspace-Hub Pattern
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 }
- Configuration File Management
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"
- State File Operations
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
}
- Cache Management
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)
- Log File Management
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 }
Complete Example: State Manager Module
#!/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
Usage in Scripts
#!/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
Best Practices
-
Use Standard Locations - Follow XDG or $HOME/.app-name
-
Initialize Early - Call init before any operations
-
Handle Permissions - Use 700 for private data
-
Clean Up Regularly - Remove old temp/cache files
-
Rotate Logs - Prevent unbounded growth
Resources
-
XDG Base Directory Spec
-
File Hierarchy Standard
Version History
- 1.0.0 (2026-01-14): Initial release - extracted from workspace-hub patterns