spree/agent-skills

spree-catalog

Use when the user is working with Spree's product catalog — Products, Variants, Options, Categories, search, images, product publication on channels. Common phrasings include "add a product type", "variants vs options", "product taxonomy", "categorize products", "product images", "Meilisearch reindex", "search broken", "product not showing in store", "publish product on channel", "master variant", "default variant", "SKU". Provides the catalog graph and the operations on it; defers to local @sp…

First seen Jun 11, 2026

Installation

$ npx skills add spree/agent-skills --skill spree-catalog

Summary

  • Use when the user is working with Spree's product catalog — Products, Variants, Options, Categories, search, images, product publication on channels.
  • Common phrasings include "add a product type", "variants vs options", "product taxonomy", "categorize products", "product images", "Meilisearch reindex", "search broken", "product not showing in store", "publish product on channel", "master variant", "default variant", "SKU".
  • Provides the catalog graph and the operations on it; defers to local @spree/docs for field-level detail.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from spree/agent-skills · top by installs.

npx skills add spree/agent-skills

Browse all from spree/agent-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 4
License LICENSE
Default branch main
Open issues 0
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 12,591 B
  • docs SUMMARY.md 554 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 95 installs

SKILL.md

Spree Catalog

Commands below use the Spree CLI form (spree …, Docker). On a classic Rails app without the CLI (typical pre-5.4), use the native mapping in the spree-project skill — bin/rails / bundle exec rake from the app root, paths without the backend/ prefix.

The catalog is everything that's for sale: Products, the Variants underneath them, the Options that distinguish those Variants, the Categories that group them, and the search index that makes them findable.

The catalog graph

Product
  ├── Variant (one master + zero or more "real" variants; master flagged via `is_master`)
  │     ├── Price (per currency)
  │     ├── StockItem (per stock location)
  │     ├── VariantMedia (images, videos, focal point — 5.5)
  │     └── OptionValue × OptionValueVariant
  ├── Category × Classification (the join)
  ├── ProductPublication × Channel (5.5 — which channels surface this product)
  ├── ProductPromotionRule (which promos this product qualifies for)
  └── Metafield (custom fields — 5.4+)

Product vs Variant

The Product is the storefront concept — name, slug, description, category. It rarely changes once published.

The Variant is the SKU — what gets added to a cart, what has a price, what has inventory. A Product has at least one Variant.

Master variant and default variant

Every Product has a "master" Variant — Product.master — which historically holds default attributes (price, weight, SKU) when the Product has no real variants. Real variants override.

product = Spree::Product.find_by(slug: 'cool-shirt')
product.master            # => the master variant (default attributes)
product.variants          # => non-master "real" variants (color/size combos)
product.variants_including_master   # => everything

If a Product has variants (color × size), the master is mostly a placeholder; default pricing/SKU still lives there as a fallback.

Product#defaultvariant is a computed helper, not a stored column. With Spree::Config[:trackinventory_levels] enabled it returns the first purchasable (in-stock or backorderable) variant; if none qualifies — or inventory tracking is off — it returns the first variant by position. A product with no real variants falls back to the master:

product.default_variant   # => first purchasable (or first-by-position) variant; master if the product has no variants

A real defaultvariantid FK on Product is planned for 6.0 (6.0-remove-master-variant.md, implementation not started). Today Product#defaultvariantid is just a memoized method returning default_variant.id, and master is still the live mechanism — not a backwards-compatibility accessor.

Options + OptionTypes + OptionValues

This is how Variants distinguish themselves.

OptionType  "Size"          ─┐
OptionType  "Color"         ─┤
                             │
ProductOptionType  Product ──┘  (which OptionTypes apply to which Product)

OptionValue  Size: "S"      ─┐
OptionValue  Size: "M"      ─┤
OptionValue  Color: "Red"   ─┤
OptionValue  Color: "Blue"  ─┘

OptionValueVariant  Variant ──┘  (which Values apply to which Variant)

A Product declares which OptionTypes apply via productoptiontypes. Each Variant of that Product picks one OptionValue per OptionType. So a "T-Shirt" Product with [Size, Color] OptionTypes has Variants like [Size=M, Color=Red], [Size=L, Color=Blue], etc.

product.option_types       # => [Size, Color]
variant.option_values      # => [Size=M, Color=Red]
variant.options_text       # => "Size: M, Color: Red"

OptionType kind (5.4)

OptionType has a kind field controlling how it renders in the admin: dropdown, colorswatch, buttons. OptionValue's colorcode field stores the hex for color_swatch rendering.

size = Spree::OptionType.create!(name: 'size', presentation: 'Size', kind: 'buttons')
color = Spree::OptionType.create!(name: 'color', presentation: 'Color', kind: 'color_swatch')

red = color.option_values.create!(name: 'red', presentation: 'Red', color_code: '#ff0000')

Categories (formerly Taxons)

Spree 5.5 added Spree::Category, a subclass of Spree::Taxon — the merchant-facing concept for the hierarchical product grouping.

Category (hierarchical — left/right via awesome_nested_set)
  ├── Classification (the join — multiple Products per Category, multiple Categories per Product)
  ├── permalink         (URL slug, hierarchical: "men/shirts/casual")
  └── i18n on name + description

Spree::Category < Spree::Taxon, sharing the spreetaxons table — but they are not interchangeable: a Category is owned directly via storeid and needs no Taxonomy (it default-scopes to manually-curated taxons), while a plain Taxon requires a parent Taxonomy. Use Spree::Category in new code; Spree::Taxon remains for backwards compatibility.

shirts = Spree::Category.find_by(permalink: 'men/shirts')
shirts.products                          # => Products directly in this Category
shirts.descendants                       # => sub-categories
shirts.active_products_with_descendants  # => active Products in this Category or any descendant

