@commercelayer/agent-cli 0.3.1 → 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,6 +5,8 @@
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';
@@ -19,6 +21,10 @@ export const template = (name) => `# Commerce Layer store. This file is the stor
19
21
  # Every product needs a price in the currency of every market.
20
22
 
21
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.
22
28
 
23
29
  markets:
24
30
  # One market per region you sell in. countries are ISO 3166 codes.
@@ -51,7 +57,8 @@ catalog:
51
57
  # source: sheets # a Google Sheet published as CSV: url is its CSV link
52
58
  # source: supabase # a Supabase table: table is its name (default products);
53
59
  # # C11R_SUPABASE_URL and C11R_SUPABASE_KEY in .commercelayer/credentials
54
- # 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
55
62
  # url: https://...
56
63
  # table: products
57
64
 
@@ -151,12 +158,16 @@ export function loadStore() {
151
158
  }
152
159
  const theme = checkTheme(doc.theme);
153
160
  checkStorefront(doc.storefront);
161
+ const language = storeLanguage(doc.language);
154
162
  const currencies = [...new Set(markets.map((m) => m.currency))];
155
- const products = checkProducts(sourced, currencies);
156
- 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 };
157
168
  }
158
169
  /** The product rules, shared by loadStore and catalog pull: errors name each product's source. */
159
- export function checkProducts(sourced, currencies) {
170
+ export function checkProducts(sourced, currencies, language = DEFAULT_LANGUAGE) {
160
171
  const skus = new Map();
161
172
  const products = [];
162
173
  for (const { product: p, source } of sourced) {
@@ -177,11 +188,16 @@ export function checkProducts(sourced, currencies) {
177
188
  if (p.image_url !== undefined && !isImageRef(String(p.image_url))) {
178
189
  fail('invalid_product', `Product ${p.sku} in ${source} has image_url "${p.image_url}".`, IMAGE_HINT);
179
190
  }
180
- if (p.category !== undefined) {
181
- const parts = typeof p.category === 'string' ? p.category.split('>').map((c) => c.trim()) : [''];
182
- if (parts.some((c) => !c))
183
- 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.');
184
- 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.');
185
201
  }
186
202
  for (const c of currencies) {
187
203
  const price = p.prices?.[c];
@@ -193,6 +209,60 @@ export function checkProducts(sourced, currencies) {
193
209
  }
194
210
  return products;
195
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
+ }
196
266
  /** An image reference that is a file in .commercelayer/img/ (img/eth.jpg), not a URL. */
197
267
  export const isLocalImage = (url) => !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//');
198
268
  /** The paths the store host serves images at, as the API accepts them. */
@@ -245,8 +315,21 @@ export function toApplied(store) {
245
315
  return {
246
316
  markets: Object.fromEntries(store.markets.map((m) => [m.code, m])),
247
317
  products: Object.fromEntries(store.products.map((p) => [p.sku, p])),
318
+ ...(store.language !== DEFAULT_LANGUAGE && { language: store.language }),
248
319
  };
249
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;
250
333
  /** JSON with sorted keys and no undefined values, so key order never reads as a change. */
251
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));
252
335
  /** The one definition of "pending changes": what apply would do. plan and status both use it. */
@@ -273,6 +356,9 @@ export function diff(state, desired) {
273
356
  if (!applied ? storefrontMode(desired) !== 'hosted' : state.storefront_mode !== undefined && (state.storefront_mode ?? 'hosted') !== storefrontMode(desired)) {
274
357
  changes.push({ action: 'update', type: 'store', id: 'storefront_mode' });
275
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' });
276
362
  const after = toApplied(desired);
277
363
  for (const kind of ['markets', 'products']) {
278
364
  const type = kind.slice(0, -1);
package/dist/ucp/shop.js CHANGED
@@ -53,6 +53,23 @@ 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 {
@@ -60,10 +77,13 @@ function variantView(v) {
60
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
  };
@@ -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,
@@ -224,7 +271,7 @@ 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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.3.1",
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": [