@commercelayer/agent-cli 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 <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. |
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. |
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. |
@@ -146,6 +151,10 @@ Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (liv
146
151
  | 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
152
  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
153
  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
+ ### Buying for the human
155
+
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`.
157
+
149
158
  ## Requirements
150
159
 
151
160
  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 }),
@@ -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(),
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
@@ -17,6 +17,7 @@ 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;
@@ -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 <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>.",
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 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 }.',
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.',
@@ -354,6 +364,133 @@ export async function claim() {
354
364
  next: 'Ask the human to open verification_url and enter user_code, then run `c11r status`.',
355
365
  };
356
366
  }
367
+ // ---------- ucp: buying for the human, step by step, from this store or any UCP store
368
+ /** The store to buy from: --store, else the one an id was made at, else this folder's. */
369
+ async function shopOrigin(values, id) {
370
+ if (values.store)
371
+ return shop.originOf(values.store);
372
+ const known = id ? shop.remembered(id) : undefined;
373
+ if (known)
374
+ return known;
375
+ 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
+ return storeOrigin();
378
+ }
379
+ const needId = (id, command) => {
380
+ if (!id)
381
+ fail('missing_argument', `${command} needs a checkout id.`, 'Use the id that c11r ucp checkout create printed (chk_...).');
382
+ return id;
383
+ };
384
+ const checkoutNext = (ck) => {
385
+ if (ck.status === 'completed')
386
+ return `Paid. Tell the human: order ${ck.order?.label ?? ck.order?.id}, and give them ${ck.order?.permalink_url}.`;
387
+ 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
+ if (ck.status === 'ready_for_complete') {
390
+ const declined = (ck.messages ?? []).some((m) => m.code === 'payment_failed');
391
+ if (declined)
392
+ return `The payment failed: tell the human. Try again only if they ask: c11r ucp checkout complete ${ck.id}.`;
393
+ 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}.`;
394
+ }
395
+ const errors = (ck.messages ?? []).filter((m) => m.type === 'error').map((m) => m.content);
396
+ 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.`;
397
+ };
398
+ export async function ucpSearch(query, values) {
399
+ 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
+ }
402
+ export async function ucpProduct(id, values) {
403
+ if (!id)
404
+ fail('missing_argument', 'ucp product needs a product id or sku.', 'Use an id or sku from c11r ucp search.');
405
+ return shop.product(await shopOrigin(values), id, values.country);
406
+ }
407
+ export async function ucpCheckoutCreate(values, lists) {
408
+ const items = (lists.item ?? []).map(shop.parseItem);
409
+ 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
+ if (items.some((l) => l.quantity < 1))
412
+ fail('invalid_argument', 'An --item quantity must be 1 or more.', 'Example: --item ETH-250:2.');
413
+ const ck = await shop.createCheckout(await shopOrigin(values), items, lists.code ?? [], values.country);
414
+ return { ...ck, next: checkoutNext(ck) };
415
+ }
416
+ const BUYER_FLAGS = { email: 'email', 'first-name': 'first_name', 'last-name': 'last_name', phone: 'phone_number' };
417
+ const ADDRESS_FLAGS = { street: 'street_address', city: 'address_locality', 'postal-code': 'postal_code', region: 'address_region', country: 'address_country' };
418
+ export async function ucpCheckoutUpdate(id, values, lists) {
419
+ needId(id, 'ucp checkout update');
420
+ const pick = (map) => {
421
+ const out = {};
422
+ for (const [flag, key] of Object.entries(map))
423
+ if (values[flag] !== undefined)
424
+ out[key] = values[flag];
425
+ return Object.keys(out).length ? out : undefined;
426
+ };
427
+ const changes = { buyer: pick(BUYER_FLAGS), address: pick(ADDRESS_FLAGS), items: lists.item?.map(shop.parseItem), add_codes: lists.code, shipping: values.shipping };
428
+ const flagged = Object.values(changes).some((v) => v !== undefined);
429
+ if (!flagged || values.file) {
430
+ // No flags: the changes as YAML or JSON on stdin or -f.
431
+ 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
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
434
+ bad('the input is not a map.');
435
+ const r = raw;
436
+ for (const k of Object.keys(r))
437
+ if (!['buyer', 'address', 'items', 'codes', 'shipping'].includes(k))
438
+ bad(`unknown key ${k}.`);
439
+ const map = (o, keys, what) => {
440
+ if (o === undefined)
441
+ return undefined;
442
+ if (!o || typeof o !== 'object' || Array.isArray(o))
443
+ bad(`${what} is not a map.`);
444
+ for (const [k, v] of Object.entries(o))
445
+ if (!keys.includes(k) || typeof v !== 'string')
446
+ bad(`${what}.${k} is not one of ${keys.join(', ')} with a text value.`);
447
+ return o;
448
+ };
449
+ if (r.buyer !== undefined)
450
+ changes.buyer = { ...map(r.buyer, Object.values(BUYER_FLAGS), 'buyer'), ...changes.buyer };
451
+ if (r.address !== undefined)
452
+ changes.address = { ...map(r.address, ['first_name', 'last_name', 'extended_address', ...Object.values(ADDRESS_FLAGS)], 'address'), ...changes.address };
453
+ if (r.items !== undefined) {
454
+ if (!Array.isArray(r.items))
455
+ 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
+ }
458
+ if (r.codes !== undefined) {
459
+ if (!Array.isArray(r.codes) || r.codes.some((c) => typeof c !== 'string'))
460
+ bad('codes is not a list of codes.');
461
+ changes.codes = [...r.codes, ...(changes.add_codes ?? [])];
462
+ }
463
+ if (r.shipping !== undefined)
464
+ changes.shipping = changes.shipping ?? String(r.shipping);
465
+ }
466
+ if (changes.items?.some((l) => !Number.isInteger(l.quantity) || l.quantity < 0))
467
+ fail('invalid_argument', 'An item quantity must be 0 (remove it) or more.', 'Example: --item ETH-250:2.');
468
+ const ck = await shop.updateCheckout(await shopOrigin(values, id), id, changes);
469
+ return { ...ck, next: checkoutNext(ck) };
470
+ }
471
+ export async function ucpCheckoutShow(id, values) {
472
+ const ck = await shop.getCheckout(await shopOrigin(values, needId(id, 'ucp checkout show')), id);
473
+ return { ...ck, next: checkoutNext(ck) };
474
+ }
475
+ export async function ucpCheckoutCancel(id, values) {
476
+ const ck = await shop.cancelCheckout(await shopOrigin(values, needId(id, 'ucp checkout cancel')), id);
477
+ return { ...ck, next: checkoutNext(ck) };
478
+ }
479
+ export async function ucpCheckoutComplete(id, values, flags) {
480
+ const ck = await shop.completeCheckout(await shopOrigin(values, needId(id, 'ucp checkout complete')), id, flags.has('--decline'));
481
+ return { ...ck, payment_handler: 'ai.commercelayer.sandbox (test payments only)', next: checkoutNext(ck) };
482
+ }
483
+ export async function ucpOrder(id, values) {
484
+ if (!id)
485
+ fail('missing_argument', 'ucp order needs an order id.', 'Use order.id from c11r ucp checkout complete.');
486
+ const o = await shop.getOrder(await shopOrigin(values, id), id);
487
+ return { ...o, next: `Tell the human: order ${o.label}, details at ${o.permalink_url}.` };
488
+ }
489
+ export async function ucpAsk(question, values) {
490
+ if (!question.trim())
491
+ fail('missing_argument', 'ucp ask needs a question.', 'Example: c11r ucp ask "Do you ship to Canada?".');
492
+ return shop.ask(await shopOrigin(values), question);
493
+ }
357
494
  // ---------- promotions: live settings in Commerce Layer, not store files
358
495
  /** What list and show print beyond the spec: dropped when it is sent back. */
359
496
  const READ_ONLY = ['status', 'uses', 'managed', 'url', 'urls', 'created_at'];
@@ -362,7 +499,14 @@ const READ_ONLY = ['status', 'uses', 'managed', 'url', 'urls', 'created_at'];
362
499
  * can be edited and sent back: the read-only fields are dropped.
363
500
  */
364
501
  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>.`;