ProductPublication (5.5 — channel-scoped visibility)

In 5.5, products belong to a Store via store_id (single owner). Visibility per Channel is managed via ProductPublication:

product.product_publications                                           # ProductPublication × Channel
product.product_publications.where(channel: store.default_channel)     # publication for the default channel

A ProductPublication has publishedat and unpublishedat windows. The Product.forstore(store) scope returns products owned by a store (storeid); per-channel visibility is checked via Product.for_channel(channel) / ProductPublications; Product.active(currency) filters to products that are live with prices in the requested currency.

Pre-5.5 (4.x, early 5.x): Products were on Stores directly via spreeproductsstores. The 5.4→5.5 upgrade migrates this. See the spree-upgrade skill.

Search

Spree ships a pluggable search provider system in 5.4+:

Provider Class Use when
Database (default) Spree::SearchProvider::Database Small catalogs (<10K products); case-insensitive substring (LIKE) matching — no typo tolerance
Meilisearch Spree::SearchProvider::Meilisearch Real-time facets, typo tolerance, large catalogs

Configured via Spree.search_provider = 'Spree::SearchProvider::Meilisearch' in backend/config/initializers/spree.rb.

Reindexing

spree rake spree:search:reindex

The task is a no-op on the Database provider (no index to maintain) and a full catalog push on Meilisearch. Required after:

  • Bulk product imports
  • Schema changes (new searchable attribute)
  • Switching providers
  • The 5.4→5.5 channels upgrade (products gain storeid and become visible to forstore)

Custom searchable attributes

Spree's search-indexed fields come from Spree::Product#searchpresentation, which returns the array of document hashes (one per market × locale combination) that gets pushed to the index. Override via a decorator or — preferred — swap the presenter via Spree::Dependencies.searchproductpresenterclass. After changes, reindex.

Images + Media

5.5 added product-level media. Media records (Spree::Asset subclasses) have a mediatype from Spree::Asset::MEDIATYPES = %w[image video externalvideo]. Images use ActiveStorage attachments; both video media types (video, externalvideo) require a URL in externalvideourl — hosted video-file uploads are not supported. focal_point enables crop-aware thumbnails on images.

product.media                                       # all media for the product
product.media.where(media_type: 'image').first      # first image

The legacy variant-level Spree::Image (via Spree::Asset) still exists for variants. Variants also expose variantmedia, associatedmedia, and gallery_media for finer-grained queries.

Images use ActiveStorage. Resized derivatives (mini/small/medium/large/xlarge/ogimage — see Spree::Config.productimagevariantsizes) are declared with preprocessed: true, so ActiveStorage generates WebP variants in background jobs right after upload.

Brand (custom — your Product's brand)

Spree doesn't ship a Brand model out of the box (different merchants want different brand models — sometimes a Category, sometimes a separate concept with logo/banner/SEO). The spree:api_resource Brand generator scaffolds one. See the spree-resource skill.

If you scaffold a Brand model, link it from Product via a decorator:

module Spree::ProductDecorator
  def self.prepended(base)
    base.belongs_to :brand, class_name: 'Spree::Brand', optional: true
    base.delegate :name, to: :brand, prefix: true, allow_nil: true
  end

  Spree::Product.prepend self
end

Common catalog operations

"My product isn't showing in the store"

Walk this list:

  1. Is it on the store? Spree::Product.forstore(store).where(id: id).exists? — if false, the Product's storeid doesn't point at this store. (Publication checks come next.)
  2. Is it published on the current channel? product.productpublications.where(channel: Spree::Current.channel).any? — if false, no ProductPublication for the channel in scope. (equivalently: Spree::Product.forchannel(Spree::Current.channel).exists?(id: product.id))
  3. Is the publication window active? (publishedat is nil OR publishedat <= Time.current) AND (unpublishedat is nil OR unpublishedat > Time.current).
  4. Does it have a price in the current currency? product.master.prices.where(currency: Spree::Current.currency).any?
  5. Is it in stock? product.instock? — false if no trackinventory variant has positive stock.
  6. Is the search index stale? If using Meilisearch, run spree rake spree:search:reindex.

"Bulk-update prices"

For currency-wide price changes, batch via Spree::Price.where(currency: 'USD').updateall('amount = amount 1.1'). After: the product is fine, but if you have PriceHistory enabled (EU Omnibus), note that updateall bypasses the aftersave callback that records history — iterate and save instead (Spree::Price.where(currency: 'USD').where.not(amount: nil).findeach { |p| p.update!(amount: p.amount 1.1) }) or create Spree::PriceHistory rows explicitly. (spree rake spree:price_history:seed is only a one-time post-migration backfill that skips any price that already has history rows.) See the spree-pricing skill.

"Add a custom field to Products"

Use Metafields (5.4) — no decorator, no schema change. First create a MetafieldDefinition (in the admin or via seed/migration) with a namespace + key + type + displayon (backend or both — the admin UI doesn't offer a front_end-only option for metafields). Then set values per record:

product.set_metafield('catalog.season', 'fall-2026')
product.get_metafield('catalog.season')&.value   # => "fall-2026" (get_metafield returns the Spree::Metafield record, or nil)

displayon: frontend (or both) surfaces the metafield on the Store API; back_end is admin-only. See Spree::Metafields concern and the spree-resource skill (--metafields flag) for built-in support.

Where to read further

  • Core concepts: node_modules/@spree/docs/dist/developer/core-concepts/products.md
  • Media: node_modules/@spree/docs/dist/developer/core-concepts/media.md
  • Search + filtering: node_modules/@spree/docs/dist/developer/core-concepts/search-filtering.md
  • Custom search provider: node_modules/@spree/docs/dist/developer/how-to/custom-search-provider.md