#!/usr/bin/env bash set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SCORECARD_MAP="${HOMELAB_SCORECARD_EXPLAIN_MAP:-${REPO_ROOT}/infra/jeannie-explain/scorecard.tsv}" FINDINGS_MAP="${HOMELAB_FINDINGS_EXPLAIN_MAP:-${REPO_ROOT}/infra/jeannie-explain/findings.tsv}" EXPLAIN_INPUT_FILE="" cleanup() { if [ -n "$EXPLAIN_INPUT_FILE" ] && [ -f "$EXPLAIN_INPUT_FILE" ]; then rm -f "$EXPLAIN_INPUT_FILE" fi } trap cleanup EXIT usage() { cat <<'EOF' Usage: ./jeannie explain scorecard [--all] ./jeannie explain [args...] ./jeannie explain output < file Explain warnings, failures, and corrective findings from Jeannie output. This is read-only and refuses mutating commands. Examples: ./jeannie explain scorecard ./jeannie explain status ./jeannie explain capacity ./jeannie explain resource-budget --details ./jeannie status | ./jeannie explain output EOF } is_read_only_command() { case "$1" in status|capacity|recover-plan|scorecard|gitops-status|cert-check|backup-status|synthetic-checks|resource-budget|route-inventory|golden-ledger|workers|change-journal|map|validate|access-audit|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|doctor-preapply|inventory-check|secrets-check|tailnet-policy-check|ai-check|security-scan|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-attack-path|control-plane) return 0 ;; *) return 1 ;; esac } is_read_only_subcommand() { local command_name="$1" local subcommand="${2:-}" case "$command_name" in workers) [ -z "$subcommand" ] || [ "$subcommand" = "list" ] ;; golden-ledger) [ -z "$subcommand" ] || [ "$subcommand" = "show" ] || [ "$subcommand" = "check" ] ;; change-journal) [ -z "$subcommand" ] || [ "$subcommand" = "list" ] || [ "$subcommand" = "path" ] ;; control-plane) [ -z "$subcommand" ] || [ "$subcommand" = "status" ] || [ "$subcommand" = "pods" ] ;; *) return 0 ;; esac } run_target() { local command_name="$1" shift || true if ! is_read_only_command "$command_name" || ! is_read_only_subcommand "$command_name" "${1:-}"; then echo "Refusing to explain by running mutating or unknown command: ./jeannie ${command_name} $*" >&2 echo "Use './jeannie explain output < saved-output.txt' for arbitrary pasted output." >&2 return 2 fi "${REPO_ROOT}/jeannie" "$command_name" "$@" 2>&1 || true } explain_output() { local topic="$1" local show_all="$2" local input_file="$3" if [ ! -s "$FINDINGS_MAP" ]; then echo "Missing findings explain map: $FINDINGS_MAP" >&2 return 1 fi if [ ! -s "$SCORECARD_MAP" ]; then echo "Missing scorecard explain map: $SCORECARD_MAP" >&2 return 1 fi python3 - "$topic" "$show_all" "$input_file" "$SCORECARD_MAP" "$FINDINGS_MAP" <<'PY' import csv import re import sys topic, show_all_arg, input_file, scorecard_map, findings_map = sys.argv[1:6] show_all = show_all_arg.lower() == "true" def read_tsv(path, key): with open(path, encoding="utf-8", newline="") as handle: return {row[key].lower(): row for row in csv.DictReader(handle, delimiter="\t")} scorecard_info = read_tsv(scorecard_map, "label") finding_info = read_tsv(findings_map, "pattern") with open(input_file, encoding="utf-8") as handle: text = handle.read() lines = text.splitlines() def split_commands(value): return [item.strip() for item in (value or "").split(";") if item.strip() and item.strip() != "none"] def print_info(info): diagnose = split_commands(info.get("diagnose_commands")) jeannie_fix = split_commands(info.get("jeannie_fix_commands")) other_fix = split_commands(info.get("other_fix_commands")) print(f" means: {info.get('meaning') or 'No explain entry exists yet.'}") if diagnose: print(" diagnose:") for command in diagnose: print(f" {command}") if jeannie_fix: print(" Jeannie fix command exists: yes") for command in jeannie_fix: print(f" {command}") else: print(" Jeannie fix command exists: no") if other_fix: print(" non-Jeannie fix:") for command in other_fix: print(f" {command}") print(f" risk: {info.get('risk') or 'unknown'}") print(f" auto-fix safe: {info.get('auto_fix') or 'no'}") if info.get("notes"): print(f" note: {info['notes']}") def parse_scorecard(): rows = [] pattern = re.compile(r"^(?P