@commercelayer/agent-cli 0.3.0 → 0.5.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.
package/dist/store.js CHANGED
@@ -5,18 +5,26 @@
5
5
  import crypto from 'node:crypto';
6
6
  import fs from 'node:fs';
7
7
  import YAML from 'yaml';
8
+ import { DEFAULT_LANGUAGE, ISO_639_1, PLAIN, TRANSLATED } from './languages.js';
9
+ import { localesOf } from './locales.js';
8
10
  import { fail } from './output.js';
9
11
  import { paths } from './files.js';
10
12
  import { productFiles, readProductFile } from './products.js';
11
13
  /** The keys a product may have. Stock is not one: inventory lives in Commerce Layer. */
12
14
  export const PRODUCT_KEYS = ['sku', 'name', 'description', 'image_url', 'category', 'prices'];
13
- export const THEME_KEYS = ['color', 'logo', 'hero', 'tagline'];
15
+ export const THEME_KEYS = ['color', 'logo', 'hero', 'tagline', 'badge'];
16
+ export const STOREFRONT_MODES = ['hosted', 'agent-only'];
17
+ export const storefrontMode = (s) => s.storefront?.mode ?? 'hosted';
14
18
  export const template = (name) => `# Commerce Layer store. This file is the store: edit it, then run
15
19
  # c11r plan to see what will change
16
20
  # c11r apply to make the change
17
21
  # Every product needs a price in the currency of every market.
18
22
 
19
23
  name: ${name}
24
+ # language: en # the products' language, ISO 639-1 (default en). Other languages
25
+ # # go in the products: name: { en: Ethiopia, it: Etiopia }, and the same
26
+ # # for description and category; in CSV, name_it, description_it, category_it.
27
+ # # The website has a page per country, in that country's language.
20
28
 
21
29
  markets:
22
30
  # One market per region you sell in. countries are ISO 3166 codes.
@@ -49,7 +57,8 @@ catalog:
49
57
  # source: sheets # a Google Sheet published as CSV: url is its CSV link
50
58
  # source: supabase # a Supabase table: table is its name (default products);
51
59
  # # C11R_SUPABASE_URL and C11R_SUPABASE_KEY in .commercelayer/credentials
52
- # source: merchant # a Google Merchant Center feed, RSS (XML) or TSV: url is the feed
60
+ # source: merchant # a Google Merchant Center feed, RSS (XML) or TSV: url is the feed;
61
+ # # translations: { it: <the it feed url> } adds other languages, by id
53
62
  # url: https://...
54
63
  # table: products
55
64
 
@@ -60,8 +69,12 @@ theme:
60
69
  # logo: img/logo.svg # or an https:// URL; without it the name is the logo
61
70
  # hero: img/hero.jpg # the home page's wide image; without it a band in the accent
62
71
  # tagline: Small-batch coffee, roasted in Milan # at most 120 characters
72
+ # badge: Demo # a short label next to the logo, at most 20 characters
63
73
 
64
74
  storefront:
75
+ # mode: hosted # hosted (default): the website for humans, and UCP for agents.
76
+ # # agent-only: the store host serves only UCP and the pages it
77
+ # # needs (checkout, order, policy pages), no catalog pages.
65
78
  # Only for a custom storefront (your own web app in this repo, published with
66
79
  # c11r storefront publish): it replaces the theme. Its folder, from the repo root.
67
80
  # path: storefront
@@ -144,12 +157,17 @@ export function loadStore() {
144
157
  fail('invalid_store', `policies.${key} is not a URL.`, 'Policy entries are full URLs starting with https://, or left out to use the default sandbox pages.');
145
158
  }
146
159
  const theme = checkTheme(doc.theme);
160
+ checkStorefront(doc.storefront);
161
+ const language = storeLanguage(doc.language);
147
162
  const currencies = [...new Set(markets.map((m) => m.currency))];
148
- const products = checkProducts(sourced, currencies);
149
- return { name: doc.name, markets, products, storefront: doc.storefront, policies, theme };
163
+ const products = checkProducts(sourced, currencies, language);
164
+ const languages = storeLanguages({ language, products });
165
+ if (languages.length > MAX_LANGUAGES)
166
+ fail('invalid_store', `The store has ${languages.length} languages: ${languages.join(', ')}.`, `A store has at most ${MAX_LANGUAGES} languages, the base one (${language}) included. Drop the translations of the others.`);
167
+ return { name: doc.name, language, markets, products, storefront: doc.storefront, policies, theme };
150
168
  }
151
169
  /** The product rules, shared by loadStore and catalog pull: errors name each product's source. */
152
- export function checkProducts(sourced, currencies) {
170
+ export function checkProducts(sourced, currencies, language = DEFAULT_LANGUAGE) {
153
171
  const skus = new Map();
154
172
  const products = [];
155
173
  for (const { product: p, source } of sourced) {
@@ -170,11 +188,16 @@ export function checkProducts(sourced, currencies) {
170
188
  if (p.image_url !== undefined && !isImageRef(String(p.image_url))) {
171
189
  fail('invalid_product', `Product ${p.sku} in ${source} has image_url "${p.image_url}".`, IMAGE_HINT);
172
190
  }
173
- if (p.category !== undefined) {
174
- const parts = typeof p.category === 'string' ? p.category.split('>').map((c) => c.trim()) : [''];
175
- if (parts.some((c) => !c))
176
- fail('invalid_product', `Product ${p.sku} in ${source} has category "${p.category}".`, 'A category is a path of names from the top level, separated by " > ", such as "Coffee > Single origin". Leave it out for no category.');
177
- p.category = parts.join(' > ');
191
+ translate(p, source, language);
192
+ if (p.category !== undefined)
193
+ p.category = categoryPath(p.category, `Product ${p.sku} in ${source} has category "${p.category}".`);
194
+ for (const [ll, t] of Object.entries(p.translations ?? {})) {
195
+ if (t.category === undefined)
196
+ continue;
197
+ t.category = categoryPath(t.category, `Product ${p.sku} in ${source} has category.${ll} "${t.category}".`);
198
+ const levels = (c) => c.split(' > ').length;
199
+ if (levels(t.category) !== levels(p.category))
200
+ fail('invalid_product', `Product ${p.sku} in ${source} has category.${ll} "${t.category}", with ${levels(t.category)} levels; "${p.category}" has ${levels(p.category)}.`, 'A translated category names the same levels as the base one, each translated: "Coffee > Single origin" is "Caffè > Monorigine" in it.');
178
201
  }
179
202
  for (const c of currencies) {
180
203
  const price = p.prices?.[c];
@@ -186,6 +209,60 @@ export function checkProducts(sourced, currencies) {
186
209
  }
187
210
  return products;
188
211
  }
212
+ /** store.yml `language:`, validated: the base language, default en. */
213
+ export function storeLanguage(raw) {
214
+ if (raw === undefined || raw === null)
215
+ return DEFAULT_LANGUAGE;
216
+ const ll = String(raw);
217
+ if (!ISO_639_1.has(ll))
218
+ fail('invalid_store', `language in store.yml is "${ll}".`, 'Use the ISO 639-1 code of the language the products are written in, in lower case: en, it, fr, de, es, ... Leave it out for en.');
219
+ return ll;
220
+ }
221
+ const categoryPath = (value, message) => {
222
+ const parts = typeof value === 'string' ? value.split('>').map((c) => c.trim()) : [''];
223
+ if (parts.some((c) => !c))
224
+ fail('invalid_product', message, 'A category is a path of names from the top level, separated by " > ", such as "Coffee > Single origin". Leave it out for no category.');
225
+ return parts.join(' > ');
226
+ };
227
+ /**
228
+ * name, description and category as a string (the base language) or a { <ll>: text } map, from
229
+ * YAML or from name_<ll> columns: the base language's text stays on the product, the others move
230
+ * to translations, sorted by language.
231
+ */
232
+ function translate(p, source, language) {
233
+ const raw = p;
234
+ if (raw.translations !== undefined)
235
+ fail('invalid_product', `Product ${p.sku} in ${source} has translations.`, 'Write the other languages in the fields themselves: name: { en: Ethiopia, it: Etiopia }, or name_it columns in CSV.');
236
+ const out = {};
237
+ for (const field of TRANSLATED) {
238
+ const v = raw[field];
239
+ if (v === undefined || v === null || typeof v !== 'object')
240
+ continue;
241
+ if (Array.isArray(v))
242
+ fail('invalid_product', `Product ${p.sku} in ${source} has ${field} as a list.`, `Write ${field} as text, or as a map of language codes to text: ${field}: { ${language}: ..., it: ... }.`);
243
+ const map = v;
244
+ for (const [ll, text] of Object.entries(map)) {
245
+ if (ll !== PLAIN && !ISO_639_1.has(ll))
246
+ fail('invalid_product', `Product ${p.sku} in ${source}: ${field}.${ll} is not a language.`, 'Use ISO 639-1 codes in lower case for the languages: en, it, fr, de, es, ...');
247
+ if (typeof text !== 'string' && typeof text !== 'number')
248
+ fail('invalid_product', `Product ${p.sku} in ${source}: ${field}.${ll} is not text.`, `Write each language's ${field} as plain text.`);
249
+ }
250
+ const plain = map[PLAIN];
251
+ const base = map[language];
252
+ if (plain !== undefined && base !== undefined && String(plain) !== String(base))
253
+ fail('invalid_product', `Product ${p.sku} in ${source} has ${field} and ${field}_${language} with different texts.`, `${language} is the store's base language: the plain ${field} column is its text. Keep one of the two.`);
254
+ const text = base ?? plain;
255
+ if (text === undefined || String(text).trim() === '')
256
+ fail('invalid_product', `Product ${p.sku} in ${source} has ${field} in ${Object.keys(map).filter((k) => k !== PLAIN).join(', ')} but not in ${language}, the store's base language.`, `Add the ${language} text (${field}: { ${language}: ... }), or set language: in store.yml to the products' base language.`);
257
+ raw[field] = String(text);
258
+ for (const [ll, t] of Object.entries(map))
259
+ if (ll !== PLAIN && ll !== language && String(t).trim() !== '')
260
+ (out[ll] ??= {})[field] = String(t).trim();
261
+ }
262
+ const languages = Object.keys(out).sort();
263
+ if (languages.length)
264
+ p.translations = Object.fromEntries(languages.map((ll) => [ll, out[ll]]));
265
+ }
189
266
  /** An image reference that is a file in .commercelayer/img/ (img/eth.jpg), not a URL. */