502
+ 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}`);
503
+ if (raw && typeof raw === 'object' && !Array.isArray(raw))
504
+ for (const k of READ_ONLY)
505
+ delete raw[k];
506
+ return raw;
507
+ }
508
+ /** YAML or JSON from -f <file> or stdin, parsed. */
509
+ async function readText(values, what, usage, invalid = 'invalid_argument') {
366
510
  let text = '';
367
511
  if (values.file) {
368
512
  if (!fs.existsSync(values.file))
@@ -385,11 +529,8 @@ async function readInput(values, what) {
385
529
  raw = YAML.parse(text);
386
530
  }
387
531
  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.');
532
+ fail(invalid, `${what}: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
389
533
  }
390
- if (raw && typeof raw === 'object' && !Array.isArray(raw))
391
- for (const k of READ_ONLY)
392
- delete raw[k];
393
534
  return raw;
394
535
  }
395
536
  /** A promotion spec, validated against store.yml's markets and products. */
@@ -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 ?? v.id,
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
+ sku: 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
+ sku: 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 skus it sells are in c11r ucp search.`);
164
+ }
165
+ }
166
+ /** --item SKU or SKU:QTY. */
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 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]) };
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.sku }, 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.sku);
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.sku }, 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 on a
272
+ // Commerce Layer store): 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 can only pay sandbox stores (${SANDBOX_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.0",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [