portage-cli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +33 -4
  4. data/known-stores/categories.yml +6841 -18
  5. data/known-stores/category-stoplist.yml +40 -0
  6. data/known-stores/category-synonyms.yml +15 -0
  7. data/lib/portage/cli/browser_import/categorize.rb +5 -2
  8. data/lib/portage/cli/buy.rb +3 -24
  9. data/lib/portage/cli/check.rb +164 -0
  10. data/lib/portage/cli/check_next_step.rb +51 -0
  11. data/lib/portage/cli/classifier/ranking.rb +135 -0
  12. data/lib/portage/cli/classifier/table.rb +63 -0
  13. data/lib/portage/cli/classifier.rb +23 -44
  14. data/lib/portage/cli/doctor.rb +16 -6
  15. data/lib/portage/cli/find.rb +5 -3
  16. data/lib/portage/cli/handoff_host.rb +33 -0
  17. data/lib/portage/cli/index/builder.rb +49 -12
  18. data/lib/portage/cli/index/database.rb +150 -0
  19. data/lib/portage/cli/index/entry_product.rb +37 -0
  20. data/lib/portage/cli/index/legacy_import.rb +54 -0
  21. data/lib/portage/cli/index/product_store.rb +73 -35
  22. data/lib/portage/cli/index/schema.rb +70 -0
  23. data/lib/portage/cli/index/search.rb +73 -0
  24. data/lib/portage/cli/index/sources/storefront_products/mapper.rb +127 -0
  25. data/lib/portage/cli/index/sources/storefront_products/pages.rb +114 -0
  26. data/lib/portage/cli/index/sources/storefront_products/robots.rb +70 -0
  27. data/lib/portage/cli/index/sources/storefront_products.rb +147 -0
  28. data/lib/portage/cli/index/sources.rb +5 -2
  29. data/lib/portage/cli/index/store.rb +23 -47
  30. data/lib/portage/cli/index.rb +1 -0
  31. data/lib/portage/cli/offer_sources.rb +17 -2
  32. data/lib/portage/cli/version.rb +1 -1
  33. data/lib/portage/cli.rb +142 -12
  34. metadata +34 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d34c9ba25977baefa9f1f802e38ae6b2175e455d246f7a0dd3a2833136c46e8
4
- data.tar.gz: 2c1d701bafd9c914115bdb02ab661906eaddba139e0627a56bfa0cf094e71d06
3
+ metadata.gz: f4200c871324b9f35de00e58c32177ec087c9922b3adda2ae45aa1b4673478f1
4
+ data.tar.gz: e719be3b212a547be84ff5fa1232b08303c96288b7acf60f0fd9c2f1982fa0ef
5
5
  SHA512:
