| # Settings File Parsing Techniques |
|
|
| Complete guide to parsing `.claude/plugin-name.local.md` files in bash scripts. |
|
|
| ## File Structure |
|
|
| Settings files use markdown with YAML frontmatter: |
|
|
| ```markdown |
| --- |
| field1: value1 |
| field2: "value with spaces" |
| numeric_field: 42 |
| boolean_field: true |
| list_field: ["item1", "item2", "item3"] |
| --- |
| |
| # Markdown Content |
| |
| This body content can be extracted separately. |
| It's useful for prompts, documentation, or additional context. |
| ``` |
| |
| ## Parsing Frontmatter |
| |
| ### Extract Frontmatter Block |
| |
| ```bash |
| #!/bin/bash |
| FILE=".claude/my-plugin.local.md" |
| |
| # Extract everything between --- markers (excluding the markers themselves) |
| FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$FILE") |
| ``` |
| |
| **How it works:** |
| - `sed -n` - Suppress automatic printing |
| - `/^---$/,/^---$/` - Range from first `---` to second `---` |
| - `{ /^---$/d; p; }` - Delete the `---` lines, print everything else |
| |
| ### Extract Individual Fields |
| |
| **String fields:** |
| ```bash |
| # Simple value |
| VALUE=$(echo "$FRONTMATTER" | grep '^field_name:' | sed 's/field_name: *//') |
| |
| # Quoted value (removes surrounding quotes) |
| VALUE=$(echo "$FRONTMATTER" | grep '^field_name:' | sed 's/field_name: *//' | sed 's/^"\(.*\)"$/\1/') |
| ``` |
| |
| **Boolean fields:** |
| ```bash |
| ENABLED=$(echo "$FRONTMATTER" | grep '^enabled:' | sed 's/enabled: *//') |
| |
| # Use in condition |
| if [[ "$ENABLED" == "true" ]]; then |
| # Enabled |
| fi |
| ``` |
| |
| **Numeric fields:** |
| ```bash |
| MAX=$(echo "$FRONTMATTER" | grep '^max_value:' | sed 's/max_value: *//') |
| |
| # Validate it's a number |
| if [[ "$MAX" =~ ^[0-9]+$ ]]; then |
| # Use in numeric comparison |
| if [[ $MAX -gt 100 ]]; then |
| # Too large |
| fi |
| fi |
| ``` |
| |
| **List fields (simple):** |
| ```bash |
| # YAML: list: ["item1", "item2", "item3"] |
| LIST=$(echo "$FRONTMATTER" | grep '^list:' | sed 's/list: *//') |
| # Result: ["item1", "item2", "item3"] |
| |
| # For simple checks: |
| if [[ "$LIST" == *"item1"* ]]; then |
| # List contains item1 |
| fi |
| ``` |
| |
| **List fields (proper parsing with jq):** |
| ```bash |
| # For proper list handling, use yq or convert to JSON |
| # This requires yq to be installed (brew install yq) |
| |
| # Extract list as JSON array |
| LIST=$(echo "$FRONTMATTER" | yq -o json '.list' 2>/dev/null) |
| |
| # Iterate over items |
| echo "$LIST" | jq -r '.[]' | while read -r item; do |
| echo "Processing: $item" |
| done |
| ``` |
| |
| ## Parsing Markdown Body |
| |
| ### Extract Body Content |
| |
| ```bash |
| #!/bin/bash |
| FILE=".claude/my-plugin.local.md" |
| |
| # Extract everything after the closing --- |
| # Counts --- markers: first is opening, second is closing, everything after is body |
| BODY=$(awk '/^---$/{i++; next} i>=2' "$FILE") |
| ``` |
| |
| **How it works:** |
| - `/^---$/` - Match `---` lines |
| - `{i++; next}` - Increment counter and skip the `---` line |
| - `i>=2` - Print all lines after second `---` |
| |
| **Handles edge case:** If `---` appears in the markdown body, it still works because we only count the first two `---` at the start. |
| |
| ### Use Body as Prompt |
| |
| ```bash |
| # Extract body |
| PROMPT=$(awk '/^---$/{i++; next} i>=2' "$RALPH_STATE_FILE") |
| |
| # Feed back to Claude |
| echo '{"decision": "block", "reason": "'"$PROMPT"'"}' | jq . |
| ``` |
| |
| **Important:** Use `jq -n --arg` for safer JSON construction with user content: |
| |
| ```bash |
| PROMPT=$(awk '/^---$/{i++; next} i>=2' "$FILE") |
| |
| # Safe JSON construction |
| jq -n --arg prompt "$PROMPT" '{ |
| "decision": "block", |
| "reason": $prompt |
| }' |
| ``` |
| |
| ## Common Parsing Patterns |
| |
| ### Pattern: Field with Default |
| |
| ```bash |
| VALUE=$(echo "$FRONTMATTER" | grep '^field:' | sed 's/field: *//' | sed 's/^"\(.*\)"$/\1/') |
| |
| # Use default if empty |
| if [[ -z "$VALUE" ]]; then |
| VALUE="default_value" |
| fi |
| ``` |
| |
| ### Pattern: Optional Field |
| |
| ```bash |
| OPTIONAL=$(echo "$FRONTMATTER" | grep '^optional_field:' | sed 's/optional_field: *//' | sed 's/^"\(.*\)"$/\1/') |
| |
| # Only use if present |
| if [[ -n "$OPTIONAL" ]] && [[ "$OPTIONAL" != "null" ]]; then |
| # Field is set, use it |
| echo "Optional field: $OPTIONAL" |
| fi |
| ``` |
| |
| ### Pattern: Multiple Fields at Once |
| |
| ```bash |
| # Parse all fields in one pass |
| while IFS=': ' read -r key value; do |
| # Remove quotes if present |
| value=$(echo "$value" | sed 's/^"\(.*\)"$/\1/') |
| |
| case "$key" in |
| enabled) |
| ENABLED="$value" |
| ;; |
| mode) |
| MODE="$value" |
| ;; |
| max_size) |
| MAX_SIZE="$value" |
| ;; |
| esac |
| done <<< "$FRONTMATTER" |
| ``` |
| |
| ## Updating Settings Files |
| |
| ### Atomic Updates |
| |
| Always use temp file + atomic move to prevent corruption: |
| |
| ```bash |
| #!/bin/bash |
| FILE=".claude/my-plugin.local.md" |
| NEW_VALUE="updated_value" |
| |
| # Create temp file |
| TEMP_FILE="${FILE}.tmp.$$" |
| |
| # Update field using sed |
| sed "s/^field_name: .*/field_name: $NEW_VALUE/" "$FILE" > "$TEMP_FILE" |
| |
| # Atomic replace |
| mv "$TEMP_FILE" "$FILE" |
| ``` |
| |
| ### Update Single Field |
| |
| ```bash |
| # Increment iteration counter |
| CURRENT=$(echo "$FRONTMATTER" | grep '^iteration:' | sed 's/iteration: *//') |
| NEXT=$((CURRENT + 1)) |
| |
| # Update file |
| TEMP_FILE="${FILE}.tmp.$$" |
| sed "s/^iteration: .*/iteration: $NEXT/" "$FILE" > "$TEMP_FILE" |
| mv "$TEMP_FILE" "$FILE" |
| ``` |
| |
| ### Update Multiple Fields |
| |
| ```bash |
| # Update several fields at once |
| TEMP_FILE="${FILE}.tmp.$$" |
| |
| sed -e "s/^iteration: .*/iteration: $NEXT_ITERATION/" \ |
| -e "s/^pr_number: .*/pr_number: $PR_NUMBER/" \ |
| -e "s/^status: .*/status: $NEW_STATUS/" \ |
| "$FILE" > "$TEMP_FILE" |
| |
| mv "$TEMP_FILE" "$FILE" |
| ``` |
| |
| ## Validation Techniques |
| |
| ### Validate File Exists and Is Readable |
| |
| ```bash |
| FILE=".claude/my-plugin.local.md" |
| |
| if [[ ! -f "$FILE" ]]; then |
| echo "Settings file not found" >&2 |
| exit 1 |
| fi |
| |
| if [[ ! -r "$FILE" ]]; then |
| echo "Settings file not readable" >&2 |
| exit 1 |
| fi |
| ``` |
| |
| ### Validate Frontmatter Structure |
| |
| ```bash |
| # Count --- markers (should be exactly 2 at start) |
| MARKER_COUNT=$(grep -c '^---$' "$FILE" 2>/dev/null || echo "0") |
| |
| if [[ $MARKER_COUNT -lt 2 ]]; then |
| echo "Invalid settings file: missing frontmatter markers" >&2 |
| exit 1 |
| fi |
| ``` |
| |
| ### Validate Field Values |
| |
| ```bash |
| MODE=$(echo "$FRONTMATTER" | grep '^mode:' | sed 's/mode: *//') |
| |
| case "$MODE" in |
| strict|standard|lenient) |
| # Valid mode |
| ;; |
| *) |
| echo "Invalid mode: $MODE (must be strict, standard, or lenient)" >&2 |
| exit 1 |
| ;; |
| esac |
| ``` |
| |
| ### Validate Numeric Ranges |
| |
| ```bash |
| MAX_SIZE=$(echo "$FRONTMATTER" | grep '^max_size:' | sed 's/max_size: *//') |
| |
| if ! [[ "$MAX_SIZE" =~ ^[0-9]+$ ]]; then |
| echo "max_size must be a number" >&2 |
| exit 1 |
| fi |
| |
| if [[ $MAX_SIZE -lt 1 ]] || [[ $MAX_SIZE -gt 10000000 ]]; then |
| echo "max_size out of range (1-10000000)" >&2 |
| exit 1 |
| fi |
| ``` |
| |
| ## Edge Cases and Gotchas |
| |
| ### Quotes in Values |
| |
| YAML allows both quoted and unquoted strings: |
| |
| ```yaml |
| # These are equivalent: |
| field1: value |
| field2: "value" |
| field3: 'value' |
| ``` |
| |
| **Handle both:** |
| ```bash |
| # Remove surrounding quotes if present |
| VALUE=$(echo "$FRONTMATTER" | grep '^field:' | sed 's/field: *//' | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\\(.*\\)'$/\\1/") |
| ``` |
| |
| ### --- in Markdown Body |
| |
| If the markdown body contains `---`, the parsing still works because we only match the first two: |
| |
| ```markdown |
| --- |
| field: value |
| --- |
| |
| # Body |
| |
| Here's a separator: |
| --- |
| |
| More content after the separator. |
| ``` |
| |
| The `awk '/^---$/{i++; next} i>=2'` pattern handles this correctly. |
| |
| ### Empty Values |
| |
| Handle missing or empty fields: |
| |
| ```yaml |
| field1: |
| field2: "" |
| field3: null |
| ``` |
| |
| **Parsing:** |
| ```bash |
| VALUE=$(echo "$FRONTMATTER" | grep '^field1:' | sed 's/field1: *//') |
| # VALUE will be empty string |
| |
| # Check for empty/null |
| if [[ -z "$VALUE" ]] || [[ "$VALUE" == "null" ]]; then |
| VALUE="default" |
| fi |
| ``` |
| |
| ### Special Characters |
| |
| Values with special characters need careful handling: |
| |
| ```yaml |
| message: "Error: Something went wrong!" |
| path: "/path/with spaces/file.txt" |
| regex: "^[a-zA-Z0-9_]+$" |
| ``` |
| |
| **Safe parsing:** |
| ```bash |
| # Always quote variables when using |
| MESSAGE=$(echo "$FRONTMATTER" | grep '^message:' | sed 's/message: *//' | sed 's/^"\(.*\)"$/\1/') |
| |
| echo "Message: $MESSAGE" # Quoted! |
| ``` |
| |
| ## Performance Optimization |
| |
| ### Cache Parsed Values |
| |
| If reading settings multiple times: |
| |
| ```bash |
| # Parse once |
| FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$FILE") |
| |
| # Extract multiple fields from cached frontmatter |
| FIELD1=$(echo "$FRONTMATTER" | grep '^field1:' | sed 's/field1: *//') |
| FIELD2=$(echo "$FRONTMATTER" | grep '^field2:' | sed 's/field2: *//') |
| FIELD3=$(echo "$FRONTMATTER" | grep '^field3:' | sed 's/field3: *//') |
| ``` |
| |
| **Don't:** Re-parse file for each field. |
| |
| ### Lazy Loading |
| |
| Only parse settings when needed: |
| |
| ```bash |
| #!/bin/bash |
| input=$(cat) |
| |
| # Quick checks first (no file I/O) |
| tool_name=$(echo "$input" | jq -r '.tool_name') |
| if [[ "$tool_name" != "Write" ]]; then |
| exit 0 # Not a write operation, skip |
| fi |
| |
| # Only now check settings file |
| if [[ -f ".claude/my-plugin.local.md" ]]; then |
| # Parse settings |
| # ... |
| fi |
| ``` |
| |
| ## Debugging |
| |
| ### Print Parsed Values |
| |
| ```bash |
| #!/bin/bash |
| set -x # Enable debug tracing |
| |
| FILE=".claude/my-plugin.local.md" |
| |
| if [[ -f "$FILE" ]]; then |
| echo "Settings file found" >&2 |
| |
| FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$FILE") |
| echo "Frontmatter:" >&2 |
| echo "$FRONTMATTER" >&2 |
| |
| ENABLED=$(echo "$FRONTMATTER" | grep '^enabled:' | sed 's/enabled: *//') |
| echo "Enabled: $ENABLED" >&2 |
| fi |
| ``` |
| |
| ### Validate Parsing |
| |
| ```bash |
| # Show what was parsed |
| echo "Parsed values:" >&2 |
| echo " enabled: $ENABLED" >&2 |
| echo " mode: $MODE" >&2 |
| echo " max_size: $MAX_SIZE" >&2 |
| |
| # Verify expected values |
| if [[ "$ENABLED" != "true" ]] && [[ "$ENABLED" != "false" ]]; then |
| echo "⚠️ Unexpected enabled value: $ENABLED" >&2 |
| fi |
| ``` |
| |
| ## Alternative: Using yq |
| |
| For complex YAML, consider using `yq`: |
| |
| ```bash |
| # Install: brew install yq |
| |
| # Parse YAML properly |
| FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$FILE") |
| |
| # Extract fields with yq |
| ENABLED=$(echo "$FRONTMATTER" | yq '.enabled') |
| MODE=$(echo "$FRONTMATTER" | yq '.mode') |
| LIST=$(echo "$FRONTMATTER" | yq -o json '.list_field') |
| |
| # Iterate list properly |
| echo "$LIST" | jq -r '.[]' | while read -r item; do |
| echo "Item: $item" |
| done |
| ``` |
| |
| **Pros:** |
| - Proper YAML parsing |
| - Handles complex structures |
| - Better list/object support |
| |
| **Cons:** |
| - Requires yq installation |
| - Additional dependency |
| - May not be available on all systems |
| |
| **Recommendation:** Use sed/grep for simple fields, yq for complex structures. |
| |
| ## Complete Example |
| |
| ```bash |
| #!/bin/bash |
| set -euo pipefail |
| |
| # Configuration |
| SETTINGS_FILE=".claude/my-plugin.local.md" |
| |
| # Quick exit if not configured |
| if [[ ! -f "$SETTINGS_FILE" ]]; then |
| # Use defaults |
| ENABLED=true |
| MODE=standard |
| MAX_SIZE=1000000 |
| else |
| # Parse frontmatter |
| FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$SETTINGS_FILE") |
| |
| # Extract fields with defaults |
| ENABLED=$(echo "$FRONTMATTER" | grep '^enabled:' | sed 's/enabled: *//') |
| ENABLED=${ENABLED:-true} |
| |
| MODE=$(echo "$FRONTMATTER" | grep '^mode:' | sed 's/mode: *//' | sed 's/^"\(.*\)"$/\1/') |
| MODE=${MODE:-standard} |
| |
| MAX_SIZE=$(echo "$FRONTMATTER" | grep '^max_size:' | sed 's/max_size: *//') |
| MAX_SIZE=${MAX_SIZE:-1000000} |
| |
| # Validate values |
| if [[ "$ENABLED" != "true" ]] && [[ "$ENABLED" != "false" ]]; then |
| echo "⚠️ Invalid enabled value, using default" >&2 |
| ENABLED=true |
| fi |
| |
| if ! [[ "$MAX_SIZE" =~ ^[0-9]+$ ]]; then |
| echo "⚠️ Invalid max_size, using default" >&2 |
| MAX_SIZE=1000000 |
| fi |
| fi |
| |
| # Quick exit if disabled |
| if [[ "$ENABLED" != "true" ]]; then |
| exit 0 |
| fi |
| |
| # Use configuration |
| echo "Configuration loaded: mode=$MODE, max_size=$MAX_SIZE" >&2 |
| |
| # Apply logic based on settings |
| case "$MODE" in |
| strict) |
| # Strict validation |
| ;; |
| standard) |
| # Standard validation |
| ;; |
| lenient) |
| # Lenient validation |
| ;; |
| esac |
| ``` |
| |
| This provides robust settings handling with defaults, validation, and error recovery. |
| |