190
267
  export const isLocalImage = (url) => !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//');
191
268
  /** The paths the store host serves images at, as the API accepts them. */
@@ -193,12 +270,28 @@ export const IMAGE_PATH = /^img\/[A-Za-z0-9._-][A-Za-z0-9._/-]*\.(png|jpg|jpeg|w
193
270
  /** image_url, logo, hero: an https URL, or img/<file> for a file in .commercelayer/img/. */
194
271
  export const isImageRef = (url) => isLocalImage(url) ? IMAGE_PATH.test(url) && !url.includes('..') && !url.includes('//') : /^https?:\/\/\S+$/.test(url);
195
272
  const IMAGE_HINT = 'Use a full https:// URL, or img/<file> for a file in .commercelayer/img/ (letters, digits, . _ - and /; extension png, jpg, jpeg, webp, gif, avif or svg, in lowercase).';
273
+ /** The storefront block of store.yml: path, and mode hosted or agent-only. */
274
+ function checkStorefront(raw) {
275
+ if (raw === undefined || raw === null)
276
+ return;
277
+ const hint = 'Write `storefront:` with `mode: hosted` (the default) or `mode: agent-only`, and `path:` for a custom storefront. See the comments in store.yml.';
278
+ if (typeof raw !== 'object' || Array.isArray(raw))
279
+ fail('invalid_storefront', 'storefront in store.yml is not a mapping.', hint);
280
+ const s = raw;
281
+ const unknown = Object.keys(s).filter((k) => !['mode', 'path'].includes(k));
282
+ if (unknown.length)
283
+ fail('invalid_storefront', `storefront in store.yml has ${unknown.join(', ')}, which it does not take.`, hint);
284
+ if (s.mode !== undefined && s.mode !== null && !STOREFRONT_MODES.includes(s.mode))
285
+ fail('invalid_storefront', `storefront.mode is "${String(s.mode)}".`, 'Use hosted (a website for humans, and UCP) or agent-only (UCP and the pages it needs, no catalog pages).');
286
+ if (s.path !== undefined && s.path !== null && typeof s.path !== 'string')
287
+ fail('invalid_storefront', 'storefront.path is not a folder name.', hint);
288
+ }
196
289
  /** The theme block of store.yml, validated: the theme would ignore bad values, so say so here. */
197
290
  function checkTheme(raw) {
198
291
  if (raw === undefined || raw === null)
199
292
  return undefined;
200
293
  if (typeof raw !== 'object' || Array.isArray(raw))
201
- fail('invalid_theme', 'theme in store.yml is not a mapping.', 'Write `theme:` with color, logo, hero and tagline under it, each optional. See the comments in store.yml.');
294
+ fail('invalid_theme', 'theme in store.yml is not a mapping.', 'Write `theme:` with color, logo, hero, tagline and badge under it, each optional. See the comments in store.yml.');
202
295
  const t = raw;
203
296
  const unknown = Object.keys(t).filter((k) => !THEME_KEYS.includes(k));
204
297
  if (unknown.length)
@@ -214,14 +307,29 @@ function checkTheme(raw) {
214
307
  fail('invalid_theme', `theme.${k} is "${theme[k]}".`, IMAGE_HINT);
215
308
  if (theme.tagline !== undefined && (theme.tagline.length > 120 || /[\r\n<>]/.test(theme.tagline)))
216
309
  fail('invalid_theme', `theme.tagline is ${theme.tagline.length > 120 ? `${theme.tagline.length} characters long` : 'not plain text on one line'}.`, 'Write the tagline as one line of plain text, at most 120 characters.');
310
+ if (theme.badge !== undefined && (theme.badge.length > 20 || !theme.badge.trim() || /[\r\n<>]/.test(theme.badge)))
311
+ fail('invalid_theme', `theme.badge is ${theme.badge.length > 20 ? `${theme.badge.length} characters long` : `"${theme.badge}"`}.`, 'Use a short plain-text label of at most 20 characters, such as Demo, with no line breaks or < >.');
217
312
  return theme;
218
313
  }
219
314
  export function toApplied(store) {
220
315
  return {
221
316
  markets: Object.fromEntries(store.markets.map((m) => [m.code, m])),
222
317
  products: Object.fromEntries(store.products.map((p) => [p.sku, p])),
318
+ ...(store.language !== DEFAULT_LANGUAGE && { language: store.language }),
223
319
  };
224
320
  }
321
+ /** The store's languages: the base one first, then every language a product is translated into. */
322
+ export const storeLanguages = (store) => [store.language, ...[...new Set(store.products.flatMap((p) => Object.keys(p.translations ?? {})))].sort()];
323
+ /** Translations in a language no country of the store speaks: kept, but no page shows them. */
324
+ export function languageWarnings(store) {
325
+ const spoken = new Set(localesOf(toApplied(store)).map((l) => l.language));
326
+ return storeLanguages(store)
327
+ .slice(1)
328
+ .filter((ll) => !spoken.has(ll))
329
+ .map((ll) => `Products are translated into ${ll}, which no country of the store speaks (${[...spoken].join(', ') || 'none'}): no page shows it, but shopping agents can ask for it over UCP.`);
330
+ }
331
+ /** At most this many languages, base included: the API's limit. */
332
+ export const MAX_LANGUAGES = 6;
225
333
  /** JSON with sorted keys and no undefined values, so key order never reads as a change. */
226
334
  export const canonical = (v) => JSON.stringify(v ?? {}, (_k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x));
227
335
  /** The one definition of "pending changes": what apply would do. plan and status both use it. */
@@ -244,6 +352,13 @@ export function diff(state, desired) {
244
352
  else if (hasTheme(before) && canonical(before) !== canonical(desired.theme))
245
353
  changes.push({ action: 'update', type: 'store', id: 'theme' });
246
354
  }
355
+ // The mode: a change from what the API holds (hosted before the first apply). An API that does not know it is skipped.
356
+ if (!applied ? storefrontMode(desired) !== 'hosted' : state.storefront_mode !== undefined && (state.storefront_mode ?? 'hosted') !== storefrontMode(desired)) {
357
+ changes.push({ action: 'update', type: 'store', id: 'storefront_mode' });
358
+ }
359
+ // The base language: a change from what is applied (en before the first apply, and when absent).
360
+ if ((applied?.language ?? DEFAULT_LANGUAGE) !== desired.language)
361
+ changes.push({ action: 'update', type: 'store', id: 'language' });
247
362
  const after = toApplied(desired);
248
363
  for (const kind of ['markets', 'products']) {
249
364
  const type = kind.slice(0, -1);
package/dist/ucp/shop.js CHANGED
@@ -53,17 +53,37 @@ export function money(amount, currency) {
53
53
  return { amount, currency, major, display: `${amount < 0 ? '-' : ''}${currency} ${Math.abs(major).toFixed(decimals)}` };
54
54
  }
55
55
  const plain = (d) => d?.plain ?? d?.markdown ?? d?.html;
56
+ /** How a variant ships, from fulfillment.methods[0]: where to, the first option's price and arrival, and whether it ships to the country asked. */
57
+ function shippingView(m, currency) {
58
+ if (!m)
59
+ return undefined;
60
+ const o = m.options?.[0];
61
+ const amount = o?.totals?.find((t) => t.type === 'fulfillment' || t.type === 'total')?.amount;
62
+ const view = {
63
+ to: m.ships_to,
64
+ option: o?.title,
65
+ price: amount !== undefined && currency ? money(amount, currency) : undefined,
66
+ earliest: o?.earliest_fulfillment_time,
67
+ latest: o?.latest_fulfillment_time,
68
+ // Only when a country was asked (--country): false when the store does not ship there.
69
+ ships_to_country: m.destination ? m.destination.ships !== false : undefined,
70
+ };
71
+ return Object.values(view).some((x) => x !== undefined) ? Object.fromEntries(Object.entries(view).filter(([, x]) => x !== undefined)) : undefined;
72
+ }
56
73
  function variantView(v) {
57
74
  const shipping = v.fulfillment?.methods?.map((m) => plain(m.description)).filter(Boolean).join('; ');
58
75
  return {
59
76
  id: v.id,
60
- sku: v.sku ?? v.id,
77
+ sku: v.sku,
61
78
  title: v.title,
62
79
  price: v.price ? money(v.price.amount, v.price.currency) : undefined,
80
+ // The price before a sale, when the store shows one.
81
+ list_price: v.list_price && v.list_price.amount !== v.price?.amount ? money(v.list_price.amount, v.list_price.currency) : undefined,
63
82
  available: v.availability?.available ?? undefined,
64
83
  availability: v.availability?.status ?? undefined,
65
84
  image_url: v.media?.find((m) => m.type === 'image')?.url,
66
85
  ships: shipping || undefined,
86
+ shipping: shippingView(v.fulfillment?.methods?.[0], v.price?.currency),
67
87
  };
68
88
  }
69
89
  export function productView(p, full = false) {
@@ -76,6 +96,10 @@ export function productView(p, full = false) {
76
96
  description: full || !description || description.length <= 200 ? description : `${description.slice(0, 197)}...`,
77
97
  url: p.url,
78
98
  image_url: image,
99
+ categories: p.categories?.length ? p.categories.map((c) => c.value) : undefined,
100
+ // A text search ranks products with every word first; partial marks one with only some of them.
101
+ partial: p.metadata?.search?.all_words === false || undefined,
102
+ matched_on: p.metadata?.search?.matched_on,
79
103
  // A search lists a variant's image only when it is not the product's.
80
104
  variants: full ? variants : variants.map((v) => ({ ...v, image_url: v.image_url === image ? undefined : v.image_url })),
81
105
  };
@@ -102,7 +126,7 @@ export function checkoutView(ck, origin, call) {
102
126
  currency: cur,
103
127
  line_items: (ck.line_items ?? []).map((l) => ({
104
128
  id: l.id,
105
- sku: l.item?.id,
129
+ item_id: l.item?.id,
106
130
  title: l.item?.title,
107
131
  quantity: l.quantity,
108
132
  unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur),
@@ -134,7 +158,7 @@ export function orderView(o, origin, call) {
134
158
  checkout_id: o.checkout_id,
135
159
  currency: cur,
136
160
  line_items: (o.line_items ?? []).map((l) => ({
137
- sku: l.item?.id,
161
+ item_id: l.item?.id,
138
162
  title: l.item?.title,
139
163
  quantity: l.quantity?.total ?? l.quantity,
140
164
  status: l.status,
@@ -160,15 +184,15 @@ async function step(origin, run) {
160
184
  fail('not_found', e.message, `The store at ${origin} does not know it. Check the id, or pass --store <the store it was made at>.`);
161
185
  if (code === 'identity_required' || e.call.status === 401)
162
186
  fail('identity_required', e.message, 'Orders are readable by the platform that placed them: this CLI can only read orders it placed.');
163
- fail('ucp_error', e.message, `The store at ${origin} refused it: tell the human what it said. The skus it sells are in c11r ucp search.`);
187
+ fail('ucp_error', e.message, `The store at ${origin} refused it: tell the human what it said. The variant ids it sells are in c11r ucp search.`);
164
188
  }
165
189
  }
166
- /** --item SKU or SKU:QTY. */
190
+ /** --item ID or ID:QTY, the variant id. */
167
191
  export function parseItem(raw) {
168
192
  const m = String(raw).match(/^(.+?)(?::(\d+))?$/);
169
193
  if (!m || !m[1].trim())
170
- fail('invalid_argument', `--item ${raw} is not SKU or SKU:QUANTITY.`, 'Example: --item ETH-250:2 (quantity 1 when left out, 0 to remove it in checkout update).');
171
- return { sku: m[1].trim(), quantity: m[2] === undefined ? 1 : Number(m[2]) };
194
+ fail('invalid_argument', `--item ${raw} is not ID or ID:QUANTITY.`, 'Use a variant id from c11r ucp search (variants[].id), for example --item ETH-250:2; quantity 1 when left out, 0 removes it in checkout update.');
195
+ return { id: m[1].trim(), quantity: m[2] === undefined ? 1 : Number(m[2]) };
172
196
  }
173
197
  export async function open(origin) {
174
198
  const store = await discover(origin);
@@ -176,17 +200,40 @@ export async function open(origin) {
176
200
  fail('checkout_unsupported', `The store at ${origin} does not offer UCP checkout to this CLI.`, 'It can only be browsed: give the human the store URL to buy there.');
177
201
  return store;
178
202
  }
179
- export async function search(origin, query, country) {
203
+ export async function search(origin, query, f = {}) {
180
204
  const store = await discover(origin);
181
- const body = { query, pagination: { limit: 50 } };
182
- if (country)
183
- body.context = { address_country: country.toUpperCase() };
205
+ const body = { pagination: { limit: 50 } };
206
+ if (query)
207
+ body.query = query;
208
+ if (f.country)
209
+ body.context = { address_country: f.country.toUpperCase() };
210
+ const filters = {};
211
+ if (f.categories?.length)
212
+ filters.categories = f.categories;
213
+ if (f.min !== undefined || f.max !== undefined) {
214
+ // filters.price is in minor units of context.currency: ask the store which currency it prices in here.
215
+ const probe = await step(origin, () => shopping.search(store, { ...body, ...(f.categories?.length ? { filters } : {}), pagination: { limit: 1 } }));
216
+ const currency = probe.body.products?.[0]?.variants?.[0]?.price?.currency;
217
+ if (currency) {
218
+ body.context = { ...body.context, currency };
219
+ filters.price = { ...(f.min !== undefined ? { min: Math.round(f.min * minorUnits(currency)) } : {}), ...(f.max !== undefined ? { max: Math.round(f.max * minorUnits(currency)) } : {}) };
220
+ }
221
+ }
222
+ if (Object.keys(filters).length)
223
+ body.filters = filters;
184
224
  const call = await step(origin, () => shopping.search(store, body));
185
225
  const r = call.body;
186
- const products = r.products ?? [];
226
+ let products = r.products ?? [];
227
+ // A store that ignores filters.price still gets the bounds applied here.
228
+ if (filters.price)
229
+ products = products.filter((p) => (p.variants ?? []).some((v) => v.price && (filters.price.min === undefined || v.price.amount >= filters.price.min) && (filters.price.max === undefined || v.price.amount <= filters.price.max)));
230
+ // Which market, currency and countries the prices are for: the store says it per variant, the same for all.
231
+ const prices = products.flatMap((p) => p.variants ?? []).find((v) => v.metadata?.prices)?.metadata.prices;
187
232
  return {
188
233
  store: origin,
189
234
  query,
235
+ prices: prices ? { market: prices.market, market_name: prices.market_name, currency: prices.currency, countries: prices.countries, tax_included: prices.tax_included } : undefined,
236
+ filters: Object.keys(filters).length ? { categories: filters.categories, min_price: f.min, max_price: f.max, currency: body.context?.currency } : undefined,
190
237
  count: products.length,
191
238
  total_count: r.pagination?.total_count,
192
239
  has_more: r.pagination?.has_next_page || undefined,
@@ -205,7 +252,7 @@ export async function product(origin, id, country) {
205
252
  }
206
253
  export async function createCheckout(origin, items, codes, country) {
207
254
  const store = await open(origin);
208
- const body = { line_items: items.map((l) => ({ item: { id: l.sku }, quantity: l.quantity })) };
255
+ const body = { line_items: items.map((l) => ({ item: { id: l.id }, quantity: l.quantity })) };
209
256
  if (country)
210
257
  body.context = { address_country: country.toUpperCase() };
211
258
  if (codes.length)
@@ -224,12 +271,12 @@ export async function updateCheckout(origin, id, ch) {
224
271
  const store = await open(origin);
225
272
  const current = (await step(origin, () => shopping.getCheckout(store, id))).body;
226
273
  if (current.status === 'completed' || current.status === 'canceled')
227
- fail('checkout_closed', `The checkout ${id} is ${current.status}.`, current.status === 'completed' ? `It is paid: c11r ucp order ${current.order?.id ?? '<id>'}.` : 'Open a new one: c11r ucp checkout create --item <sku>.');
274
+ fail('checkout_closed', `The checkout ${id} is ${current.status}.`, current.status === 'completed' ? `It is an order now: c11r ucp order ${current.order?.id ?? '<id>'}.` : 'Open a new one: c11r ucp checkout create --item <sku>.');
228
275
  const body = replay(current);
229
276
  if (ch.buyer)
230
277
  body.buyer = clean({ ...(current.buyer ?? {}), ...ch.buyer });
231
278
  for (const l of ch.items ?? []) {
232
- const i = body.line_items.findIndex((x) => x.item.id === l.sku);
279
+ const i = body.line_items.findIndex((x) => x.item.id === l.id);
233
280
  if (l.quantity === 0) {
234
281
  if (i >= 0)
235
282
  body.line_items.splice(i, 1);
@@ -237,7 +284,7 @@ export async function updateCheckout(origin, id, ch) {
237
284
  else if (i >= 0)
238
285
  body.line_items[i].quantity = l.quantity;
239
286
  else
240
- body.line_items.push({ item: { id: l.sku }, quantity: l.quantity });
287
+ body.line_items.push({ item: { id: l.id }, quantity: l.quantity });
241
288
  }
242
289
  if (!body.line_items.length)
243
290
  fail('invalid_argument', 'The update leaves the checkout with no items.', `Keep at least one item, or cancel it: c11r ucp checkout cancel ${id}.`);
@@ -268,8 +315,8 @@ export async function updateCheckout(origin, id, ch) {
268
315
  again.fulfillment.methods[0].groups[0].selected_option_id = group.options[0].id;
269
316
  call = await step(origin, () => shopping.updateCheckout(store, id, again));
270
317
  }
271
- // A store can answer ready_for_complete before the shipping is in the totals (seen once on a
272
- // Commerce Layer store): read it again rather than show the human a total without shipping.
318
+ // A store can answer ready_for_complete before the shipping is in the totals (seen once, since
319
+ // fixed on the store host): read it again rather than show the human a total without shipping.
273
320
  const ck = call.body;
274
321
  if (ck.status === 'ready_for_complete' && ck.fulfillment?.methods?.[0]?.groups?.[0]?.selected_option_id && !(ck.totals ?? []).some((t) => t.type === 'fulfillment')) {
275
322
  call = await step(origin, () => shopping.getCheckout(store, id));
@@ -285,7 +332,7 @@ export async function completeCheckout(origin, id, decline) {
285
332
  const store = await open(origin);
286
333
  if (!store.profile.ucp?.payment_handlers?.[SANDBOX_HANDLER]) {
287
334
  const handlers = Object.keys(store.profile.ucp?.payment_handlers ?? {});
288
- fail('payment_handler_unsupported', `The store at ${origin} takes ${handlers.length ? handlers.join(', ') : 'no payment handler this CLI knows'}; this CLI can only pay sandbox stores (${SANDBOX_HANDLER}) today.`, `Give the human the checkout's continue_url to pay there: c11r ucp checkout show ${id}.`);
335
+ fail('payment_handler_unsupported', `The store at ${origin} takes ${handlers.length ? handlers.join(', ') : 'no payment handler this CLI knows'}; this CLI pays only with the ${SANDBOX_HANDLER} test handler today.`, `Give the human the checkout's continue_url to pay there: c11r ucp checkout show ${id}.`);
289
336
  }
290
337
  const call = await step(origin, () => shopping.completeCheckout(store, id, { payment: { instruments: [sandboxInstrument(decline)] }, signals: { 'dev.ucp.user_agent': 'c11r' } }));
291
338
  const ck = call.body;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [