#!/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|capacity-advisor|capacity-limits|recover-plan|incident|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|ask|ai-evals|impact|review-last-change|security-scan|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-attack-path|prompt-injection-lab|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")} def read_tsv_rows(path): with open(path, encoding="utf-8", newline="") as handle: return list(csv.DictReader(handle, delimiter="\t")) scorecard_info = read_tsv(scorecard_map, "label") finding_rows = read_tsv_rows(findings_map) 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, indent=" "): 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"{indent}Why: {info.get('meaning') or 'No explain entry exists yet.'}") if diagnose: print(f"{indent}Next command: {diagnose[0]}") if len(diagnose) > 1: print(f"{indent}More checks:") for command in diagnose[1:]: print(f"{indent} {command}") if jeannie_fix: print(f"{indent}Jeannie fix command:") for command in jeannie_fix: print(f"{indent} {command}") else: print(f"{indent}Jeannie fix command: none yet") if other_fix: print(f"{indent}Code/config fix:") for command in other_fix: print(f"{indent} {command}") print(f"{indent}Risk: {info.get('risk') or 'unknown'}") print(f"{indent}Auto-fix safe: {info.get('auto_fix') or 'no'}") if info.get("notes"): print(f"{indent}Note: {info['notes']}") def parse_scorecard(): rows = [] pattern = re.compile(r"^(?P