6
- metadata.gz: ec3b6bf08d832abbe197591050b229f38a3650603feb6e94b47a6e84fbb9d3b9ddf531c51e84d71c8b708131b655e6b501e6a55662908eb6edb60981da4f14a2
7
- data.tar.gz: 137b718fd1283383aa70879a6f2290a35dc1bc3f3a898ceff6965019b110329caf943dcf3ef39c04c29d1661beee3da6edd3a056877b7a6c3a932debce90c99f
6
+ metadata.gz: 3bb580b0b6ac745462ecc241b4e151fa16c2a8651ac32aa6d53c84aca0b910da6095182680b6a832b3431539904a697332279de9b9340fb491581821eccf6519
7
+ data.tar.gz: 7a75983b229489dc8519caa7a176122b00c8f41f56922f4b373c551a9ab044dace860f7ecdd6ba93d63feddf84177391d3ae92f13a6280acd138e704590ea4f3
data/CHANGELOG.md CHANGED
@@ -6,6 +6,70 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.11.0] - 2026-10-01
10
+
11
+ - **Category classification uses the whole taxonomy.** `known-stores/categories.yml` is now generated
12
+ by `script/categories` (stdlib only) from Google's product taxonomy, edition 2021-09-21, instead of
13
+ coming from a script that was never committed. It keeps the same ids and the same top-two-level keys
14
+ (so `~/.portage/categories.yml` overrides still work), but a level-2 node's `keywords` now include
15
+ its descendants' names ("Chandeliers" counts for Lighting), and its parent's words sit in a separate
16
+ `parent_keywords` list. The file grows from 20KB to 101KB. A shipped stoplist
17
+ (`known-stores/category-stoplist.yml`, every word with its reason) removes merchandising and url words
18
+ ("new", "collection", "sale", "gift", "accessories", "products", ...) from both the data and the input,
19
+ and a small synonyms file (`category-synonyms.yml`, each word tied to a golden case) adds the few
20
+ words the taxonomy lacks ("pendant", "sconce"). `Classifier.categories_for` keeps its signature (plus
21
+ an optional `stoplist_path:`) and returns at most three ids, best first. Scoring: a keyword counts
22
+ 2 and a `parent_keyword` 1, times how often the input repeats the word (1 + ln count) and, for the
23
+ cut, how rare it is. A word counts once per node, and an id scoring under half the strongest is
24
+ dropped. Ties go to the node whose own name the input covers most, then to one whose name has no
25
+ stoplisted word, then to the smaller node. On a 100-case golden set (Light Yard, JB Hi-Fi, shopper
26
+ queries, browser-import titles and urls) top-1 accuracy goes from 12% to 70%. "Pendant Light" is
27
+ Lighting, "New Collection" is no longer Toll Collection Devices, and 160 of Light Yard's 164 products
28
+ classify as Lighting (1 before). Products already in the index keep the category they were crawled
29
+ with: crawl the store again (`portage index build --sources storefront_products`) to re-classify them.
30
+ - **The local index is now a SQLite file.** `~/.portage/index/index.sqlite3` (mode 0600, WAL)
31
+ replaces `stores.json` and `products.json`, through the new `sqlite3` gem dependency (`~> 2.9`,
32
+ precompiled for macOS and Linux). An existing `stores.json`/`products.json` is imported once on
33
+ first use and renamed to `*.json.migrated`; it is never deleted. `Index::Store` and
34
+ `Index::ProductStore` keep their APIs, and `ProductStore#upsert_many` writes a batch in one
35
+ transaction. Index write failures now raise instead of being dropped. `portage doctor` reports the
36
+ database path, row counts and whether FTS5 is available. The known-stores cache and `index export`
37
+ output stay JSON.
38
+ - **Storefront catalogue crawl and `portage index search`.** A new opt-in index source,
39
+ `storefront_products`, reads a Shopify store's public `/products.json` into the local index. Run it
40
+ with `portage index add URL --crawl` for one store, or `portage index build --sources
41
+ storefront_products` for stores already indexed (25 a run, least recently crawled first). Each
42
+ product is mapped through the UCP `Product` shape and stored with its handle, URL, first image,
43
+ options and variant ids. Price and availability are dropped before anything is written. A crawl
44
+ reads at most 20 pages of 250 a store, 1s apart. It waits out one 429's `Retry-After` (capped at 60s)
45
+ and stops on a second, and it obeys `robots.txt` for every page URL. It never contacts a hand-off-only
46
+ host, and it skips a 404, a redirect or a non-JSON answer (a bot wall). What happened is kept on the
47
+ store entry as `crawl`. `portage index search QUERY [--category ID] [--store HOST] [--limit N]
48
+ [--json]` searches the index locally with SQLite FTS5, ranked by bm25, and sends no request. Without
49
+ FTS5 it falls back to an unranked text match and reports `engine: "like"`. `index show --products` now
50
+ pages (`--page N`, `--per-page N`, default 50). `portage check` suggests the `index add ... --crawl`
51
+ command for a Shopify or native-UCP store (`index_hint`) but never runs it. `find` and `buy` never
52
+ crawl.
53
+ - **Product cards for agents.** `find` offers (and `shopify_catalog` offers) gain a `product` field:
54
+ the store's UCP `Product` wire hash as served, with `media` cut to the first image, so an agent or
55
+ UI can draw a card without another request. The flat fields (`title`, `amount`, `currency`, `url`,
56
+ `product_id`, `store`) are unchanged, the retailer API sources leave `product` out, and `history`
57
+ does not save it. `portage index search` results are marked `live: false` and each hit gains
58
+ `product`, built from the fields the index keeps (title, handle, URL, first image, options, variant
59
+ ids, category), never a price. Text output says the hits are not live.
60
+
61
+ ## [0.10.0] - 2026-09-29
62
+
63
+ - **`portage check <url> [--json]`.** Reports whether Portage can buy from a store and
64
+ how: `verdict` (`automated`, `webmcp`, `handoff`, `unsupported`), `next_step`, and the
65
+ detail behind them (native UCP, platform, adapter install and missing env, hand-off-only,
66
+ WebMCP status). Wraps `Portage::Ucp::Check` and follows the precedence `buy` uses.
67
+ Plain GETs only, hand-off-only hosts are not contacted, and WebMCP is read only from an
68
+ already-open Portage profile tab. Exits `0` for `automated` and `webmcp`. The hand-off-only
69
+ test moved into a shared `HandoffHost` so `buy` and `check` can't disagree.
70
+ Follows a homepage `<link rel="ucp">` manifest pointer through `portage-ucp` 0.11.0,
71
+ which this release now requires (`~> 0.11`).
72
+
9
73
  ## [0.9.0] - 2026-09-29
10
74
 
11
75
  - **Human pick and approve** (`docs/plans/human-pick-and-approve.md`, Phases 1-3).
data/README.md CHANGED
@@ -133,6 +133,7 @@ portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
133
133
  portage find --query "..." [--max-price N] [--limit N] [--json]
