surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
| 1 | # Settings File Parsing Techniques |
| 2 | |
| 3 | Complete guide to parsing `.claude/plugin-name.local.md` files in bash scripts. |
| 4 | |
| 5 | ## File Structure |
| 6 | |
| 7 | Settings files use markdown with YAML frontmatter: |
| 8 | |
| 9 | ```markdown |
| 10 | --- |
| 11 | field1: value1 |
| 12 | field2: "value with spaces" |
| 13 | numeric_field: 42 |
| 14 | boolean_field: true |
| 15 | list_field: ["item1", "item2", "item3"] |
| 16 | --- |
| 17 | |
| 18 | # Markdown Content |
| 19 | |
| 20 | This body content can be extracted separately. |
| 21 | It's useful for prompts, documentation, or additional context. |
| 22 | ``` |
| 23 | |
| 24 | ## Parsing Frontmatter |
| 25 | |
| 26 | ### Extract Frontmatter Block |
| 27 | |
| 28 | ```bash |
| 29 | #!/bin/bash |
| 30 | FILE=".claude/my-plugin.local.md" |
| 31 | |
| 32 | # Extract everything between --- markers (excluding the markers themselves) |
| 33 | FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$FILE") |
| 34 | ``` |
| 35 | |
| 36 | **How it works:** |
| 37 | - `sed -n` - Suppress automatic printing |
| 38 | - `/^---$/,/^---$/` - Range from first `---` to second `---` |
| 39 | - `{ /^---$/d; p; }` - Delete the `---` lines, print everything else |
| 40 | |
| 41 | ### Extract Individual Fields |
| 42 | |
| 43 | **String fields:** |
| 44 | ```bash |
| 45 | # Simple value |
| 46 | VALUE=$(echo "$FRONTMATTER" | grep '^field_name:' | sed 's/field_name: *//') |
| 47 | |
| 48 | # Quoted value (removes surrounding quotes) |
| 49 | VALUE=$(echo "$FRONTMATTER" | grep '^field_name:' | sed 's/field_name: *//' | sed 's/^"\(.*\)"$/\1/') |
| 50 | ``` |
| 51 | |
| 52 | **Boolean fields:** |
| 53 | ```bash |
| 54 | ENABLED=$(echo "$FRONTMATTER" | grep '^enabled:' | sed 's/enabled: *//') |
| 55 | |
| 56 | # Use in condition |
| 57 | if [[ "$ENABLED" == "true" ]]; then |
| 58 | # Enabled |
| 59 | fi |
| 60 | ``` |
| 61 | |
| 62 | **Numeric fields:** |
| 63 | ```bash |
| 64 | MAX=$(echo "$FRONTMATTER" | grep '^max_value:' | sed 's/max_value: *//') |
| 65 | |
| 66 | # Validate it's a number |
| 67 | if [[ "$MAX" =~ ^[0-9]+$ ]]; then |
| 68 | # Use in numeric comparison |
| 69 | if [[ $MAX -gt 100 ]]; then |
| 70 | # Too large |
| 71 | fi |
| 72 | fi |
| 73 | ``` |
| 74 | |
| 75 | **List fields (simple):** |
| 76 | ```bash |
| 77 | # YAML: list: ["item1", "item2", "item3"] |
| 78 | LIST=$(echo "$FRONTMATTER" | grep '^list:' | sed 's/list: *//') |
| 79 | # Result: ["item1", "item2", "item3"] |
| 80 | |
| 81 | # For simple checks: |
| 82 | if [[ "$LIST" == *"item1"* ]]; then |
| 83 | # List contains item1 |
| 84 | fi |
| 85 | ``` |
| 86 | |
| 87 | **List fields (proper parsing with jq):** |
| 88 | ```bash |
| 89 | # For proper list handling, use yq or convert to JSON |
| 90 | # This requires yq to be installed (brew install yq) |
| 91 | |
| 92 | # Extract list as JSON array |
| 93 | LIST=$(echo "$FRONTMATTER" | yq -o json '.list' 2>/dev/null) |
| 94 | |
| 95 | # Iterate over items |
| 96 | echo "$LIST" | jq -r '.[]' | while read -r item; do |
| 97 | echo "Processing: $item" |
| 98 | done |
| 99 | ``` |
| 100 | |
| 101 | ## Parsing Markdown Body |
| 102 | |
| 103 | ### Extract Body Content |
| 104 | |
| 105 | ```bash |
| 106 | #!/bin/bash |
| 107 | FILE=".claude/my-plugin.local.md" |
| 108 | |
| 109 | # Extract everything after the closing --- |
| 110 | # Counts --- markers: first is opening, second is closing, everything after is body |
| 111 | BODY=$(awk '/^---$/{i++; next} i>=2' "$FILE") |
| 112 | ``` |
| 113 | |
| 114 | **How it works:** |
| 115 | - `/^---$/` - Match `---` lines |
| 116 | - `{i++; next}` - Increment counter and skip the `---` line |
| 117 | - `i>=2` - Print all lines after second `---` |
| 118 | |
| 119 | **Handles edge case:** If `---` appears in the markdown body, it still works because we only count the first two `---` at the start. |
| 120 | |
| 121 | ### Use Body as Prompt |
| 122 | |
| 123 | ```bash |
| 124 | # Extract body |
| 125 | PROMPT=$(awk '/^---$/{i++; next} i>=2' "$RALPH_STATE_FILE") |
| 126 | |
| 127 | # Feed back to Claude |
| 128 | echo '{"decision": "block", "reason": "'"$PROMPT"'"}' | jq . |
| 129 | ``` |
| 130 | |
| 131 | **Important:** Use `jq -n --arg` for safer JSON construction with user content: |
| 132 | |
| 133 | ```bash |
| 134 | PROMPT=$(awk '/^---$/{i++; next} i>=2' "$FILE") |
| 135 | |
| 136 | # Safe JSON construction |
| 137 | jq -n --arg prompt "$PROMPT" '{ |
| 138 | "decision": "block", |
| 139 | "reason": $prompt |
| 140 | }' |
| 141 | ``` |
| 142 | |
| 143 | ## Common Parsing Patterns |
| 144 | |
| 145 | ### Pattern: Field with Default |
| 146 | |
| 147 | ```bash |
| 148 | VALUE=$(echo "$FRONTMATTER" | grep '^field:' | sed 's/field: *//' | sed 's/^"\(.*\)"$/\1/') |
| 149 | |
| 150 | # Use default if empty |
| 151 | if [[ -z "$VALUE" ]]; then |
| 152 | VALUE="default_value" |
| 153 | fi |
| 154 | ``` |
| 155 | |
| 156 | ### Pattern: Optional Field |
| 157 | |
| 158 | ```bash |
| 159 | OPTIONAL=$(echo "$FRONTMATTER" | grep '^optional_field:' | sed 's/optional_field: *//' | sed 's/^"\(.*\)"$/\1/') |
| 160 | |
| 161 | # Only use if present |
| 162 | if [[ -n "$OPTIONAL" ]] && [[ "$OPTIONAL" != "null" ]]; then |
| 163 | # Field is set, use it |
| 164 | echo "Optional field: $OPTIONAL" |
| 165 | fi |
| 166 | ``` |
| 167 | |
| 168 | ### Pattern: Multiple Fields at Once |
| 169 | |
| 170 | ```bash |
| 171 | # Parse all fields in one pass |
| 172 | while IFS=': ' read -r key value; do |
| 173 | # Remove quotes if present |
| 174 | value=$(echo "$value" | sed 's/^"\(.*\)"$/\1/') |
| 175 | |
| 176 | case "$key" in |
| 177 | enabled) |
| 178 | ENABLED="$value" |
| 179 | ;; |
| 180 | mode) |
| 181 | MODE="$value" |
| 182 | ;; |
| 183 | max_size) |
| 184 | MAX_SIZE="$value" |
| 185 | ;; |
| 186 | esac |
| 187 | done <<< "$FRONTMATTER" |
| 188 | ``` |
| 189 | |
| 190 | ## Updating Settings Files |
| 191 | |
| 192 | ### Atomic Updates |
| 193 | |
| 194 | Always use temp file + atomic move to prevent corruption: |
| 195 | |
| 196 | ```bash |
| 197 | #!/bin/bash |
| 198 | FILE=".claude/my-plugin.local.md" |
| 199 | NEW_VALUE="updated_value" |
| 200 | |
| 201 | # Create temp file |
| 202 | TEMP_FILE="${FILE}.tmp.$$" |
| 203 | |
| 204 | # Update field using sed |
| 205 | sed "s/^field_name: .*/field_name: $NEW_VALUE/" "$FILE" > "$TEMP_FILE" |
| 206 | |
| 207 | # Atomic replace |
| 208 | mv "$TEMP_FILE" "$FILE" |
| 209 | ``` |
| 210 | |
| 211 | ### Update Single Field |
| 212 | |
| 213 | ```bash |
| 214 | # Increment iteration counter |
| 215 | CURRENT=$(echo "$FRONTMATTER" | grep '^iteration:' | sed 's/iteration: *//') |
| 216 | NEXT=$((CURRENT + 1)) |
| 217 | |
| 218 | # Update file |
| 219 | TEMP_FILE="${FILE}.tmp.$$" |
| 220 | sed "s/^iteration: .*/iteration: $NEXT/" "$FILE" > "$TEMP_FILE" |
| 221 | mv "$TEMP_FILE" "$FILE" |
| 222 | ``` |
| 223 | |
| 224 | ### Update Multiple Fields |
| 225 | |
| 226 | ```bash |
| 227 | # Update several fields at once |
| 228 | TEMP_FILE="${FILE}.tmp.$$" |
| 229 | |
| 230 | sed -e "s/^iteration: .*/iteration: $NEXT_ITERATION/" \ |
| 231 | -e "s/^pr_number: .*/pr_number: $PR_NUMBER/" \ |
| 232 | -e "s/^status: .*/status: $NEW_STATUS/" \ |
| 233 | "$FILE" > "$TEMP_FILE" |
| 234 | |
| 235 | mv "$TEMP_FILE" "$FILE" |
| 236 | ``` |
| 237 | |
| 238 | ## Validation Techniques |
| 239 | |
| 240 | ### Validate File Exists and Is Readable |
| 241 | |
| 242 | ```bash |
| 243 | FILE=".claude/my-plugin.local.md" |
| 244 | |
| 245 | if [[ ! -f "$FILE" ]]; then |
| 246 | echo "Settings file not found" >&2 |
| 247 | exit 1 |
| 248 | fi |
| 249 | |
| 250 | if [[ ! -r "$FILE" ]]; then |
| 251 | echo "Settings file not readable" >&2 |
| 252 | exit 1 |
| 253 | fi |
| 254 | ``` |
| 255 | |
| 256 | ### Validate Frontmatter Structure |
| 257 | |
| 258 | ```bash |
| 259 | # Count --- markers (should be exactly 2 at start) |
| 260 | MARKER_COUNT=$(grep -c '^---$' "$FILE" 2>/dev/null || echo "0") |
| 261 | |
| 262 | if [[ $MARKER_COUNT -lt 2 ]]; then |
| 263 | echo "Invalid settings file: missing frontmatter markers" >&2 |
| 264 | exit 1 |
| 265 | fi |
| 266 | ``` |
| 267 | |
| 268 | ### Validate Field Values |
| 269 | |
| 270 | ```bash |
| 271 | MODE=$(echo "$FRONTMATTER" | grep '^mode:' | sed 's/mode: *//') |
| 272 | |
| 273 | case "$MODE" in |
| 274 | strict|standard|lenient) |
| 275 | # Valid mode |
| 276 | ;; |
| 277 | *) |
| 278 | echo "Invalid mode: $MODE (must be strict, standard, or lenient)" >&2 |
| 279 | exit 1 |
| 280 | ;; |
| 281 | esac |
| 282 | ``` |
| 283 | |
| 284 | ### Validate Numeric Ranges |
| 285 | |
| 286 | ```bash |
| 287 | MAX_SIZE=$(echo "$FRONTMATTER" | grep '^max_size:' | sed 's/max_size: *//') |
| 288 | |
| 289 | if ! [[ "$MAX_SIZE" =~ ^[0-9]+$ ]]; then |
| 290 | echo "max_size must be a number" >&2 |
| 291 | exit 1 |
| 292 | fi |
| 293 | |
| 294 | if [[ $MAX_SIZE -lt 1 ]] || [[ $MAX_SIZE -gt 10000000 ]]; then |
| 295 | echo "max_size out of range (1-10000000)" >&2 |
| 296 | exit 1 |
| 297 | fi |
| 298 | ``` |
| 299 | |
| 300 | ## Edge Cases and Gotchas |
| 301 | |
| 302 | ### Quotes in Values |
| 303 | |
| 304 | YAML allows both quoted and unquoted strings: |
| 305 | |
| 306 | ```yaml |
| 307 | # These are equivalent: |
| 308 | field1: value |
| 309 | field2: "value" |
| 310 | field3: 'value' |
| 311 | ``` |
| 312 | |
| 313 | **Handle both:** |
| 314 | ```bash |
| 315 | # Remove surrounding quotes if present |
| 316 | VALUE=$(echo "$FRONTMATTER" | grep '^field:' | sed 's/field: *//' | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\\(.*\\)'$/\\1/") |
| 317 | ``` |
| 318 | |
| 319 | ### --- in Markdown Body |
| 320 | |
| 321 | If the markdown body contains `---`, the parsing still works because we only match the first two: |
| 322 | |
| 323 | ```markdown |
| 324 | --- |
| 325 | field: value |
| 326 | --- |
| 327 | |
| 328 | # Body |
| 329 | |
| 330 | Here's a separator: |
| 331 | --- |
| 332 | |
| 333 | More content after the separator. |
| 334 | ``` |
| 335 | |
| 336 | The `awk '/^---$/{i++; next} i>=2'` pattern handles this correctly. |
| 337 | |
| 338 | ### Empty Values |
| 339 | |
| 340 | Handle missing or empty fields: |
| 341 | |
| 342 | ```yaml |
| 343 | field1: |
| 344 | field2: "" |
| 345 | field3: null |
| 346 | ``` |
| 347 | |
| 348 | **Parsing:** |
| 349 | ```bash |
| 350 | VALUE=$(echo "$FRONTMATTER" | grep '^field1:' | sed 's/field1: *//') |
| 351 | # VALUE will be empty string |
| 352 | |
| 353 | # Check for empty/null |
| 354 | if [[ -z "$VALUE" ]] || [[ "$VALUE" == "null" ]]; then |
| 355 | VALUE="default" |
| 356 | fi |
| 357 | ``` |
| 358 | |
| 359 | ### Special Characters |
| 360 | |
| 361 | Values with special characters need careful handling: |
| 362 | |
| 363 | ```yaml |
| 364 | message: "Error: Something went wrong!" |
| 365 | path: "/path/with spaces/file.txt" |
| 366 | regex: "^[a-zA-Z0-9_]+$" |
| 367 | ``` |
| 368 | |
| 369 | **Safe parsing:** |
| 370 | ```bash |
| 371 | # Always quote variables when using |
| 372 | MESSAGE=$(echo "$FRONTMATTER" | grep '^message:' | sed 's/message: *//' | sed 's/^"\(.*\)"$/\1/') |
| 373 | |
| 374 | echo "Message: $MESSAGE" # Quoted! |
| 375 | ``` |
| 376 | |
| 377 | ## Performance Optimization |
| 378 | |
| 379 | ### Cache Parsed Values |
| 380 | |
| 381 | If reading settings multiple times: |
| 382 | |
| 383 | ```bash |
| 384 | # Parse once |
| 385 | FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$FILE") |
| 386 | |
| 387 | # Extract multiple fields from cached frontmatter |
| 388 | FIELD1=$(echo "$FRONTMATTER" | grep '^field1:' | sed 's/field1: *//') |
| 389 | FIELD2=$(echo "$FRONTMATTER" | grep '^field2:' | sed 's/field2: *//') |
| 390 | FIELD3=$(echo "$FRONTMATTER" | grep '^field3:' | sed 's/field3: *//') |
| 391 | ``` |
| 392 | |
| 393 | **Don't:** Re-parse file for each field. |
| 394 | |
| 395 | ### Lazy Loading |
| 396 | |
| 397 | Only parse settings when needed: |
| 398 | |
| 399 | ```bash |
| 400 | #!/bin/bash |
| 401 | input=$(cat) |
| 402 | |
| 403 | # Quick checks first (no file I/O) |
| 404 | tool_name=$(echo "$input" | jq -r '.tool_name') |
| 405 | if [[ "$tool_name" != "Write" ]]; then |
| 406 | exit 0 # Not a write operation, skip |
| 407 | fi |
| 408 | |
| 409 | # Only now check settings file |
| 410 | if [[ -f ".claude/my-plugin.local.md" ]]; then |
| 411 | # Parse settings |
| 412 | # ... |
| 413 | fi |
| 414 | ``` |
| 415 | |
| 416 | ## Debugging |
| 417 | |
| 418 | ### Print Parsed Values |
| 419 | |
| 420 | ```bash |
| 421 | #!/bin/bash |
| 422 | set -x # Enable debug tracing |
| 423 | |
| 424 | FILE=".claude/my-plugin.local.md" |
| 425 | |
| 426 | if [[ -f "$FILE" ]]; then |
| 427 | echo "Settings file found" >&2 |
| 428 | |
| 429 | FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$FILE") |
| 430 | echo "Frontmatter:" >&2 |
| 431 | echo "$FRONTMATTER" >&2 |
| 432 | |
| 433 | ENABLED=$(echo "$FRONTMATTER" | grep '^enabled:' | sed 's/enabled: *//') |
| 434 | echo "Enabled: $ENABLED" >&2 |
| 435 | fi |
| 436 | ``` |
| 437 | |
| 438 | ### Validate Parsing |
| 439 | |
| 440 | ```bash |
| 441 | # Show what was parsed |
| 442 | echo "Parsed values:" >&2 |
| 443 | echo " enabled: $ENABLED" >&2 |
| 444 | echo " mode: $MODE" >&2 |
| 445 | echo " max_size: $MAX_SIZE" >&2 |
| 446 | |
| 447 | # Verify expected values |
| 448 | if [[ "$ENABLED" != "true" ]] && [[ "$ENABLED" != "false" ]]; then |
| 449 | echo "⚠️ Unexpected enabled value: $ENABLED" >&2 |
| 450 | fi |
| 451 | ``` |
| 452 | |
| 453 | ## Alternative: Using yq |
| 454 | |
| 455 | For complex YAML, consider using `yq`: |
| 456 | |
| 457 | ```bash |
| 458 | # Install: brew install yq |
| 459 | |
| 460 | # Parse YAML properly |
| 461 | FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$FILE") |
| 462 | |
| 463 | # Extract fields with yq |
| 464 | ENABLED=$(echo "$FRONTMATTER" | yq '.enabled') |
| 465 | MODE=$(echo "$FRONTMATTER" | yq '.mode') |
| 466 | LIST=$(echo "$FRONTMATTER" | yq -o json '.list_field') |
| 467 | |
| 468 | # Iterate list properly |
| 469 | echo "$LIST" | jq -r '.[]' | while read -r item; do |
| 470 | echo "Item: $item" |
| 471 | done |
| 472 | ``` |
| 473 | |
| 474 | **Pros:** |
| 475 | - Proper YAML parsing |
| 476 | - Handles complex structures |
| 477 | - Better list/object support |
| 478 | |
| 479 | **Cons:** |
| 480 | - Requires yq installation |
| 481 | - Additional dependency |
| 482 | - May not be available on all systems |
| 483 | |
| 484 | **Recommendation:** Use sed/grep for simple fields, yq for complex structures. |
| 485 | |
| 486 | ## Complete Example |
| 487 | |
| 488 | ```bash |
| 489 | #!/bin/bash |
| 490 | set -euo pipefail |
| 491 | |
| 492 | # Configuration |
| 493 | SETTINGS_FILE=".claude/my-plugin.local.md" |
| 494 | |
| 495 | # Quick exit if not configured |
| 496 | if [[ ! -f "$SETTINGS_FILE" ]]; then |
| 497 | # Use defaults |
| 498 | ENABLED=true |
| 499 | MODE=standard |
| 500 | MAX_SIZE=1000000 |
| 501 | else |
| 502 | # Parse frontmatter |
| 503 | FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$SETTINGS_FILE") |
| 504 | |
| 505 | # Extract fields with defaults |
| 506 | ENABLED=$(echo "$FRONTMATTER" | grep '^enabled:' | sed 's/enabled: *//') |
| 507 | ENABLED=${ENABLED:-true} |
| 508 | |
| 509 | MODE=$(echo "$FRONTMATTER" | grep '^mode:' | sed 's/mode: *//' | sed 's/^"\(.*\)"$/\1/') |
| 510 | MODE=${MODE:-standard} |
| 511 | |
| 512 | MAX_SIZE=$(echo "$FRONTMATTER" | grep '^max_size:' | sed 's/max_size: *//') |
| 513 | MAX_SIZE=${MAX_SIZE:-1000000} |
| 514 | |
| 515 | # Validate values |
| 516 | if [[ "$ENABLED" != "true" ]] && [[ "$ENABLED" != "false" ]]; then |
| 517 | echo "⚠️ Invalid enabled value, using default" >&2 |
| 518 | ENABLED=true |
| 519 | fi |
| 520 | |
| 521 | if ! [[ "$MAX_SIZE" =~ ^[0-9]+$ ]]; then |
| 522 | echo "⚠️ Invalid max_size, using default" >&2 |
| 523 | MAX_SIZE=1000000 |
| 524 | fi |
| 525 | fi |
| 526 | |
| 527 | # Quick exit if disabled |
| 528 | if [[ "$ENABLED" != "true" ]]; then |
| 529 | exit 0 |
| 530 | fi |
| 531 | |
| 532 | # Use configuration |
| 533 | echo "Configuration loaded: mode=$MODE, max_size=$MAX_SIZE" >&2 |
| 534 | |
| 535 | # Apply logic based on settings |
| 536 | case "$MODE" in |
| 537 | strict) |
| 538 | # Strict validation |
| 539 | ;; |
| 540 | standard) |
| 541 | # Standard validation |
| 542 | ;; |
| 543 | lenient) |
| 544 | # Lenient validation |
| 545 | ;; |
| 546 | esac |
| 547 | ``` |
| 548 | |
| 549 | This provides robust settings handling with defaults, validation, and error recovery. |