diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d6348e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + go: + name: Go build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + cache-dependency-path: cli/go.sum + + - name: Build + working-directory: cli + run: go build ./... + + - name: Vet + working-directory: cli + run: go vet ./... + + python: + name: Python syntax + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: py_compile every script + run: | + set -e + for f in scripts/*.py cli/_vintage_helper.py; do + echo "Checking $f" + python3 -m py_compile "$f" + done + + shell: + name: Shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + - name: Check install/uninstall scripts + run: | + shellcheck -e SC2086 -e SC1091 -e SC2154 \ + cli/install.sh cli/uninstall.sh \ + cli/install-vintage.sh cli/uninstall-vintage.sh \ No newline at end of file diff --git a/cli/install.sh b/cli/install.sh index 1af6fac..a45e561 100755 --- a/cli/install.sh +++ b/cli/install.sh @@ -1,22 +1,15 @@ #!/usr/bin/env bash -# install.sh — installer for hypercar-colorscripts (v0.3 with vintage opt-in) +# install.sh — installer for hypercar-colorscripts (v0.3.2) # -# Asks LOGIN-shell detected user one question per concern: -# - default rendering mode (auto/ansi) -# - whether to include vintage cars (pre-2000) -# - whether to also install into a secondary rc file (e.g. .bashrc) -# - final go/no-go before modifying any rc file -# -# Vintage cars are managed by _vintage_helper.py. After installation, -# the user can add or remove vintage cars without rerunning install.sh: -# ./install-vintage.sh # add vintage -# ./uninstall-vintage.sh # remove vintage +# Removed: shell tab completion installation (bash/zsh/fish). +# Everything else preserved from v0.3.1. # # Flags: # --system binary -> /usr/local/bin (sudo) # --no-shell skip all .rc modifications -# --yes / -y skip confirmations; defaults to modern-only, auto mode +# --yes / -y skip confirmations # --with-vintage include vintage cars even with --yes +# --no-mix don't mix pokemon even if pokemon-colorscripts is present # --uninstall forward to uninstall.sh set -uo pipefail @@ -28,13 +21,14 @@ warn() { echo -e "${YELLOW}⚠${NC} $*"; } fail() { echo -e "${RED}✗${NC} $*" >&2; exit 1; } fsize() { stat -c %s "$1" 2>/dev/null || stat -f %z "$1" 2>/dev/null || echo 0; } -SYSTEM_INSTALL=0; NO_SHELL=0; ASSUME_YES=0; WITH_VINTAGE=0 +SYSTEM_INSTALL=0; NO_SHELL=0; ASSUME_YES=0; WITH_VINTAGE=0; NO_MIX=0 for arg in "$@"; do case "$arg" in --system) SYSTEM_INSTALL=1 ;; --no-shell) NO_SHELL=1 ;; --yes|-y) ASSUME_YES=1 ;; --with-vintage) WITH_VINTAGE=1 ;; + --no-mix) NO_MIX=1 ;; --uninstall) exec "$(dirname "${BASH_SOURCE[0]}")/uninstall.sh" ;; --help|-h) sed -n '2,/^set -uo/p' "$0" | sed 's/^# \?//'; exit 0 ;; esac @@ -52,63 +46,57 @@ HELPER="$SCRIPT_DIR/_vintage_helper.py" echo -e "${BLUE}═══ hypercar-colorscripts installer ═══${NC}" -# ─── 1. Prereqs ───────────────────────────────── +# 1. Prereqs step "1/8 Prerequisites" command -v chafa >/dev/null 2>&1 || fail "chafa required — sudo dnf install chafa" ok "chafa" - PYTHON="$(command -v python3 || command -v python || true)" [[ -n "$PYTHON" ]] || fail "python required" ok "python: $PYTHON" - [[ -f "$HELPER" ]] || fail "missing helper: $HELPER" -# ─── 2. Build ─────────────────────────────────── +HAS_POKEMON=0 +if command -v pokemon-colorscripts >/dev/null 2>&1; then + HAS_POKEMON=1 + ok "pokemon-colorscripts detected at $(command -v pokemon-colorscripts)" +fi + +# 2. Build step "2/8 Binary" [[ -x "$SCRIPT_DIR/$BINARY" ]] || (cd "$SCRIPT_DIR" && make build) || fail "make build failed" ok "$SCRIPT_DIR/$BINARY" -# ─── 3. Source data ───────────────────────────── +# 3. Source data step "3/8 Source data" -[[ -f "$SRC_MANIFEST" ]] || fail "manifest.json missing — run scripts/build_manifest.py" +[[ -f "$SRC_MANIFEST" ]] || fail "manifest.json missing" [[ -d "$SRC_ASSETS/images" ]] || fail "assets/images/ missing" -ok "$(ls "$SRC_ASSETS/images" | wc -l) PNGs in source" - -# Inspect source via helper eval "$($PYTHON "$HELPER" info "$SRC_MANIFEST")" || fail "helper info failed" VINTAGE_MB=$((VINTAGE_BYTES / 1024 / 1024)) -ok "Source: $MODERN_COUNT modern + $VINTAGE_COUNT vintage cars (pre-2000, ~${VINTAGE_MB} MB)" +ok "Source: $MODERN_COUNT modern + $VINTAGE_COUNT vintage cars (~${VINTAGE_MB} MB)" -# ─── 4. Default rendering mode ────────────────── +# 4. Rendering mode step "4/8 Default rendering mode" DEFAULT_MODE="auto" if [[ $ASSUME_YES -eq 0 ]]; then - echo "Choose how cars/pokemon render at terminal startup:" - echo " 1) auto — real image in Kitty/Ghostty/WezTerm, ANSI text elsewhere (recommended)" - echo " 2) ansi — always pre-rendered text (any terminal, including git bash)" + echo "How to render at terminal startup:" + echo " 1) auto — real image in Kitty/Ghostty/WezTerm, ANSI text elsewhere" + echo " 2) ansi — always pre-rendered text" read -rp "Choice [1]: " m case "${m:-1}" in 2) DEFAULT_MODE="ansi" ;; *) DEFAULT_MODE="auto" ;; esac fi -ok "Mode: $DEFAULT_MODE (change later: hypercar-colorscripts --set-mode auto|ansi)" +ok "Mode: $DEFAULT_MODE" -# ─── 5. Vintage choice ────────────────────────── +# 5. Vintage step "5/8 Vintage cars" - -# Detect previous install state so default flips intelligently PREV_HAS_VINTAGE=0 if [[ -f "$DATA_DIR/manifest.json" ]]; then eval "$($PYTHON "$HELPER" status "$DATA_DIR")" PREV_HAS_VINTAGE=${HAS_VINTAGE:-0} fi - INCLUDE_VINTAGE=$WITH_VINTAGE -if [[ $ASSUME_YES -eq 1 ]]; then - # --yes: respect --with-vintage flag, otherwise default to modern-only - : -else +if [[ $ASSUME_YES -eq 0 ]]; then echo "Include $VINTAGE_COUNT vintage cars (pre-2000)? Adds ~${VINTAGE_MB} MB." if [[ $PREV_HAS_VINTAGE -eq 1 ]]; then - echo "(Currently installed with vintage)" read -rp "Include vintage? [Y/n]: " ans [[ "${ans:-Y}" =~ ^[Nn] ]] && INCLUDE_VINTAGE=0 || INCLUDE_VINTAGE=1 else @@ -117,22 +105,38 @@ else fi fi -if [[ $INCLUDE_VINTAGE -eq 1 ]]; then - ok "Will install: all $TOTAL_COUNT cars" +# 6. Pokemon mixing +step "6/8 Pokemon mixing" +POKEMON_MIX="false" +if [[ $HAS_POKEMON -eq 1 && $NO_MIX -eq 0 ]]; then + if [[ $ASSUME_YES -eq 1 ]]; then + POKEMON_MIX="true" + ok "Pokemon mixing enabled (--yes)" + else + echo "pokemon-colorscripts is installed." + read -rp "Mix pokemon 50/50 with cars at terminal startup? [Y/n]: " ans + if [[ "${ans:-Y}" =~ ^[Nn] ]]; then + POKEMON_MIX="false" + ok "Pokemon mixing disabled (only cars will show)" + else + POKEMON_MIX="true" + ok "Pokemon mixing enabled" + fi + fi +elif [[ $NO_MIX -eq 1 ]]; then + ok "Pokemon mixing disabled (--no-mix)" else - ok "Will install: $MODERN_COUNT modern cars (vintage available later via ./install-vintage.sh)" + ok "pokemon-colorscripts not installed — mixing not applicable" fi +echo " (toggle later: hypercar-colorscripts --set-mix on|off)" -# ─── 6. Install assets + config + binary ──────── -step "6/8 Install assets, config, binary" +# 7. Assets + config + binary +step "7/8 Install assets, config, binary" mkdir -p "$DATA_DIR" - if [[ $INCLUDE_VINTAGE -eq 1 ]]; then - "$PYTHON" "$HELPER" install-all "$SRC_MANIFEST" "$SRC_ASSETS" "$DATA_DIR" \ - || fail "install-all failed" + "$PYTHON" "$HELPER" install-all "$SRC_MANIFEST" "$SRC_ASSETS" "$DATA_DIR" || fail "install-all failed" else - "$PYTHON" "$HELPER" install-modern "$SRC_MANIFEST" "$SRC_ASSETS" "$DATA_DIR" \ - || fail "install-modern failed" + "$PYTHON" "$HELPER" install-modern "$SRC_MANIFEST" "$SRC_ASSETS" "$DATA_DIR" || fail "install-modern failed" fi ok "Assets in $DATA_DIR" @@ -141,6 +145,7 @@ cat > "$CONFIG_FILE" <>> hypercar-colorscripts PATH >>>/,/# <<< hypercar-colorscripts PATH <<>> hypercar-colorscripts >>>/,/# <<< hypercar-colorscripts <<>> hypercar-colorscripts PATH >>>' - printf '%s\n' 'export PATH="$HOME/.local/bin:$PATH"' + printf '%s\n' "export PATH=\"\$HOME/.local/bin:\$PATH\"" printf '%s\n' '# <<< hypercar-colorscripts PATH <<<' printf '\n' cat "$RC" - } > "$TMP" && mv "$TMP" "$RC" || fail "PATH write failed" + } > "$TMP" + if ! mv "$TMP" "$RC"; then + fail "PATH write failed" + fi ok "Added PATH export" fi @@ -248,19 +250,24 @@ __HC_CFG="$HOME/.config/fastfetch/config-pokemon.jsonc" __HC_CONF="$HOME/.config/hypercar-colorscripts/config.json" __HC_MODE="auto" +__HC_MIX="true" if [ -f "$__HC_CONF" ]; then - __HC_MODE=$(sed -n 's/.*"default_mode"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$__HC_CONF" 2>/dev/null | head -1) - [ -z "$__HC_MODE" ] && __HC_MODE="auto" + __HC_TMP=$(sed -n 's/.*"default_mode"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$__HC_CONF" 2>/dev/null | head -1) + [ -n "$__HC_TMP" ] && __HC_MODE="$__HC_TMP" + __HC_TMP=$(sed -n 's/.*"pokemon_mix"[[:space:]]*:[[:space:]]*\(true\|false\).*/\1/p' "$__HC_CONF" 2>/dev/null | head -1) + [ -n "$__HC_TMP" ] && __HC_MIX="$__HC_TMP" + unset __HC_TMP fi __HC_IMG_OK=0 case "${TERM:-}" in xterm-kitty*) __HC_IMG_OK=1 ;; esac -[ -n "${KITTY_WINDOW_ID:-}" ] && __HC_IMG_OK=1 -[ "${TERM_PROGRAM:-}" = "WezTerm" ] && __HC_IMG_OK=1 -[ -n "${GHOSTTY_RESOURCES_DIR:-}" ] && __HC_IMG_OK=1 +[ -n "${KITTY_WINDOW_ID:-}" ] && __HC_IMG_OK=1 +[ "${TERM_PROGRAM:-}" = "WezTerm" ] && __HC_IMG_OK=1 +[ "${TERM_PROGRAM:-}" = "iTerm.app" ] && __HC_IMG_OK=1 +[ -n "${GHOSTTY_RESOURCES_DIR:-}" ] && __HC_IMG_OK=1 [ "$__HC_MODE" = "ansi" ] && __HC_IMG_OK=0 -if [ $(($RANDOM % 2)) -eq 0 ] && command -v pokemon-colorscripts >/dev/null 2>&1; then +if [ "$__HC_MIX" = "true" ] && [ $(($RANDOM % 2)) -eq 0 ] && command -v pokemon-colorscripts >/dev/null 2>&1; then if command -v fastfetch >/dev/null 2>&1 && [ -f "$__HC_CFG" ]; then pokemon-colorscripts --no-title -s -r | \ fastfetch -c "$__HC_CFG" --logo-type file-raw --logo-height 10 --logo-width 5 --logo - @@ -281,27 +288,22 @@ else fi fi fi -unset __HC_DATA __HC_CFG __HC_CONF __HC_MODE __HC_IMG_OK __HC_IMG __HC_ANSI +unset __HC_DATA __HC_CFG __HC_CONF __HC_MODE __HC_MIX __HC_IMG_OK __HC_IMG __HC_ANSI # <<< hypercar-colorscripts <<< HC_BLOCK_END AFTER=$(fsize "$RC") - - [[ $AFTER -gt $BEFORE ]] || fail "$RC didn't grow ($BEFORE -> $AFTER bytes)" - grep -q '# >>> hypercar-colorscripts >>>' "$RC" || fail "$RC missing marker after append" - ok "Block appended ($((AFTER - BEFORE)) bytes, line $(grep -n '# >>> hypercar-colorscripts >>>' "$RC" | head -1 | cut -d: -f1))" - ok "Backup: $BACKUP" + [[ $AFTER -gt $BEFORE ]] || fail "$RC didn't grow" + grep -q '# >>> hypercar-colorscripts >>>' "$RC" || fail "$RC missing marker" + ok "Block appended ($((AFTER - BEFORE)) bytes)" done echo echo -e "${GREEN}═══ Done ═══${NC}" -[[ $INCLUDE_VINTAGE -eq 1 ]] && \ - echo "Installed: $TOTAL_COUNT cars (modern + vintage)" || \ - echo "Installed: $MODERN_COUNT cars (modern only)" -echo "Mode: $DEFAULT_MODE (toggle with: hypercar-colorscripts --set-mode auto|ansi)" -echo "Rc files: ${RC_FILES[*]}" +[[ $INCLUDE_VINTAGE -eq 1 ]] && echo "Installed: $TOTAL_COUNT cars (modern + vintage)" || echo "Installed: $MODERN_COUNT cars" +echo "Mode: $DEFAULT_MODE Pokemon mix: $POKEMON_MIX" echo -echo "Vintage management (run from this repo):" -echo " ./install-vintage.sh add vintage cars" -echo " ./uninstall-vintage.sh remove vintage cars" +echo "Toggle later:" +echo " hypercar-colorscripts --set-mode auto|ansi" +echo " hypercar-colorscripts --set-mix on|off" echo echo "Open a new terminal to see the changes." \ No newline at end of file diff --git a/cli/internal/cmd/doctor.go b/cli/internal/cmd/doctor.go new file mode 100644 index 0000000..d5bf455 --- /dev/null +++ b/cli/internal/cmd/doctor.go @@ -0,0 +1,256 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "hypercar-colorscripts/internal/config" + "hypercar-colorscripts/internal/manifest" +) + +const ( + cRed = "\033[0;31m" + cGreen = "\033[0;32m" + cYellow = "\033[1;33m" + cCyan = "\033[0;36m" + cReset = "\033[0m" +) + +func runDoctor() error { + fmt.Println("hypercar-colorscripts doctor") + fmt.Println(strings.Repeat("─", 60)) + + // 1. System dependencies + fmt.Println("\nSystem dependencies:") + docCheckCommand("chafa", true, "required — image rendering") + docCheckCommand("fastfetch", false, "recommended — system info beside image") + docCheckCommand("pokemon-colorscripts", false, "optional — enables pokemon mixing") + + // 2. Terminal capability + fmt.Println("\nTerminal capability:") + docCheckTerminal() + + // 3. Config + fmt.Println("\nConfiguration:") + cfg, cfgErr := config.Load() + if cfgErr != nil { + fmt.Printf(" %s✗%s config not loadable: %v\n", cRed, cReset, cfgErr) + } else { + fmt.Printf(" %s✓%s config file: %s\n", cGreen, cReset, config.Path()) + fmt.Printf(" data_dir: %s\n", cfg.DataDir) + fmt.Printf(" default_mode: %s\n", cfg.DefaultMode) + if cfg.PokemonMix != nil { + fmt.Printf(" pokemon_mix: %v\n", *cfg.PokemonMix) + } else { + fmt.Printf(" pokemon_mix: unset (defaults to on)\n") + } + } + + // 4. Manifest + assets + fmt.Println("\nInstalled data:") + dataDir := "" + if cfg.DataDir != "" { + dataDir = cfg.DataDir + } else { + home, _ := os.UserHomeDir() + dataDir = filepath.Join(home, ".local/share/hypercar-colorscripts") + } + manifestPath := filepath.Join(dataDir, "manifest.json") + docCheckManifest(manifestPath, dataDir) + + // 5. Fastfetch config + fmt.Println("\nStartup integration:") + home, _ := os.UserHomeDir() + ffCfg := filepath.Join(home, ".config/fastfetch/config-pokemon.jsonc") + if _, err := os.Stat(ffCfg); err == nil { + fmt.Printf(" %s✓%s fastfetch config: %s\n", cGreen, cReset, ffCfg) + } else { + fmt.Printf(" %s○%s fastfetch config: not found at %s\n", cYellow, cReset, ffCfg) + fmt.Printf(" (startup will use plain output — install fastfetch + config for full experience)\n") + } + docCheckShellRc() + + // 6. Effective pokemon-mixing state (the previously confusing part) + fmt.Println("\nPokemon integration (effective state):") + docCheckPokemonMixing(cfg) + + fmt.Println("\n" + strings.Repeat("─", 60)) + fmt.Println("Legend: ✓ working ○ optional/missing ⚠ needs attention ✗ broken") + return nil +} + +// docCheckPokemonMixing reports the ACTUAL mixing behavior the user will see, +// combining: pokemon-colorscripts availability, shell block presence, and the +// pokemon_mix config setting. +func docCheckPokemonMixing(cfg config.Config) { + // (a) is pokemon-colorscripts on PATH? + pokePath, err := exec.LookPath("pokemon-colorscripts") + pokeInstalled := err == nil + + // (b) does the shell startup block exist? + home, _ := os.UserHomeDir() + shellBlockFound := false + for _, rc := range []string{".zshrc", ".bashrc"} { + data, err := os.ReadFile(filepath.Join(home, rc)) + if err == nil && strings.Contains(string(data), "# >>> hypercar-colorscripts >>>") { + shellBlockFound = true + break + } + } + + // (c) pokemon_mix setting in config (defaults true when field is absent) + mixEnabled := cfg.EffectiveMix() + + // Report each piece + if pokeInstalled { + fmt.Printf(" %s✓%s pokemon-colorscripts installed at %s\n", cGreen, cReset, pokePath) + } else { + fmt.Printf(" %s○%s pokemon-colorscripts not installed\n", cYellow, cReset) + } + + if shellBlockFound { + fmt.Printf(" %s✓%s shell startup block present\n", cGreen, cReset) + } else { + fmt.Printf(" %s○%s shell startup block not found\n", cYellow, cReset) + } + + if mixEnabled { + fmt.Printf(" %s✓%s pokemon_mix in config: on\n", cGreen, cReset) + } else { + fmt.Printf(" %s○%s pokemon_mix in config: off (set by user)\n", cYellow, cReset) + } + + // Effective behavior + fmt.Println() + switch { + case pokeInstalled && shellBlockFound && mixEnabled: + fmt.Printf(" %sEffective behavior:%s new terminals show 50%% pokemon, 50%% car\n", cCyan, cReset) + case pokeInstalled && shellBlockFound && !mixEnabled: + fmt.Printf(" %sEffective behavior:%s new terminals always show a car (pokemon_mix=off)\n", cCyan, cReset) + case !pokeInstalled && shellBlockFound: + fmt.Printf(" %sEffective behavior:%s new terminals always show a car\n", cCyan, cReset) + case pokeInstalled && !shellBlockFound: + fmt.Printf(" %sEffective behavior:%s no startup output (block missing — run install.sh)\n", cCyan, cReset) + default: + fmt.Printf(" %sEffective behavior:%s no startup output\n", cCyan, cReset) + } + + // Legacy warning for with_pokemon (deprecated) + if cfg.WithPokemon { + fmt.Printf("\n %s⚠%s legacy with_pokemon=true in config.json (this field is deprecated)\n", cYellow, cReset) + fmt.Printf(" Use --set-mix on/off to control pokemon mixing instead.\n") + } +} + +func docCheckCommand(name string, required bool, desc string) { + path, err := exec.LookPath(name) + if err != nil { + if required { + fmt.Printf(" %s✗%s %-25s NOT FOUND (%s)\n", cRed, cReset, name, desc) + } else { + fmt.Printf(" %s○%s %-25s not found (%s)\n", cYellow, cReset, name, desc) + } + return + } + fmt.Printf(" %s✓%s %-25s %s\n", cGreen, cReset, name, path) + _ = desc +} + +func docCheckTerminal() { + term := os.Getenv("TERM") + termProgram := os.Getenv("TERM_PROGRAM") + kittyID := os.Getenv("KITTY_WINDOW_ID") + ghosttyDir := os.Getenv("GHOSTTY_RESOURCES_DIR") + + imgOK := false + protocol := "none" + switch { + case kittyID != "": + imgOK, protocol = true, "Kitty graphics protocol" + case termProgram == "WezTerm": + imgOK, protocol = true, "WezTerm imgcat" + case ghosttyDir != "": + imgOK, protocol = true, "Ghostty (Kitty-compatible)" + case strings.HasPrefix(term, "xterm-kitty"): + imgOK, protocol = true, "Kitty via TERM" + case termProgram == "iTerm.app": + imgOK, protocol = true, "iTerm2 inline images" + } + if imgOK { + fmt.Printf(" %s✓%s image protocol: %s\n", cGreen, cReset, protocol) + } else { + fmt.Printf(" %s○%s image protocol: none detected (will use ANSI fallback)\n", cYellow, cReset) + } + fmt.Printf(" TERM=%s TERM_PROGRAM=%s KITTY_WINDOW_ID=%q\n", term, termProgram, kittyID) +} + +func docCheckManifest(manifestPath, dataDir string) { + data, err := os.ReadFile(manifestPath) + if err != nil { + fmt.Printf(" %s✗%s manifest.json NOT FOUND at %s\n", cRed, cReset, manifestPath) + fmt.Printf(" Run: cd cli && ./install.sh\n") + return + } + var m manifest.Manifest + if err := json.Unmarshal(data, &m); err != nil { + fmt.Printf(" %s✗%s manifest.json corrupt: %v\n", cRed, cReset, err) + return + } + fmt.Printf(" %s✓%s manifest.json: %d cars (%d KB)\n", + cGreen, cReset, len(m.Cars), len(data)/1024) + + imgDir := filepath.Join(dataDir, "assets/images") + ansiDir := filepath.Join(dataDir, "assets/ansi") + imgCount := docCountFiles(imgDir, ".png") + ansiCount := docCountFiles(ansiDir, ".txt") + + fmt.Printf(" %s✓%s images: %d PNG files\n", cGreen, cReset, imgCount) + fmt.Printf(" %s✓%s ansi: %d TXT files\n", cGreen, cReset, ansiCount) + + if imgCount != len(m.Cars) || ansiCount != len(m.Cars) { + fmt.Printf(" %s⚠%s asset count mismatch: manifest=%d images=%d ansi=%d\n", + cYellow, cReset, len(m.Cars), imgCount, ansiCount) + } +} + +func docCountFiles(dir, ext string) int { + entries, err := os.ReadDir(dir) + if err != nil { + return 0 + } + n := 0 + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ext) { + n++ + } + } + return n +} + +func docCheckShellRc() { + home, _ := os.UserHomeDir() + for _, rc := range []string{".zshrc", ".bashrc"} { + path := filepath.Join(home, rc) + data, err := os.ReadFile(path) + if err != nil { + continue + } + s := string(data) + hasBlock := strings.Contains(s, "# >>> hypercar-colorscripts >>>") + hasPath := strings.Contains(s, "# >>> hypercar-colorscripts PATH >>>") + switch { + case hasBlock && hasPath: + fmt.Printf(" %s✓%s ~/%s: startup block + PATH export present\n", cGreen, cReset, rc) + case hasBlock: + fmt.Printf(" %s⚠%s ~/%s: startup block present but PATH export missing\n", cYellow, cReset, rc) + case hasPath: + fmt.Printf(" %s⚠%s ~/%s: PATH export present but startup block missing\n", cYellow, cReset, rc) + default: + fmt.Printf(" %s○%s ~/%s: no hypercar integration\n", cYellow, cReset, rc) + } + } +} diff --git a/cli/internal/cmd/list.go b/cli/internal/cmd/list.go new file mode 100644 index 0000000..d008cde --- /dev/null +++ b/cli/internal/cmd/list.go @@ -0,0 +1,140 @@ +package cmd + +import ( + "fmt" + "sort" + "strconv" + + "hypercar-colorscripts/internal/manifest" +) + +// The three Print*List functions below replace flat-list output with +// grouped, count-aware output. Call these from root.go where you +// currently handle --list-brands, --list-decades, --list-categories. + +type brandStats struct { + count int + minY int + maxY int +} + +// PrintBrandList prints one line per brand: "Ferrari 91 cars 1947–2024" +func PrintBrandList(mf *manifest.Manifest) { + m := make(map[string]*brandStats) + for _, c := range mf.Cars { + if c.Brand == "" { + continue + } + s, ok := m[c.Brand] + if !ok { + s = &brandStats{minY: 99999, maxY: 0} + m[c.Brand] = s + } + s.count++ + if y, err := strconv.Atoi(c.StartYear); err == nil && y > 0 { + if y < s.minY { + s.minY = y + } + if y > s.maxY { + s.maxY = y + } + } + if y, err := strconv.Atoi(c.EndYear); err == nil && y > 0 { + if y > s.maxY { + s.maxY = y + } + } + } + + brands := make([]string, 0, len(m)) + for b := range m { + brands = append(brands, b) + } + sort.Strings(brands) + + maxLen := 0 + for _, b := range brands { + if len(b) > maxLen { + maxLen = len(b) + } + } + + for _, b := range brands { + s := m[b] + years := yearRange(s.minY, s.maxY) + fmt.Printf(" %-*s %4d cars %s\n", maxLen, b, s.count, years) + } + fmt.Printf("\n%d brands, %d cars total\n", len(brands), len(mf.Cars)) +} + +// PrintDecadeList prints one line per decade in chronological order. +func PrintDecadeList(mf *manifest.Manifest) { + counts := make(map[string]int) + for _, c := range mf.Cars { + if c.Decade == "" { + continue + } + counts[c.Decade]++ + } + + decades := make([]string, 0, len(counts)) + for d := range counts { + decades = append(decades, d) + } + sort.Strings(decades) // "1940s" < "1950s" < ... alpha == chrono for decades + + for _, d := range decades { + era := "modern" + if y, err := strconv.Atoi(d[:len(d)-1]); err == nil && y < 2000 { + era = "vintage" + } + fmt.Printf(" %-8s %4d cars (%s)\n", d, counts[d], era) + } + fmt.Printf("\n%d decades, %d cars total\n", len(decades), len(mf.Cars)) +} + +// PrintCategoryList prints one line per category, sorted by count desc. +func PrintCategoryList(mf *manifest.Manifest) { + counts := make(map[string]int) + for _, c := range mf.Cars { + if c.Category == "" { + continue + } + counts[c.Category]++ + } + type kv struct { + k string + v int + } + pairs := make([]kv, 0, len(counts)) + for k, v := range counts { + pairs = append(pairs, kv{k, v}) + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].v != pairs[j].v { + return pairs[i].v > pairs[j].v + } + return pairs[i].k < pairs[j].k + }) + + maxLen := 0 + for _, p := range pairs { + if len(p.k) > maxLen { + maxLen = len(p.k) + } + } + for _, p := range pairs { + fmt.Printf(" %-*s %4d cars\n", maxLen, p.k, p.v) + } + fmt.Printf("\n%d categories, %d cars total\n", len(pairs), len(mf.Cars)) +} + +func yearRange(minY, maxY int) string { + if minY == 99999 || maxY == 0 { + return "—" + } + if minY == maxY { + return strconv.Itoa(minY) + } + return fmt.Sprintf("%d–%d", minY, maxY) +} diff --git a/cli/internal/cmd/root.go b/cli/internal/cmd/root.go index 1ffee8e..e12c067 100644 --- a/cli/internal/cmd/root.go +++ b/cli/internal/cmd/root.go @@ -23,6 +23,7 @@ import ( var ( flagDataDir string flagSetMode string // --set-mode flag + flagSetMix string // --set-mix flag flagCar string flagInfo string @@ -60,17 +61,32 @@ var ( func Execute(version string) error { root := &cobra.Command{ - Use: "hypercar-colorscripts", - Short: "Print a random hypercar in your terminal", + Use: "hypercar-colorscripts", + Short: "Print a random hypercar in your terminal", + Long: `hypercar-colorscripts displays a random hypercar in your terminal +with support for filtering by brand, decade, category, and more. + +Examples: + hypercar-colorscripts # Show a random car + hypercar-colorscripts --brand ferrari # Show a random Ferrari + hypercar-colorscripts --list-brands # List all brands + hypercar-colorscripts --info ferrari_f40 # Show details about a car + hypercar-colorscripts --set-mix off # Disable pokemon mixing + hypercar-colorscripts --doctor # Debug your setup`, Version: version, SilenceUsage: true, SilenceErrors: true, RunE: run, + // CHANGE 2: Disable Cobra's built-in 'completion' subcommand + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + }, } pf := root.PersistentFlags() pf.StringVar(&flagDataDir, "data-dir", "", "override manifest+assets directory") pf.StringVar(&flagSetMode, "set-mode", "", "update default_mode in config (auto|ansi) and exit") + pf.StringVar(&flagSetMix, "set-mix", "", "toggle pokemon mixing in shell startup (on|off) and exit") f := root.Flags() // Selection @@ -116,6 +132,8 @@ func Execute(version string) error { root.MarkFlagsMutuallyExclusive("auto", "ansi-only", "image-only") root.MarkFlagsMutuallyExclusive("with-pokemon", "no-pokemon", "pokemon-only") + // CHANGE 1: All RegisterFlagCompletionFunc calls DELETED + return root.Execute() } @@ -154,6 +172,38 @@ func run(cmd *cobra.Command, args []string) error { return nil } + // --set-mix short-circuits everything else + if flagSetMix != "" { + var mix bool + switch strings.ToLower(flagSetMix) { + case "on", "true", "yes", "1": + mix = true + case "off", "false", "no", "0": + mix = false + default: + return fmt.Errorf("invalid --set-mix value %q (must be 'on' or 'off')", flagSetMix) + } + cfg.WithPokemon = mix + if err := config.Save(cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + state := "on" + if !mix { + state = "off" + } + fmt.Printf("pokemon_mix set to: %s\n", state) + if mix { + if _, err := os.Stat("/usr/bin/pokemon-colorscripts"); err != nil { + if _, err := exec.LookPath("pokemon-colorscripts"); err != nil { + fmt.Println("Note: pokemon-colorscripts is not installed, so mixing won't happen even with --set-mix on.") + fmt.Println("Install it from https://gitlab.com/phoneybadger/pokemon-colorscripts") + } + } + } + fmt.Println("Open a new terminal to see the change take effect at startup.") + return nil + } + // --pokemon-only short-circuits everything else if flagPokemonOnly { if !pokemon.Available() { @@ -176,11 +226,14 @@ func run(cmd *cobra.Command, args []string) error { // Listing & inspection commands switch { case flagListBrands: - return printUnique(mf.UniqueBrands()) + PrintBrandList(mf) + return nil case flagListDecades: - return printUnique(mf.UniqueDecades()) + PrintDecadeList(mf) + return nil case flagListCategories: - return printUnique(mf.UniqueCategories()) + PrintCategoryList(mf) + return nil case flagListPowertrains: return printUnique(mf.UniquePowertrains()) case flagListCountries: @@ -364,6 +417,7 @@ func printLicenseHistogram(mf *manifest.Manifest) error { return nil } +// doDoctor is called from --doctor func doDoctor(dataDir string, cfg config.Config) error { fmt.Println("=== hypercar-colorscripts diagnostics ===") fmt.Printf("Config path: %s\n", config.Path()) diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 5882867..818d466 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -16,14 +16,32 @@ type Config struct { // DefaultMode: "auto" (image when chafa exists) or "ansi" (always cat). DefaultMode string `json:"default_mode"` - // WithPokemon: if true, sometimes shell out to pokemon-colorscripts. - WithPokemon bool `json:"with_pokemon"` + // PokemonMix: controls shell-level pokemon mixing. + // nil = "not set" (defaults to true for backward compatibility) + // true/false = explicitly set by user via --set-mix + PokemonMix *bool `json:"pokemon_mix,omitempty"` + + // WithPokemon: deprecated — binary internal mixing (unused). + // Kept for backward compatibility with existing configs. + WithPokemon bool `json:"with_pokemon"` + + // PokemonRatio: chance of picking pokemon when mixing is enabled. PokemonRatio float64 `json:"pokemon_ratio"` // ShowAttribution prints CC credit line below the art. ShowAttribution bool `json:"show_attribution"` } +// EffectiveMix returns whether the shell block should mix pokemon. +// Defaults to true when the field is absent from config.json (preserves +// pre-v0.3.1 behavior for existing installs). +func (c Config) EffectiveMix() bool { + if c.PokemonMix == nil { + return true + } + return *c.PokemonMix +} + // Defaults returns sensible defaults so the binary works even // without a config file (important when called from .zshrc — must // never prompt or block). @@ -32,6 +50,7 @@ func Defaults() Config { return Config{ DataDir: filepath.Join(home, ".local", "share", "hypercar-colorscripts"), DefaultMode: "auto", + PokemonMix: nil, // nil = defaults to true in EffectiveMix() WithPokemon: false, PokemonRatio: 0.5, ShowAttribution: false, diff --git a/cli/uninstall.sh b/cli/uninstall.sh index d3ce016..0d552fe 100755 --- a/cli/uninstall.sh +++ b/cli/uninstall.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# uninstall.sh — remove hypercar-colorscripts cleanly (v0.2) +# uninstall.sh — remove hypercar-colorscripts cleanly (v0.3.2) # -# Now handles multi-rc installs (both .zshrc and .bashrc if the -# installer wrote to both). +# Removed: completion file cleanup (shell completions feature dropped). +# Best-effort deletes any leftover completion files from previous versions +# so nobody ends up with stale files after upgrading. # # Options: # 1) Only hypercar-colorscripts (default) @@ -10,7 +11,7 @@ set -uo pipefail -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' ok() { echo -e "${GREEN}✓${NC} $*"; } warn() { echo -e "${YELLOW}⚠${NC} $*"; } @@ -33,15 +34,15 @@ echo " - $BIN_USER (if exists)" echo " - $BIN_SYS (if exists)" echo " - $DATA_DIR/" echo " - $CONFIG_DIR/" -echo " - hypercar blocks from ~/.zshrc AND ~/.bashrc (whichever exist)" +echo " - hypercar blocks from ~/.zshrc AND ~/.bashrc (backups made)" [[ "$choice" == "2" ]] && echo " - pokemon-colorscripts" read -rp "Continue? [y/N]: " confirm [[ ! "${confirm:-N}" =~ ^[Yy]$ ]] && { echo "Aborted."; exit 0; } -# Process every rc file the installer might have touched +# ─── Process each rc file ────────────────────── for RC in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile"; do [[ -f "$RC" ]] || continue - if ! grep -q "hypercar-colorscripts" "$RC" 2>/dev/null; then + if ! grep -qE "hypercar-colorscripts|hypercar-installer-disabled" "$RC" 2>/dev/null; then continue fi @@ -52,33 +53,44 @@ for RC in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile"; do sed -i.tmp '/# >>> hypercar-colorscripts PATH >>>/,/# <<< hypercar-colorscripts PATH <<>> hypercar-colorscripts >>>/,/# <<< hypercar-colorscripts <</dev/null || true rm -f "$RC.tmp" ok " Cleaned $RC" done -# Binaries +# ─── Binaries ────────────────────────────────── [[ -f "$BIN_USER" ]] && rm -f "$BIN_USER" && ok "Removed $BIN_USER" if [[ -f "$BIN_SYS" ]]; then if [[ -w "$BIN_SYS" ]]; then rm -f "$BIN_SYS"; else sudo rm -f "$BIN_SYS"; fi ok "Removed $BIN_SYS" fi -# Data + config dirs +# ─── Data + config dirs ──────────────────────── [[ -d "$DATA_DIR" ]] && rm -rf "$DATA_DIR" && ok "Removed $DATA_DIR" [[ -d "$CONFIG_DIR" ]] && rm -rf "$CONFIG_DIR" && ok "Removed $CONFIG_DIR" -# Option 2: also remove pokemon-colorscripts +# ─── Cleanup stale completion files from prior versions ──────── +# The completions feature was removed in v0.3.2. If a previous install +# left files behind, clean them up silently. +for f in "$HOME/.local/share/bash-completion/completions/hypercar-colorscripts" \ + "$HOME/.local/share/zsh/site-functions/_hypercar-colorscripts" \ + "$HOME/.config/fish/completions/hypercar-colorscripts.fish"; do + if [[ -f "$f" ]]; then + rm -f "$f" + ok "Removed stale completion file: $f" + fi +done + +# ─── Option 2: also uninstall pokemon-colorscripts ─ if [[ "$choice" == "2" ]] && command -v pokemon-colorscripts >/dev/null 2>&1; then echo echo -e "${BLUE}Removing pokemon-colorscripts...${NC}" @@ -97,7 +109,6 @@ if [[ "$choice" == "2" ]] && command -v pokemon-colorscripts >/dev/null 2>&1; th ok "Removed $d" fi done - # Remove any (now-restored) pokemon line from rc files for RC in "$HOME/.zshrc" "$HOME/.bashrc"; do [[ -f "$RC" ]] && sed -i.tmp '/^pokemon-colorscripts/d' "$RC" && rm -f "$RC.tmp" done