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
+}