diff --git a/README.md b/README.md index 945d6f1..9819192 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

Random hypercar art in your terminal.
One of the first open-source projects to bring curated automotive art to terminal startup screens.
- 633 curated cars across 24 brands, verified free-license, mixed with system info via fastfetch. + 690 curated cars across 29 brands, verified free-license, mixed with system info via fastfetch.

@@ -36,12 +36,12 @@ The tool is a single Go binary. Nothing runs in the background. Nothing writes o ## Features -- **633 cars, 24 brands, 1940s → 2020s.** Ferrari, Lamborghini, McLaren, Pagani, Koenigsegg, Bugatti, Porsche, Rolls-Royce, Aston Martin, Lotus, Rimac, Hennessey, plus Japanese and American icons. +- **690 cars, 29 brands, 1940s → 2020s.** Ferrari, Lamborghini, McLaren, Pagani, Koenigsegg, Bugatti, Porsche, Rolls-Royce, Aston Martin, Lotus, Rimac, Hennessey, plus Japanese and American icons. - **Every image is free-license.** CC0, CC BY, CC BY-SA, GFDL, Public Domain, or Free Art. Every image ships with a JSON attribution file. See [`CREDITS.md`](CREDITS.md). - **Smart lookup.** Type `--car chiron` or `--car "rolls royce ghost"` or `--car "porsche 911"` — it resolves aliases, matches by tokens, corrects typos, and shows a numbered picker when multiple cars fit. - **Real images or ANSI fallback**, chosen automatically based on your terminal's capabilities. Force one mode with `--set-mode auto|ansi`. - **Pokemon mixing is opt-in.** The installer asks; you can toggle later with `--set-mix on|off`. -- **Vintage cars are opt-in.** Default install is modern (2000+). Add pre-2000 cars any time with `./install-vintage.sh`. +- **Vintage cars are opt-in.** Default install is modern (2000+). Add pre-2000 cars any time with `--install-vintage` or `./install-vintage.sh`. - **`--doctor`** health check tells you exactly what's wrong when something isn't working. - **Contributor scripts** for adding new cars (`add_car.py`) or replacing existing images (`manual_override.py`) with automated background removal and license validation. - **Zero background daemons, zero telemetry, zero non-standard paths.** Uses XDG directories. @@ -98,7 +98,7 @@ Pick [1-10, q to quit]: Lamborghini 73 cars 1963–2024 ... -24 brands, 633 cars total +29 brands, 690 cars total ``` @@ -160,7 +160,7 @@ make build The installer asks these questions: 1. **Rendering mode** — `auto` (image when possible, ANSI otherwise) or `ansi` (always ANSI) -2. **Include vintage cars?** — pre-2000 cars, ~15 MB extra (default: no; add later with `./install-vintage.sh`) +2. **Include vintage cars?** — pre-2000 cars, ~15 MB extra (default: no; add later with `--install-vintage` or `./install-vintage.sh`) 3. **Mix pokemon with cars?** — only asked if `pokemon-colorscripts` is installed (toggle later with `--set-mix on|off`) 4. **Which shell rc files** — installs into your login shell's rc by default; offers to also install into secondary shells if present @@ -290,6 +290,8 @@ Use this first when troubleshooting. | `--list-categories` | List all categories with counts | | `--set-mode auto\|ansi` | Change default rendering mode | | `--set-mix on\|off` | Toggle pokemon mixing in shell startup | +| `--install-vintage` | Add pre-2000 cars (needs source repo) | +| `--uninstall-vintage` | Remove pre-2000 cars (self-contained) | | `--doctor` | Environment health check | | `--no-newline` | Suppress trailing newline (for shell prompts) | | `--data-dir ` | Override the data directory | @@ -297,24 +299,49 @@ Use this first when troubleshooting. ## Vintage cars -Vintage = pre-2000. Off by default because most people want modern cars. +Vintage = pre-2000. Off by default because most people want modern cars, +but easy to add or remove anytime — either from the binary or from the +cloned source repo. -**Add vintage cars** to an existing install: +### From the installed binary + +**Add vintage cars:** ```bash -cd path/to/hypercar-colorscripts/cli -./install-vintage.sh +hypercar-colorscripts --install-vintage ``` -Shows disk impact (~15 MB), asks confirmation, adds the cars. Idempotent — safe to re-run. +The binary looks for your source clone at these locations, in order: + +1. `$HYPERCAR_SRC` (if set) +2. Current directory +3. `~/hypercar-colorscripts`, `~/car-test/hypercar-colorscripts`, + `~/projects/hypercar-colorscripts`, `~/src/hypercar-colorscripts`, + `~/Documents/hypercar-colorscripts`, `~/Downloads/hypercar-colorscripts` -**Remove vintage cars** from an existing install: +If found, it runs `install-vintage.sh` automatically. If not found, it +prints clone instructions with the `HYPERCAR_SRC` escape hatch. + +**Remove vintage cars:** ```bash -./uninstall-vintage.sh +hypercar-colorscripts --uninstall-vintage ``` -Works even without the cloned repo — operates entirely on `~/.local/share/hypercar-colorscripts/`. To bring vintage back later, re-run `./install-vintage.sh` from the cloned repo. +This is fully self-contained — no source repo needed. It reads the +installed manifest, removes pre-2000 cars, deletes their asset files, +and updates the manifest. + +### From the source repo + +Same functionality via shell scripts, useful if the binary isn't +installed yet or if you prefer scripts: + +```bash +cd path/to/hypercar-colorscripts/cli +./install-vintage.sh # add vintage +./uninstall-vintage.sh # remove vintage +``` ## Terminal support @@ -503,7 +530,7 @@ hypercar-colorscripts/ ├── NOTICE ← attribution requirements ├── COPYING.assets ← code-vs-asset licensing explanation ├── CREDITS.md ← per-image attribution summary -├── manifest.json ← 633 cars, generated by build_manifest.py +├── manifest.json ← 690 cars, generated by build_manifest.py │ ├── assets/ │ ├── images/ ← 512×256 PNGs, one per car diff --git a/cli/internal/cmd/root.go b/cli/internal/cmd/root.go index e12c067..ffc89fc 100644 --- a/cli/internal/cmd/root.go +++ b/cli/internal/cmd/root.go @@ -25,6 +25,10 @@ var ( flagSetMode string // --set-mode flag flagSetMix string // --set-mix flag + // CHANGE 1: Added vintage flags + flagInstallVintage bool + flagUninstallVintage bool + flagCar string flagInfo string flagLicense string @@ -77,7 +81,6 @@ Examples: SilenceUsage: true, SilenceErrors: true, RunE: run, - // CHANGE 2: Disable Cobra's built-in 'completion' subcommand CompletionOptions: cobra.CompletionOptions{ DisableDefaultCmd: true, }, @@ -88,6 +91,10 @@ Examples: 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") + // CHANGE 2: Registered vintage flags + pf.BoolVar(&flagInstallVintage, "install-vintage", false, "install vintage cars (pre-2000) from the source repo") + pf.BoolVar(&flagUninstallVintage, "uninstall-vintage", false, "remove vintage cars (pre-2000) from the installation") + f := root.Flags() // Selection f.StringVar(&flagCar, "car", "", "show this specific car (slug)") @@ -132,8 +139,6 @@ Examples: root.MarkFlagsMutuallyExclusive("auto", "ansi-only", "image-only") root.MarkFlagsMutuallyExclusive("with-pokemon", "no-pokemon", "pokemon-only") - // CHANGE 1: All RegisterFlagCompletionFunc calls DELETED - return root.Execute() } @@ -204,6 +209,14 @@ func run(cmd *cobra.Command, args []string) error { return nil } + // CHANGE 3: Dispatch vintage flags + if flagInstallVintage { + return runInstallVintage() + } + if flagUninstallVintage { + return runUninstallVintage() + } + // --pokemon-only short-circuits everything else if flagPokemonOnly { if !pokemon.Available() { diff --git a/cli/internal/cmd/vintage.go b/cli/internal/cmd/vintage.go new file mode 100644 index 0000000..ab9f680 --- /dev/null +++ b/cli/internal/cmd/vintage.go @@ -0,0 +1,287 @@ +package cmd + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "hypercar-colorscripts/internal/config" + "hypercar-colorscripts/internal/manifest" +) + +const vintageCutoffYear = 2000 + +// parseYear turns "1985" into 1985. Returns 0 for empty or malformed values. +// If the string is a range like "1985-1989" or "1985/1990", the first segment +// is used. A zero return means "unknown", and isVintage below treats that as +// NOT vintage (safer default — don't delete cars with bad metadata). +func parseYear(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + for _, sep := range []string{"-", "/", " ", "–"} { // last is en-dash + if i := strings.Index(s, sep); i > 0 { + s = s[:i] + break + } + } + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return 0 + } + return n +} + +// isVintage reports whether a car was released before vintageCutoffYear. +// Cars with an unparseable / missing start_year are NOT treated as vintage +// so bad metadata never causes accidental deletion. +func isVintage(c manifest.Car) bool { + y := parseYear(c.StartYear) + return y > 0 && y < vintageCutoffYear +} + +// ─── --install-vintage ───────────────────────────────────────────── + +// runInstallVintage locates the source git repo and executes +// install-vintage.sh from it. Vintage installation requires the source +// image files, so this can't be fully self-contained. +func runInstallVintage() error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + manifestPath := filepath.Join(cfg.DataDir, "manifest.json") + + if hasInstalledVintage(manifestPath) { + fmt.Println("Vintage cars are already installed.") + fmt.Println("To remove them, use: hypercar-colorscripts --uninstall-vintage") + return nil + } + + repoRoot := findSourceRepo() + if repoRoot == "" { + printCloneInstructions() + return fmt.Errorf("source repository not found") + } + + fmt.Printf("Found source repository at: %s\n\n", repoRoot) + + script := filepath.Join(repoRoot, "cli", "install-vintage.sh") + c := exec.Command(script) + c.Dir = filepath.Join(repoRoot, "cli") + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Stdin = os.Stdin + return c.Run() +} + +// findSourceRepo looks for a hypercar-colorscripts git clone in likely places. +// Returns the absolute path to the repo root, or empty string if not found. +func findSourceRepo() string { + if src := os.Getenv("HYPERCAR_SRC"); src != "" { + if p := validateSourceRepo(src); p != "" { + return p + } + fmt.Fprintf(os.Stderr, "warning: HYPERCAR_SRC=%q is set but doesn't look like a valid clone\n", src) + } + + if cwd, err := os.Getwd(); err == nil { + if p := validateSourceRepo(cwd); p != "" { + return p + } + if p := validateSourceRepo(filepath.Dir(cwd)); p != "" { + return p + } + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + for _, sub := range []string{ + "hypercar-colorscripts", + "car-test/hypercar-colorscripts", + "projects/hypercar-colorscripts", + "src/hypercar-colorscripts", + "Documents/hypercar-colorscripts", + "Downloads/hypercar-colorscripts", + } { + if p := validateSourceRepo(filepath.Join(home, sub)); p != "" { + return p + } + } + return "" +} + +// validateSourceRepo checks that a directory has the files we need to install +// vintage cars from. Returns the absolute path if valid, empty otherwise. +func validateSourceRepo(root string) string { + abs, err := filepath.Abs(root) + if err != nil { + return "" + } + for _, needed := range []string{ + "manifest.json", + "assets/images", + "assets/ansi", + "assets/licenses", + "cli/install-vintage.sh", + "cli/_vintage_helper.py", + } { + if _, err := os.Stat(filepath.Join(abs, needed)); err != nil { + return "" + } + } + return abs +} + +func printCloneInstructions() { + fmt.Fprintln(os.Stderr, "Could not locate the hypercar-colorscripts source repository.") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Vintage car installation needs the source image files, which are") + fmt.Fprintln(os.Stderr, "in the git repository. There are two ways to proceed:") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "OPTION A — clone the repo and run install-vintage.sh directly:") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, " git clone https://github.com/tirthrajraval/hypercar-colorscripts.git") + fmt.Fprintln(os.Stderr, " cd hypercar-colorscripts/cli") + fmt.Fprintln(os.Stderr, " ./install-vintage.sh") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "OPTION B — point HYPERCAR_SRC at an existing clone and retry:") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, " export HYPERCAR_SRC=/path/to/your/hypercar-colorscripts") + fmt.Fprintln(os.Stderr, " hypercar-colorscripts --install-vintage") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "The tool also auto-detects clones at these locations:") + fmt.Fprintln(os.Stderr, " ~/hypercar-colorscripts") + fmt.Fprintln(os.Stderr, " ~/car-test/hypercar-colorscripts") + fmt.Fprintln(os.Stderr, " ~/projects/hypercar-colorscripts") + fmt.Fprintln(os.Stderr, " ~/src/hypercar-colorscripts") + fmt.Fprintln(os.Stderr, " ~/Documents/hypercar-colorscripts") + fmt.Fprintln(os.Stderr, " ~/Downloads/hypercar-colorscripts") +} + +// ─── --uninstall-vintage ─────────────────────────────────────────── + +// runUninstallVintage removes vintage cars from the installed data directory. +// Fully self-contained — no shell scripts, no source repo needed. +func runUninstallVintage() error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + manifestPath := filepath.Join(cfg.DataDir, "manifest.json") + + data, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("reading manifest: %w", err) + } + var m manifest.Manifest + if err := json.Unmarshal(data, &m); err != nil { + return fmt.Errorf("parsing manifest: %w", err) + } + + // Partition cars. Cars is map[string]Car, so we build a new map + // containing only the modern entries and collect the vintage keys + // separately so we know which files to delete. + var vintageSlugs []string + modernCars := make(map[string]manifest.Car, len(m.Cars)) + for slug, c := range m.Cars { + if isVintage(c) { + vintageSlugs = append(vintageSlugs, slug) + } else { + modernCars[slug] = c + } + } + + if len(vintageSlugs) == 0 { + fmt.Println("No vintage cars are currently installed. Nothing to remove.") + return nil + } + + // Estimate disk space that will be freed + var totalBytes int64 + for _, slug := range vintageSlugs { + for _, sub := range []struct{ dir, ext string }{ + {"assets/images", ".png"}, + {"assets/ansi", ".txt"}, + {"assets/licenses", ".json"}, + } { + if info, err := os.Stat(filepath.Join(cfg.DataDir, sub.dir, slug+sub.ext)); err == nil { + totalBytes += info.Size() + } + } + } + mb := float64(totalBytes) / (1024 * 1024) + + fmt.Printf("Found %d vintage cars installed (approximately %.1f MB).\n", len(vintageSlugs), mb) + fmt.Printf("This will remove them from %s\n", cfg.DataDir) + fmt.Print("Continue? [y/N]: ") + + reader := bufio.NewReader(os.Stdin) + ans, _ := reader.ReadString('\n') + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(ans)), "y") { + fmt.Println("Aborted.") + return nil + } + + // Delete asset files for each vintage car + filesRemoved := 0 + for _, slug := range vintageSlugs { + for _, sub := range []struct{ dir, ext string }{ + {"assets/images", ".png"}, + {"assets/ansi", ".txt"}, + {"assets/licenses", ".json"}, + } { + p := filepath.Join(cfg.DataDir, sub.dir, slug+sub.ext) + if err := os.Remove(p); err == nil { + filesRemoved++ + } + } + } + + // Write the filtered manifest back + m.Cars = modernCars + m.TotalCars = len(modernCars) + newData, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("serializing filtered manifest: %w", err) + } + if err := os.WriteFile(manifestPath, newData, 0644); err != nil { + return fmt.Errorf("writing manifest: %w", err) + } + + fmt.Printf("\nRemoved %d vintage cars (%d files, ~%.1f MB freed).\n", + len(vintageSlugs), filesRemoved, mb) + fmt.Printf("Manifest now has %d modern cars.\n", len(modernCars)) + fmt.Println("\nTo add them back later, run:") + fmt.Println(" hypercar-colorscripts --install-vintage") + return nil +} + +// ─── shared helpers ─────────────────────────────────────────────── + +// hasInstalledVintage returns true if the installed manifest contains +// at least one car with start_year < vintageCutoffYear. +func hasInstalledVintage(manifestPath string) bool { + data, err := os.ReadFile(manifestPath) + if err != nil { + return false + } + var m manifest.Manifest + if err := json.Unmarshal(data, &m); err != nil { + return false + } + for _, c := range m.Cars { + if isVintage(c) { + return true + } + } + return false +}