diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a95243e8..155c5c75 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,7 +30,7 @@ on:
- main
env:
- VERSION_NUMBER: 'v1.7.4'
+ VERSION_NUMBER: 'v1.8.0'
DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli'
AWS_REGION: 'us-west-2'
diff --git a/.gitignore b/.gitignore
index 4e571b13..30e3fbd4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,7 +50,6 @@ dbt_packages/
logs/
target/
-dbt_packages/
card_data/infrastructure/supabase/access-token
/card_data/infrastructure/supabase/access-token
@@ -61,5 +60,6 @@ card_data/.tmp*/**
card_data/pipelines/poke_cli_dbt/.user.yml
/card_data/supabase/
+/card_data/sample_scripts/
card_data/~/
diff --git a/.gitleaksignore b/.gitleaksignore
new file mode 100644
index 00000000..46497dcd
--- /dev/null
+++ b/.gitleaksignore
@@ -0,0 +1,3 @@
+cmd/card/setslist.go:generic-api-key:116
+cmd/card/cardlist.go:generic-api-key:157
+codecov.yml:generic-api-key:2
\ No newline at end of file
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 04c0950f..16995b20 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -14,7 +14,7 @@ builds:
- windows
- darwin
ldflags:
- - -s -w -X main.version=v1.7.4
+ - -s -w -X main.version=v1.8.0
archives:
- formats: [ 'zip' ]
diff --git a/Dockerfile b/Dockerfile
index 8db3b266..7605a0c6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,7 @@ RUN go mod download
COPY . .
-RUN go build -ldflags "-X main.version=v1.7.4" -o poke-cli .
+RUN go build -ldflags "-X main.version=v1.8.0" -o poke-cli .
# build 2
FROM --platform=$BUILDPLATFORM alpine:3.22
diff --git a/README.md b/README.md
index a4529df4..fb4e1e77 100644
--- a/README.md
+++ b/README.md
@@ -2,12 +2,9 @@
Pokémon CLI
-
+
-
-

-

@@ -18,17 +15,21 @@
`poke-cli` is a hybrid of a classic CLI and a modern TUI tool for viewing data about Pokémon! This is my first Go project.
View the [documentation](https://docs.poke-cli.com)!
-The architecture behind how the tool works is straight forward:
-1. Each command indicates which [API](https://pokeapi.co/) endpoint to use.
-2. Flags provide more information and can be stacked together or used individually.
-3. Each command has a `-h | --help` flag that is built-in with Golang's `flag` package.
-
-View future plans in the [Roadmap](#roadmap) section.
+* [Demo](#demo)
+* [Installation](#installation)
+* [Usage](#usage)
+* [Roadmap](#roadmap)
+* [Tested Terminals](#tested-terminals)
---
## Demo
-
+### Video Game Data
+
+
+
+### Trading Card Game Data
+
---
## Installation
@@ -94,11 +95,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
3. Choose how to interact with the container:
* Run a single command and exit:
```bash
- docker run --rm -it digitalghostdev/poke-cli:v1.7.4
[subcommand] flag]
+ docker run --rm -it digitalghostdev/poke-cli:v1.8.0 [subcommand] flag]
```
* Enter the container and use its shell:
```bash
- docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.7.4 -c "cd /app && exec sh"
+ docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.0 -c "cd /app && exec sh"
# placed into the /app directory, run the program with './poke-cli'
# example: ./poke-cli ability swift-swim
```
@@ -162,6 +163,7 @@ By running `poke-cli [-h | --help]`, it'll display information on how to use the
│ COMMANDS: │
│ ability Get details about an ability │
│ berry Get details about a berry │
+│ card Get details about a TCG card │
│ item Get details about an item │
│ move Get details about a move │
│ natures Get details about all natures │
@@ -187,6 +189,12 @@ Below is a list of the planned/completed commands and flags:
- [x] `ability`: get data about an ability.
- [x] `-p | --pokemon`: display Pokémon that learn this ability.
- [x] `berry`: get data about a berry.
+- [ ] `card`: get data about a TCG card.
+ - [x] add mega evolution data
+ - [x] add scarlet & violet data
+ - [ ] add sword & shield data
+ - [ ] add sun & moon data
+ - [ ] add x & y data
- [x] `item`: get data about an item.
- [x] `move`: get data about a move.
- [ ] `-p | --pokemon`: display Pokémon that learn this move.
@@ -208,15 +216,16 @@ Below is a list of the planned/completed commands and flags:
---
## Tested Terminals
-| Terminal | OS | Status | Issues |
-|-------------------|:-------------------------:|:------:|---------------------------------------------------------------------------------|
-| Alacritty | macOS, Ubuntu,
Windows | ✅ | None |
-| Ghostty | macOS | ✅ | None |
-| HyperJS | macOS | ✅ | None |
-| iTerm2 | macOS | ✅ | None |
-| Built-in Terminal | Ubuntu, Debian,
Fedora | ✅ | None |
-| Built-in Terminal | Alpine | ⚠️ | Some colors aren't supported.
`pokemon --image=xx` flag pixel issues. |
-| Built-in Terminal | macOS | ⚠️ | `pokemon --image=xx` flag pixel issues. |
-| Tabby | Ubuntu | ✅ | None |
-| WezTerm | macOS, Windows | ✅ | None |
-| Built-in Terminal | Windows | ✅ | None |
\ No newline at end of file
+| Terminal | OS | Status | Issues |
+|-------------------|:-------------------------:|:------:|----------------------------------------------------------------------------------------------|
+| Alacritty | macOS, Ubuntu,
Windows | 🟡 | - Does not support sixel for TCG images. |
+| Ghostty | macOS | 🟡 | - Does not support sixel for TCG images. |
+| HyperJS | macOS | 🟡 | - Does not support sixel for TCG images. |
+| iTerm2 | macOS | 🟢 | - None |
+| Built-in Terminal | Ubuntu, Debian,
Fedora | 🟡 | - Does not support sixel for TCG images. |
+| Built-in Terminal | Alpine | 🟡 | - Some colors aren't supported.
- `pokemon --image=xx` flag pixel issues. |
+| Built-in Terminal | macOS | 🟠 | - Does not support sixel for TCG images.
- `pokemon --image=xx` flag pixel issues. |
+| Foot | Ubuntu | 🟢 | - None |
+| Tabby | Ubuntu | 🟢 | - None |
+| WezTerm | macOS, Windows | 🟡 | - Windows version has issues with displaying TCG images. |
+| Built-in Terminal | Windows | 🟢 | - None |
\ No newline at end of file
diff --git a/card_data/dagster.yaml b/card_data/dagster.yaml
index bad89452..8ddd96d0 100644
--- a/card_data/dagster.yaml
+++ b/card_data/dagster.yaml
@@ -9,4 +9,7 @@ storage:
db_name: postgres
port: 5432
params:
- sslmode: require
\ No newline at end of file
+ sslmode: require
+
+telemetry:
+ enabled: false
\ No newline at end of file
diff --git a/card_data/pipelines/definitions.py b/card_data/pipelines/definitions.py
index dc7a2a64..3c3bf81b 100644
--- a/card_data/pipelines/definitions.py
+++ b/card_data/pipelines/definitions.py
@@ -5,7 +5,7 @@
import dagster as dg
from .defs.extract.extract_pricing_data import build_dataframe
-from .defs.load.load_pricing_data import load_pricing_data
+from .defs.load.load_pricing_data import load_pricing_data, data_quality_checks_on_pricing
@definitions
@@ -17,7 +17,7 @@ def defs():
# Define the pricing pipeline job that materializes the assets and downstream dbt model
pricing_pipeline_job = dg.define_asset_job(
name="pricing_pipeline_job",
- selection=dg.AssetSelection.assets(build_dataframe, load_pricing_data).downstream(include_self=True),
+ selection=dg.AssetSelection.assets(build_dataframe).downstream(include_self=True),
)
price_schedule = dg.ScheduleDefinition(
@@ -28,7 +28,7 @@ def defs():
)
defs_pricing = dg.Definitions(
- assets=[build_dataframe, load_pricing_data],
+ assets=[build_dataframe, load_pricing_data, data_quality_checks_on_pricing],
jobs=[pricing_pipeline_job],
schedules=[price_schedule],
)
\ No newline at end of file
diff --git a/card_data/pipelines/defs/extract/extract_data.py b/card_data/pipelines/defs/extract/extract_data.py
index 3aa7c9a4..b523b1cf 100644
--- a/card_data/pipelines/defs/extract/extract_data.py
+++ b/card_data/pipelines/defs/extract/extract_data.py
@@ -90,10 +90,10 @@ def extract_set_data() -> pl.DataFrame:
@dg.asset(kinds={"API"}, name="extract_card_url_from_set_data")
def extract_card_url_from_set() -> list:
urls = [
- "https://api.tcgdex.net/v2/en/sets/swsh3"
+ "https://api.tcgdex.net/v2/en/sets/me02"
]
- all_card_urls = [] # Initialize empty list to collect all URLs
+ all_card_urls = []
for url in urls:
try:
@@ -103,7 +103,7 @@ def extract_card_url_from_set() -> list:
data = r.json()["cards"]
set_card_urls = [f"https://api.tcgdex.net/v2/en/cards/{card['id']}" for card in data]
- all_card_urls.extend(set_card_urls) # Add all URLs from this set
+ all_card_urls.extend(set_card_urls)
time.sleep(0.1)
diff --git a/card_data/pipelines/defs/extract/extract_pricing_data.py b/card_data/pipelines/defs/extract/extract_pricing_data.py
index 15e68cfe..377af301 100644
--- a/card_data/pipelines/defs/extract/extract_pricing_data.py
+++ b/card_data/pipelines/defs/extract/extract_pricing_data.py
@@ -10,6 +10,22 @@
SET_PRODUCT_MATCHING = {
"sv01": "22873",
"sv02": "23120",
+ "sv03": "23228",
+ "sv03.5": "23237",
+ "sv04": "23286",
+ "sv04.5": "23353",
+ "sv05": "23381",
+ "sv06": "23473",
+ "sv06.5": "23529",
+ "sv07": "23537",
+ "sv08": "23651",
+ "sv08.5": "23821",
+ "sv09": "24073",
+ "sv10": "24269",
+ "sv10.5b": "24325",
+ "sv10.5w": "24326",
+ "me01": "24380",
+ "me02": "24448"
}
@@ -49,16 +65,17 @@ def pull_product_information(set_number: str) -> pl.DataFrame:
product_id = SET_PRODUCT_MATCHING[set_number]
# Fetch product data
- products_url = (f"https://tcgcsv.com/tcgplayer/3/{product_id}/products")
+ products_url = f"https://tcgcsv.com/tcgplayer/3/{product_id}/products"
products_data = requests.get(products_url, timeout=30).json()
# Fetch pricing data
- prices_url = (f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices")
+ prices_url = f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices"
prices_data = requests.get(prices_url, timeout=30).json()
price_dict = {
price["productId"]: price.get("marketPrice")
for price in prices_data.get("results", [])
+ if price.get("subTypeName") != "Reverse Holofoil"
}
cards_data = []
diff --git a/card_data/pipelines/defs/load/load_pricing_data.py b/card_data/pipelines/defs/load/load_pricing_data.py
index 5f15f859..cce644f4 100644
--- a/card_data/pipelines/defs/load/load_pricing_data.py
+++ b/card_data/pipelines/defs/load/load_pricing_data.py
@@ -1,3 +1,6 @@
+import subprocess
+from pathlib import Path
+
import dagster as dg
import polars as pl
from dagster import RetryPolicy, Backoff
@@ -23,3 +26,36 @@ def load_pricing_data(build_pricing_dataframe: pl.DataFrame) -> None:
except OperationalError as e:
print(colored(" ✖", "red"), "Connection error in load_pricing_data():", e)
raise
+
+
+@dg.asset(
+ deps=[load_pricing_data],
+ kinds={"Soda"},
+ name="data_quality_checks_on_pricing",
+)
+def data_quality_checks_on_pricing() -> None:
+ current_file_dir = Path(__file__).parent
+ print(f"Setting cwd to: {current_file_dir}")
+
+ result = subprocess.run(
+ [
+ "soda",
+ "scan",
+ "-d",
+ "supabase",
+ "-c",
+ "../../soda/configuration.yml",
+ "../../soda/checks_pricing.yml",
+ ],
+ capture_output=True,
+ text=True,
+ cwd=current_file_dir,
+ )
+
+ if result.stdout:
+ print(result.stdout)
+ if result.stderr:
+ print(result.stderr)
+
+ if result.returncode != 0:
+ raise Exception(f"Soda data quality checks failed with return code {result.returncode}")
diff --git a/card_data/pipelines/defs/transformation/transform_data.py b/card_data/pipelines/defs/transformation/transform_data.py
index c47c8fb3..dcb85347 100644
--- a/card_data/pipelines/defs/transformation/transform_data.py
+++ b/card_data/pipelines/defs/transformation/transform_data.py
@@ -16,7 +16,7 @@ def get_asset_key(self, dbt_resource_props):
"series": "quality_checks_series",
"sets": "load_set_data",
"cards": "load_card_data",
- "pricing_data": "load_pricing_data",
+ "pricing_data": "data_quality_checks_on_pricing",
}
if name in source_mapping:
return dg.AssetKey([source_mapping[name]])
diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml
index 104f295f..06c038e4 100644
--- a/card_data/pipelines/poke_cli_dbt/dbt_project.yml
+++ b/card_data/pipelines/poke_cli_dbt/dbt_project.yml
@@ -1,5 +1,5 @@
name: 'poke_cli_dbt'
-version: '1.7.4'
+version: '1.8.0'
profile: 'poke_cli_dbt'
diff --git a/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql b/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql
index 89b55a15..0bbdbda7 100644
--- a/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql
+++ b/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql
@@ -1,4 +1,9 @@
{% macro enable_rls() %}
ALTER TABLE {{ this }} ENABLE ROW LEVEL SECURITY;
- CREATE POLICY "Enable read access for all users" ON {{ this }} TO PUBLIC USING (true);
+ CREATE POLICY "Enable Read Access for All Users"
+ ON {{ this }}
+ AS PERMISSIVE
+ FOR SELECT
+ TO PUBLIC
+ USING (true);
{% endmacro %}
\ No newline at end of file
diff --git a/card_data/pipelines/poke_cli_dbt/macros/create_view.sql b/card_data/pipelines/poke_cli_dbt/macros/create_view.sql
new file mode 100644
index 00000000..c9b29529
--- /dev/null
+++ b/card_data/pipelines/poke_cli_dbt/macros/create_view.sql
@@ -0,0 +1,40 @@
+{% macro create_view() %}
+ CREATE OR REPLACE VIEW public.card_pricing_view
+ WITH (security_invoker = true) AS
+ WITH cards_cte AS (
+ SELECT
+ set_id,
+ image,
+ name,
+ "localId",
+ "set_cardCount_official",
+ CONCAT(name, ' - ', "localId", '/', LPAD("set_cardCount_official"::text, 3, '0')) AS card_combined_name,
+ set_name
+ FROM public.cards
+ ),
+
+ cards_pricing_cte AS (
+ SELECT
+ product_id,
+ market_price,
+ CONCAT(name, ' - ', card_number) AS card_combined_name,
+ card_number
+ FROM public.pricing_data
+ )
+
+ SELECT
+ c.set_id,
+ c.name,
+ CONCAT(p.card_number, ' - ', c.name) AS number_plus_name,
+ CONCAT(c.image, '/high.png') AS image_url,
+ c.set_name,
+ c."localId",
+ p."market_price",
+ p."card_number"
+ FROM
+ cards_cte AS c
+ INNER JOIN
+ cards_pricing_cte AS p
+ ON c.card_combined_name = p.card_combined_name
+ ORDER BY c."localId"
+{% endmacro %}
\ No newline at end of file
diff --git a/card_data/pipelines/poke_cli_dbt/models/cards.sql b/card_data/pipelines/poke_cli_dbt/models/cards.sql
index ef5be21b..b9087784 100644
--- a/card_data/pipelines/poke_cli_dbt/models/cards.sql
+++ b/card_data/pipelines/poke_cli_dbt/models/cards.sql
@@ -3,5 +3,5 @@
post_hook="{{ enable_rls() }}"
) }}
-SELECT id, set_id, image, name, "localId", category, hp, "set_cardCount_official", set_name
+SELECT id, set_id, image, name, "localId", category, hp, "set_cardCount_official", set_name, illustrator
FROM {{ source('staging', 'cards') }}
\ No newline at end of file
diff --git a/card_data/pipelines/poke_cli_dbt/models/pricing_data.sql b/card_data/pipelines/poke_cli_dbt/models/pricing_data.sql
index dff35155..abfd7392 100644
--- a/card_data/pipelines/poke_cli_dbt/models/pricing_data.sql
+++ b/card_data/pipelines/poke_cli_dbt/models/pricing_data.sql
@@ -1,6 +1,9 @@
{{ config(
materialized='table',
- post_hook="{{ enable_rls() }}"
+ post_hook=[
+ "{{ enable_rls() }}",
+ "{{ create_view() }}"
+ ]
) }}
SELECT product_id, name, card_number, market_price
diff --git a/card_data/pipelines/soda/checks_pricing.yml b/card_data/pipelines/soda/checks_pricing.yml
new file mode 100644
index 00000000..cf5e5f07
--- /dev/null
+++ b/card_data/pipelines/soda/checks_pricing.yml
@@ -0,0 +1,75 @@
+checks for pricing_data:
+ # Row count validation - currently have 4216 rows
+ # Expect at least 4000 cards
+ - row_count > 4000:
+ name: Minimum row count check
+
+ # Warn if row count drops significantly
+ - row_count > 4200:
+ warn:
+ when fail
+ name: Row count sanity check (warn if below expected)
+
+ # Schema validation checks
+ - schema:
+ fail:
+ when required column missing: [product_id, name, card_number, market_price]
+ when wrong column type:
+ product_id: bigint
+ name: text
+ card_number: text
+ market_price: double precision
+
+ # Completeness checks - product_id, name, card_number should always be present
+ - missing_count(product_id) = 0:
+ name: Product ID completeness
+
+ - missing_count(name) = 0:
+ name: Card name completeness
+
+ - missing_count(card_number) = 0:
+ name: Card number completeness
+
+ # Data uniqueness checks
+ - duplicate_count(product_id) = 0:
+ name: Product ID uniqueness
+
+ # Data format validation
+ # Card numbers should be alphanumeric with slashes (e.g., "013/198", "4", "005/086")
+ - invalid_count(card_number) = 0:
+ valid regex: '^[A-Za-z0-9/]+$'
+ name: Card number format validation
+
+ # Card names should not be empty and should be reasonable length (<100 chars)
+ - invalid_count(name) = 0:
+ valid min length: 1
+ valid max length: 100
+ name: Card name length validation
+
+ # Data range validation
+ # Product IDs should be positive 6-digit numbers (observed range: 475k-642k)
+ - invalid_count(product_id) = 0:
+ valid min: 100000
+ valid max: 999999999
+ name: Product ID range validation
+
+ # Market prices (when present) should be positive and reasonable
+ # Current range: $0.02 to $1119.08
+ - invalid_percent(market_price) < 1%:
+ valid min: 0.01
+ valid max: 10000
+ name: Market price range validation ($0.01-$10,000)
+
+ # Statistical validation - average price should be reasonable
+ # Current average is ~$6.01, allow range of $2-$20 for sanity
+ - avg(market_price):
+ warn:
+ when < 2
+ when > 20
+ name: Average market price sanity check
+
+ # Anomaly detection - check for extreme outliers
+ - max(market_price) < 5000:
+ warn:
+ when fail
+ name: Maximum price outlier detection (warn if >$5000)
\ No newline at end of file
diff --git a/card_data/pyproject.toml b/card_data/pyproject.toml
index e14f49f5..5f2118a3 100644
--- a/card_data/pyproject.toml
+++ b/card_data/pyproject.toml
@@ -41,3 +41,9 @@ root_module = "pipelines"
registry_modules = [
"pipelines.components.*",
]
+
+[tool.uv]
+override-dependencies = [
+ "deepdiff==8.6.1",
+ "starlette==0.49.1",
+]
\ No newline at end of file
diff --git a/card_data/uv.lock b/card_data/uv.lock
index c4547e07..aeae284c 100644
--- a/card_data/uv.lock
+++ b/card_data/uv.lock
@@ -2,6 +2,12 @@ version = 1
revision = 2
requires-python = ">=3.12"
+[manifest]
+overrides = [
+ { name = "deepdiff", specifier = "==8.6.1" },
+ { name = "starlette", specifier = "==0.49.1" },
+]
+
[[package]]
name = "agate"
version = "1.9.1"
@@ -703,14 +709,14 @@ wheels = [
[[package]]
name = "deepdiff"
-version = "7.0.1"
+version = "8.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ordered-set" },
+ { name = "orderly-set" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/70/10/6f4b0bd0627d542f63a24f38e29d77095dc63d5f45bc1a7b4a6ca8750fa9/deepdiff-7.0.1.tar.gz", hash = "sha256:260c16f052d4badbf60351b4f77e8390bee03a0b516246f6839bc813fb429ddf", size = 421718, upload-time = "2024-04-08T22:59:24.578Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/76/36c9aab3d5c19a94091f7c6c6e784efca50d87b124bf026c36e94719f33c/deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a", size = 634054, upload-time = "2025-09-03T19:40:41.461Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/18/e6/d27d37dc55dbf40cdbd665aa52844b065ac760c9a02a02265f97ea7a4256/deepdiff-7.0.1-py3-none-any.whl", hash = "sha256:447760081918216aa4fd4ca78a4b6a848b81307b2ea94c810255334b759e1dc3", size = 80825, upload-time = "2024-04-08T22:59:21.885Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b", size = 91378, upload-time = "2025-09-03T19:40:39.679Z" },
]
[[package]]
@@ -1374,12 +1380,12 @@ wheels = [
]
[[package]]
-name = "ordered-set"
-version = "4.1.0"
+name = "orderly-set"
+version = "5.5.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" },
]
[[package]]
@@ -2211,15 +2217,15 @@ wheels = [
[[package]]
name = "starlette"
-version = "0.47.2"
+version = "0.49.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/3f/507c21db33b66fb027a332f2cb3abbbe924cc3a79ced12f01ed8645955c9/starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb", size = 2654703, upload-time = "2025-10-28T17:34:10.928Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" },
+ { url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" },
]
[[package]]
diff --git a/cli.go b/cli.go
index cca5a412..906856bc 100644
--- a/cli.go
+++ b/cli.go
@@ -9,6 +9,7 @@ import (
"github.com/digitalghost-dev/poke-cli/cmd/ability"
"github.com/digitalghost-dev/poke-cli/cmd/berry"
+ "github.com/digitalghost-dev/poke-cli/cmd/card"
"github.com/digitalghost-dev/poke-cli/cmd/item"
"github.com/digitalghost-dev/poke-cli/cmd/move"
"github.com/digitalghost-dev/poke-cli/cmd/natures"
@@ -71,6 +72,7 @@ func runCLI(args []string) int {
"\n\n", styling.StyleBold.Render("COMMANDS:"),
fmt.Sprintf("\n\t%-15s %s", "ability", "Get details about an ability"),
fmt.Sprintf("\n\t%-15s %s", "berry", "Get details about a berry"),
+ fmt.Sprintf("\n\t%-15s %s", "card", "Get details about a TCG card"),
fmt.Sprintf("\n\t%-15s %s", "item", "Get details about an item"),
fmt.Sprintf("\n\t%-15s %s", "move", "Get details about a move"),
fmt.Sprintf("\n\t%-15s %s", "natures", "Get details about all natures"),
@@ -107,6 +109,7 @@ func runCLI(args []string) int {
commands := map[string]func() int{
"ability": utils.HandleCommandOutput(ability.AbilityCommand),
"berry": utils.HandleCommandOutput(berry.BerryCommand),
+ "card": utils.HandleCommandOutput(card.CardCommand),
"item": utils.HandleCommandOutput(item.ItemCommand),
"move": utils.HandleCommandOutput(move.MoveCommand),
"natures": utils.HandleCommandOutput(natures.NaturesCommand),
@@ -147,6 +150,7 @@ func runCLI(args []string) int {
styling.StyleBold.Render("\nCommands:"),
fmt.Sprintf("\n\t%-15s %s", "ability", "Get details about an ability"),
fmt.Sprintf("\n\t%-15s %s", "berry", "Get details about a berry"),
+ fmt.Sprintf("\n\t%-15s %s", "card", "Get details about a TCG card"),
fmt.Sprintf("\n\t%-15s %s", "item", "Get details about an item"),
fmt.Sprintf("\n\t%-15s %s", "move", "Get details about a move"),
fmt.Sprintf("\n\t%-15s %s", "natures", "Get details about all natures"),
diff --git a/cmd/berry/berryinfo.go b/cmd/berry/berryinfo.go
index 534dc4ee..3b284dfa 100644
--- a/cmd/berry/berryinfo.go
+++ b/cmd/berry/berryinfo.go
@@ -11,7 +11,6 @@ import (
"github.com/disintegration/imaging"
)
-// BerryName prints information based on currently selected berry.
func BerryName(berryName string) string {
return "Berry: " + berryName
}
diff --git a/cmd/card/card.go b/cmd/card/card.go
new file mode 100644
index 00000000..dc3ed96e
--- /dev/null
+++ b/cmd/card/card.go
@@ -0,0 +1,115 @@
+package card
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/digitalghost-dev/poke-cli/cmd/utils"
+ "github.com/digitalghost-dev/poke-cli/styling"
+)
+
+func CardCommand() (string, error) {
+ var output strings.Builder
+
+ flag.Usage = func() {
+ helpMessage := styling.HelpBorder.Render(
+ "View data about cards from the TCG!\n\n",
+ styling.StyleBold.Render("USAGE:"),
+ fmt.Sprintf("\n\t%s %s %s", "poke-cli", styling.StyleBold.Render("card"), "[flag]"),
+ "\n\n",
+ styling.StyleBold.Render("FLAGS:"),
+ fmt.Sprintf("\n\t%-30s %s", "-h, --help", "Prints out the help menu"),
+ )
+ output.WriteString(helpMessage)
+ }
+
+ flag.Parse()
+
+ // Handle help flag
+ if len(os.Args) == 3 && (os.Args[2] == "-h" || os.Args[2] == "--help") {
+ flag.Usage()
+ return output.String(), nil
+ }
+
+ // Validate arguments
+ if err := utils.ValidateCardArgs(os.Args); err != nil {
+ output.WriteString(err.Error())
+ return output.String(), err
+ }
+
+ seriesModel := SeriesList()
+ // Program 1: Series selection
+ finalModel, err := tea.NewProgram(seriesModel).Run()
+ if err != nil {
+ return "", fmt.Errorf("error running series selection program: %w", err)
+ }
+
+ result, ok := finalModel.(SeriesModel)
+ if !ok {
+ return "", fmt.Errorf("unexpected model type from series selection: got %T, want SeriesModel", finalModel)
+ }
+
+ if result.SeriesID != "" {
+ // Program 2: Sets selection
+ setsModel, err := SetsList(result.SeriesID)
+
+ if err != nil {
+ return "", fmt.Errorf("error loading sets: %w", err)
+ }
+
+ finalSetsModel, err := tea.NewProgram(setsModel).Run()
+ if err != nil {
+ return "", fmt.Errorf("error running sets selection program: %w", err)
+ }
+
+ setsResult, ok := finalSetsModel.(SetsModel)
+ if !ok {
+ return "", fmt.Errorf("unexpected model type from sets selection: got %T, want SetsModel", finalSetsModel)
+ }
+
+ if setsResult.Quitting {
+ return output.String(), nil
+ }
+
+ // Program 3: Cards display
+ if setsResult.SetID != "" {
+ cardsModel, err := CardsList(setsResult.SetID)
+ if err != nil {
+ return "", fmt.Errorf("error loading cards: %w", err)
+ }
+
+ for {
+ finalCardsModel, err := tea.NewProgram(cardsModel, tea.WithAltScreen()).Run()
+ if err != nil {
+ return "", fmt.Errorf("error running cards program: %w", err)
+ }
+
+ cardsResult, ok := finalCardsModel.(CardsModel)
+ if !ok {
+ return "", fmt.Errorf("unexpected model type from cards display: got %T, want CardsModel", finalCardsModel)
+ }
+
+ if cardsResult.ViewImage {
+ // Launch image viewer
+ imageURL := cardsResult.ImageMap[cardsResult.SelectedOption]
+ imageModel := ImageRenderer(cardsResult.SelectedOption, imageURL)
+ _, err := tea.NewProgram(imageModel, tea.WithAltScreen()).Run()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: image viewer error: %v\n", err)
+ }
+
+ // Re-launch cards with same state
+ cardsResult.ViewImage = false
+ cardsModel = cardsResult
+ } else {
+ break
+ }
+ }
+ }
+ }
+
+ return output.String(), nil
+}
diff --git a/cmd/card/card_test.go b/cmd/card/card_test.go
new file mode 100644
index 00000000..209e0df0
--- /dev/null
+++ b/cmd/card/card_test.go
@@ -0,0 +1,54 @@
+package card
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestCardCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ wantErr bool
+ contains string
+ }{
+ {
+ name: "help flag short",
+ args: []string{"poke-cli", "card", "-h"},
+ wantErr: false,
+ contains: "USAGE:",
+ },
+ {
+ name: "help flag long",
+ args: []string{"poke-cli", "card", "--help"},
+ wantErr: false,
+ contains: "FLAGS:",
+ },
+ {
+ name: "invalid args",
+ args: []string{"poke-cli", "card", "invalid-arg"},
+ wantErr: true,
+ contains: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = tt.args
+ defer func() { os.Args = oldArgs }()
+
+ output, err := CardCommand()
+
+ if (err != nil) != tt.wantErr {
+ t.Errorf("CardCommand() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if tt.contains != "" && !strings.Contains(output, tt.contains) {
+ t.Errorf("CardCommand() output should contain %q, got %q", tt.contains, output)
+ }
+ })
+ }
+}
diff --git a/cmd/card/cardinfo.go b/cmd/card/cardinfo.go
new file mode 100644
index 00000000..18d4705d
--- /dev/null
+++ b/cmd/card/cardinfo.go
@@ -0,0 +1,58 @@
+package card
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "image"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/charmbracelet/x/ansi/sixel"
+ "golang.org/x/image/draw"
+)
+
+func resizeImage(img image.Image, width, height int) image.Image {
+ dst := image.NewRGBA(image.Rect(0, 0, width, height))
+ draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
+ return dst
+}
+
+func CardImage(imageURL string) (string, error) {
+ client := &http.Client{
+ Timeout: time.Second * 15,
+ }
+ parsedURL, err := url.Parse(imageURL)
+ if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
+ return "", errors.New("invalid URL scheme")
+ }
+ resp, err := client.Get(imageURL)
+ if err != nil {
+ return "", fmt.Errorf("failed to fetch image: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("non-200 response: %d", resp.StatusCode)
+ }
+
+ limitedBody := io.LimitReader(resp.Body, 10*1024*1024)
+ img, _, err := image.Decode(limitedBody)
+ if err != nil {
+ return "", fmt.Errorf("failed to decode image: %w", err)
+ }
+
+ resized := resizeImage(img, 500, 675)
+
+ // Build Sixel string to return
+ var buf bytes.Buffer
+ buf.WriteString("\x1bPq")
+ if err := new(sixel.Encoder).Encode(&buf, resized); err != nil {
+ return "", fmt.Errorf("failed to encode sixel: %w", err)
+ }
+ buf.WriteString("\x1b\\")
+
+ return buf.String(), nil
+}
diff --git a/cmd/card/cardinfo_test.go b/cmd/card/cardinfo_test.go
new file mode 100644
index 00000000..ba23f7d3
--- /dev/null
+++ b/cmd/card/cardinfo_test.go
@@ -0,0 +1,161 @@
+package card
+
+import (
+ "image"
+ "image/color"
+ "image/png"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestResizeImage(t *testing.T) {
+ // Create a simple test image (100x100 red square)
+ testImg := image.NewRGBA(image.Rect(0, 0, 100, 100))
+ red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
+ for y := 0; y < 100; y++ {
+ for x := 0; x < 100; x++ {
+ testImg.Set(x, y, red)
+ }
+ }
+
+ tests := []struct {
+ name string
+ img image.Image
+ width int
+ height int
+ wantWidth int
+ wantHeight int
+ }{
+ {
+ name: "resize to smaller dimensions",
+ img: testImg,
+ width: 50,
+ height: 50,
+ wantWidth: 50,
+ wantHeight: 50,
+ },
+ {
+ name: "resize to larger dimensions",
+ img: testImg,
+ width: 200,
+ height: 200,
+ wantWidth: 200,
+ wantHeight: 200,
+ },
+ {
+ name: "resize to card dimensions",
+ img: testImg,
+ width: 500,
+ height: 675,
+ wantWidth: 500,
+ wantHeight: 675,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := resizeImage(tt.img, tt.width, tt.height)
+ bounds := result.Bounds()
+
+ if bounds.Dx() != tt.wantWidth {
+ t.Errorf("resizeImage() width = %v, want %v", bounds.Dx(), tt.wantWidth)
+ }
+ if bounds.Dy() != tt.wantHeight {
+ t.Errorf("resizeImage() height = %v, want %v", bounds.Dy(), tt.wantHeight)
+ }
+ })
+ }
+}
+
+func TestCardImage_Success(t *testing.T) {
+ // Create a test HTTP server that serves a small PNG image
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Create a minimal 10x10 PNG image
+ img := image.NewRGBA(image.Rect(0, 0, 10, 10))
+ blue := color.RGBA{R: 0, G: 0, B: 255, A: 255}
+ for y := 0; y < 10; y++ {
+ for x := 0; x < 10; x++ {
+ img.Set(x, y, blue)
+ }
+ }
+
+ w.Header().Set("Content-Type", "image/png")
+ w.WriteHeader(http.StatusOK)
+ err := png.Encode(w, img)
+ if err != nil {
+ return
+ }
+ }))
+ defer server.Close()
+
+ result, err := CardImage(server.URL)
+
+ if err != nil {
+ t.Errorf("CardImage() error = %v, want nil", err)
+ return
+ }
+
+ // Check that result is a valid Sixel string
+ if !strings.HasPrefix(result, "\x1bPq") {
+ t.Error("CardImage() should return string starting with Sixel header")
+ }
+
+ if !strings.HasSuffix(result, "\x1b\\") {
+ t.Error("CardImage() should return string ending with Sixel terminator")
+ }
+
+ if len(result) == 0 {
+ t.Error("CardImage() should return non-empty string")
+ }
+}
+
+func TestCardImage_EncodingError(t *testing.T) {
+ // Create a test HTTP server that serves invalid image data
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte("not a valid PNG"))
+ if err != nil {
+ return
+ }
+ }))
+ defer server.Close()
+
+ result, err := CardImage(server.URL)
+
+ if err == nil {
+ t.Error("CardImage() should return error for invalid image data")
+ }
+
+ if result != "" {
+ t.Errorf("CardImage() on error should return empty string, got %v", result)
+ }
+
+ if !strings.Contains(err.Error(), "failed to decode image") {
+ t.Errorf("Error message should mention 'failed to decode image', got: %v", err)
+ }
+}
+
+func TestCardImage_Non200Response(t *testing.T) {
+ // Create a test HTTP server that returns 404
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer server.Close()
+
+ result, err := CardImage(server.URL)
+
+ if err == nil {
+ t.Error("CardImage() should return error for non-200 response")
+ }
+
+ if result != "" {
+ t.Errorf("CardImage() on error should return empty string, got %v", result)
+ }
+
+ if !strings.Contains(err.Error(), "non-200 response") {
+ t.Errorf("Error message should mention 'non-200 response', got: %v", err)
+ }
+}
diff --git a/cmd/card/cardlist.go b/cmd/card/cardlist.go
new file mode 100644
index 00000000..ac86f22c
--- /dev/null
+++ b/cmd/card/cardlist.go
@@ -0,0 +1,179 @@
+package card
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/charmbracelet/bubbles/table"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/digitalghost-dev/poke-cli/styling"
+)
+
+type CardsModel struct {
+ Choice string
+ IllustratorMap map[string]string
+ ImageMap map[string]string
+ PriceMap map[string]string
+ Quitting bool
+ SelectedOption string
+ SeriesName string
+ Table table.Model
+ ViewImage bool
+}
+
+func (m CardsModel) Init() tea.Cmd {
+ return nil
+}
+
+func (m CardsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var bubbleCmd tea.Cmd
+
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "esc", "ctrl+c":
+ m.Quitting = true
+ return m, tea.Quit
+ case " ":
+ m.ViewImage = true
+ return m, tea.Quit
+ }
+ }
+
+ m.Table, bubbleCmd = m.Table.Update(msg)
+
+ // Keep the selected option in sync on every update
+ if row := m.Table.SelectedRow(); len(row) > 0 {
+ name := row[0]
+ if name != m.SelectedOption {
+ m.SelectedOption = name
+ }
+ }
+
+ return m, bubbleCmd
+}
+
+func (m CardsModel) View() string {
+ if m.Quitting {
+ return "\n Quitting card search...\n\n"
+ }
+
+ selectedCard := ""
+ if row := m.Table.SelectedRow(); len(row) > 0 {
+ cardName := row[0]
+ price := m.PriceMap[cardName]
+ if price == "" {
+ price = "Price: Not available"
+ }
+ illustrator := m.IllustratorMap[cardName]
+ selectedCard = cardName + "\n---\n" + price + "\n---\n" + illustrator
+ }
+
+ leftPanel := styling.TypesTableBorder.Render(m.Table.View())
+
+ rightPanel := lipgloss.NewStyle().
+ Width(40).
+ Height(29).
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("#FFCC00")).
+ Padding(1).
+ Render(selectedCard)
+
+ screen := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
+
+ return fmt.Sprintf("Highlight a card!\n%s\n%s",
+ screen,
+ styling.KeyMenu.Render("↑ (move up)\n↓ (move down)\nspace (view image)\nctrl+c | esc (quit)"))
+}
+
+type cardData struct {
+ Illustrator string `json:"illustrator"`
+ ImageURL string `json:"image_url"`
+ MarketPrice float64 `json:"market_price"`
+ Name string `json:"name"`
+ NumberPlusName string `json:"number_plus_name"`
+}
+
+// CardsList creates and returns a new CardsModel with cards from a specific set
+func CardsList(setID string) (CardsModel, error) {
+ url := fmt.Sprintf("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/card_pricing_view?set_id=eq.%s&select=number_plus_name,market_price,image_url,illustrator&order=localId", setID)
+ body, err := CallCardData(url)
+ if err != nil {
+ return CardsModel{}, fmt.Errorf("failed to fetch card data: %w", err)
+ }
+
+ var allCards []cardData
+ err = json.Unmarshal(body, &allCards)
+ if err != nil {
+ return CardsModel{}, fmt.Errorf("failed to unmarshal card data: %w", err)
+ }
+
+ // Extract card names and build table rows + price map
+ rows := make([]table.Row, len(allCards))
+ priceMap := make(map[string]string)
+ imageMap := make(map[string]string)
+ illustratorMap := make(map[string]string)
+ for i, card := range allCards {
+ rows[i] = []string{card.NumberPlusName}
+ priceMap[card.NumberPlusName] = fmt.Sprintf("Price: $%.2f", card.MarketPrice)
+ illustratorMap[card.NumberPlusName] = "Illustrator: " + card.Illustrator
+ imageMap[card.NumberPlusName] = card.ImageURL
+ }
+
+ t := table.New(
+ table.WithColumns([]table.Column{{Title: "Card Name", Width: 35}}),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ table.WithHeight(28),
+ )
+
+ s := table.DefaultStyles()
+ s.Header = s.Header.
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(lipgloss.Color("#FFCC00")).
+ BorderBottom(true)
+ s.Selected = s.Selected.
+ Foreground(lipgloss.Color("#000")).
+ Background(lipgloss.Color("#FFCC00"))
+ t.SetStyles(s)
+
+ return CardsModel{
+ IllustratorMap: illustratorMap,
+ ImageMap: imageMap,
+ PriceMap: priceMap,
+ Table: t,
+ }, nil
+}
+
+func CallCardData(url string) ([]byte, error) {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("error creating request: %w", err)
+ }
+
+ req.Header.Add("apikey", "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j")
+ req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j")
+ req.Header.Add("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("error making GET request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading response body: %w", err)
+ }
+
+ return body, nil
+}
diff --git a/cmd/card/cardlist_test.go b/cmd/card/cardlist_test.go
new file mode 100644
index 00000000..7df05944
--- /dev/null
+++ b/cmd/card/cardlist_test.go
@@ -0,0 +1,256 @@
+package card
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/bubbles/table"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestCardsModel_Init(t *testing.T) {
+ model := CardsModel{
+ SeriesName: "sv",
+ }
+
+ cmd := model.Init()
+ if cmd != nil {
+ t.Error("Init() should return nil")
+ }
+}
+
+func TestCardsModel_Update_EscKey(t *testing.T) {
+ rows := []table.Row{
+ {"001/198 - Bulbasaur"},
+ {"002/198 - Ivysaur"},
+ }
+ columns := []table.Column{
+ {Title: "Card Name", Width: 35},
+ }
+ tbl := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ )
+
+ model := CardsModel{
+ Table: tbl,
+ Quitting: false,
+ }
+
+ msg := tea.KeyMsg{Type: tea.KeyEsc}
+ newModel, cmd := model.Update(msg)
+
+ resultModel := newModel.(CardsModel)
+
+ if !resultModel.Quitting {
+ t.Error("Quitting should be set to true when ESC is pressed")
+ }
+
+ if cmd == nil {
+ t.Error("Update with ESC should return tea.Quit command")
+ }
+}
+
+func TestCardsModel_Update_CtrlC(t *testing.T) {
+ rows := []table.Row{
+ {"001/198 - Bulbasaur"},
+ }
+ columns := []table.Column{
+ {Title: "Card Name", Width: 35},
+ }
+ tbl := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ )
+
+ model := CardsModel{
+ Table: tbl,
+ Quitting: false,
+ }
+
+ msg := tea.KeyMsg{Type: tea.KeyCtrlC}
+ newModel, cmd := model.Update(msg)
+
+ resultModel := newModel.(CardsModel)
+
+ if !resultModel.Quitting {
+ t.Error("Quitting should be set to true when Ctrl+C is pressed")
+ }
+
+ if cmd == nil {
+ t.Error("Update with Ctrl+C should return tea.Quit command")
+ }
+}
+
+func TestCardsModel_Update_SpaceBar(t *testing.T) {
+ rows := []table.Row{
+ {"001/198 - Bulbasaur"},
+ }
+ columns := []table.Column{
+ {Title: "Card Name", Width: 35},
+ }
+ tbl := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ )
+
+ model := CardsModel{
+ Table: tbl,
+ ViewImage: false,
+ }
+
+ msg := tea.KeyMsg{Type: tea.KeySpace}
+ newModel, cmd := model.Update(msg)
+
+ resultModel := newModel.(CardsModel)
+
+ if !resultModel.ViewImage {
+ t.Error("ViewImage should be set to true when SPACE is pressed")
+ }
+
+ if cmd == nil {
+ t.Error("Update with SPACE should return tea.Quit command")
+ }
+}
+
+func TestCardsModel_Update_SelectionSync(t *testing.T) {
+ rows := []table.Row{
+ {"001/198 - Bulbasaur"},
+ {"002/198 - Ivysaur"},
+ }
+ columns := []table.Column{
+ {Title: "Card Name", Width: 35},
+ }
+ tbl := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ )
+
+ model := CardsModel{
+ Table: tbl,
+ SelectedOption: "",
+ }
+
+ // Simulate a key press that won't quit (e.g., arrow down)
+ msg := tea.KeyMsg{Type: tea.KeyDown}
+ newModel, _ := model.Update(msg)
+
+ resultModel := newModel.(CardsModel)
+
+ // The selected option should be updated to the current row
+ if resultModel.SelectedOption == "" {
+ t.Error("SelectedOption should be synced after table update")
+ }
+}
+
+func TestCardsModel_View_Quitting(t *testing.T) {
+ model := CardsModel{
+ Quitting: true,
+ }
+
+ result := model.View()
+
+ if !strings.Contains(result, "Quitting card search") {
+ t.Errorf("View() when quitting should contain 'Quitting card search', got: %s", result)
+ }
+}
+
+func TestCardsModel_View_Normal(t *testing.T) {
+ rows := []table.Row{
+ {"001/198 - Bulbasaur"},
+ }
+ columns := []table.Column{
+ {Title: "Card Name", Width: 35},
+ }
+ tbl := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ )
+
+ priceMap := map[string]string{
+ "001/198 - Bulbasaur": "Price: $1.50",
+ }
+
+ model := CardsModel{
+ Table: tbl,
+ PriceMap: priceMap,
+ Quitting: false,
+ }
+
+ result := model.View()
+
+ if result == "" {
+ t.Error("View() should not return empty string in normal state")
+ }
+
+ // Check that it contains the key menu
+ if !strings.Contains(result, "move up") {
+ t.Error("View() should contain key menu instructions")
+ }
+}
+
+func TestCardsModel_View_PriceDisplay(t *testing.T) {
+ rows := []table.Row{
+ {"001/198 - Bulbasaur"},
+ }
+ columns := []table.Column{
+ {Title: "Card Name", Width: 35},
+ }
+ tbl := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ )
+
+ priceMap := map[string]string{
+ "001/198 - Bulbasaur": "Price: $1.50",
+ }
+
+ model := CardsModel{
+ Table: tbl,
+ PriceMap: priceMap,
+ Quitting: false,
+ }
+
+ result := model.View()
+
+ // The view should include the card name
+ if !strings.Contains(result, "001/198 - Bulbasaur") {
+ t.Error("View() should display selected card name")
+ }
+}
+
+func TestCardsModel_View_MissingPrice(t *testing.T) {
+ rows := []table.Row{
+ {"001/198 - Bulbasaur"},
+ }
+ columns := []table.Column{
+ {Title: "Card Name", Width: 35},
+ }
+ tbl := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ )
+
+ // Empty price map - simulates missing price data
+ priceMap := map[string]string{}
+
+ model := CardsModel{
+ Table: tbl,
+ PriceMap: priceMap,
+ Quitting: false,
+ }
+
+ result := model.View()
+
+ // Should show "Price: Not available" when price is missing
+ if !strings.Contains(result, "Price: Not available") {
+ t.Error("View() should display 'Price: Not available' for cards without pricing")
+ }
+}
diff --git a/cmd/card/design.go b/cmd/card/design.go
new file mode 100644
index 00000000..097144c1
--- /dev/null
+++ b/cmd/card/design.go
@@ -0,0 +1,47 @@
+package card
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+var (
+ titleStyle = lipgloss.NewStyle().MarginLeft(2)
+ itemStyle = lipgloss.NewStyle().PaddingLeft(4)
+ selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.AdaptiveColor{Light: "#E1AD01", Dark: "#FFDE00"})
+ paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
+ helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
+ quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4)
+)
+
+type item string
+
+func (i item) FilterValue() string { return "" }
+
+type itemDelegate struct{}
+
+func (d itemDelegate) Height() int { return 1 }
+func (d itemDelegate) Spacing() int { return 0 }
+func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
+func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+ i, ok := listItem.(item)
+ if !ok {
+ return
+ }
+
+ str := string(i)
+
+ fn := itemStyle.Render
+ if index == m.Index() {
+ fn = func(s ...string) string {
+ return selectedItemStyle.Render("> " + strings.Join(s, " "))
+ }
+ }
+
+ fmt.Fprint(w, fn(str))
+}
diff --git a/cmd/card/design_test.go b/cmd/card/design_test.go
new file mode 100644
index 00000000..f938972e
--- /dev/null
+++ b/cmd/card/design_test.go
@@ -0,0 +1,88 @@
+package card
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestItemFilterValue(t *testing.T) {
+ testItem := item("Test Item")
+ filterValue := testItem.FilterValue()
+
+ if filterValue != "" {
+ t.Errorf("Expected FilterValue to return empty string, got '%s'", filterValue)
+ }
+}
+
+func TestItemDelegateHeight(t *testing.T) {
+ delegate := itemDelegate{}
+ height := delegate.Height()
+
+ if height != 1 {
+ t.Errorf("Expected Height to return 1, got %d", height)
+ }
+}
+
+func TestItemDelegateSpacing(t *testing.T) {
+ delegate := itemDelegate{}
+ spacing := delegate.Spacing()
+
+ if spacing != 0 {
+ t.Errorf("Expected Spacing to return 0, got %d", spacing)
+ }
+}
+
+func TestItemDelegateUpdate(t *testing.T) {
+ delegate := itemDelegate{}
+ cmd := delegate.Update(tea.KeyMsg{}, &list.Model{})
+
+ if cmd != nil {
+ t.Error("Expected Update to return nil, got non-nil value")
+ }
+}
+
+func TestItemDelegateRender(t *testing.T) {
+ delegate := itemDelegate{}
+
+ items := []list.Item{
+ item("First Item"),
+ item("Second Item"),
+ item("Third Item"),
+ }
+
+ l := list.New(items, delegate, 20, 10)
+
+ var buf bytes.Buffer
+ delegate.Render(&buf, l, 0, items[0])
+
+ output := buf.String()
+ if output == "" {
+ t.Error("Expected non-empty output from Render")
+ }
+}
+
+func TestItemDelegateRenderSelected(t *testing.T) {
+ delegate := itemDelegate{}
+
+ items := []list.Item{
+ item("First Item"),
+ item("Second Item"),
+ }
+
+ l := list.New(items, delegate, 20, 10)
+
+ var buf bytes.Buffer
+ delegate.Render(&buf, l, l.Index(), items[l.Index()])
+
+ output := buf.String()
+ if output == "" {
+ t.Error("Expected non-empty output for selected item")
+ }
+
+ if len(output) == 0 {
+ t.Error("Selected item should produce rendered output")
+ }
+}
diff --git a/cmd/card/imageviewer.go b/cmd/card/imageviewer.go
new file mode 100644
index 00000000..e8f00846
--- /dev/null
+++ b/cmd/card/imageviewer.go
@@ -0,0 +1,40 @@
+package card
+
+import (
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type ImageModel struct {
+ CardName string
+ ImageURL string
+ Error error
+}
+
+func (m ImageModel) Init() tea.Cmd {
+ return nil
+}
+
+func (m ImageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c", "esc":
+ return m, tea.Quit
+ }
+ }
+ return m, nil
+}
+
+func (m ImageModel) View() string {
+ return m.ImageURL
+}
+
+func ImageRenderer(cardName string, imageURL string) ImageModel {
+ imageData, err := CardImage(imageURL)
+
+ return ImageModel{
+ CardName: cardName,
+ ImageURL: imageData,
+ Error: err,
+ }
+}
diff --git a/cmd/card/imageviewer_test.go b/cmd/card/imageviewer_test.go
new file mode 100644
index 00000000..7b2efa5f
--- /dev/null
+++ b/cmd/card/imageviewer_test.go
@@ -0,0 +1,179 @@
+package card
+
+import (
+ "image"
+ "image/color"
+ "image/png"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestImageModel_Init(t *testing.T) {
+ model := ImageModel{
+ CardName: "001/198 - Pineco",
+ ImageURL: "test-sixel-data",
+ }
+
+ cmd := model.Init()
+ if cmd != nil {
+ t.Error("Init() should return nil")
+ }
+}
+
+func TestImageModel_Update_EscKey(t *testing.T) {
+ model := ImageModel{
+ CardName: "001/198 - Pineco",
+ ImageURL: "test-sixel-data",
+ }
+
+ // Test ESC key
+ msg := tea.KeyMsg{Type: tea.KeyEsc}
+ newModel, cmd := model.Update(msg)
+
+ // Should return quit command
+ if cmd == nil {
+ t.Error("Update with ESC should return tea.Quit command")
+ }
+
+ // Model should be returned (even if quitting)
+ if _, ok := newModel.(ImageModel); !ok {
+ t.Error("Update should return ImageModel")
+ }
+}
+
+func TestImageModel_Update_CtrlC(t *testing.T) {
+ model := ImageModel{
+ CardName: "001/198 - Pineco",
+ ImageURL: "test-sixel-data",
+ }
+
+ msg := tea.KeyMsg{Type: tea.KeyCtrlC}
+ _, cmd := model.Update(msg)
+
+ // Should return quit command
+ if cmd == nil {
+ t.Error("Update with Ctrl+C should return tea.Quit command")
+ }
+}
+
+func TestImageModel_Update_DifferentKey(t *testing.T) {
+ model := ImageModel{
+ CardName: "001/198 - Pineco",
+ ImageURL: "test-sixel-data",
+ }
+
+ msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}
+ _, cmd := model.Update(msg)
+
+ if cmd != nil {
+ t.Error("Update with non-quit key should not return a command")
+ }
+}
+
+func TestImageModel_View(t *testing.T) {
+ expectedURL := "test-sixel-data-123"
+ model := ImageModel{
+ CardName: "001/198 - Pineco",
+ ImageURL: expectedURL,
+ }
+
+ result := model.View()
+
+ if result != expectedURL {
+ t.Errorf("View() = %v, want %v", result, expectedURL)
+ }
+}
+
+func TestImageModel_View_Empty(t *testing.T) {
+ model := ImageModel{
+ CardName: "001/198 - Pineco",
+ ImageURL: "",
+ }
+
+ result := model.View()
+
+ if result != "" {
+ t.Errorf("View() with empty ImageURL should return empty string, got %v", result)
+ }
+}
+
+func TestImageRenderer_Success(t *testing.T) {
+ // Create a test HTTP server that serves a valid PNG image
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ img := image.NewRGBA(image.Rect(0, 0, 10, 10))
+ blue := color.RGBA{R: 0, G: 0, B: 255, A: 255}
+ for y := 0; y < 10; y++ {
+ for x := 0; x < 10; x++ {
+ img.Set(x, y, blue)
+ }
+ }
+
+ w.Header().Set("Content-Type", "image/png")
+ w.WriteHeader(http.StatusOK)
+ _ = png.Encode(w, img)
+ }))
+ defer server.Close()
+
+ model := ImageRenderer("Pikachu", server.URL)
+
+ if model.CardName != "Pikachu" {
+ t.Errorf("ImageRenderer() CardName = %v, want %v", model.CardName, "Pikachu")
+ }
+
+ if model.Error != nil {
+ t.Errorf("ImageRenderer() Error should be nil on success, got %v", model.Error)
+ }
+
+ if model.ImageURL == "" {
+ t.Error("ImageRenderer() ImageURL should not be empty on success")
+ }
+}
+
+func TestImageRenderer_Error(t *testing.T) {
+ // Create a test HTTP server that returns an error
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer server.Close()
+
+ model := ImageRenderer("Charizard", server.URL)
+
+ if model.CardName != "Charizard" {
+ t.Errorf("ImageRenderer() CardName = %v, want %v", model.CardName, "Charizard")
+ }
+
+ if model.Error == nil {
+ t.Error("ImageRenderer() Error should not be nil when image fetch fails")
+ }
+
+ if model.ImageURL != "" {
+ t.Errorf("ImageRenderer() ImageURL should be empty on error, got %v", model.ImageURL)
+ }
+}
+
+func TestImageRenderer_InvalidImage(t *testing.T) {
+ // Create a test HTTP server that returns invalid image data
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("not a valid image"))
+ }))
+ defer server.Close()
+
+ model := ImageRenderer("Mewtwo", server.URL)
+
+ if model.CardName != "Mewtwo" {
+ t.Errorf("ImageRenderer() CardName = %v, want %v", model.CardName, "Mewtwo")
+ }
+
+ if model.Error == nil {
+ t.Error("ImageRenderer() Error should not be nil when image decoding fails")
+ }
+
+ if model.ImageURL != "" {
+ t.Errorf("ImageRenderer() ImageURL should be empty on error, got %v", model.ImageURL)
+ }
+}
diff --git a/cmd/card/serieslist.go b/cmd/card/serieslist.go
new file mode 100644
index 00000000..520fc6ed
--- /dev/null
+++ b/cmd/card/serieslist.go
@@ -0,0 +1,80 @@
+package card
+
+import (
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+var seriesIDMap = map[string]string{
+ "Mega Evolution": "me",
+ "Scarlet & Violet": "sv",
+ "Sword & Shield": "swsh",
+}
+
+type SeriesModel struct {
+ List list.Model
+ Choice string
+ SeriesID string
+ Quitting bool
+}
+
+func (m SeriesModel) Init() tea.Cmd {
+ return nil
+}
+
+func (m SeriesModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c", "esc":
+ m.Quitting = true
+ return m, tea.Quit
+ case "enter":
+ i, ok := m.List.SelectedItem().(item)
+ if ok {
+ m.Choice = string(i)
+ m.SeriesID = seriesIDMap[string(i)]
+ }
+ return m, tea.Quit
+ }
+
+ case tea.WindowSizeMsg:
+ m.List.SetWidth(msg.Width)
+ return m, nil
+ }
+
+ var cmd tea.Cmd
+ m.List, cmd = m.List.Update(msg)
+ return m, cmd
+}
+
+func (m SeriesModel) View() string {
+ if m.Quitting {
+ return "\n Quitting card search...\n\n"
+ }
+ if m.Choice != "" {
+ return quitTextStyle.Render("Series selected:", m.Choice)
+ }
+
+ return "\n" + m.List.View()
+}
+
+func SeriesList() SeriesModel {
+ items := []list.Item{
+ item("Mega Evolution"),
+ item("Scarlet & Violet"),
+ }
+
+ const listWidth = 20
+ const listHeight = 12
+
+ l := list.New(items, itemDelegate{}, listWidth, listHeight)
+ l.Title = "First, pick a series"
+ l.SetShowStatusBar(false)
+ l.SetFilteringEnabled(false)
+ l.Styles.Title = titleStyle
+ l.Styles.PaginationStyle = paginationStyle
+ l.Styles.HelpStyle = helpStyle
+
+ return SeriesModel{List: l}
+}
diff --git a/cmd/card/serieslist_test.go b/cmd/card/serieslist_test.go
new file mode 100644
index 00000000..e77926b9
--- /dev/null
+++ b/cmd/card/serieslist_test.go
@@ -0,0 +1,144 @@
+package card
+
+import (
+ "testing"
+ "time"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/exp/teatest"
+)
+
+func TestSeriesModelInit(t *testing.T) {
+ items := []list.Item{
+ item("Mega Evolution"),
+ item("Scarlet & Violet"),
+ item("Sword & Shield"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 12)
+ model := SeriesModel{List: l}
+
+ cmd := model.Init()
+ if cmd != nil {
+ t.Errorf("Expected Init() to return nil, got %v", cmd)
+ }
+}
+
+func TestSeriesModelQuit(t *testing.T) {
+ items := []list.Item{
+ item("Mega Evolution"),
+ item("Scarlet & Violet"),
+ item("Sword & Shield"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 12)
+ model := SeriesModel{List: l}
+
+ testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 24))
+
+ // Test ctrl+c quit
+ testModel.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
+ testModel.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
+
+ final := testModel.FinalModel(t).(SeriesModel)
+
+ if !final.Quitting {
+ t.Errorf("Expected model to be quitting after ctrl+c")
+ }
+}
+
+func TestSeriesModelEscQuit(t *testing.T) {
+ items := []list.Item{
+ item("Mega Evolution"),
+ item("Scarlet & Violet"),
+ item("Sword & Shield"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 12)
+ model := SeriesModel{List: l}
+
+ testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 24))
+
+ // Test esc quit
+ testModel.Send(tea.KeyMsg{Type: tea.KeyEsc})
+ testModel.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
+
+ final := testModel.FinalModel(t).(SeriesModel)
+
+ if !final.Quitting {
+ t.Errorf("Expected model to be quitting after esc")
+ }
+}
+
+func TestSeriesModelSelection(t *testing.T) {
+ items := []list.Item{
+ item("Mega Evolution"),
+ item("Scarlet & Violet"),
+ item("Sword & Shield"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 12)
+ model := SeriesModel{List: l}
+
+ testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 24))
+
+ // Navigate and select
+ testModel.Send(tea.KeyMsg{Type: tea.KeyDown}) // Move to second item
+ testModel.Send(tea.KeyMsg{Type: tea.KeyEnter}) // Select it
+ testModel.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
+
+ final := testModel.FinalModel(t).(SeriesModel)
+
+ if final.Choice == "" {
+ t.Errorf("Expected a choice to be made, got empty string")
+ }
+ if final.Choice != "Scarlet & Violet" {
+ t.Errorf("Expected choice to be 'Scarlet & Violet', got '%s'", final.Choice)
+ }
+}
+
+func TestSeriesModelWindowResize(t *testing.T) {
+ items := []list.Item{
+ item("Mega Evolution"),
+ item("Scarlet & Violet"),
+ item("Sword & Shield"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 12)
+ model := SeriesModel{List: l}
+
+ // Send window resize message
+ updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
+ finalModel := updatedModel.(SeriesModel)
+
+ if finalModel.List.Width() != 100 {
+ t.Errorf("Expected list width to be 100 after resize, got %d", finalModel.List.Width())
+ }
+}
+
+func TestSeriesModelView(t *testing.T) {
+ items := []list.Item{
+ item("Mega Evolution"),
+ item("Scarlet & Violet"),
+ item("Sword & Shield"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 12)
+
+ // Test normal view
+ model := SeriesModel{List: l}
+ view := model.View()
+ if view == "" {
+ t.Errorf("Expected non-empty view, got empty string")
+ }
+
+ // Test quitting view
+ model.Quitting = true
+ view = model.View()
+ if view != "\n Quitting card search...\n\n" {
+ t.Errorf("Expected quitting message, got '%s'", view)
+ }
+
+ // Test choice made view
+ model.Quitting = false
+ model.Choice = "Scarlet & Violet"
+ view = model.View()
+ if view == "" {
+ t.Errorf("Expected non-empty view for choice, got empty string")
+ }
+}
diff --git a/cmd/card/setslist.go b/cmd/card/setslist.go
new file mode 100644
index 00000000..4c55e701
--- /dev/null
+++ b/cmd/card/setslist.go
@@ -0,0 +1,142 @@
+package card
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type SetsModel struct {
+ List list.Model
+ Choice string
+ SetID string
+ Quitting bool
+ SeriesName string
+ setsIDMap map[string]string // Maps set name -> set_id
+}
+
+func (m SetsModel) Init() tea.Cmd {
+ return nil
+}
+
+func (m SetsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c", "esc":
+ m.Quitting = true
+ return m, tea.Quit
+ case "enter":
+ i, ok := m.List.SelectedItem().(item)
+ if ok {
+ m.Choice = string(i)
+ m.SetID = m.setsIDMap[string(i)] // Look up the set_id
+ }
+ return m, tea.Quit
+ }
+
+ case tea.WindowSizeMsg:
+ m.List.SetWidth(msg.Width)
+ return m, nil
+ }
+
+ var cmd tea.Cmd
+ m.List, cmd = m.List.Update(msg)
+ return m, cmd
+}
+
+func (m SetsModel) View() string {
+ if m.Quitting {
+ return "\n Quitting card search...\n\n"
+ }
+ if m.Choice != "" {
+ return quitTextStyle.Render("Set selected:", m.Choice)
+ }
+
+ return "\n" + m.List.View()
+}
+
+type setData struct {
+ SeriesID string `json:"series_id"`
+ SetID string `json:"set_id"`
+ SetName string `json:"set_name"`
+ OfficialCardCount int `json:"official_card_count"`
+ TotalCardCount int `json:"total_card_count"`
+ Logo string `json:"logo"`
+ Symbol string `json:"symbol"`
+}
+
+func SetsList(seriesID string) (SetsModel, error) {
+ body, err := callSetsData("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/sets")
+ if err != nil {
+ return SetsModel{}, fmt.Errorf("error getting sets data: %v", err)
+ }
+ var allSets []setData
+
+ err = json.Unmarshal(body, &allSets)
+ if err != nil {
+ return SetsModel{}, fmt.Errorf("error parsing sets data: %v", err)
+ }
+
+ // Filter sets by series_id and build ID map
+ var items []list.Item
+ setsIDMap := make(map[string]string)
+ for _, set := range allSets {
+ if set.SeriesID == seriesID {
+ items = append(items, item(set.SetName))
+ setsIDMap[set.SetName] = set.SetID // Map name -> ID
+ }
+ }
+
+ const listWidth = 20
+ const listHeight = 20
+
+ l := list.New(items, itemDelegate{}, listWidth, listHeight)
+ l.Title = fmt.Sprintf("Pick a set from the %s series", seriesID)
+ l.SetShowStatusBar(false)
+ l.SetFilteringEnabled(false)
+ l.Styles.Title = titleStyle
+ l.Styles.PaginationStyle = paginationStyle
+ l.Styles.HelpStyle = helpStyle
+
+ return SetsModel{
+ List: l,
+ SeriesName: seriesID,
+ setsIDMap: setsIDMap,
+ },
+ nil
+}
+
+func callSetsData(url string) ([]byte, error) {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("error creating request: %w", err)
+ }
+
+ req.Header.Add("apikey", "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j")
+ req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j")
+ req.Header.Add("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("error making GET request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading response body: %w", err)
+ }
+
+ return body, nil
+}
diff --git a/cmd/card/setslist_test.go b/cmd/card/setslist_test.go
new file mode 100644
index 00000000..e01befdb
--- /dev/null
+++ b/cmd/card/setslist_test.go
@@ -0,0 +1,188 @@
+package card
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestSetsModel_Init(t *testing.T) {
+ model := SetsModel{
+ SeriesName: "sv",
+ Quitting: false,
+ }
+
+ cmd := model.Init()
+ if cmd != nil {
+ t.Error("Init() should return nil")
+ }
+}
+
+func TestSetsModel_Update_EscKey(t *testing.T) {
+ items := []list.Item{
+ item("Scarlet & Violet"),
+ item("Paldea Evolved"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 20)
+
+ model := SetsModel{
+ List: l,
+ SeriesName: "sv",
+ Quitting: false,
+ }
+
+ msg := tea.KeyMsg{Type: tea.KeyEsc}
+ newModel, cmd := model.Update(msg)
+
+ resultModel, ok := newModel.(SetsModel)
+ if !ok {
+ t.Fatalf("expected SetsModel, got %T", newModel)
+ }
+
+ if !resultModel.Quitting {
+ t.Error("Quitting should be set to true when ESC is pressed")
+ }
+
+ if cmd == nil {
+ t.Error("Update with ESC should return tea.Quit command")
+ }
+}
+
+func TestSetsModel_Update_CtrlC(t *testing.T) {
+ items := []list.Item{
+ item("Scarlet & Violet"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 20)
+
+ model := SetsModel{
+ List: l,
+ SeriesName: "sv",
+ Quitting: false,
+ }
+
+ msg := tea.KeyMsg{Type: tea.KeyCtrlC}
+ newModel, cmd := model.Update(msg)
+
+ resultModel, ok := newModel.(SetsModel)
+ if !ok {
+ t.Fatalf("expected SetsModel, got %T", newModel)
+ }
+
+ if !resultModel.Quitting {
+ t.Error("Quitting should be set to true when Ctrl+C is pressed")
+ }
+
+ if cmd == nil {
+ t.Error("Update with Ctrl+C should return tea.Quit command")
+ }
+}
+
+func TestSetsModel_Update_WindowSizeMsg(t *testing.T) {
+ items := []list.Item{
+ item("Scarlet & Violet"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 20)
+
+ model := SetsModel{
+ List: l,
+ SeriesName: "sv",
+ }
+
+ msg := tea.WindowSizeMsg{Width: 100, Height: 50}
+ newModel, cmd := model.Update(msg)
+
+ resultModel, ok := newModel.(SetsModel)
+ if !ok {
+ t.Fatalf("expected SetsModel, got %T", newModel)
+ }
+
+ if cmd != nil {
+ t.Error("WindowSizeMsg should not return a command")
+ }
+
+ if resultModel.Quitting {
+ t.Error("WindowSizeMsg should not set Quitting to true")
+ }
+}
+
+func TestSetsModel_View_Quitting(t *testing.T) {
+ items := []list.Item{
+ item("Scarlet & Violet"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 20)
+
+ model := SetsModel{
+ List: l,
+ Quitting: true,
+ }
+
+ result := model.View()
+
+ if !strings.Contains(result, "Quitting card search") {
+ t.Errorf("View() when quitting should contain 'Quitting card search', got: %s", result)
+ }
+}
+
+func TestSetsModel_View_ChoiceMade(t *testing.T) {
+ items := []list.Item{
+ item("Scarlet & Violet"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 20)
+
+ model := SetsModel{
+ List: l,
+ Choice: "Scarlet & Violet",
+ }
+
+ result := model.View()
+
+ if !strings.Contains(result, "Set selected: Scarlet & Violet") {
+ t.Errorf("View() with choice should contain 'Set selected: Scarlet & Violet', got: %s", result)
+ }
+}
+
+func TestSetsModel_View_Normal(t *testing.T) {
+ items := []list.Item{
+ item("Scarlet & Violet"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 20)
+
+ model := SetsModel{
+ List: l,
+ Quitting: false,
+ Choice: "",
+ }
+
+ result := model.View()
+
+ if result == "" {
+ t.Error("View() should not return empty string in normal state")
+ }
+}
+
+func TestSetsModel_Update_EnterKey(t *testing.T) {
+ items := []list.Item{
+ item("Scarlet & Violet"),
+ item("Paldea Evolved"),
+ }
+ l := list.New(items, itemDelegate{}, 20, 20)
+
+ setsIDMap := map[string]string{
+ "Scarlet & Violet": "sv01",
+ "Paldea Evolved": "sv02",
+ }
+
+ model := SetsModel{
+ List: l,
+ setsIDMap: setsIDMap,
+ }
+
+ msg := tea.KeyMsg{Type: tea.KeyEnter}
+ _, cmd := model.Update(msg)
+
+ if cmd == nil {
+ t.Error("Update with Enter should return tea.Quit command")
+ }
+}
diff --git a/cmd/search/model_input.go b/cmd/search/model_input.go
index 82457e32..2eaf57bb 100644
--- a/cmd/search/model_input.go
+++ b/cmd/search/model_input.go
@@ -2,11 +2,12 @@ package search
import (
"fmt"
+ "strings"
+
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/digitalghost-dev/poke-cli/styling"
- "strings"
)
// UpdateInput handles text input updates.
diff --git a/cmd/search/model_input_test.go b/cmd/search/model_input_test.go
index 4b3be154..3d71a251 100644
--- a/cmd/search/model_input_test.go
+++ b/cmd/search/model_input_test.go
@@ -1,9 +1,10 @@
package search
import (
+ "testing"
+
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
- "testing"
)
func TestUpdateInput(t *testing.T) {
diff --git a/cmd/search/model_selection.go b/cmd/search/model_selection.go
index 02f4a65e..02f5d573 100644
--- a/cmd/search/model_selection.go
+++ b/cmd/search/model_selection.go
@@ -2,6 +2,7 @@ package search
import (
"fmt"
+
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/digitalghost-dev/poke-cli/styling"
diff --git a/cmd/search/parse.go b/cmd/search/parse.go
index 284d3416..50b7e7b9 100644
--- a/cmd/search/parse.go
+++ b/cmd/search/parse.go
@@ -1,8 +1,9 @@
package search
import (
- "github.com/digitalghost-dev/poke-cli/connections"
"strings"
+
+ "github.com/digitalghost-dev/poke-cli/connections"
)
type Resource struct {
diff --git a/cmd/search/search.go b/cmd/search/search.go
index b99370a0..c103fc83 100644
--- a/cmd/search/search.go
+++ b/cmd/search/search.go
@@ -3,11 +3,12 @@ package search
import (
"flag"
"fmt"
+ "os"
+
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/digitalghost-dev/poke-cli/cmd/utils"
"github.com/digitalghost-dev/poke-cli/styling"
- "os"
)
func SearchCommand() {
@@ -72,9 +73,10 @@ func (m Model) Init() tea.Cmd {
// Update handles keypresses and updates the state.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- if msg, ok := msg.(tea.KeyMsg); ok {
- k := msg.String()
- if k == "esc" || k == "ctrl+c" {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c", "esc":
m.Quitting = true
return m, tea.Quit
}
diff --git a/cmd/search/search_test.go b/cmd/search/search_test.go
index 9033437b..93807740 100644
--- a/cmd/search/search_test.go
+++ b/cmd/search/search_test.go
@@ -3,12 +3,13 @@ package search
import (
"bytes"
"fmt"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/digitalghost-dev/poke-cli/styling"
"os"
"strings"
"testing"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/digitalghost-dev/poke-cli/styling"
+
"github.com/stretchr/testify/assert"
)
diff --git a/cmd/utils/validateargs.go b/cmd/utils/validateargs.go
index c76f194e..ab2d80c6 100644
--- a/cmd/utils/validateargs.go
+++ b/cmd/utils/validateargs.go
@@ -42,6 +42,7 @@ func ValidateAbilityArgs(args []string) error {
return nil
}
+// ValidateBerryArgs validates the command line arguments
func ValidateBerryArgs(args []string) error {
if err := checkLength(args, 3); err != nil {
return err
@@ -54,6 +55,19 @@ func ValidateBerryArgs(args []string) error {
return nil
}
+// ValidateCardArgs validates the command line arguments
+func ValidateCardArgs(args []string) error {
+ if err := checkLength(args, 3); err != nil {
+ return err
+ }
+
+ if err := checkNoOtherOptions(args, 3, ""); err != nil {
+ return err
+ }
+
+ return nil
+}
+
// ValidateItemArgs validates the command line arguments
func ValidateItemArgs(args []string) error {
if err := checkLength(args, 3); err != nil {
diff --git a/cmd/utils/validateargs_test.go b/cmd/utils/validateargs_test.go
index 99c60dc0..c2617162 100644
--- a/cmd/utils/validateargs_test.go
+++ b/cmd/utils/validateargs_test.go
@@ -1,10 +1,11 @@
package utils
import (
+ "testing"
+
"github.com/digitalghost-dev/poke-cli/styling"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "testing"
)
func TestCheckLength(t *testing.T) {
@@ -221,6 +222,45 @@ func TestValidateBerryArgs(t *testing.T) {
}
}
+// TestValidateCardArgs tests the ValidateCardArgs function
+func TestValidateCardArgs(t *testing.T) {
+ validInputs := [][]string{
+ {"poke-cli", "card"},
+ {"poke-cli", "card", "--help"},
+ }
+
+ for _, input := range validInputs {
+ err := ValidateCardArgs(input)
+ require.NoError(t, err, "Expected no error for valid input")
+ }
+
+ invalidInputs := [][]string{
+ {"poke-cli", "card", "scarlet"},
+ }
+
+ for _, input := range invalidInputs {
+ err := ValidateCardArgs(input)
+ require.Error(t, err, "Expected error for invalid input")
+ }
+
+ tooManyArgs := [][]string{
+ {"poke-cli", "card", "scarlet", "violet"},
+ }
+
+ expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯")
+
+ for _, input := range tooManyArgs {
+ err := ValidateCardArgs(input)
+
+ if err == nil {
+ t.Fatalf("Expected an error for input %v, but got nil", input)
+ }
+
+ strippedErr := styling.StripANSI(err.Error())
+ assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input")
+ }
+}
+
// TestValidateItemArgs tests the ValidateItemArgs function
func TestValidateItemArgs(t *testing.T) {
validInputs := [][]string{
diff --git a/docs/Infrastructure_Guide/index.md b/docs/Infrastructure_Guide/index.md
index 115afb55..67189a63 100644
--- a/docs/Infrastructure_Guide/index.md
+++ b/docs/Infrastructure_Guide/index.md
@@ -4,7 +4,7 @@ weight: 1
# 1 // Overview
-This section serves as a knowledge base for the project’s backend infrastructure. It was created for a few purposes:
+This section serves as a knowledge base for the project’s backend data infrastructure. It was created for a few purposes:
1. To document how I built everything, so I can easily reference it later.
2. To help others learn how to build something similar.
@@ -21,6 +21,19 @@ The VGC data simply calls one API.
If building on a different operating system, please find the equivalent command. Links will be provided for install
guides for all operating systems when possible.
+## Data Infrastructure Diagram
+
+
+1. TCGPlayer pricing data and TCGDex card data are called and processed through a data pipeline orchestrated by Dagster
+and hosted on AWS.
+2. When the pipeline starts, Pydantic validates the incoming API data against a pre-defined schema, ensuring the data
+types match the expected structure.
+3. Polars is used to create DataFrames.
+4. The data is loaded into a Supabase staging schema.
+5. Soda data quality checks are performed.
+6. `dbt` runs tests and builds the final tables in a Supabase production schema.
+7. Users are then able to query the `pokeapi.co` or `supabase` APIs for either video game or trading card data, respectively.
+
## Tools & Services
Below is a list of all the tools and services used in this project's infrastructure:
diff --git a/docs/assets/data_infrastructure_diagram.svg b/docs/assets/data_infrastructure_diagram.svg
new file mode 100644
index 00000000..933e228b
--- /dev/null
+++ b/docs/assets/data_infrastructure_diagram.svg
@@ -0,0 +1,5 @@
+
+
+
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 6dfd4ea1..920a8a2f 100644
--- a/go.mod
+++ b/go.mod
@@ -3,16 +3,18 @@ module github.com/digitalghost-dev/poke-cli
go 1.24.9
require (
- github.com/charmbracelet/bubbles v0.21.0
- github.com/charmbracelet/bubbletea v1.3.6
- github.com/charmbracelet/huh v0.7.0
+ github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
- github.com/charmbracelet/x/exp/strings v0.0.0-20250708181618-a60a724ba6c3
- github.com/charmbracelet/x/exp/teatest v0.0.0-20250702191427-5bdfc8f2e4ff
- github.com/charmbracelet/x/term v0.2.1
+ github.com/charmbracelet/x/ansi v0.11.0
+ github.com/charmbracelet/x/exp/strings v0.0.0-20251110210702-903592506081
+ github.com/charmbracelet/x/exp/teatest v0.0.0-20251110210702-903592506081
+ github.com/charmbracelet/x/term v0.2.2
github.com/disintegration/imaging v1.6.2
- github.com/stretchr/testify v1.10.0
- golang.org/x/text v0.27.0
+ github.com/stretchr/testify v1.11.1
+ golang.org/x/image v0.33.0
+ golang.org/x/text v0.31.0
modernc.org/sqlite v1.39.1
)
@@ -20,32 +22,34 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
+ github.com/bits-and-blooms/bitset v1.24.3 // indirect
github.com/catppuccin/go v0.3.0 // indirect
- github.com/charmbracelet/colorprofile v0.3.1 // indirect
- github.com/charmbracelet/x/ansi v0.9.3 // indirect
- github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
- github.com/charmbracelet/x/exp/golden v0.0.0-20250702191427-5bdfc8f2e4ff // indirect
+ github.com/charmbracelet/colorprofile v0.3.3 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
+ github.com/charmbracelet/x/exp/golden v0.0.0-20251110210702-903592506081 // indirect
+ github.com/clipperhouse/displaywidth v0.5.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
- github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
- github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
- golang.org/x/image v0.28.0 // indirect
- golang.org/x/sync v0.16.0 // indirect
- golang.org/x/sys v0.36.0 // indirect
+ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
+ golang.org/x/sys v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/go.sum b/go.sum
index 1cb34172..a31baba8 100644
--- a/go.sum
+++ b/go.sum
@@ -6,38 +6,46 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/bits-and-blooms/bitset v1.24.3 h1:Bte86SlO3lwPQqww+7BE9ZuUCKIjfqnG5jtEyqA9y9Y=
+github.com/bits-and-blooms/bitset v1.24.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
-github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
-github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
-github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
-github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
-github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
-github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
-github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
-github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
+github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
+github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
+github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
-github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
-github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
-github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
-github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA=
+github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE=
+github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
+github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250702191427-5bdfc8f2e4ff h1:mEllIwjDs9aKqnzckYmNZqxoULwp4afFLVgH9x9QAGA=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250702191427-5bdfc8f2e4ff/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
-github.com/charmbracelet/x/exp/strings v0.0.0-20250708181618-a60a724ba6c3 h1:QTf5vyE6CO+zZiF83Je/r3zWol31h3sKRJ7Vt2PwJVg=
-github.com/charmbracelet/x/exp/strings v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k=
-github.com/charmbracelet/x/exp/teatest v0.0.0-20250702191427-5bdfc8f2e4ff h1:DKxPeQDSnQPDxD27Bq8nUDwiSecy2Yf+nT3U/TlPXs8=
-github.com/charmbracelet/x/exp/teatest v0.0.0-20250702191427-5bdfc8f2e4ff/go.mod h1:RXbDhep1qKL/SEz2IuOhOUrsNHDKGqRmGks1nZStKyU=
-github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
-github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/charmbracelet/x/exp/golden v0.0.0-20251110210702-903592506081 h1:0pHMO3V29SZJuasznHm3s3XkQZUgoBXQM+VILYoVj50=
+github.com/charmbracelet/x/exp/golden v0.0.0-20251110210702-903592506081/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw=
+github.com/charmbracelet/x/exp/strings v0.0.0-20251110210702-903592506081 h1:pTHy/fb1lG8MTw0FizbBQV9HHXEO2+MtPXkcE0S44nM=
+github.com/charmbracelet/x/exp/strings v0.0.0-20251110210702-903592506081/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
+github.com/charmbracelet/x/exp/teatest v0.0.0-20251110210702-903592506081 h1:4V7nggB2MvTMnI03immNNETBuRZHZE9N/awjP77IooY=
+github.com/charmbracelet/x/exp/teatest v0.0.0-20251110210702-903592506081/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
+github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I=
+github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
+github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -52,14 +60,16 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
-github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -68,37 +78,38 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
-github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
-golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
-golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
-golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
-golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
-golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
-golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
-golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
+golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
+golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
+golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
-golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
-golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
-golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
+golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/nfpm.yaml b/nfpm.yaml
index d5ff4ad5..dca2fa6d 100644
--- a/nfpm.yaml
+++ b/nfpm.yaml
@@ -1,7 +1,7 @@
name: "poke-cli"
arch: "arm64"
platform: "linux"
-version: "v1.7.4"
+version: "v1.8.0"
section: "default"
version_schema: semver
maintainer: "Christian S"
diff --git a/testdata/cli_help.golden b/testdata/cli_help.golden
index f2840b8d..a86fca94 100644
--- a/testdata/cli_help.golden
+++ b/testdata/cli_help.golden
@@ -14,6 +14,7 @@
│ COMMANDS: │
│ ability Get details about an ability │
│ berry Get details about a berry │
+│ card Get details about a TCG card │
│ item Get details about an item │
│ move Get details about a move │
│ natures Get details about all natures │
diff --git a/testdata/cli_incorrect_command.golden b/testdata/cli_incorrect_command.golden
index e57f2eb9..67df9721 100644
--- a/testdata/cli_incorrect_command.golden
+++ b/testdata/cli_incorrect_command.golden
@@ -5,6 +5,7 @@
│Commands: │
│ ability Get details about an ability │
│ berry Get details about a berry │
+│ card Get details about a TCG card │
│ item Get details about an item │
│ move Get details about a move │
│ natures Get details about all natures │
diff --git a/testdata/main_latest_flag.golden b/testdata/main_latest_flag.golden
index 262d4786..4166dd4b 100644
--- a/testdata/main_latest_flag.golden
+++ b/testdata/main_latest_flag.golden
@@ -2,6 +2,6 @@
┃ ┃
┃ Latest available release ┃
┃ on GitHub: ┃
-┃ • v1.7.3 ┃
+┃ • v1.7.4 ┃
┃ ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