@commercelayer/agent-cli 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -32,10 +32,10 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
32
32
  | `orders show <id or number>` | One order. |
33
33
  | `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
34
34
  | `ucp search [<query>] [--country <CC>]` · `ucp product <id>` | Buy for the human, step by step (see [Buying for the human](#buying-for-the-human)): the store's products, prices in major units in `price.display`. |
35
- | `ucp checkout create --item <sku>[:<qty>] … [--code <discount>] [--country <CC>]` | Open a checkout; `messages` say what is still missing (email, shipping address). |
36
- | `ucp checkout update <id> [--email …] [--street …] [--item <sku>:<qty>] [--code …] [--shipping <option>]` | Give the buyer's details (flags, or YAML or JSON on stdin or `-f`), change items or codes; `ready_for_complete` once nothing is missing, with shipping in the total. |
35
+ | `ucp checkout create --item <variant id>[:<qty>] … [--code <discount>] [--country <CC>]` | Open a checkout; `messages` say what is still missing (email, shipping address). |
36
+ | `ucp checkout update <id> [--email …] [--street …] [--item <variant id>:<qty>] [--code …] [--shipping <option>]` | Give the buyer's details (flags, or YAML or JSON on stdin or `-f`), change items or codes; `ready_for_complete` once nothing is missing, with shipping in the total. |
37
37
  | `ucp checkout show <id>` · `cancel <id>` · `complete <id> [--decline]` | Read, cancel, or pay a checkout with the sandbox handler (after the human said yes): `order` has `label` and `permalink_url`. |
38
- | `ucp order <id>` · `ucp ask "<question>"` | Read an order the CLI placed; ask the store a question. Every `ucp` buying command takes `--store <host>` to buy from any UCP store. |
38
+ | `ucp order <id>` · `ucp ask "<question>"` | Read an order the CLI placed; ask the store a question. Every `ucp` buying command takes `--store <host>` to buy from any store that speaks UCP. |
39
39
  | `ucp check` | Walk every UCP capability the store advertises and report what passes. Places and pays one real test order on the way (the output says which). Exits 1 on a failed check. |
40
40
  | `status` | The sandbox, the sales channel client id, the published storefront, what is applied, the last import job (`apply`), the pending changes (exactly what `plan` lists; a store that does not load is pending too, with the error), orders, the UCP profile URL. |
41
41
  | `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
@@ -69,6 +69,9 @@ theme:
69
69
  logo: img/logo.svg # or an https:// URL; default: the name as a wordmark
70
70
  hero: img/hero.jpg # the home page's wide image; default: a band in the accent
71
71
  tagline: Small-batch coffee, roasted in Milan # at most 120 characters; default: "Ships to <countries>"
72
+ badge: Demo # a short label next to the logo, at most 20 characters
73
+ storefront:
74
+ mode: hosted # default; agent-only: only UCP and the pages it needs, no catalog pages
72
75
  policies:
73
76
  # Pages shopping agents show buyers before they pay. Without them the store
74
77
  # serves default sandbox pages at <preview_url>/policies/<name>.
@@ -134,9 +137,11 @@ The theme renders the home page (logo, hero with name and tagline, products by t
134
137
 
135
138
  One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`, `theme.logo`, `theme.hero`) is `.commercelayer/img/<file>`, uploaded by `apply`, served at `/img/<file>`, whatever is in front.
136
139
 
140
+ `storefront.mode: agent-only` makes a store for shopping agents only: the store host serves UCP and the pages UCP needs (checkout, order, policy pages, `/buy` permalinks, images), a one-line home page pointing to `/.well-known/ucp`, and no catalog or product pages (`/products/…` and `/categories/…` redirect to `/`, so links shared earlier land on the agent page), nor a custom storefront. Removing it, or `mode: hosted`, brings the website back on the next `apply`.
141
+
137
142
  A custom storefront replaces the theme: `storefront.path` in `store.yml` names its folder, `storefront init` writes a drop-in.js scaffold, `storefront publish` uploads it, `storefront check` checks it. Its pages link product images at `/img/`; its own files (fonts, icons, styles) go in `assets/`, not `img/`.
138
143
 
139
- Error codes: `invalid_theme`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_unsupported`, `invalid_link`, `link_exists`, `link_not_managed` (reserved), `links_unsupported`, `missing_images`, `asset_too_large`, `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
144
+ Error codes: `invalid_theme`, `invalid_storefront`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_unsupported`, `invalid_link`, `link_exists`, `link_not_managed` (reserved), `links_unsupported`, `missing_images`, `asset_too_large`, `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
140
145
  ## The store speaks UCP
141
146
  Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
142
147
  | What | Where |
@@ -153,7 +158,7 @@ Transport rules the store enforces: `UCP-Agent` (an HTTPS platform profile URL)
153
158
  The CLI is a UCP **platform**: its profile is served at `https://commercelayer.ai/ucp/agent-cli/profile.json` (`ucp profile --agent` prints it) and goes in `UCP-Agent` on every call. The store negotiates the capability intersection with it. `orders test` is the buyer; `ucp check` runs 30-odd checks across profile, transport rules, catalog, cart, discount, checkout, fulfillment, consent, payment, signatures, idempotency, order, identity linking, ask and permalink.
154
159
  ### Buying for the human
155
160
 
156
- An agent can buy from any UCP store on behalf of its human, one step per command, talking to the human in between: `ucp search` to show the products, `ucp checkout create --item <sku>:<qty>` for what they picked, `ucp checkout update <id>` with the email and shipping address they give (never invented), then, once they said yes to the total, `ucp checkout complete <id>`, and the order's `label` and `permalink_url` back to them. Money comes as `{ amount, currency, major, display }`: `amount` in minor units as UCP has it, `display` for the human. The store is `--store <hostname or origin>`, or this folder's store when there is one; checkout and order ids are remembered with their store (in `~/.local/state/c11r/ucp.json`), so later steps need no `--store`. Paying needs the store's sandbox handler (`ai.commercelayer.sandbox`, test payments only): a store with a real gateway answers `payment_handler_unsupported`, and the human pays at the checkout's `continue_url`. Responses are signature-checked like `orders test`: `signature` is `verified`, `invalid`, `unknown_key` or `unsigned`.
161
+ An agent can buy from any UCP store on behalf of its human, one step per command, talking to the human in between: `ucp search` to show the products, `ucp checkout create --item <variant id>:<qty>` for what they picked (the `variants[].id` search lists; the sku on Commerce Layer stores), `ucp checkout update <id>` with the email and shipping address they give (never invented), then, once they said yes to the total, `ucp checkout complete <id>`, and the order's `label` and `permalink_url` back to them. Money comes as `{ amount, currency, major, display }`: `amount` in minor units as UCP has it, `display` for the human. The store is `--store <hostname or origin>`, or this folder's store when there is one; checkout and order ids are remembered with their store (in `~/.local/state/c11r/ucp.json`), so later steps need no `--store`. Paying needs the store's sandbox handler (`ai.commercelayer.sandbox`, test payments only): any store without it (every store taking real payments) answers `payment_handler_unsupported`, and the human pays at the checkout's `continue_url`. Responses are signature-checked like `orders test`: `signature` is `verified`, `invalid`, `unknown_key` or `unsigned`.
157
162
 
158
163
  ## Requirements
159
164
 
@@ -7,7 +7,7 @@ import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
7
7
  import { bearer, error, json, readJson } from './http.js';
8
8
  function publicState(r) {
9
9
  const { token: _token, ucp: _ucp, pending: _pending, asset_files: _files, ...state } = r;
10
- return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
10
+ return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, storefront_mode: r.storefront_mode ?? 'hosted', assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
11
11
  }
12
12
  /** Like Commerce Layer refusing a sku in an import. Only the stub does this, so tests can exercise apply_failed. */
13
13
  const REFUSED = /^REFUSED-/;
@@ -166,7 +166,9 @@ export function handleManagement(req) {
166
166
  writeDb(db);
167
167
  if (record.apply?.status === 'in_progress')
168
168
  return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
169
- const { applied, hash, name, policies, theme, assets, allow_delete, full } = readJson(req);
169
+ const { applied, hash, name, policies, theme, storefront_mode, assets, allow_delete, full } = readJson(req);
170
+ if (storefront_mode != null && storefront_mode !== 'hosted' && storefront_mode !== 'agent-only')
171
+ return error(422, 'invalid_storefront', `storefront_mode is "${storefront_mode}".`, 'Use hosted or agent-only.');
170
172
  const before = record.applied?.products ?? {};
171
173
  const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
172
174
  if (removed.length && !allow_delete)
@@ -177,6 +179,8 @@ export function handleManagement(req) {
177
179
  record.policies = policies ?? {};
178
180
  if (theme !== undefined)
179
181
  record.theme = theme;
182
+ // Absent or null is hosted, like the API: removing storefront: from store.yml reverts it.
183
+ record.storefront_mode = (storefront_mode ?? 'hosted');
180
184
  // Images not in the list are no longer used: dropped now. No list (older CLIs): nothing is dropped.
181
185
  if (assets) {
182
186
  for (const p of Object.keys(record.assets ?? {})) {
@@ -96,7 +96,8 @@ function variant(ctx, p, market) {
96
96
  sku: p.sku,
97
97
  title: p.name,
98
98
  description: { plain: p.description ?? p.name },
99
- url: `${ctx.origin}/products/${encodeURIComponent(p.sku)}`,
99
+ // Agent-only stores have no product pages to link to.
100
+ url: ctx.record.storefront_mode === 'agent-only' ? undefined : `${ctx.origin}/products/${encodeURIComponent(p.sku)}`,
100
101
  price,
101
102
  // Inventory lives in Commerce Layer; the sandbox catalog has no stock, so everything is in stock.
102
103
  availability: { available: true, status: 'in_stock' },
@@ -4,7 +4,7 @@
4
4
  // at /products/<sku>. Prices come from what is applied; "Buy" opens the cart permalink.
5
5
  // A product without an image shows its tile, and an unset hero the generated one, both
6
6
  // drawn at /tiles/ (renderTile) as the store host does.
7
- const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
7
+ export const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
8
8
  /** Black or white, whichever reads better on the accent. */
9
9
  function onAccent(hex) {
10
10
  const h = hex.length === 4 ? hex.slice(1).replace(/./g, (c) => c + c) : hex.slice(1);
@@ -21,7 +21,8 @@ export function renderTheme(r, path, search) {
21
21
  const keep = market && market !== markets[0] ? `?market=${encodeURIComponent(market.code)}` : '';
22
22
  const price = (p) => (market && p.prices[market.currency] !== undefined ? `${market.currency} ${p.prices[market.currency].toFixed(2)}` : '');
23
23
  const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
24
- const logo = theme.logo ? `<img class="logo" src="/${esc(theme.logo.replace(/^\//, ''))}" alt="${esc(name)}">` : `<span class="wordmark">${esc(name)}</span>`;
24
+ const badge = theme.badge ? ` <span class="badge">${esc(theme.badge)}</span>` : '';
25
+ const logo = (theme.logo ? `<img class="logo" src="/${esc(theme.logo.replace(/^\//, ''))}" alt="${esc(name)}">` : `<span class="wordmark">${esc(name)}</span>`) + badge;
25
26
  const img = (url) => (/^https?:/.test(url) ? url : `/${url}`);
26
27
  const image = (p) => p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(p.name)}">` : `<span class="placeholder"><img src="/tiles/${esc(encodeURIComponent(p.sku))}.svg" alt="${esc(p.name)}" loading="lazy"></span>`;
27
28
  const card = (p) => `<article class="product"><a href="/products/${encodeURIComponent(p.sku)}${keep}">${image(p)}<h3>${esc(p.name)}</h3></a><p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></article>`;
@@ -54,7 +55,7 @@ export function renderTheme(r, path, search) {
54
55
  <title>${esc(title)}</title>
55
56
  <meta name="description" content="${esc(tagline)}">
56
57
  <!-- c11r stub: a stand-in for the hosted theme. -->
57
- <style>:root{--accent:${color};--on-accent:${onAccent(color)}}body{margin:0;font-family:system-ui,sans-serif;color:#16181d}header{display:flex;justify-content:space-between;padding:16px 24px}.logo{max-height:40px}.hero{padding:64px 24px;background-size:cover}.band{background:var(--accent);color:var(--on-accent)}.grid{display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.buy{background:var(--accent);color:var(--on-accent);padding:8px 14px}main{padding:24px}</style>
58
+ <style>:root{--accent:${color};--on-accent:${onAccent(color)}}body{margin:0;font-family:system-ui,sans-serif;color:#16181d}header{display:flex;justify-content:space-between;padding:16px 24px}.logo{max-height:40px}.badge{margin-left:8px;padding:2px 8px;border-radius:999px;background:var(--accent);color:var(--on-accent);font-size:12px;vertical-align:middle}.hero{padding:64px 24px;background-size:cover}.band{background:var(--accent);color:var(--on-accent)}.grid{display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.buy{background:var(--accent);color:var(--on-accent);padding:8px 14px}main{padding:24px}</style>
58
59
  </head>
59
60
  <body>
60
61
  <header><a href="/${keep}">${logo}</a><nav>${markets.map((x) => `<a href="?market=${encodeURIComponent(x.code)}">${esc(x.name)}</a>`).join(' ')}</nav></header>
@@ -8,7 +8,7 @@ import { contentDigest, signHeaders } from '../../ucp/signing.js';
8
8
  import { strip } from './db.js';
9
9
  import { json } from './http.js';
10
10
  import * as shop from './shopping.js';
11
- import { renderTheme, renderTile } from './theme.js';
11
+ import { esc, renderTheme, renderTile } from './theme.js';
12
12
  const SCOPES = ['dev.ucp.shopping.order:read', 'dev.ucp.shopping.order:manage'];
13
13
  const OUR_CAPABILITIES = [...SHOPPING_CAPABILITIES, 'dev.ucp.common.ask', ...Object.keys(EXTENSIONS), 'dev.ucp.common.identity_linking', 'dev.ucp.shopping.permalink'];
14
14
  // ---------- profile
@@ -308,8 +308,15 @@ function serveStorefront(r, path, search) {
308
308
  // /tiles/ is the store host's own, in front of a custom storefront too: unknown tiles are a 404.
309
309
  if (path.startsWith('/tiles/'))
310
310
  return renderTile(r, path) ?? html(404, 'Not found', `<p>No ${path} on this store.</p>`);
311
+ // Agent-only: no pages for humans beyond what UCP needs; the images stay, UCP's media point at them.
312
+ const agentOnly = r.storefront_mode === 'agent-only';
313
+ // Links shared while the store was hosted land on the agent page.
314
+ if (agentOnly && /^\/(products|categories)\/./.test(path))
315
+ return { status: 302, headers: { location: '/' }, body: '' };
316
+ if (agentOnly && path === '/')
317
+ return html(200, r.name ?? r.org, `<p>${esc(r.name ?? r.org)} sells to shopping agents over UCP: <a href="/.well-known/ucp">/.well-known/ucp</a>.</p>`);
311
318
  // A custom storefront (an uploaded index.html) serves every path; otherwise the theme renders its routes.
312
- if (files['index.html'] === undefined) {
319
+ if (!agentOnly && files['index.html'] === undefined) {
313
320
  const page = renderTheme(r, path, search);
314
321
  if (page)
315
322
  return page;
@@ -320,6 +327,8 @@ function serveStorefront(r, path, search) {
320
327
  const ext = path.split('.').pop() ?? '';
321
328
  return { status: 200, headers: { 'content-type': MIME[ext] ?? 'application/octet-stream' }, body: Buffer.from(asset, 'base64').toString('binary') };
322
329
  }
330
+ if (agentOnly)
331
+ return html(404, 'Not found', `<p>This store has no pages for ${esc(path)}: it sells over UCP.</p>`);
323
332
  const candidates = [path.replace(/^\//, ''), `${path.replace(/^\/|\/$/g, '')}/index.html`.replace(/^\//, ''), ...(path === '/' ? ['index.html'] : [])];
324
333
  for (const c of candidates) {
325
334
  if (files[c] !== undefined) {
package/dist/commands.js CHANGED
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { createApi } from './api/client.js';
7
7
  import { ignoreCredentials, paths, readCredentials, requireCredentials, writeAgentNotes, writeCredentials } from './files.js';
8
8
  import { CliError, fail, progress } from './output.js';
9
- import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from './store.js';
9
+ import { diff, hashStore, loadStore, storefrontMode, storefrontPath, template, toApplied } from './store.js';
10
10
  import { storefrontFiles } from './storefront.js';
11
11
  import { imageNotes, planAssets, uploadAssets } from './assets.js';
12
12
  import { pull } from './catalog.js';
@@ -24,7 +24,7 @@ export const VERSION = createRequire(import.meta.url)('../package.json').version
24
24
  export const HELP = {
25
25
  init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials, and a section in AGENTS.md and CLAUDE.md (created, or appended once) that points later agents in this repo to the skills: commit them. Reuses the store if store.yml already exists.',
26
26
  plan: 'Show what apply would create, update or delete: markets, products, images, and the store\'s name, policies and theme; missing_images lists references with no file in .commercelayer/img/ (apply refuses them), unused_images files nothing references, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
27
- apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products, the theme (its settings, and the images in .commercelayer/img/ that the theme and products reference, uploaded when new or changed), name and policies. The store\'s website is live at preview_url after apply. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
27
+ apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products, the storefront mode (storefront.mode: hosted, the default, or agent-only: UCP and the pages it needs, no catalog pages), the theme (its settings, badge included, and the images in .commercelayer/img/ that the theme and products reference, uploaded when new or changed), name and policies. The store\'s website is live at preview_url after apply. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
28
28
  'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category and one price column per market currency, in lowercase (eur, usd). Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
29
29
  'storefront init': 'Custom storefronts only: the hosted theme needs none of this (set theme: in store.yml and apply). Write a static storefront on drop-in.js, no build step: storefront/index.html (the products by category, a minicart that opens on add to cart, the hosted cart and checkout, and a product page at #/p/<sku>), styles.css (theme tokens first) and app.js. Product images are the store\'s, at /img/ (.commercelayer/img/, uploaded by apply); the site\'s own files go in storefront/assets/. Refuses to overwrite unless --force is given.',
30
30
  'storefront check': 'Custom storefronts only. Check the published storefront at the preview URL: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides, title, lang, viewport, description, and whether the folder changed since publish. Prints { status, summary, checks: [{ id, kind, status: pass|warn|fail, message, fix, items }] }; exits 1 when a check fails.',
@@ -47,14 +47,14 @@ export const HELP = {
47
47
  'orders show': 'Show one order: c11r orders show <id or number>.',
48
48
  'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
49
49
  'ucp check': 'Walk every UCP capability the store advertises and report what passes. Places and pays one real test order on the way. Exits 1 when a check fails.',
50
- 'ucp search': "Buy for the human, step 1: search a store's catalog over UCP. c11r ucp search [<query>] [--country CC] [--store <host>]; no query lists the whole catalog (up to 50). Returns { store, query, count, total_count, has_more, products: [{ id, title, description, url, image_url, variants: [{ id, sku, title, price: { amount, currency, major, display }, available, availability, ships }] }], signature }. Show the human the titles and price.display (amount is in minor units: never show it), let them pick, then c11r ucp checkout create --item <sku>. --store is any UCP store (hostname or https origin); the default is this folder's store.",
51
- 'ucp product': 'Look at one product over UCP, with every variant and the full description: c11r ucp product <product id or sku> [--country CC] [--store <host>]. Returns { store, product: { id, title, description, url, image_url, variants: [...] }, signature }, variants as in ucp search.',
52
- 'ucp checkout create': "Buy for the human, step 2: open a checkout with the items the human picked. c11r ucp checkout create --item <sku>[:<qty>] [--item ...] [--code <discount>] [--country CC] [--store <host>]. Returns the checkout: { id, status, store, currency, line_items: [{ id, sku, title, quantity, unit_price, total }], buyer, ship_to, shipping, discounts, codes, totals: [{ type, label, amount, major, display }], total, messages, continue_url, expires_at, links, signature }. status is incomplete until the buyer's email and shipping address are given: messages say what is missing. Ask the human for them, never invent them, then c11r ucp checkout update <id>. The id is remembered with its store: later steps need no --store.",
53
- 'ucp checkout update': "Buy for the human, step 3: give the buyer's details, change items or codes. c11r ucp checkout update <id> [--email --first-name --last-name --phone] [--street --city --postal-code --region --country] [--item <sku>:<qty> (0 removes)] [--code <discount> (adds)] [--shipping <option id>]; or the same as YAML or JSON on stdin or -f <file>: { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: [\"SKU:2\"], codes: [...] (replaces), shipping }. Only what is given changes. Returns the checkout as in create: status ready_for_complete once nothing is missing, with shipping and the total including it; or incomplete with messages (address_undeliverable says where the store ships). Show the human the line items, shipping and total.display, ask for an explicit yes, then c11r ucp checkout complete <id>.",
50
+ 'ucp search': "Buy for the human, step 1: search a store's catalog over UCP. c11r ucp search [<query>] [--country CC] [--store <host>]; no query lists the whole catalog (up to 50). Returns { store, query, count, total_count, has_more, products: [{ id, title, description, url, image_url, variants: [{ id, sku, title, price: { amount, currency, major, display }, available, availability, ships }] }], signature }. Show the human the titles and price.display (amount is in minor units: never show it), let them pick, then c11r ucp checkout create --item <variant id>. --store is any store that speaks UCP (hostname or https origin); the default is this folder's store.",
51
+ 'ucp product': 'Look at one product over UCP, with every variant and the full description: c11r ucp product <product id> [--country CC] [--store <host>]. Returns { store, product: { id, title, description, url, image_url, variants: [...] }, signature }, variants as in ucp search.',
52
+ 'ucp checkout create': "Buy for the human, step 2: open a checkout with the items the human picked. c11r ucp checkout create --item <variant id>[:<qty>] [--item ...] [--code <discount>] [--country CC] [--store <host>]. Returns the checkout: { id, status, store, currency, line_items: [{ id, item_id, title, quantity, unit_price, total }], buyer, ship_to, shipping, discounts, codes, totals: [{ type, label, amount, major, display }], total, messages, continue_url, expires_at, links, signature }. status is incomplete until the buyer's email and shipping address are given: messages say what is missing. Ask the human for them, never invent them, then c11r ucp checkout update <id>. The id is remembered with its store: later steps need no --store.",
53
+ 'ucp checkout update': "Buy for the human, step 3: give the buyer's details, change items or codes. c11r ucp checkout update <id> [--email --first-name --last-name --phone] [--street --city --postal-code --region --country] [--item <variant id>:<qty> (0 removes)] [--code <discount> (adds)] [--shipping <option id>]; or the same as YAML or JSON on stdin or -f <file>: { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: [\"ID:2\"], codes: [...] (replaces), shipping }. Only what is given changes. Returns the checkout as in create: status ready_for_complete once nothing is missing, with shipping and the total including it; or incomplete with messages (address_undeliverable says where the store ships). Show the human the line items, shipping and total.display, ask for an explicit yes, then c11r ucp checkout complete <id>.",
54
54
  'ucp checkout show': 'Show a checkout as the store has it now: c11r ucp checkout show <id> [--store <host>]. Returns the checkout as in ucp checkout create.',
55
55
  'ucp checkout cancel': 'Cancel a checkout the human no longer wants: c11r ucp checkout cancel <id> [--store <host>]. Returns the checkout, status canceled.',
56
- 'ucp checkout complete': "Buy for the human, step 4, only after the human said yes to the total: pay the checkout. c11r ucp checkout complete <id> [--decline] [--store <host>]. Pays with the sandbox handler (ai.commercelayer.sandbox: test payments, no money moves); --decline uses the declined test card. Returns the checkout with status completed and order: { id, label, permalink_url }, plus payment_handler. Tell the human the order label and give them permalink_url. A store with no sandbox handler (a live store with a real payment gateway) answers payment_handler_unsupported: give the human continue_url to pay there. A declined payment leaves status ready_for_complete with a payment_failed message.",
57
- 'ucp order': 'Read an order this CLI placed: c11r ucp order <id> [--store <host>]. Returns { id, label, permalink_url, store, checkout_id, currency, line_items: [{ sku, title, quantity, status, total }], fulfillment: [{ method, destination, description }], events, totals, signature }.',
56
+ 'ucp checkout complete': "Buy for the human, step 4, only after the human said yes to the total: pay the checkout. c11r ucp checkout complete <id> [--decline] [--store <host>]. Pays with the ai.commercelayer.sandbox test handler (no money moves): only stores that advertise it can be paid; --decline uses the declined test card. Returns the checkout with status completed and order: { id, label, permalink_url }, plus payment_handler. Tell the human the order label and give them permalink_url. A store without that handler (any store taking real payments) answers payment_handler_unsupported: give the human continue_url to pay there. A declined payment leaves status ready_for_complete with a payment_failed message.",
57
+ 'ucp order': 'Read an order this CLI placed: c11r ucp order <id> [--store <host>]. Returns { id, label, permalink_url, store, checkout_id, currency, line_items: [{ item_id, title, quantity, status, total }], fulfillment: [{ method, destination, description }], events, totals, signature }.',
58
58
  'ucp ask': 'Ask a store a question in plain language over UCP (shipping, returns, what it sells): c11r ucp ask "<question>" [--store <host>]. Returns { store, question, answer, links, messages, signature }. The answer is the store\'s: quote it, do not embellish it. Ask only answers; buy with ucp checkout.',
59
59
  status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists).',
60
60
  claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
@@ -184,6 +184,7 @@ export async function apply(flags) {
184
184
  name: desired.name,
185
185
  policies: desired.policies,
186
186
  theme: desired.theme ?? {},
187
+ storefront_mode: storefrontMode(desired),
187
188
  ...(assets.supported && { assets: assets.keep }),
188
189
  ...(flags.has('--allow-delete') && { allow_delete: true }),
189
190
  ...(flags.has('--full') && { full: true }),
@@ -373,7 +374,7 @@ async function shopOrigin(values, id) {
373
374
  if (known)
374
375
  return known;
375
376
  if (!readCredentials())
376
- fail('no_store', 'There is no store in this folder, and no --store was given.', 'Pass the store to buy from: --store <hostname or https origin>.');
377
+ fail('no_store', 'There is no store in this folder, and no --store was given.', 'Pass the store to buy from, any store that speaks UCP: --store <hostname or https origin>.');
377
378
  return storeOrigin();
378
379
  }
379
380
  const needId = (id, command) => {
@@ -385,7 +386,7 @@ const checkoutNext = (ck) => {
385
386
  if (ck.status === 'completed')
386
387
  return `Paid. Tell the human: order ${ck.order?.label ?? ck.order?.id}, and give them ${ck.order?.permalink_url}.`;
387
388
  if (ck.status === 'canceled')
388
- return 'Canceled. Open a new one with c11r ucp checkout create --item <sku> if the human wants to buy.';
389
+ return 'Canceled. Open a new one with c11r ucp checkout create --item <variant id> if the human wants to buy.';
389
390
  if (ck.status === 'ready_for_complete') {
390
391
  const declined = (ck.messages ?? []).some((m) => m.code === 'payment_failed');
391
392
  if (declined)
@@ -397,17 +398,17 @@ const checkoutNext = (ck) => {
397
398
  };
398
399
  export async function ucpSearch(query, values) {
399
400
  const r = await shop.search(await shopOrigin(values), query, values.country);
400
- return { ...r, next: r.count ? 'Show the human the products (title, price.display), let them pick, then c11r ucp checkout create --item <sku>[:<qty>].' : 'Nothing matched: try another query, or none for the whole catalog.' };
401
+ return { ...r, next: r.count ? 'Show the human the products (title, price.display), let them pick, then c11r ucp checkout create --item <variant id>[:<qty>] (variants[].id).' : 'Nothing matched: try another query, or none for the whole catalog.' };
401
402
  }
402
403
  export async function ucpProduct(id, values) {
403
404
  if (!id)
404
- fail('missing_argument', 'ucp product needs a product id or sku.', 'Use an id or sku from c11r ucp search.');
405
+ fail('missing_argument', 'ucp product needs a product id.', 'Use a product id from c11r ucp search.');
405
406
  return shop.product(await shopOrigin(values), id, values.country);
406
407
  }
407
408
  export async function ucpCheckoutCreate(values, lists) {
408
409
  const items = (lists.item ?? []).map(shop.parseItem);
409
410
  if (!items.length)
410
- fail('missing_argument', 'ucp checkout create needs at least one --item.', 'Example: c11r ucp checkout create --item ETH-250:2. The skus are in c11r ucp search.');
411
+ fail('missing_argument', 'ucp checkout create needs at least one --item.', 'Example: c11r ucp checkout create --item ETH-250:2, with variant ids from c11r ucp search (variants[].id).');
411
412
  if (items.some((l) => l.quantity < 1))
412
413
  fail('invalid_argument', 'An --item quantity must be 1 or more.', 'Example: --item ETH-250:2.');
413
414
  const ck = await shop.createCheckout(await shopOrigin(values), items, lists.code ?? [], values.country);
@@ -429,7 +430,7 @@ export async function ucpCheckoutUpdate(id, values, lists) {
429
430
  if (!flagged || values.file) {
430
431
  // No flags: the changes as YAML or JSON on stdin or -f.
431
432
  const raw = await readText(values, 'checkout update', `Give the changes as flags (--email ..., --street ...) or as YAML or JSON on stdin or -f <file>: { buyer: {...}, address: {...}, items, codes, shipping }.`);
432
- const bad = (msg) => fail('invalid_argument', `checkout update: ${msg}`, 'The input is { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: ["SKU:2"], codes: [...], shipping }.');
433
+ const bad = (msg) => fail('invalid_argument', `checkout update: ${msg}`, 'The input is { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: ["ID:2"], codes: [...], shipping }.');
433
434
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
434
435
  bad('the input is not a map.');
435
436
  const r = raw;
@@ -453,7 +454,7 @@ export async function ucpCheckoutUpdate(id, values, lists) {
453
454
  if (r.items !== undefined) {
454
455
  if (!Array.isArray(r.items))
455
456
  bad('items is not a list.');
456
- changes.items = [...r.items.map((x) => (typeof x === 'string' ? shop.parseItem(x) : x && typeof x === 'object' && typeof x.sku === 'string' && Number.isInteger(x.quantity) ? { sku: x.sku, quantity: x.quantity } : bad('items holds "SKU:QTY" or { sku, quantity }.'))), ...(changes.items ?? [])];
457
+ changes.items = [...r.items.map((x) => (typeof x === 'string' ? shop.parseItem(x) : x && typeof x === 'object' && typeof x.id === 'string' && Number.isInteger(x.quantity) ? { id: x.id, quantity: x.quantity } : bad('items holds "ID:QTY" or { id, quantity }, with variant ids.'))), ...(changes.items ?? [])];
457
458
  }
458
459
  if (r.codes !== undefined) {
459
460
  if (!Array.isArray(r.codes) || r.codes.some((c) => typeof c !== 'string'))
package/dist/store.js CHANGED
@@ -10,7 +10,9 @@ import { paths } from './files.js';
10
10
  import { productFiles, readProductFile } from './products.js';
11
11
  /** The keys a product may have. Stock is not one: inventory lives in Commerce Layer. */
12
12
  export const PRODUCT_KEYS = ['sku', 'name', 'description', 'image_url', 'category', 'prices'];
13
- export const THEME_KEYS = ['color', 'logo', 'hero', 'tagline'];
13
+ export const THEME_KEYS = ['color', 'logo', 'hero', 'tagline', 'badge'];
14
+ export const STOREFRONT_MODES = ['hosted', 'agent-only'];
15
+ export const storefrontMode = (s) => s.storefront?.mode ?? 'hosted';
14
16
  export const template = (name) => `# Commerce Layer store. This file is the store: edit it, then run
15
17
  # c11r plan to see what will change
16
18
  # c11r apply to make the change
@@ -60,8 +62,12 @@ theme:
60
62
  # logo: img/logo.svg # or an https:// URL; without it the name is the logo
61
63
  # hero: img/hero.jpg # the home page's wide image; without it a band in the accent
62
64
  # tagline: Small-batch coffee, roasted in Milan # at most 120 characters
65
+ # badge: Demo # a short label next to the logo, at most 20 characters
63
66
 
64
67
  storefront:
68
+ # mode: hosted # hosted (default): the website for humans, and UCP for agents.
69
+ # # agent-only: the store host serves only UCP and the pages it
70
+ # # needs (checkout, order, policy pages), no catalog pages.
65
71
  # Only for a custom storefront (your own web app in this repo, published with
66
72
  # c11r storefront publish): it replaces the theme. Its folder, from the repo root.
67
73
  # path: storefront
@@ -144,6 +150,7 @@ export function loadStore() {
144
150
  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
151
  }
146
152
  const theme = checkTheme(doc.theme);
153
+ checkStorefront(doc.storefront);
147
154
  const currencies = [...new Set(markets.map((m) => m.currency))];
148
155
  const products = checkProducts(sourced, currencies);
149
156
  return { name: doc.name, markets, products, storefront: doc.storefront, policies, theme };
@@ -193,12 +200,28 @@ export const IMAGE_PATH = /^img\/[A-Za-z0-9._-][A-Za-z0-9._/-]*\.(png|jpg|jpeg|w
193
200
  /** image_url, logo, hero: an https URL, or img/<file> for a file in .commercelayer/img/. */
194
201
  export const isImageRef = (url) => isLocalImage(url) ? IMAGE_PATH.test(url) && !url.includes('..') && !url.includes('//') : /^https?:\/\/\S+$/.test(url);
195
202
  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).';
203
+ /** The storefront block of store.yml: path, and mode hosted or agent-only. */
204
+ function checkStorefront(raw) {
205
+ if (raw === undefined || raw === null)
206
+ return;
207
+ 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.';
208
+ if (typeof raw !== 'object' || Array.isArray(raw))
209
+ fail('invalid_storefront', 'storefront in store.yml is not a mapping.', hint);
210
+ const s = raw;
211
+ const unknown = Object.keys(s).filter((k) => !['mode', 'path'].includes(k));
212
+ if (unknown.length)
213
+ fail('invalid_storefront', `storefront in store.yml has ${unknown.join(', ')}, which it does not take.`, hint);
214
+ if (s.mode !== undefined && s.mode !== null && !STOREFRONT_MODES.includes(s.mode))
215
+ 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).');
216
+ if (s.path !== undefined && s.path !== null && typeof s.path !== 'string')
217
+ fail('invalid_storefront', 'storefront.path is not a folder name.', hint);
218
+ }
196
219
  /** The theme block of store.yml, validated: the theme would ignore bad values, so say so here. */
197
220
  function checkTheme(raw) {
198
221
  if (raw === undefined || raw === null)
199
222
  return undefined;
200
223
  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.');
224
+ 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
225
  const t = raw;
203
226
  const unknown = Object.keys(t).filter((k) => !THEME_KEYS.includes(k));
204
227
  if (unknown.length)
@@ -214,6 +237,8 @@ function checkTheme(raw) {
214
237
  fail('invalid_theme', `theme.${k} is "${theme[k]}".`, IMAGE_HINT);
215
238
  if (theme.tagline !== undefined && (theme.tagline.length > 120 || /[\r\n<>]/.test(theme.tagline)))
216
239
  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.');
240
+ if (theme.badge !== undefined && (theme.badge.length > 20 || !theme.badge.trim() || /[\r\n<>]/.test(theme.badge)))
241
+ 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
242
  return theme;
218
243
  }
219
244
  export function toApplied(store) {
@@ -244,6 +269,10 @@ export function diff(state, desired) {
244
269
  else if (hasTheme(before) && canonical(before) !== canonical(desired.theme))
245
270
  changes.push({ action: 'update', type: 'store', id: 'theme' });
246
271
  }
272
+ // The mode: a change from what the API holds (hosted before the first apply). An API that does not know it is skipped.
273
+ if (!applied ? storefrontMode(desired) !== 'hosted' : state.storefront_mode !== undefined && (state.storefront_mode ?? 'hosted') !== storefrontMode(desired)) {
274
+ changes.push({ action: 'update', type: 'store', id: 'storefront_mode' });
275
+ }
247
276
  const after = toApplied(desired);
248
277
  for (const kind of ['markets', 'products']) {
249
278
  const type = kind.slice(0, -1);
package/dist/ucp/shop.js CHANGED
@@ -57,7 +57,7 @@ function variantView(v) {
57
57
  const shipping = v.fulfillment?.methods?.map((m) => plain(m.description)).filter(Boolean).join('; ');
58
58
  return {
59
59
  id: v.id,
60
- sku: v.sku ?? v.id,
60
+ sku: v.sku,
61
61
  title: v.title,
62
62
  price: v.price ? money(v.price.amount, v.price.currency) : undefined,
63
63
  available: v.availability?.available ?? undefined,
@@ -102,7 +102,7 @@ export function checkoutView(ck, origin, call) {
102
102
  currency: cur,
103
103
  line_items: (ck.line_items ?? []).map((l) => ({
104
104
  id: l.id,
105
- sku: l.item?.id,
105
+ item_id: l.item?.id,
106
106
  title: l.item?.title,
107
107
  quantity: l.quantity,
108
108
  unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur),
@@ -134,7 +134,7 @@ export function orderView(o, origin, call) {
134
134
  checkout_id: o.checkout_id,
135
135
  currency: cur,
136
136
  line_items: (o.line_items ?? []).map((l) => ({
137
- sku: l.item?.id,
137
+ item_id: l.item?.id,
138
138
  title: l.item?.title,
139
139
  quantity: l.quantity?.total ?? l.quantity,
140
140
  status: l.status,
@@ -160,15 +160,15 @@ async function step(origin, run) {
160
160
  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
161
  if (code === 'identity_required' || e.call.status === 401)
162
162
  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.`);
163
+ 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
164
  }
165
165
  }
166
- /** --item SKU or SKU:QTY. */
166
+ /** --item ID or ID:QTY, the variant id. */
167
167
  export function parseItem(raw) {
168
168
  const m = String(raw).match(/^(.+?)(?::(\d+))?$/);
169
169
  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]) };
170
+ 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.');
171
+ return { id: m[1].trim(), quantity: m[2] === undefined ? 1 : Number(m[2]) };
172
172
  }
173
173
  export async function open(origin) {
174
174
  const store = await discover(origin);
@@ -205,7 +205,7 @@ export async function product(origin, id, country) {
205
205
  }
206
206
  export async function createCheckout(origin, items, codes, country) {
207
207
  const store = await open(origin);
208
- const body = { line_items: items.map((l) => ({ item: { id: l.sku }, quantity: l.quantity })) };
208
+ const body = { line_items: items.map((l) => ({ item: { id: l.id }, quantity: l.quantity })) };
209
209
  if (country)
210
210
  body.context = { address_country: country.toUpperCase() };
211
211
  if (codes.length)
@@ -229,7 +229,7 @@ export async function updateCheckout(origin, id, ch) {
229
229
  if (ch.buyer)
230
230
  body.buyer = clean({ ...(current.buyer ?? {}), ...ch.buyer });
231
231
  for (const l of ch.items ?? []) {
232
- const i = body.line_items.findIndex((x) => x.item.id === l.sku);
232
+ const i = body.line_items.findIndex((x) => x.item.id === l.id);
233
233
  if (l.quantity === 0) {
234
234
  if (i >= 0)
235
235
  body.line_items.splice(i, 1);
@@ -237,7 +237,7 @@ export async function updateCheckout(origin, id, ch) {
237
237
  else if (i >= 0)
238
238
  body.line_items[i].quantity = l.quantity;
239
239
  else
240
- body.line_items.push({ item: { id: l.sku }, quantity: l.quantity });
240
+ body.line_items.push({ item: { id: l.id }, quantity: l.quantity });
241
241
  }
242
242
  if (!body.line_items.length)
243
243
  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 +268,8 @@ export async function updateCheckout(origin, id, ch) {
268
268
  again.fulfillment.methods[0].groups[0].selected_option_id = group.options[0].id;
269
269
  call = await step(origin, () => shopping.updateCheckout(store, id, again));
270
270
  }
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.
271
+ // A store can answer ready_for_complete before the shipping is in the totals (seen once, since
272
+ // fixed on the store host): read it again rather than show the human a total without shipping.
273
273
  const ck = call.body;
274
274
  if (ck.status === 'ready_for_complete' && ck.fulfillment?.methods?.[0]?.groups?.[0]?.selected_option_id && !(ck.totals ?? []).some((t) => t.type === 'fulfillment')) {
275
275
  call = await step(origin, () => shopping.getCheckout(store, id));
@@ -285,7 +285,7 @@ export async function completeCheckout(origin, id, decline) {
285
285
  const store = await open(origin);
286
286
  if (!store.profile.ucp?.payment_handlers?.[SANDBOX_HANDLER]) {
287
287
  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}.`);
288
+ 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
289
  }
290
290
  const call = await step(origin, () => shopping.completeCheckout(store, id, { payment: { instruments: [sandboxInstrument(decline)] }, signals: { 'dev.ucp.user_agent': 'c11r' } }));
291
291
  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.3.1",
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": [