@commercelayer/agent-cli 0.2.5 → 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
@@ -31,6 +31,11 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
31
31
  | `orders list` | The orders placed in the store, newest first, as UCP order entities. |
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
+ | `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 <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
+ | `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 store that speaks UCP. |
34
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. |
35
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. |
36
41
  | `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
@@ -64,6 +69,9 @@ theme:
64
69
  logo: img/logo.svg # or an https:// URL; default: the name as a wordmark
65
70
  hero: img/hero.jpg # the home page's wide image; default: a band in the accent
66
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
67
75
  policies:
68
76
  # Pages shopping agents show buyers before they pay. Without them the store
69
77
  # serves default sandbox pages at <preview_url>/policies/<name>.
@@ -129,9 +137,11 @@ The theme renders the home page (logo, hero with name and tagline, products by t
129
137
 
130
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.
131
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
+
132
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/`.
133
143
 
134
- 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`.
135
145
  ## The store speaks UCP
136
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:
137
147
  | What | Where |
@@ -146,6 +156,10 @@ Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (liv
146
156
  | Pages | `/policies/{privacy,terms,returns}` (defaults when `store.yml` has no `policies`), `/carts/{id}`, `/checkout-sessions/{id}` (`continue_url`), `/orders/{id}` (`permalink_url`), and the published storefront at `/`. |
147
157
  Transport rules the store enforces: `UCP-Agent` (an HTTPS platform profile URL) and `Request-Id` on every request; `Idempotency-Key` on every state change, replayed when the body matches and `409` when it does not; `Content-Digest` verified when present. Every service response carries `Content-Digest`, `Signature-Input` and `Signature` (ES256 over `@status`, `content-digest`, `content-type`, key `kid` from the profile). Orders are private: readable by the platform that placed them or with a buyer token carrying an order scope; anything else gets `401` with `WWW-Authenticate: Bearer`. Catalog ids are the skus (`product.id` is `prod_<sku>`, `variant.id` is the sku); amounts are integers in minor units.
148
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.
159
+ ### Buying for the human
160
+
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`.
162
+
149
163
  ## Requirements
150
164
 
151
165
  Node 20 or later. One runtime dependency (`yaml`).