134
134
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
135
135
  [--max-price N] [--json]
136
+ portage check <url> [--json]
136
137
  portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
137
138
  [--choose REF | --compare REF | --view REF]
138
139
  portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
@@ -154,8 +155,9 @@ portage policy set [--per-transaction-cap N --currency CUR]
154
155
  portage orders reconcile [--checkout ID] [--json]
155
156
  portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
156
157
  portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
157
- portage index show [--stores|--products] [--json]
158
- portage index add <url> [--json]
158
+ portage index show [--stores|--products [--page N] [--per-page N]] [--json]
159
+ portage index search QUERY [--category ID] [--store HOST] [--limit N] [--json]
160
+ portage index add <url> [--crawl] [--json]
159
161
  portage index remove <host> [--json]
160
162
  portage index sources [--json]
161
163
  portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
@@ -218,6 +220,18 @@ used (an unreadable value, an out-of-range threshold) stops the buy before
218
220
  anything runs: on stderr normally, or as a JSON report with `outcome:
219
221
  "invalid_option"` under `--json`.
220
222
 
223
+ ### Check
224
+
225
+ `portage check <url> [--json]` answers "can Portage buy from this store, and how?".
226
+ It checks for a native `/.well-known/ucp` manifest, detects the platform, notes
227
+ whether its adapter gem is installed and which env vars are missing, and looks for
228
+ WebMCP tools, using the same hand-off-only rules as `buy`. `verdict` is
229
+ `automated`, `webmcp`, `handoff` or `unsupported`, with a plain-English
230
+ `next_step`. Exits `0` for `automated` and `webmcp`. It sends plain GETs only, and
231
+ never contacts a hand-off-only host. WebMCP is read only from a tab your Portage
232
+ browser profile already has open on the store; it never launches a browser.
233
+ Takes the `--proxy*` flags.
234
+
221
235
  ### Compare
222
236
 
223
237
  `portage compare <url> --product-id ID` finds other stores selling the same
@@ -465,13 +479,16 @@ portage index build --sources shopify_catalog,stores_file
465
479
  portage index build --queries queries.txt # one query per line, instead of the built-in taxonomy sweep
466
480
  portage index refresh # re-verify entries older than 7 days, add new ones
467
481
  portage index show --stores --json
468
- portage index show --products --json
482
+ portage index show --products --page 2 --json # 50 a page; --per-page N
483
+ portage index search "wall light" --store some-shop.example --json
469
484
  portage index add https://some-shop.example
485
+ portage index add https://some-shop.example --crawl # also read its /products.json catalogue
486
+ portage index build --sources storefront_products # crawl the catalogues of stores already indexed
470
487
  portage index remove some-shop.example
471
488
  portage index sources # name, what each fetches, source file path
472
489
  ```
473
490
 
474
- Stored at `~/.portage/index/{stores,products}.json`, **never in git** and
491
+ Stored in `~/.portage/index/index.sqlite3` (mode 0600), **never in git** and
475
492
  **never containing a price or stock field** — those are always fetched live.
476
493
  Each new origin gets exactly one `/.well-known/ucp` probe (capped at 500 new
477
494
  probes per run), throttled, with progress output. Sources:
@@ -483,6 +500,18 @@ probes per run), throttled, with progress output. Sources:
483
500
  | `browser` | Whatever `portage browser import` (below) already saved — this source itself never reads a browser | on, but yields nothing unless you've run `browser import` |
484
501
  | `wikidata` | Retailers'/brands' official sites via a public SPARQL query | opt-in (`--sources wikidata`) |
485
502
  | `webmcp_sweep` | Which WebMCP preset an origin matches, when a bridge is attached | opt-in, needs a bridge |
503
+ | `storefront_products` | Each indexed Shopify store's own `/products.json`: title, brand, handle, URL, first image, options and variant ids, mapped through the UCP `Product` shape with price and availability dropped | opt-in (`--sources storefront_products`, or `index add URL --crawl`) |
504
+
505
+ **Catalogue crawls are polite and opt-in.** `storefront_products` never runs
506
+ from `find` or `buy` (`portage check` only prints the `index add ... --crawl`
507
+ command). It crawls at most 20 pages of 250 products a store and 25 stores a
508
+ run, least recently crawled first, 1s apart. It waits out one 429's
509
+ `Retry-After` (capped at 60s) and stops that store on a second. It obeys
510
+ `robots.txt` for every page URL, never contacts a hand-off-only host, and
511
+ skips a store that answers 404, a redirect or anything but products JSON (a
512
+ bot wall). What happened is kept on the store entry as `crawl`.
513
+ `portage index search` then searches those products locally (SQLite FTS5,
514
+ or a plain text match if FTS5 is missing), with no request.
486
515
 
487
516
  **The index is untrusted data, on the same footing as any other `find`
488
517
  candidate.** It never feeds `Policy#merchant_allowlist` and never counts as