@@ -66,7 +66,8 @@ export function createApi(auth) {
66
66
  deleteLink: async (id) => {
67
67
  await found('links', id, request('DELETE', one('links', id)));
68
68
  },
69
- createSandbox: (name) => request('POST', '/sandboxes', { name }),
69
+ // C11R_TEST=1 (our own test runs) asks the API to tag the organization as a test.
70
+ createSandbox: (name) => request('POST', '/sandboxes', process.env.C11R_TEST === '1' ? { name, test: true } : { name }),
70
71
  getStore: () => request('GET', org()),
71
72
  applyStore: (body) => request('PUT', `${org()}/resources`, body),
72
73
  uploadAssets: (files) => request('PUT', `${org()}/assets`, { files }),
@@ -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-/;
@@ -111,7 +111,8 @@ export function handleManagement(req) {
111
111
  if (req.method === 'POST' && path === '/sandboxes') {
112
112
  // Like Commerce Layer: the organization is named `name`, and its slug is derived
113
113
  // from it, suffixed -2, -3, ... when taken. The slug is the org and the store host.
114
- const name = String(readJson(req)?.name ?? 'store');
114
+ const body = readJson(req);
115
+ const name = String(body?.name ?? 'store');
115
116
  const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'store';
116
117
  let slug = base;
117
118
  for (let n = 2; db[slug]; n++)
@@ -124,6 +125,7 @@ export function handleManagement(req) {
124
125
  org: slug,
125
126
  token: `sbx_${crypto.randomBytes(16).toString('hex')}`,
126
127
  sandbox: true,
128
+ ...(body?.test === true ? { test: true } : {}),
127
129
  claimed: false,
128
130
  claim_url: `https://commercelayer.ai/claim/${crypto.randomBytes(6).toString('hex')}`,
129
131
  expires_at: expires.toISOString(),
@@ -164,7 +166,9 @@ export function handleManagement(req) {
164
166
  writeDb(db);
165
167
  if (record.apply?.status === 'in_progress')
166
168
  return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
167
- 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.');
168
172
  const before = record.applied?.products ?? {};
169
173
  const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
170
174
  if (removed.length && !allow_delete)
@@ -175,6 +179,8 @@ export function handleManagement(req) {
175
179
  record.policies = policies ?? {};
176
180
  if (theme !== undefined)
177
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');
178
184
  // Images not in the list are no longer used: dropped now. No list (older CLIs): nothing is dropped.
179
185
  if (assets) {
180
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/bin.js CHANGED
@@ -4,10 +4,16 @@
4
4
  // Output is always JSON; exit code 1 on an error or a failed check.
5
5
  import * as cmd from './commands.js';
6
6
  import { CliError, print } from './output.js';
7
- const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file']);
7
+ const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file', '--store', '--item', '--email', '--first-name', '--last-name', '--phone', '--street', '--city', '--postal-code', '--region', '--shipping']);
8
8
  const argv = process.argv.slice(2);
9
9
  const flags = new Set();
10
10
  const values = {};
11
+ /** Every value of an option given more than once (--item, --code), in order; values keeps the last. */
12
+ const lists = {};
13
+ const set = (k, v) => {
14
+ values[k] = v;
15
+ (lists[k] ??= []).push(v);
16
+ };
11
17
  const args = [];
12
18
  for (let i = 0; i < argv.length; i++) {
13
19
  const a = argv[i];
@@ -21,9 +27,9 @@ for (let i = 0; i < argv.length; i++) {
21
27
  }
22
28
  const eq = a.indexOf('=');
23
29
  if (eq > 0)
24
- values[a.slice(2, eq)] = a.slice(eq + 1);
30
+ set(a.slice(2, eq), a.slice(eq + 1));
25
31
  else if (VALUE_OPTIONS.has(a) && i + 1 < argv.length && !argv[i + 1].startsWith('--'))
26
- values[a.slice(2)] = argv[++i];
32
+ set(a.slice(2), argv[++i]);
27
33
  else
28
34
  flags.add(a);
29
35
  }
@@ -96,7 +102,29 @@ async function run() {
96
102
  return cmd.ucpProfile(flags);
97
103
  if (sub === 'check')
98
104
  return cmd.ucpCheck();
99
- throw new CliError('unknown_command', `There is no command "ucp ${sub ?? ''}".`, 'Use `c11r ucp profile` or `c11r ucp check`.');
105
+ if (sub === 'search')
106
+ return cmd.ucpSearch(args.slice(2).join(' '), values);
107
+ if (sub === 'product')
108
+ return cmd.ucpProduct(args[2], values);
109
+ if (sub === 'order')
110
+ return cmd.ucpOrder(args[2], values);
111
+ if (sub === 'ask')
112
+ return cmd.ucpAsk(args.slice(2).join(' '), values);
113
+ if (sub === 'checkout') {
114
+ const [, , action, id] = args;
115
+ if (action === 'create')
116
+ return cmd.ucpCheckoutCreate(values, lists);
117
+ if (action === 'update')
118
+ return cmd.ucpCheckoutUpdate(id, values, lists);
119
+ if (action === 'show')
120
+ return cmd.ucpCheckoutShow(id, values);
121
+ if (action === 'cancel')
122
+ return cmd.ucpCheckoutCancel(id, values);
123
+ if (action === 'complete')
124
+ return cmd.ucpCheckoutComplete(id, values, flags);
125
+ throw new CliError('unknown_command', `There is no command "ucp checkout ${action ?? ''}".`, 'Use `c11r ucp checkout create`, `update <id>`, `show <id>`, `cancel <id>` or `complete <id>`.');
126
+ }
127
+ throw new CliError('unknown_command', `There is no command "ucp ${sub ?? ''}".`, 'Use `c11r ucp search`, `product`, `checkout`, `order`, `ask`, `profile` or `check`.');
100
128
  default:
101
129
  throw new CliError('unknown_command', `There is no command "${name}".`, `Run \`c11r help\`. Commands: ${Object.keys(cmd.HELP).join(', ')}.`);
102
130
  }
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';
@@ -17,13 +17,14 @@ import { storefrontCheck } from './check.js';
17
17
  import { buy } from './ucp/buy.js';
18
18
  import { check } from './ucp/check.js';
19
19
  import { discover } from './ucp/client.js';
20
+ import * as shop from './ucp/shop.js';
20
21
  import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
21
22
  /** The package version, read from package.json so a release bumps it in one place. */
22
23
  export const VERSION = createRequire(import.meta.url)('../package.json').version;
23
24
  export const HELP = {
24
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.',
25
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).',
26
- 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.',
27
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.',
28
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.',
29
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.',
@@ -46,6 +47,15 @@ export const HELP = {
46
47
  'orders show': 'Show one order: c11r orders show <id or number>.',
47
48
  'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
48
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 <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
+ '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
+ '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 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
+ '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.',
49
59
  status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists).',
50
60
  claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
51
61
  help: 'Describe the commands. `c11r help <command>` for one command, `c11r help <group>` (catalog, orders, storefront, ucp) for a group.',
@@ -174,6 +184,7 @@ export async function apply(flags) {
174
184
  name: desired.name,
175
185
  policies: desired.policies,
176
186
  theme: desired.theme ?? {},
187
+ storefront_mode: storefrontMode(desired),
177
188
  ...(assets.supported && { assets: assets.keep }),
178
189
  ...(flags.has('--allow-delete') && { allow_delete: true }),
179
190
  ...(flags.has('--full') && { full: true }),
@@ -354,6 +365,133 @@ export async function claim() {
354
365
  next: 'Ask the human to open verification_url and enter user_code, then run `c11r status`.',
355
366
  };
356
367
  }
368
+ // ---------- ucp: buying for the human, step by step, from this store or any UCP store
369
+ /** The store to buy from: --store, else the one an id was made at, else this folder's. */
370
+ async function shopOrigin(values, id) {
371
+ if (values.store)
372
+ return shop.originOf(values.store);
373
+ const known = id ? shop.remembered(id) : undefined;
374
+ if (known)
375
+ return known;
376
+ if (!readCredentials())
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>.');
378
+ return storeOrigin();
379
+ }
380
+ const needId = (id, command) => {
381
+ if (!id)
382
+ fail('missing_argument', `${command} needs a checkout id.`, 'Use the id that c11r ucp checkout create printed (chk_...).');
383
+ return id;
384
+ };
385
+ const checkoutNext = (ck) => {
386
+ if (ck.status === 'completed')
387
+ return `Paid. Tell the human: order ${ck.order?.label ?? ck.order?.id}, and give them ${ck.order?.permalink_url}.`;
388
+ if (ck.status === 'canceled')
389
+ return 'Canceled. Open a new one with c11r ucp checkout create --item <variant id> if the human wants to buy.';
390
+ if (ck.status === 'ready_for_complete') {
391
+ const declined = (ck.messages ?? []).some((m) => m.code === 'payment_failed');
392
+ if (declined)
393
+ return `The payment failed: tell the human. Try again only if they ask: c11r ucp checkout complete ${ck.id}.`;
394
+ return `Show the human the items, shipping and total (${ck.total?.display}), ask for an explicit yes, then run c11r ucp checkout complete ${ck.id}.`;
395
+ }
396
+ const errors = (ck.messages ?? []).filter((m) => m.type === 'error').map((m) => m.content);
397
+ return `Still needed: ${errors.join(' ') || 'see messages'} Ask the human (never invent buyer details), then run c11r ucp checkout update ${ck.id} with what they said.`;
398
+ };
399
+ export async function ucpSearch(query, values) {
400
+ const r = await shop.search(await shopOrigin(values), query, values.country);
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.' };
402
+ }
403
+ export async function ucpProduct(id, values) {
404
+ if (!id)
405
+ fail('missing_argument', 'ucp product needs a product id.', 'Use a product id from c11r ucp search.');
406
+ return shop.product(await shopOrigin(values), id, values.country);
407
+ }
408
+ export async function ucpCheckoutCreate(values, lists) {
409
+ const items = (lists.item ?? []).map(shop.parseItem);
410
+ if (!items.length)
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).');
412
+ if (items.some((l) => l.quantity < 1))
413
+ fail('invalid_argument', 'An --item quantity must be 1 or more.', 'Example: --item ETH-250:2.');
414
+ const ck = await shop.createCheckout(await shopOrigin(values), items, lists.code ?? [], values.country);
415
+ return { ...ck, next: checkoutNext(ck) };
416
+ }
417
+ const BUYER_FLAGS = { email: 'email', 'first-name': 'first_name', 'last-name': 'last_name', phone: 'phone_number' };
418
+ const ADDRESS_FLAGS = { street: 'street_address', city: 'address_locality', 'postal-code': 'postal_code', region: 'address_region', country: 'address_country' };
419
+ export async function ucpCheckoutUpdate(id, values, lists) {
420
+ needId(id, 'ucp checkout update');
421
+ const pick = (map) => {
422
+ const out = {};
423
+ for (const [flag, key] of Object.entries(map))
424
+ if (values[flag] !== undefined)
425
+ out[key] = values[flag];
426
+ return Object.keys(out).length ? out : undefined;
427
+ };
428
+ const changes = { buyer: pick(BUYER_FLAGS), address: pick(ADDRESS_FLAGS), items: lists.item?.map(shop.parseItem), add_codes: lists.code, shipping: values.shipping };
429
+ const flagged = Object.values(changes).some((v) => v !== undefined);
430
+ if (!flagged || values.file) {
431
+ // No flags: the changes as YAML or JSON on stdin or -f.
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 }.`);
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 }.');
434
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
435
+ bad('the input is not a map.');
436
+ const r = raw;
437
+ for (const k of Object.keys(r))
438
+ if (!['buyer', 'address', 'items', 'codes', 'shipping'].includes(k))
439
+ bad(`unknown key ${k}.`);
440
+ const map = (o, keys, what) => {
441
+ if (o === undefined)
442
+ return undefined;
443
+ if (!o || typeof o !== 'object' || Array.isArray(o))
444
+ bad(`${what} is not a map.`);
445
+ for (const [k, v] of Object.entries(o))
446
+ if (!keys.includes(k) || typeof v !== 'string')
447
+ bad(`${what}.${k} is not one of ${keys.join(', ')} with a text value.`);
448
+ return o;
449
+ };
450
+ if (r.buyer !== undefined)
451
+ changes.buyer = { ...map(r.buyer, Object.values(BUYER_FLAGS), 'buyer'), ...changes.buyer };
452
+ if (r.address !== undefined)
453
+ changes.address = { ...map(r.address, ['first_name', 'last_name', 'extended_address', ...Object.values(ADDRESS_FLAGS)], 'address'), ...changes.address };
454
+ if (r.items !== undefined) {
455
+ if (!Array.isArray(r.items))
456
+ bad('items is not a list.');
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 ?? [])];
458
+ }
459
+ if (r.codes !== undefined) {
460
+ if (!Array.isArray(r.codes) || r.codes.some((c) => typeof c !== 'string'))
461
+ bad('codes is not a list of codes.');
462
+ changes.codes = [...r.codes, ...(changes.add_codes ?? [])];
463
+ }
464
+ if (r.shipping !== undefined)
465
+ changes.shipping = changes.shipping ?? String(r.shipping);
466
+ }
467
+ if (changes.items?.some((l) => !Number.isInteger(l.quantity) || l.quantity < 0))
468
+ fail('invalid_argument', 'An item quantity must be 0 (remove it) or more.', 'Example: --item ETH-250:2.');
469
+ const ck = await shop.updateCheckout(await shopOrigin(values, id), id, changes);
470
+ return { ...ck, next: checkoutNext(ck) };
471
+ }
472
+ export async function ucpCheckoutShow(id, values) {
473
+ const ck = await shop.getCheckout(await shopOrigin(values, needId(id, 'ucp checkout show')), id);
474
+ return { ...ck, next: checkoutNext(ck) };
475
+ }
476
+ export async function ucpCheckoutCancel(id, values) {
477
+ const ck = await shop.cancelCheckout(await shopOrigin(values, needId(id, 'ucp checkout cancel')), id);
478
+ return { ...ck, next: checkoutNext(ck) };
479
+ }
480
+ export async function ucpCheckoutComplete(id, values, flags) {
481
+ const ck = await shop.completeCheckout(await shopOrigin(values, needId(id, 'ucp checkout complete')), id, flags.has('--decline'));
482
+ return { ...ck, payment_handler: 'ai.commercelayer.sandbox (test payments only)', next: checkoutNext(ck) };
483
+ }
484
+ export async function ucpOrder(id, values) {
485
+ if (!id)
486
+ fail('missing_argument', 'ucp order needs an order id.', 'Use order.id from c11r ucp checkout complete.');
487
+ const o = await shop.getOrder(await shopOrigin(values, id), id);
488
+ return { ...o, next: `Tell the human: order ${o.label}, details at ${o.permalink_url}.` };
489
+ }
490
+ export async function ucpAsk(question, values) {
491
+ if (!question.trim())
492
+ fail('missing_argument', 'ucp ask needs a question.', 'Example: c11r ucp ask "Do you ship to Canada?".');
493
+ return shop.ask(await shopOrigin(values), question);
494
+ }
357
495
  // ---------- promotions: live settings in Commerce Layer, not store files
358
496
  /** What list and show print beyond the spec: dropped when it is sent back. */
359
497
  const READ_ONLY = ['status', 'uses', 'managed', 'url', 'urls', 'created_at'];
@@ -362,7 +500,14 @@ const READ_ONLY = ['status', 'uses', 'managed', 'url', 'urls', 'created_at'];
362
500
  * can be edited and sent back: the read-only fields are dropped.
363
501
  */
364
502
  async function readInput(values, what) {
365
- const usage = `Give the ${what} as YAML or JSON on stdin (c11r ${what}s create <<'EOF' ... EOF) or with -f <file>.`;
503
+ const raw = await readText(values, what, `Give the ${what} as YAML or JSON on stdin (c11r ${what}s create <<'EOF' ... EOF) or with -f <file>.`, `invalid_${what}`);
504
+ if (raw && typeof raw === 'object' && !Array.isArray(raw))
505
+ for (const k of READ_ONLY)
506
+ delete raw[k];
507
+ return raw;
508
+ }
509
+ /** YAML or JSON from -f <file> or stdin, parsed. */
510
+ async function readText(values, what, usage, invalid = 'invalid_argument') {
366
511
  let text = '';
367
512
  if (values.file) {
368
513
  if (!fs.existsSync(values.file))
@@ -385,11 +530,8 @@ async function readInput(values, what) {
385
530
  raw = YAML.parse(text);
386
531
  }
387
532
  catch (e) {
388
- fail(`invalid_${what}`, `${what}: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
533
+ fail(invalid, `${what}: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
389
534
  }
390
- if (raw && typeof raw === 'object' && !Array.isArray(raw))
391
- for (const k of READ_ONLY)
392
- delete raw[k];
393
535
  return raw;
394
536
  }
395
537
  /** A promotion spec, validated against store.yml's markets and products. */
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);
@@ -74,7 +74,7 @@ export function agentProfile() {
74
74
  },
75
75
  },
76
76
  name: 'Commerce Layer agent CLI',
77
- description: 'c11r, the Commerce Layer CLI for coding agents, acting as a shopping platform: it places test orders and checks a store speaks UCP.',
77
+ description: 'The Commerce Layer agent CLI: a shopping agent that buys from UCP stores on behalf of a human, places test orders in its own store and checks that a store speaks UCP.',
78
78
  };
79
79
  }
80
80
  /** Minor units per currency unit (ISO 4217 exponent). */
@@ -0,0 +1,308 @@
1
+ // Buying from a store step by step, for an agent shopping on behalf of its human: search,
2
+ // look at a product, open a checkout, give the buyer's details, pay, read the order. Each
3
+ // step is one command, so the agent talks to the human in between. The results are trimmed
4
+ // for a conversation: prices in major units as well as minor, what is still missing.
5
+ // buy.ts is the one-shot test buyer; this is the real one, with the human's details.
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fail } from '../output.js';
10
+ import { discover, replay, shopping, UcpError } from './client.js';
11
+ import { sandboxInstrument } from './buy.js';
12
+ import { minorUnits, SANDBOX_HANDLER } from './platform.js';
13
+ // ---------- which store an id belongs to
14
+ /** Checkout and order ids, by the store origin they were made at: later steps need no --store. */
15
+ const stateFile = () => process.env.C11R_UCP_STATE ?? path.join(process.env.XDG_STATE_HOME ?? path.join(os.homedir(), '.local', 'state'), 'c11r', 'ucp.json');
16
+ const MAX_REMEMBERED = 200;
17
+ function readState() {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ }
25
+ export function remember(id, origin) {
26
+ const s = readState();
27
+ delete s[id];
28
+ s[id] = origin;
29
+ const keys = Object.keys(s);
30
+ for (const k of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED)))
31
+ delete s[k];
32
+ fs.mkdirSync(path.dirname(stateFile()), { recursive: true });
33
+ fs.writeFileSync(stateFile(), JSON.stringify(s, null, 2) + '\n', { mode: 0o600 });
34
+ }
35
+ export const remembered = (id) => readState()[id];
36
+ /** A store origin from --store: an origin, a URL on the store, or a bare hostname. */
37
+ export function originOf(given) {
38
+ let url;
39
+ try {
40
+ url = new URL(/^[a-z]+:\/\//i.test(given) ? given : `https://${given}`);
41
+ }
42
+ catch {
43
+ fail('invalid_argument', `--store ${given} is not a URL or hostname.`, 'Give the store as a hostname (shop.example.com) or an origin (https://shop.example.com).');
44
+ }
45
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)))
46
+ fail('invalid_argument', `--store ${given} is not https.`, 'UCP stores are served over https: give https://<host>.');
47
+ return url.origin;
48
+ }
49
+ export function money(amount, currency) {
50
+ const minor = minorUnits(currency);
51
+ const decimals = Math.round(Math.log10(minor));
52
+ const major = amount / minor;
53
+ return { amount, currency, major, display: `${amount < 0 ? '-' : ''}${currency} ${Math.abs(major).toFixed(decimals)}` };
54
+ }
55
+ const plain = (d) => d?.plain ?? d?.markdown ?? d?.html;
56
+ function variantView(v) {
57
+ const shipping = v.fulfillment?.methods?.map((m) => plain(m.description)).filter(Boolean).join('; ');
58
+ return {
59
+ id: v.id,
60
+ sku: v.sku,
61
+ title: v.title,
62
+ price: v.price ? money(v.price.amount, v.price.currency) : undefined,
63
+ available: v.availability?.available ?? undefined,
64
+ availability: v.availability?.status ?? undefined,
65
+ image_url: v.media?.find((m) => m.type === 'image')?.url,
66
+ ships: shipping || undefined,
67
+ };
68
+ }
69
+ export function productView(p, full = false) {
70
+ const variants = (p.variants ?? []).map(variantView);
71
+ const description = plain(p.description);
72
+ const image = p.media?.find((m) => m.type === 'image')?.url ?? variants[0]?.image_url;
73
+ return {
74
+ id: p.id,
75
+ title: p.title,
76
+ description: full || !description || description.length <= 200 ? description : `${description.slice(0, 197)}...`,
77
+ url: p.url,
78
+ image_url: image,
79
+ // A search lists a variant's image only when it is not the product's.
80
+ variants: full ? variants : variants.map((v) => ({ ...v, image_url: v.image_url === image ? undefined : v.image_url })),
81
+ };
82
+ }
83
+ const sign = (c) => c.signature ?? 'unsigned';
84
+ function selectedDestination(ck) {
85
+ const m = ck.fulfillment?.methods?.[0];
86
+ return m?.destinations?.find((d) => d.id === m.selected_destination_id);
87
+ }
88
+ /** A checkout for the conversation: what is in it, what it costs, what is still missing. */
89
+ export function checkoutView(ck, origin, call) {
90
+ const cur = ck.currency;
91
+ const method = ck.fulfillment?.methods?.[0];
92
+ const group = method?.groups?.[0];
93
+ const options = group?.options ?? [];
94
+ const option = options.find((o) => o.id === group?.selected_option_id);
95
+ const optionView = (o) => ({ id: o.id, title: o.title, description: plain(o.description), price: o.totals?.find((t) => t.type === 'total') ? money(o.totals.find((t) => t.type === 'total').amount, cur) : undefined });
96
+ const { id: _id, type: _type, ...address } = selectedDestination(ck) ?? {};
97
+ const total = (ck.totals ?? []).find((t) => t.type === 'total');
98
+ return {
99
+ id: ck.id,
100
+ status: ck.status,
101
+ store: origin,
102
+ currency: cur,
103
+ line_items: (ck.line_items ?? []).map((l) => ({
104
+ id: l.id,
105
+ item_id: l.item?.id,
106
+ title: l.item?.title,
107
+ quantity: l.quantity,
108
+ unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur),
109
+ total: money((l.totals ?? []).find((t) => t.type === 'total')?.amount ?? 0, cur),
110
+ })),
111
+ buyer: ck.buyer ? Object.fromEntries(Object.entries(ck.buyer).filter(([k]) => k !== 'consent')) : undefined,
112
+ ship_to: Object.keys(address).length ? address : undefined,
113
+ shipping: option ? optionView(option) : undefined,
114
+ shipping_options: options.length > 1 ? options.map(optionView) : undefined,
115
+ discounts: ck.discounts?.applied?.length ? ck.discounts.applied.map((d) => ({ code: d.code, title: d.title, automatic: d.automatic, amount: money(d.amount, cur) })) : undefined,
116
+ codes: ck.discounts?.codes?.length ? ck.discounts.codes : undefined,
117
+ totals: (ck.totals ?? []).map((t) => ({ type: t.type, label: t.display_text, ...money(t.amount, cur) })),
118
+ total: total ? money(total.amount, cur) : undefined,
119
+ messages: ck.messages,
120
+ order: ck.order,
121
+ continue_url: ck.continue_url,
122
+ expires_at: ck.expires_at,
123
+ links: ck.links,
124
+ signature: sign(call),
125
+ };
126
+ }
127
+ export function orderView(o, origin, call) {
128
+ const cur = o.currency;
129
+ return {
130
+ id: o.id,
131
+ label: o.label,
132
+ permalink_url: o.permalink_url,
133
+ store: origin,
134
+ checkout_id: o.checkout_id,
135
+ currency: cur,
136
+ line_items: (o.line_items ?? []).map((l) => ({
137
+ item_id: l.item?.id,
138
+ title: l.item?.title,
139
+ quantity: l.quantity?.total ?? l.quantity,
140
+ status: l.status,
141
+ total: money((l.totals ?? []).find((t) => t.type === 'total')?.amount ?? 0, cur),
142
+ })),
143
+ fulfillment: (o.fulfillment?.expectations ?? []).map((e) => ({ method: e.method_type, destination: e.destination, description: e.description })),
144
+ events: o.fulfillment?.events?.length ? o.fulfillment.events : undefined,
145
+ totals: (o.totals ?? []).map((t) => ({ type: t.type, label: t.display_text, ...money(t.amount, cur) })),
146
+ signature: sign(call),
147
+ };
148
+ }
149
+ // ---------- the steps
150
+ /** Turn a store's refusal into a CLI error; a missing checkout or order is not_found. */
151
+ async function step(origin, run) {
152
+ try {
153
+ return await run();
154
+ }
155
+ catch (e) {
156
+ if (!(e instanceof UcpError))
157
+ throw e;
158
+ const code = e.call.body?.messages?.[0]?.code;
159
+ if (code === 'not_found' || e.call.status === 404)
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
+ if (code === 'identity_required' || e.call.status === 401)
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 variant ids it sells are in c11r ucp search.`);
164
+ }
165
+ }
166
+ /** --item ID or ID:QTY, the variant id. */
167
+ export function parseItem(raw) {
168
+ const m = String(raw).match(/^(.+?)(?::(\d+))?$/);
169
+ if (!m || !m[1].trim())
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
+ }
173
+ export async function open(origin) {
174
+ const store = await discover(origin);
175
+ if (!store.capabilities.has('dev.ucp.shopping.checkout'))
176
+ 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
+ return store;
178
+ }
179
+ export async function search(origin, query, country) {
180
+ const store = await discover(origin);
181
+ const body = { query, pagination: { limit: 50 } };
182
+ if (country)
183
+ body.context = { address_country: country.toUpperCase() };
184
+ const call = await step(origin, () => shopping.search(store, body));
185
+ const r = call.body;
186
+ const products = r.products ?? [];
187
+ return {
188
+ store: origin,
189
+ query,
190
+ count: products.length,
191
+ total_count: r.pagination?.total_count,
192
+ has_more: r.pagination?.has_next_page || undefined,
193
+ products: products.map((p) => productView(p)),
194
+ messages: r.messages,
195
+ signature: sign(call),
196
+ };
197
+ }
198
+ export async function product(origin, id, country) {
199
+ const store = await discover(origin);
200
+ const body = { id };
201
+ if (country)
202
+ body.context = { address_country: country.toUpperCase() };
203
+ const call = await step(origin, () => shopping.product(store, body));
204
+ return { store: origin, product: productView(call.body.product, true), signature: sign(call) };
205
+ }
206
+ export async function createCheckout(origin, items, codes, country) {
207
+ const store = await open(origin);
208
+ const body = { line_items: items.map((l) => ({ item: { id: l.id }, quantity: l.quantity })) };
209
+ if (country)
210
+ body.context = { address_country: country.toUpperCase() };
211
+ if (codes.length)
212
+ body.discounts = { codes };
213
+ const call = await step(origin, () => shopping.createCheckout(store, body));
214
+ remember(call.body.id, origin);
215
+ return checkoutView(call.body, origin, call);
216
+ }
217
+ export async function getCheckout(origin, id) {
218
+ const store = await open(origin);
219
+ const call = await step(origin, () => shopping.getCheckout(store, id));
220
+ return checkoutView(call.body, origin, call);
221
+ }
222
+ const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== ''));
223
+ export async function updateCheckout(origin, id, ch) {
224
+ const store = await open(origin);
225
+ const current = (await step(origin, () => shopping.getCheckout(store, id))).body;
226
+ 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>.');
228
+ const body = replay(current);
229
+ if (ch.buyer)
230
+ body.buyer = clean({ ...(current.buyer ?? {}), ...ch.buyer });
231
+ for (const l of ch.items ?? []) {
232
+ const i = body.line_items.findIndex((x) => x.item.id === l.id);
233
+ if (l.quantity === 0) {
234
+ if (i >= 0)
235
+ body.line_items.splice(i, 1);
236
+ }
237
+ else if (i >= 0)
238
+ body.line_items[i].quantity = l.quantity;
239
+ else
240
+ body.line_items.push({ item: { id: l.id }, quantity: l.quantity });
241
+ }
242
+ if (!body.line_items.length)
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}.`);
244
+ const codes = ch.codes ?? [...(current.discounts?.codes ?? []), ...(ch.add_codes ?? [])];
245
+ if (ch.codes || ch.add_codes?.length)
246
+ body.discounts = { codes: [...new Set(codes)] };
247
+ if (ch.address || ch.shipping) {
248
+ const method = body.fulfillment?.methods?.[0] ?? { type: 'shipping', line_item_ids: (current.line_items ?? []).map((l) => l.id) };
249
+ if (ch.address) {
250
+ const prev = selectedDestination(current) ?? {};
251
+ const buyer = (body.buyer ?? {});
252
+ const dest = clean({ ...prev, first_name: buyer.first_name, last_name: buyer.last_name, ...ch.address, id: prev.id ?? 'dest_1' });
253
+ if (dest.address_country)
254
+ dest.address_country = String(dest.address_country).toUpperCase();
255
+ delete dest.type;
256
+ method.destinations = [dest];
257
+ method.selected_destination_id = dest.id;
258
+ }
259
+ if (ch.shipping)
260
+ method.groups = (method.groups?.length ? method.groups : [{ id: 'grp_1' }]).map((g) => ({ ...g, selected_option_id: ch.shipping }));
261
+ body.fulfillment = { methods: [method, ...(body.fulfillment?.methods?.slice(1) ?? [])] };
262
+ }
263
+ let call = await step(origin, () => shopping.updateCheckout(store, id, body));
264
+ // A store that offers shipping options without picking one: take its first, and say which in the result.
265
+ const group = call.body.fulfillment?.methods?.[0]?.groups?.[0];
266
+ if (group && !group.selected_option_id && group.options?.length) {
267
+ const again = replay(call.body);
268
+ again.fulfillment.methods[0].groups[0].selected_option_id = group.options[0].id;
269
+ call = await step(origin, () => shopping.updateCheckout(store, id, again));
270
+ }
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
+ const ck = call.body;
274
+ if (ck.status === 'ready_for_complete' && ck.fulfillment?.methods?.[0]?.groups?.[0]?.selected_option_id && !(ck.totals ?? []).some((t) => t.type === 'fulfillment')) {
275
+ call = await step(origin, () => shopping.getCheckout(store, id));
276
+ }
277
+ return checkoutView(call.body, origin, call);
278
+ }
279
+ export async function cancelCheckout(origin, id) {
280
+ const store = await open(origin);
281
+ const call = await step(origin, () => shopping.cancelCheckout(store, id));
282
+ return checkoutView(call.body, origin, call);
283
+ }
284
+ export async function completeCheckout(origin, id, decline) {
285
+ const store = await open(origin);
286
+ if (!store.profile.ucp?.payment_handlers?.[SANDBOX_HANDLER]) {
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 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
+ }
290
+ const call = await step(origin, () => shopping.completeCheckout(store, id, { payment: { instruments: [sandboxInstrument(decline)] }, signals: { 'dev.ucp.user_agent': 'c11r' } }));
291
+ const ck = call.body;
292
+ if (ck.order?.id)
293
+ remember(ck.order.id, origin);
294
+ return checkoutView(ck, origin, call);
295
+ }
296
+ export async function getOrder(origin, id) {
297
+ const store = await discover(origin);
298
+ const call = await step(origin, () => shopping.getOrder(store, id));
299
+ return orderView(call.body, origin, call);
300
+ }
301
+ export async function ask(origin, question) {
302
+ const store = await discover(origin);
303
+ if (!store.capabilities.has('dev.ucp.common.ask'))
304
+ fail('ask_unsupported', `The store at ${origin} does not answer questions over UCP.`, 'Search its catalog instead: c11r ucp search <query>.');
305
+ const call = await step(origin, () => shopping.ask(store, { query: question }));
306
+ const r = call.body;
307
+ return { store: origin, question, answer: plain(r.answer), links: r.links, messages: r.messages, signature: sign(call) };
308
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.2.5",
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": [