@commercelayer/agent-cli 1.0.0-dev.12 → 1.0.0-dev.13

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
@@ -15,7 +15,7 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
15
15
  - Flags are the schema's fields, in kebab or snake case: `--price-list` and `--price_list` are the same.
16
16
  - On `list`, `--<field> <value>` filters by equality and `--<field>.<op> <value>` by another operator: `--placed-at.gte 2026-11-01`, `--status.in placed,approved`. `--waiting` lists what needs a decision now.
17
17
  - A ref flag, or one the schema marks `nullable`, given `null` or an empty value sends `null` and takes it off: `c11r customers update ada@example.com --customer-group null` sends `customer_group: null`, `c11r orders update 1001 --coupon-code null` sends `coupon_code: null`. A ref the schema marks `nullable: false`, and any other field, sends the text.
18
- - A dotted flag sets part of an object: `--customer.email ada@example.com`. An `in` filter takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it (`--tax-calculator.in "UK VAT, manual"`), and a list that has one is sent as a JSON array. Any other array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes `key=value` pairs, one object per flag: `--items sku=COFFEE,quantity=2`. `--data` takes the fields as one JSON object, or `@` and a file name.
18
+ - A dotted flag sets part of an object: `--customer.email ada@example.com`. An `in` filter takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it (`--tax-calculator.in "UK VAT, manual"`), and a list that has one is sent as a JSON array. Any other array takes the flag repeated or a JSON array, and the same rule holds: enums, numbers and dates also separated by commas, a name or text one value per flag (`--payment-settings "Invoice, 60 days"`); an array of objects takes `key=value` pairs, one object per flag: `--items sku=COFFEE,quantity=2`. `--data` takes the fields as one JSON object, or `@` and a file name.
19
19
  - Amounts are in major units: `--amount 12.50`.
20
20
  - A secret field the schema marks `secret_input` (provider credentials) has no flag of its own: `--credentials-file <file>` reads it, as JSON or `KEY=value` lines, from a plain file only its owner can read (mode 600). Without the file, a terminal asks for it without echo; with neither, the command stops with `secret_required`. It is refused as a flag value or in `--data`, and never printed.
21
21
  - A field with documented values the API does not enforce (`known`: webhook topics) takes any value. One outside them is sent anyway, with the warning `unknown_value` and `did_you_mean`. Help lists up to 20 of them, and past that says how many and that `c11r schema <noun>` has them all.
@@ -35,6 +35,8 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
35
35
  | `c11r report sales [--from] [--to] [--by] [--interval] [--market] [--top]` | Sales in a window, from the Metrics API. A plain noun: its flags come from the API's schema. |
36
36
  | `c11r auth login [--client-id] [--secret-file] [--keep-file]` | The human's own Commerce Layer login, from their Provisioning API application. See below. |
37
37
  | `c11r auth logout`, `auth switch <slug> [--global]` | Forgetting the login; the organization commands act on. |
38
+ | `c11r shop profile\|search\|get\|ask <store>` | Reading a store that speaks UCP, as a shopping agent: what it offers, its catalog, a variant, a question. No organization. See below. |
39
+ | `c11r shop checkout <store> [<checkout>]`, `shop order <store> <checkout>` | Buying there for the human: their details after their yes on what is shared, then the payment after their yes on the total, with the sandbox test handler only. |
38
40
 
39
41
  A loop runs a skill a Manage or Report noun's schema names in `loop.skill`, or in `loop.skills` for a noun with several (`manage-orders`, `manage-stock`, `report-sales`, `report-pending`, ...); a report loop allows no verb and only reports. create and update refuse any other skill, and any verb that loop does not list, with the allowed values. A loop policy allows verbs: a bare verb is the skill's own noun (`approve`), `<noun>.<verb>` another Manage noun's verb on the same item (`payments.capture`). Verbs that delete or return money are never open to a policy.
40
42
 
@@ -83,7 +85,7 @@ Records that share a key (two shipping methods with the same name in one market,
83
85
 
84
86
  After a Configure write that went through (create, update, delete or a domain verb), in a folder whose `store.yml` names the same organization, the answer warns `config_drift` and `.c11r/drift` notes the write; `config plan` repeats the warning, and `config export` clears it.
85
87
 
86
- `c11r scaffold drop-in --out <file>` writes the drop-in.js snippet as one HTML file: the part for the page's `<head>`, then the part for its `<body>`, each under a comment saying where it goes. It never replaces a different file without `--yes`. Without `--out`, the snippet stays in the JSON answer.
88
+ `c11r scaffold drop-in --out <file>` writes the drop-in.js snippet as one HTML file: the part for the page's `<head>`, then the part for its `<body>`, each under a comment saying where it goes. The file must be inside the project folder: a path that leads out of it, by `..`, an absolute path or a symlink, is refused with `invalid_value` on `out` before anything is sent. It never replaces a different file without `--yes`. Without `--out`, the snippet stays in the JSON answer.
87
89
 
88
90
  `c11r exports get <id> --out <file>` downloads a completed export while its link lasts (5 minutes), unzipped unless the name ends in `.gz`; `--wait` reads the job again until it completes.
89
91
 
@@ -128,6 +130,17 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
128
130
 
129
131
  `rate_limited` is retried twice with a short backoff, for reads and writes: nothing was processed. `commercelayer_busy` and `commercelayer_unavailable` are retried for reads only; a write that may have reached Commerce Layer fails with its next step. The one exception is `sandbox create`: it sends a fresh `Idempotency-Key`, and after a 5xx or a lost connection it repeats the create with the same key, so the API answers the sandbox it made and its token is stored. Each retry waits as long as the API says (`retry_after` seconds in the error, or the `Retry-After` header), at most 30 seconds; without it, 1 then 3 seconds. When the API asks for longer than 30 seconds, the CLI does not wait: the error goes to the agent, with `retry_after`, to plan around. Each retry writes a line to stderr.
130
132
 
133
+ ## Shop
134
+
135
+ `c11r shop` buys for the human from any store that speaks UCP. The CLI talks to the store directly, with no organization and no call to the API. `<store>` is the store's host (`shop.example.com`) or an https URL on it.
136
+
137
+ - Every call carries `UCP-Agent: profile="https://commercelayer.ai/ucp/agent-cli/profile.json"`, a `Request-Id`, a `Content-Digest` with a body, and an `Idempotency-Key` on a write. The CLI holds no keys and signs no request. The store's answers are checked against the keys its `/.well-known/ucp` publishes: a digest that does not match, or a signature that does not verify, is `invalid_response` and the answer is not used. An unsigned answer warns `answer_unsigned`. A store that wants signed requests is `not_supported`.
138
+ - `profile`, `search`, `get` and `ask` only read. `get` and `ask` take the variant or the question as the bare value after the store.
139
+ - `checkout` opens a checkout with `--item ID=QTY` (repeated) and the buyer's `--email`, `--first-name`, `--last-name`, `--phone` and `--address.street`, `--address.line-2`, `--address.locality`, `--address.postal-code`, `--address.region`, `--address.country`, all but the line 2 and the region required. Each is the human's, never invented: a missing one is `missing_value` with a human step. Before any of them is sent, `confirmation_required` shows every value and the host (`Sharing with shop.example.com: Ada Lovelace, ada@example.com, ...`); the agent asks the human and, on their yes, runs it again with `--yes`. `checkout <store> <checkout>` changes one (items, the buyer, the address, `--shipping <option>`), or with nothing to change reads it again. The answer has the totals with shipping and, when it is ready, `next` is the order.
140
+ - `order <store> <checkout>` shows the total and asks the same way, then pays with the sandbox test handler (`ai.commercelayer.sandbox`), where no money moves, and answers the order. On a checkout already paid it answers the order and pays nothing. A checkout that is not ready is `invalid_transition`: when the store's messages name the fields it still needs, `next_step` is the command with their flags (`--phone PHONE`), and when they do not, it is a human step at the store's checkout page; a declined payment is `store_refused` with the store's words.
141
+ - The CLI has no flag for card data. A store without the sandbox handler is `payment_handler_unsupported` before anything of the human's is sent, with the store's URL for the human to buy there in person.
142
+ - With `--dry-run`, checkout answers what it would send and order the total it would pay: nothing is sent.
143
+
131
144
  ## Environment
132
145
 
133
146
  | Variable | What it does |
package/dist/argv.js CHANGED
@@ -181,9 +181,10 @@ export function coerce(key, spec, raw) {
181
181
  // One object per flag (--items sku=A,quantity=2, repeated), or scalars separated by commas.
182
182
  if (item === 'object')
183
183
  return [coerce(key, { type: 'object', properties: spec.properties }, raw)];
184
- // An in filter on names or text takes the value whole: a name may have a comma ("UK VAT, manual").
185
- // Several are the flag repeated, or a JSON array. Enums, numbers and dates still split on commas.
186
- if (spec.filter?.op === 'in' && (item === 'string' || item === 'ref'))
184
+ // A name or text is one value per flag, in a filter or not: a name may have a comma
185
+ // ("Invoice, 60 days"). Several are the flag repeated, or a JSON array. Enums, numbers and
186
+ // dates still split on commas.
187
+ if (item === 'string' || item === 'ref')
187
188
  return [raw];
188
189
  return raw
189
190
  .split(',')
@@ -11,7 +11,7 @@ import crypto from 'node:crypto';
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import zlib from 'node:zlib';
14
- import { credentialsFile, hintOf, readLink, storeBooking, storedBooking, storeOrg, writeLink } from '../config.js';
14
+ import { credentialsFile, hintOf, projectDir, readLink, storeBooking, storedBooking, storeOrg, writeLink } from '../config.js';
15
15
  import { confirm, terminal } from '../confirm.js';
16
16
  import { fail } from '../errors.js';
17
17
  import { apiUrl } from '../http.js';
@@ -272,19 +272,49 @@ export const exports = {
272
272
  return { ...r, data: { ...rest, file, bytes: bytes.length, unzipped: zipped && !file.endsWith('.gz') } };
273
273
  },
274
274
  };
275
+ /** Where a path really lands: symlinks followed, dangling ones too, and the missing rest as written. */
276
+ function realPath(p, hops = 0) {
277
+ let st = null;
278
+ try {
279
+ st = fs.lstatSync(p);
280
+ }
281
+ catch {
282
+ st = null;
283
+ }
284
+ if (st?.isSymbolicLink() && hops < 40)
285
+ return realPath(path.resolve(realPath(path.dirname(p), hops + 1), fs.readlinkSync(p)), hops + 1);
286
+ if (st)
287
+ return fs.realpathSync(p);
288
+ const parent = path.dirname(p);
289
+ return parent === p ? p : path.join(realPath(parent, hops), path.basename(p));
290
+ }
291
+ /**
292
+ * --out resolved from here, refused (invalid_value on out) when it lands outside the project folder:
293
+ * by .., by an absolute path, or by a symlink on the way (the file itself or a folder above it).
294
+ */
295
+ function insideProject(out) {
296
+ const file = path.resolve(out);
297
+ const root = fs.realpathSync(projectDir());
298
+ const target = realPath(file);
299
+ const rel = path.relative(root, target);
300
+ if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
301
+ fail('invalid_value', `--out ${out} is outside the project folder ${root}: c11r writes only inside it.`, { path: 'out', details: { project: root, resolved: target } });
302
+ }
303
+ return file;
304
+ }
275
305
  // scaffold (46): drop-in --out writes the snippet as one HTML file, its head part and its body part
276
306
  // each under a comment saying where it goes. It never replaces a different file without --yes.
277
307
  export const scaffold = {
278
308
  flags: {
279
309
  'drop-in': {
280
- out: { type: 'string', to: 'local', description: 'Write the snippet to this HTML file: the part for the page\'s <head>, then the part for its <body>, each under a comment saying where it goes. A different file there is replaced only with --yes.' },
310
+ out: { type: 'string', to: 'local', description: 'Write the snippet to this HTML file: the part for the page\'s <head>, then the part for its <body>, each under a comment saying where it goes. It must be inside the project folder. A different file there is replaced only with --yes.' },
281
311
  },
282
312
  },
283
313
  async run(run) {
284
314
  const out = run.parsed.values.out;
285
315
  if (run.verb.name !== 'drop-in' || !out)
286
316
  return undefined;
287
- const file = path.resolve(out);
317
+ const file = insideProject(out);
288
318
  // Before anything is sent: a different file there stays without --yes.
289
319
  const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
290
320
  const r = await run.send(resourceRequest('scaffold', run.verb, undefined, run.parsed.values));
@@ -4,5 +4,6 @@
4
4
  import { auth } from './auth.js';
5
5
  import { loops } from './loops.js';
6
6
  import { sandbox, status } from './sandbox.js';
7
- export const builtins = { sandbox, status, loops, auth };
7
+ import { shop } from './shop.js';
8
+ export const builtins = { sandbox, status, loops, auth, shop };
8
9
  export { extensions } from './extensions.js';
@@ -21,7 +21,7 @@ const policyFlags = (required) => [
21
21
  { name: 'skill', type: 'string', required, description: 'The skill the loop runs, as in manage-orders or report-sales: one a noun\'s schema names in loop.skill or loop.skills.' },
22
22
  { name: 'every', type: 'string', required, description: 'How often a pass should run, as in 15m, 2h or 1d. The scheduler reads it; the CLI does not wait.' },
23
23
  { name: 'allow', type: 'array', description: 'A verb the policy allows, repeated: approve, payments.capture. Only verbs the noun\'s schema lists in loop.verbs. Without it the loop only reports what is waiting.' },
24
- { name: 'max_total', type: 'array', description: 'The largest order total acted on, in major units: 500 for every currency, or EUR=500, repeated per currency. Above it, the item waits for the human.' },
24
+ { name: 'max_total', type: 'array', description: 'The largest order total acted on, in major units: 500 for every currency, or EUR=500 per currency, the flag repeated or separated by commas. Above it, the item waits for the human.' },
25
25
  { name: 'market', type: 'array', description: 'A market code the loop acts in, repeated. Items in other markets wait. Without it, every market.' },
26
26
  ];
27
27
  export const loops = {
@@ -147,7 +147,9 @@ async function write(run, name, file) {
147
147
  });
148
148
  }
149
149
  /** 500, or EUR=500 per currency. */
150
- function maxTotal(items) {
150
+ function maxTotal(given) {
151
+ // Amounts and currency codes hold no comma: EUR=500,USD=550 is two, as is the flag repeated.
152
+ const items = given.flatMap((i) => i.split(',')).map((i) => i.trim()).filter(Boolean);
151
153
  const plain = items.filter((i) => !i.includes('='));
152
154
  if (plain.length && plain.length !== items.length)
153
155
  fail('invalid_value', '--max-total takes one amount, or one CURRENCY=amount per currency, not both.', { path: 'max_total' });
@@ -0,0 +1,504 @@
1
+ // c11r shop (shop-agent-stores): the agent buys for its human from any store that speaks UCP.
2
+ // Built in: the CLI talks to the store directly, needs no organization (state none), and holds no
3
+ // keys (see ucp.ts). Each step is one command, so the agent talks to the human in between:
4
+ //
5
+ // profile, search, get, ask read the store; nothing of the human's is sent.
6
+ // checkout sends the buyer's details, the human's own words, after their yes
7
+ // on exactly what goes to which host. No money moves.
8
+ // order pays with the sandbox handler, after their yes on the total. On a
9
+ // checkout already paid, it answers the order and pays nothing.
10
+ //
11
+ // Only the sandbox handler: a store without it is refused before anything of the human's is sent.
12
+ import { confirm } from '../confirm.js';
13
+ import { fail } from '../errors.js';
14
+ import { envelope } from '../http.js';
15
+ import { parseSchema } from '../schema.js';
16
+ import { call, discover, handlersOf, minorOf, money, mustPay, originOf, sandboxPayment } from '../ucp.js';
17
+ const ADDRESS = [
18
+ { name: 'street', type: 'string', description: 'Street and number: Via Roma 1.' },
19
+ { name: 'line_2', type: 'string', description: 'Apartment, floor, care of.' },
20
+ { name: 'locality', type: 'string', description: 'The city or town.' },
21
+ { name: 'postal_code', type: 'string', description: 'The postal code.' },
22
+ { name: 'region', type: 'string', description: 'The state or province, where the country has them: CA, TO.' },
23
+ { name: 'country', type: 'string', description: 'The country, as a two-letter code: IT.' },
24
+ ];
25
+ const BUYER = [
26
+ { name: 'email', type: 'string', description: 'The buyer\'s email, as the human gave it.' },
27
+ { name: 'first_name', type: 'string', description: 'The buyer\'s first name, as the human gave it.' },
28
+ { name: 'last_name', type: 'string', description: 'The buyer\'s last name, as the human gave it.' },
29
+ { name: 'phone', type: 'string', description: 'The buyer\'s phone number, as the human gave it.' },
30
+ { name: 'address', type: 'object', properties: ADDRESS, description: 'Where to ship, as the human gave it: --address.street, --address.locality, --address.postal-code, --address.region, --address.country.' },
31
+ ];
32
+ const STORE_ID = { accepts: ['host', 'url'], example: 'shop.example.com' };
33
+ export const shop = {
34
+ schema: parseSchema({
35
+ noun: 'shop',
36
+ group: 'shop',
37
+ skill: 'shop-agent-stores',
38
+ kind: 'task',
39
+ description: 'Buying for the human from any store that speaks UCP, as a shopping agent: the CLI talks to the store directly, with no organization. It pays only with the sandbox test handler, where no money moves. The buyer\'s details are the human\'s, never invented, and nothing of theirs is sent or paid without their yes.',
40
+ id: STORE_ID,
41
+ verbs: [
42
+ { verb: 'profile', method: 'GET', path: '/shop/{id}/profile', state: 'none', live: false, effect: 'Reads the store\'s UCP profile: what it offers this CLI, its payment handlers, and can_pay, whether this CLI can pay there.' },
43
+ {
44
+ verb: 'search',
45
+ method: 'GET',
46
+ path: '/shop/{id}/search',
47
+ state: 'none',
48
+ live: false,
49
+ query: [
50
+ { name: 'query', type: 'string', description: 'What the human looks for, in their words.' },
51
+ { name: 'country', type: 'string', description: 'The country to ship to, as a two-letter code: prices and shipping for it.' },
52
+ { name: 'category', type: 'array', items: 'string', description: 'A category or a parent of one (Coffee for Coffee > Single origin), repeated: any of them matches.' },
53
+ { name: 'min_price', type: 'number', description: 'The lowest price, in major units of the store\'s currency.' },
54
+ { name: 'max_price', type: 'number', description: 'The highest price, in major units of the store\'s currency.' },
55
+ { name: 'in_stock', type: 'boolean', description: 'Only what is in stock.' },
56
+ { name: 'sort', type: 'enum', values: ['relevance', 'price_asc', 'price_desc'], description: 'The order of the results.' },
57
+ { name: 'limit', type: 'integer', description: 'How many products, 50 at most. Default 20.' },
58
+ ],
59
+ effect: 'Searches the store\'s catalog: products with their variants, prices, availability and images. A variant id is what checkout --item takes.',
60
+ },
61
+ {
62
+ verb: 'get',
63
+ method: 'GET',
64
+ path: '/shop/{id}/get',
65
+ state: 'none',
66
+ live: false,
67
+ query: [
68
+ { name: 'variant', type: 'array', items: 'string', required: true, description: 'A variant id from search, repeated for several. The first bare value after the store is one.' },
69
+ { name: 'country', type: 'string', description: 'The country to ship to, as a two-letter code.' },
70
+ ],
71
+ effect: 'Looks variants up by id: the full product, its price, availability and how it ships.',
72
+ },
73
+ {
74
+ verb: 'ask',
75
+ method: 'GET',
76
+ path: '/shop/{id}/ask',
77
+ state: 'none',
78
+ live: false,
79
+ query: [{ name: 'question', type: 'string', required: true, description: 'The human\'s question, in their words. The first bare value after the store.' }],
80
+ effect: 'Asks the store a question in plain words, when it answers them over UCP (dev.ucp.common.ask).',
81
+ },
82
+ {
83
+ verb: 'checkout',
84
+ method: 'POST',
85
+ path: '/shop/{id}/checkout',
86
+ state: 'none',
87
+ live: false,
88
+ body: [
89
+ { name: 'checkout', type: 'string', description: 'A checkout to change or read again, by the id checkout answered. The first bare value after the store. Without it, a new checkout.' },
90
+ { name: 'item', type: 'array', items: 'string', description: 'A variant id and its quantity, ID=QTY (ID alone is 1), repeated. On a checkout given, QTY 0 takes it out.' },
91
+ ...BUYER,
92
+ { name: 'shipping', type: 'string', description: 'A shipping option id from shipping_options. Without it, the store\'s first.' },
93
+ ],
94
+ effect: 'Opens a checkout, or changes one: items, the buyer and where to ship. No money moves. The buyer\'s details go to the store only after the human\'s yes on exactly what is shared and with which host; ask the human for each, never invent one. Answers the totals with shipping, and what the store still needs.',
95
+ },
96
+ {
97
+ verb: 'order',
98
+ method: 'POST',
99
+ path: '/shop/{id}/order',
100
+ state: 'none',
101
+ live: false,
102
+ body: [{ name: 'checkout', type: 'string', required: true, description: 'The checkout to pay, by the id checkout answered. The first bare value after the store.' }],
103
+ effect: 'Pays a checkout that is ready with the sandbox test handler (no money moves), after the human\'s yes on the total, and answers the order. On a checkout already paid, answers its order and pays nothing.',
104
+ },
105
+ ],
106
+ }),
107
+ positional: { get: 'variant', ask: 'question', checkout: 'checkout', order: 'checkout' },
108
+ async run(run) {
109
+ const origin = originOf(run.parsed.id);
110
+ const store = await discover(origin);
111
+ switch (run.verb.name) {
112
+ case 'profile':
113
+ return profile(store);
114
+ case 'search':
115
+ return search(run, store);
116
+ case 'get':
117
+ return get(run, store);
118
+ case 'ask':
119
+ return ask(run, store);
120
+ case 'checkout':
121
+ return checkout(run, store);
122
+ case 'order':
123
+ return order(run, store);
124
+ }
125
+ return undefined;
126
+ },
127
+ };
128
+ const cmd = (store, ...rest) => ['c11r shop', ...rest.slice(0, 1), store.host, ...rest.slice(1)].join(' ');
129
+ const plain = (d) => (typeof d === 'string' ? d : (d?.plain ?? d?.markdown ?? d?.html));
130
+ const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && !v.length)));
131
+ const upper = (v) => (typeof v === 'string' && v ? v.toUpperCase() : undefined);
132
+ /** An answer, with signed: false and a warning when the store did not sign it. */
133
+ function answer(data, c, extra = {}) {
134
+ const unsigned = c?.signature === 'unsigned';
135
+ return envelope(data, {
136
+ ...extra,
137
+ warnings: [...(extra.warnings ?? []), ...(unsigned ? [{ code: 'answer_unsigned', message: 'The store did not sign this answer, so it cannot be checked as the store\'s own.' }] : [])],
138
+ });
139
+ }
140
+ // ---------- profile
141
+ function profile(store) {
142
+ const { handlers, can_pay } = handlersOf(store);
143
+ return envelope({
144
+ store: store.host,
145
+ name: store.profile.name ?? undefined,
146
+ ucp_version: store.profile.ucp?.version,
147
+ endpoint: store.endpoint,
148
+ capabilities: [...store.capabilities].sort(),
149
+ payment_handlers: handlers,
150
+ can_pay,
151
+ signed: store.keys.length > 0,
152
+ }, {
153
+ next: [{ command: cmd(store, 'search', '--query WORDS'), why: 'what the store sells' }],
154
+ warnings: can_pay ? [] : [{ code: 'cannot_pay_here', message: `This CLI pays only with the sandbox test handler; ${store.host} ${handlers.length ? `takes ${handlers.join(', ')}` : 'takes no payment over UCP'}. The human buys there in person.` }],
155
+ });
156
+ }
157
+ // ---------- reading
158
+ function variantView(v) {
159
+ const m = v.fulfillment?.methods?.[0];
160
+ return drop({
161
+ id: v.id,
162
+ sku: v.sku,
163
+ title: v.title,
164
+ price: v.price ? money(v.price.amount, v.price.currency) : undefined,
165
+ list_price: v.list_price && v.list_price.amount !== v.price?.amount ? money(v.list_price.amount, v.list_price.currency) : undefined,
166
+ available: v.availability?.available,
167
+ availability: v.availability?.status,
168
+ image_url: v.media?.find((x) => x.type === 'image')?.url,
169
+ ships_to: m?.ships_to,
170
+ ships_to_country: m?.destination ? m.destination.ships !== false : undefined,
171
+ });
172
+ }
173
+ function productView(p, full) {
174
+ const description = plain(p.description);
175
+ return drop({
176
+ id: p.id,
177
+ title: p.title,
178
+ description: full || !description || description.length <= 200 ? description : `${description.slice(0, 197)}...`,
179
+ url: p.url,
180
+ image_url: p.media?.find((x) => x.type === 'image')?.url,
181
+ categories: Array.isArray(p.categories) ? p.categories.map((c) => c.value ?? c) : undefined,
182
+ variants: (p.variants ?? []).map(variantView),
183
+ });
184
+ }
185
+ const context = (country) => (upper(country) ? { context: { address_country: upper(country) } } : {});
186
+ async function search(run, store) {
187
+ if (!store.capabilities.has('dev.ucp.shopping.catalog.search'))
188
+ fail('not_supported', `${store.host} offers no catalog search over UCP.`, { next_step: { action: 'human_step', url: store.origin } });
189
+ const v = run.parsed.values;
190
+ const limit = Math.min(Math.max(Number(v.limit ?? 20), 1), 50);
191
+ const body = { pagination: { limit }, ...context(v.country) };
192
+ if (typeof v.query === 'string' && v.query)
193
+ body.query = v.query;
194
+ const filters = {};
195
+ if (Array.isArray(v.category) && v.category.length)
196
+ filters.categories = v.category;
197
+ if (v.in_stock)
198
+ filters.availability = 'in_stock';
199
+ const sort = v.sort && v.sort !== 'relevance' ? v.sort : undefined;
200
+ if (sort)
201
+ body.sort = sort;
202
+ if (v.min_price !== undefined || v.max_price !== undefined) {
203
+ // filters.price is in minor units of the currency the store prices in here: ask it which.
204
+ const probe = await call(store, 'POST', `${store.endpoint}/catalog/search`, { ...body, ...(Object.keys(filters).length ? { filters } : {}), pagination: { limit: 1 } }, 'a catalog search');
205
+ const currency = probe.body?.products?.[0]?.variants?.[0]?.price?.currency;
206
+ if (currency) {
207
+ body.context = { ...(body.context ?? {}), currency };
208
+ filters.price = drop({ min: v.min_price === undefined ? undefined : minorOf(Number(v.min_price), currency), max: v.max_price === undefined ? undefined : minorOf(Number(v.max_price), currency) });
209
+ }
210
+ }
211
+ if (Object.keys(filters).length)
212
+ body.filters = filters;
213
+ const c = await call(store, 'POST', `${store.endpoint}/catalog/search`, body, 'a catalog search');
214
+ const products = c.body?.products ?? [];
215
+ const first = products[0]?.variants?.[0]?.id;
216
+ return answer(drop({
217
+ store: store.host,
218
+ query: body.query,
219
+ count: products.length,
220
+ total_count: c.body?.pagination?.total_count,
221
+ has_more: c.body?.pagination?.has_next_page || undefined,
222
+ products: products.map((p) => productView(p, false)),
223
+ messages: c.body?.messages,
224
+ }), c, { next: first ? [{ command: cmd(store, 'get', first), why: 'the whole product, and how it ships' }] : [] });
225
+ }
226
+ async function get(run, store) {
227
+ if (!store.capabilities.has('dev.ucp.shopping.catalog.lookup'))
228
+ fail('not_supported', `${store.host} offers no catalog lookup over UCP: search finds products there.`, { next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
229
+ const ids = run.parsed.values.variant;
230
+ const c = await call(store, 'POST', `${store.endpoint}/catalog/lookup`, { ids, ...context(run.parsed.values.country) }, ids.length > 1 ? `variants ${ids.join(', ')}` : `variant ${ids[0]}`);
231
+ const products = c.body?.products ?? [];
232
+ const found = new Set(products.flatMap((p) => (p.variants ?? []).map((x) => x.id)));
233
+ const missing = ids.filter((id) => !found.has(id));
234
+ if (!products.length)
235
+ fail('not_found', `${store.host} sells no variant ${ids.join(', ')}.`, { path: 'variant', next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
236
+ return answer(drop({ store: store.host, products: products.map((p) => productView(p, true)), missing }), c, {
237
+ next: [{ command: cmd(store, 'checkout', `--item ${ids[0]}=QTY ${HUMAN_FLAGS}`), why: 'buy it: every detail from the human, never invented' }],
238
+ warnings: missing.length ? [{ code: 'not_found', message: `${store.host} did not answer ${missing.join(', ')}.`, path: 'variant' }] : [],
239
+ });
240
+ }
241
+ async function ask(run, store) {
242
+ if (!store.capabilities.has('dev.ucp.common.ask'))
243
+ fail('not_supported', `${store.host} does not answer questions over UCP: its catalog search may.`, { next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
244
+ const question = run.parsed.values.question;
245
+ const c = await call(store, 'POST', `${store.commonEndpoint ?? store.endpoint}/ask`, { query: question }, 'a question');
246
+ return answer(drop({ store: store.host, question, answer: plain(c.body?.answer), links: c.body?.links, messages: c.body?.messages }), c);
247
+ }
248
+ function items(raw) {
249
+ return (raw ?? []).map((s) => {
250
+ const m = s.match(/^(.+?)(?:=(\d+))?$/);
251
+ if (!m || !m[1].trim())
252
+ fail('invalid_value', `--item ${s} is not ID or ID=QTY: a variant id from search, as in ETH-250=2.`, { path: 'item' });
253
+ return { id: m[1].trim(), quantity: m[2] === undefined ? 1 : Number(m[2]) };
254
+ });
255
+ }
256
+ /** The buyer and the address as the flags give them, in UCP's names. Only what was given. */
257
+ function given(v) {
258
+ const a = (v.address ?? {});
259
+ const buyer = drop({ email: v.email, first_name: v.first_name, last_name: v.last_name, phone_number: v.phone });
260
+ const address = drop({ street_address: a.street, extended_address: a.line_2, address_locality: a.locality, postal_code: a.postal_code, address_region: upper(a.region), address_country: upper(a.country) });
261
+ return { buyer, address };
262
+ }
263
+ const NEEDED = [
264
+ ['--email', (b) => b.email],
265
+ ['--first-name', (b) => b.first_name],
266
+ ['--last-name', (b) => b.last_name],
267
+ ['--phone', (b) => b.phone_number],
268
+ ['--address.street', (_, a) => a.street_address],
269
+ ['--address.locality', (_, a) => a.address_locality],
270
+ ['--address.postal-code', (_, a) => a.postal_code],
271
+ ['--address.country', (_, a) => a.address_country],
272
+ ];
273
+ /** Every detail a new checkout needs, each a CAPS word the human's value replaces. */
274
+ const HUMAN_FLAGS = '--email EMAIL --first-name FIRST_NAME --last-name LAST_NAME --phone PHONE --address.street STREET --address.locality CITY --address.postal-code POSTAL_CODE --address.country COUNTRY';
275
+ /** The flags that give what a store's messages say is missing, by the UCP field they name. */
276
+ const FIELD_FLAGS = [
277
+ [/\bemail\b/, '--email EMAIL'],
278
+ [/\bfirst_name\b/, '--first-name FIRST_NAME'],
279
+ [/\blast_name\b/, '--last-name LAST_NAME'],
280
+ [/\bphone(_number)?\b/, '--phone PHONE'],
281
+ [/\bstreet_address\b/, '--address.street STREET'],
282
+ [/\bextended_address\b/, '--address.line-2 LINE_2'],
283
+ [/\baddress_locality\b/, '--address.locality CITY'],
284
+ [/\bpostal_code\b/, '--address.postal-code POSTAL_CODE'],
285
+ [/\baddress_region\b/, '--address.region REGION'],
286
+ [/\baddress_country\b/, '--address.country COUNTRY'],
287
+ [/\bselected_option_id\b/, '--shipping OPTION_ID'],
288
+ [/\bline_items\b/, '--item VARIANT=QTY'],
289
+ ];
290
+ /** A runnable command for what the store still needs, or null when its messages name no field. */
291
+ function stillNeeds(ck, store) {
292
+ const said = (ck.messages ?? []).map((m) => [m.path, m.code].filter(Boolean).join(' ')).join(' ');
293
+ const flags = FIELD_FLAGS.filter(([re]) => re.test(said)).map(([, f]) => f);
294
+ return flags.length ? cmd(store, 'checkout', ck.id, ...flags) : null;
295
+ }
296
+ /** What the human sees before their details go: every value, in one line. */
297
+ function sharing(buyer, address) {
298
+ const name = [buyer.first_name, buyer.last_name].filter(Boolean).join(' ');
299
+ const place = [[address.postal_code, address.address_locality].filter(Boolean).join(' '), address.address_region, address.address_country].filter(Boolean).join(' ');
300
+ return [name, buyer.email, buyer.phone_number, address.street_address, address.extended_address, place].filter(Boolean).join(', ');
301
+ }
302
+ /** The update payload for a checkout: everything the platform may write, sent whole again. */
303
+ function replay(ck) {
304
+ const out = { line_items: (ck.line_items ?? []).map((l) => ({ id: l.id, item: { id: l.item?.id }, quantity: l.quantity })) };
305
+ for (const k of ['buyer', 'context'])
306
+ if (ck[k] !== undefined)
307
+ out[k] = ck[k];
308
+ if (ck.discounts?.codes?.length)
309
+ out.discounts = { codes: ck.discounts.codes };
310
+ if (ck.fulfillment?.methods) {
311
+ out.fulfillment = {
312
+ methods: ck.fulfillment.methods.map((m) => ({
313
+ id: m.id,
314
+ type: m.type,
315
+ line_item_ids: m.line_item_ids,
316
+ destinations: m.destinations,
317
+ selected_destination_id: m.selected_destination_id ?? null,
318
+ groups: (m.groups ?? []).map((g) => ({ id: g.id, selected_option_id: g.selected_option_id ?? null })),
319
+ })),
320
+ };
321
+ }
322
+ return out;
323
+ }
324
+ const destinationOf = (ck) => {
325
+ const m = ck.fulfillment?.methods?.[0];
326
+ return m?.destinations?.find((d) => d.id === m.selected_destination_id);
327
+ };
328
+ function checkoutView(ck, store) {
329
+ const cur = ck.currency;
330
+ const group = ck.fulfillment?.methods?.[0]?.groups?.[0];
331
+ const options = group?.options ?? [];
332
+ const option = options.find((o) => o.id === group?.selected_option_id);
333
+ const totalOf = (t) => t?.find((x) => x.type === 'total')?.amount;
334
+ const optionView = (o) => drop({ id: o.id, title: o.title, description: plain(o.description), price: totalOf(o.totals) === undefined ? undefined : money(totalOf(o.totals), cur) });
335
+ const { id: _id, type: _type, ...ship_to } = destinationOf(ck) ?? {};
336
+ const total = totalOf(ck.totals);
337
+ return drop({
338
+ id: ck.id,
339
+ status: ck.status,
340
+ store: store.host,
341
+ currency: cur,
342
+ line_items: (ck.line_items ?? []).map((l) => drop({ item_id: l.item?.id, title: l.item?.title, quantity: l.quantity, unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur), total: totalOf(l.totals) === undefined ? undefined : money(totalOf(l.totals), cur) })),
343
+ buyer: ck.buyer ? drop(Object.fromEntries(Object.entries(ck.buyer).filter(([k]) => k !== 'consent'))) : undefined,
344
+ ship_to: Object.keys(ship_to).length ? ship_to : undefined,
345
+ shipping: option ? optionView(option) : undefined,
346
+ shipping_options: options.length > 1 ? options.map(optionView) : undefined,
347
+ totals: (ck.totals ?? []).map((t) => drop({ type: t.type, label: t.display_text, ...money(t.amount, cur) })),
348
+ total: total === undefined ? undefined : money(total, cur),
349
+ messages: ck.messages,
350
+ order: ck.order,
351
+ continue_url: ck.continue_url,
352
+ expires_at: ck.expires_at,
353
+ });
354
+ }
355
+ function nextOf(ck, store) {
356
+ if (ck.status === 'ready_for_complete')
357
+ return [{ command: cmd(store, 'order', ck.id), why: `pay ${ck.totals ? money(ck.totals.find((t) => t.type === 'total')?.amount ?? 0, ck.currency).display : 'the total'} with the sandbox test handler, after the human's yes on it` }];
358
+ if (ck.status === 'completed' && ck.order?.id)
359
+ return [{ command: cmd(store, 'order', ck.id), why: 'the order' }];
360
+ const needs = stillNeeds(ck, store);
361
+ return needs ? [{ command: needs, why: 'what the store says in messages is missing, from the human' }] : [];
362
+ }
363
+ async function checkout(run, store) {
364
+ const v = run.parsed.values;
365
+ const id = v.checkout;
366
+ // Nothing of the human's goes to a store this CLI cannot pay at.
367
+ mustPay(store);
368
+ const lines = items(v.item);
369
+ const { buyer, address } = given(v);
370
+ const current = id ? (await call(store, 'GET', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}`, undefined, `checkout ${id}`)).body : null;
371
+ if (current && (current.status === 'completed' || current.status === 'canceled')) {
372
+ if (lines.length || Object.keys(buyer).length || Object.keys(address).length || v.shipping) {
373
+ fail('invalid_transition', `Checkout ${id} is ${current.status}: it takes no changes.`, { details: { status: current.status }, next_step: current.status === 'completed' ? { action: 'fix_request', command: cmd(store, 'order', id) } : { action: 'fix_request', command: cmd(store, 'checkout', `--item VARIANT=QTY ${HUMAN_FLAGS}`) } }, 'rejected');
374
+ }
375
+ }
376
+ if (!current) {
377
+ if (!lines.length)
378
+ fail('missing_value', 'A new checkout needs at least one --item: a variant id from search, as in --item ETH-250=1.', { path: 'item', next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
379
+ const missing = NEEDED.filter(([, pick]) => !pick(buyer, address)).map(([f]) => f);
380
+ if (missing.length) {
381
+ fail('missing_value', `A checkout needs the buyer's ${missing.join(', ')}. Ask the human for each: they are the human's, never invented.`, { path: missing[0].replace(/^--/, '').replace(/-/g, '_'), details: { missing }, next_step: { action: 'human_step' } });
382
+ }
383
+ }
384
+ // A read: a checkout given with nothing to change.
385
+ if (current && !lines.length && !Object.keys(buyer).length && !Object.keys(address).length && !v.shipping) {
386
+ return envelope(checkoutView(current, store), { next: nextOf(current, store) });
387
+ }
388
+ // What goes to the store of the human's: every value, shown back to them first.
389
+ const shared = Object.keys(buyer).length || Object.keys(address).length ? sharing(buyer, address) : null;
390
+ if (shared && !run.globals.dryRun) {
391
+ await confirm(`Share the buyer's details with ${store.host}`, run.line, { yes: run.globals.yes, prompter: run.prompter, sentence: `Sharing with ${store.host}: ${shared}`, details: { store: store.host, buyer, ship_to: address } });
392
+ }
393
+ const body = current ? replay(current) : { line_items: lines.map((l) => ({ item: { id: l.id }, quantity: l.quantity })), ...context(address.address_country) };
394
+ if (current) {
395
+ for (const l of lines) {
396
+ const i = body.line_items.findIndex((x) => x.item.id === l.id);
397
+ if (l.quantity === 0) {
398
+ if (i >= 0)
399
+ body.line_items.splice(i, 1);
400
+ }
401
+ else if (i >= 0)
402
+ body.line_items[i].quantity = l.quantity;
403
+ else
404
+ body.line_items.push({ item: { id: l.id }, quantity: l.quantity });
405
+ }
406
+ if (!body.line_items.length)
407
+ fail('invalid_value', `That leaves checkout ${id} with no items: keep one, or let it expire.`, { path: 'item' });
408
+ }
409
+ if (Object.keys(buyer).length)
410
+ body.buyer = drop({ ...(body.buyer ?? {}), ...buyer });
411
+ if (Object.keys(address).length || v.shipping) {
412
+ const method = body.fulfillment?.methods?.[0] ?? { type: 'shipping' };
413
+ if (Object.keys(address).length) {
414
+ const prev = (current && destinationOf(current)) ?? {};
415
+ const b = (body.buyer ?? {});
416
+ const dest = drop({ ...prev, first_name: b.first_name, last_name: b.last_name, phone_number: b.phone_number, ...address, id: prev.id ?? 'dest_1' });
417
+ delete dest.type;
418
+ method.destinations = [dest];
419
+ method.selected_destination_id = dest.id;
420
+ if (address.address_country)
421
+ body.context = { ...(body.context ?? {}), address_country: address.address_country };
422
+ }
423
+ if (v.shipping)
424
+ method.groups = (method.groups?.length ? method.groups : [{ id: 'grp_1' }]).map((g) => ({ ...g, selected_option_id: v.shipping }));
425
+ body.fulfillment = { methods: [method, ...(body.fulfillment?.methods?.slice(1) ?? [])] };
426
+ }
427
+ if (run.globals.dryRun)
428
+ return envelope({ dry_run: true, store: store.host, checkout: id ?? null, would_send: body, sharing: shared });
429
+ let c;
430
+ if (current)
431
+ c = await call(store, 'PUT', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}`, body, `checkout ${id}`);
432
+ else {
433
+ // A new checkout: the items first, then the buyer and the address on it, as the store takes them.
434
+ const { buyer: b, fulfillment: f, ...open } = body;
435
+ c = await call(store, 'POST', `${store.endpoint}/checkout-sessions`, open, 'a new checkout');
436
+ const again = replay(c.body);
437
+ if (b)
438
+ again.buyer = b;
439
+ if (f) {
440
+ const m = f.methods[0];
441
+ again.fulfillment = { methods: [{ ...m, line_item_ids: (c.body.line_items ?? []).map((l) => l.id) }] };
442
+ }
443
+ c = await call(store, 'PUT', `${store.endpoint}/checkout-sessions/${encodeURIComponent(c.body.id)}`, again, `checkout ${c.body.id}`);
444
+ }
445
+ // A store that offers shipping options without picking one: its first, said in the answer.
446
+ const group = c.body.fulfillment?.methods?.[0]?.groups?.[0];
447
+ if (group && !group.selected_option_id && group.options?.length) {
448
+ const again = replay(c.body);
449
+ again.fulfillment.methods[0].groups[0].selected_option_id = group.options[0].id;
450
+ c = await call(store, 'PUT', `${store.endpoint}/checkout-sessions/${encodeURIComponent(c.body.id)}`, again, `checkout ${c.body.id}`);
451
+ }
452
+ const ck = c.body;
453
+ return answer(checkoutView(ck, store), c, { next: nextOf(ck, store) });
454
+ }
455
+ // ---------- order
456
+ function orderView(o, store, checkoutId) {
457
+ const cur = o.currency;
458
+ const totalOf = (t) => t?.find((x) => x.type === 'total')?.amount;
459
+ return drop({
460
+ id: o.id,
461
+ label: o.label,
462
+ permalink_url: o.permalink_url,
463
+ store: store.host,
464
+ checkout: checkoutId,
465
+ currency: cur,
466
+ line_items: (o.line_items ?? []).map((l) => drop({ item_id: l.item?.id, title: l.item?.title, quantity: l.quantity?.total ?? l.quantity, status: l.status, total: totalOf(l.totals) === undefined || !cur ? undefined : money(totalOf(l.totals), cur) })),
467
+ fulfillment: (o.fulfillment?.expectations ?? []).map((e) => drop({ method: e.method_type, destination: e.destination, description: plain(e.description) })),
468
+ totals: cur ? (o.totals ?? []).map((t) => drop({ type: t.type, label: t.display_text, ...money(t.amount, cur) })) : undefined,
469
+ total: cur && totalOf(o.totals) !== undefined ? money(totalOf(o.totals), cur) : undefined,
470
+ });
471
+ }
472
+ async function placed(store, ck, paid) {
473
+ const ref = ck.order;
474
+ const c = await call(store, 'GET', `${store.endpoint}/orders/${encodeURIComponent(ref.id)}`, undefined, `order ${ref.id}`);
475
+ return answer({ ...orderView({ label: ref.label, permalink_url: ref.permalink_url, ...c.body }, store, ck.id), paid_now: paid }, c);
476
+ }
477
+ async function order(run, store) {
478
+ const id = run.parsed.values.checkout;
479
+ const ck = (await call(store, 'GET', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}`, undefined, `checkout ${id}`)).body;
480
+ // Paid already: the order, and nothing paid again.
481
+ if (ck.status === 'completed' && ck.order?.id)
482
+ return placed(store, ck, false);
483
+ if (ck.status === 'canceled')
484
+ fail('invalid_transition', `Checkout ${id} was canceled: it cannot be paid.`, { details: { status: ck.status }, next_step: { action: 'fix_request', command: cmd(store, 'checkout', `--item VARIANT=QTY ${HUMAN_FLAGS}`) } }, 'rejected');
485
+ mustPay(store, { continue_url: ck.continue_url ?? null });
486
+ if (ck.status !== 'ready_for_complete') {
487
+ const said = (ck.messages ?? []).map((m) => m.content).filter(Boolean).join(' ');
488
+ fail('invalid_transition', `Checkout ${id} is ${ck.status}, not ready to pay${said ? `: ${said}` : '.'}`, { details: { status: ck.status, messages: ck.messages ?? [] }, next_step: stillNeeds(ck, store) ? { action: 'fix_request', command: stillNeeds(ck, store) } : { action: 'human_step', url: ck.continue_url ?? null } }, 'rejected');
489
+ }
490
+ const totalAmount = (ck.totals ?? []).find((t) => t.type === 'total')?.amount;
491
+ const total = totalAmount === undefined ? null : money(totalAmount, ck.currency);
492
+ const sentence = `Paying ${total?.display ?? 'the total'} to ${store.host} for checkout ${id}, with the sandbox test handler (no money moves)`;
493
+ if (run.globals.dryRun)
494
+ return envelope({ dry_run: true, store: store.host, checkout: id, total, would_pay: true });
495
+ await confirm(`Pay checkout ${id}`, run.line, { yes: run.globals.yes, prompter: run.prompter, sentence, details: { store: store.host, checkout: id, total } });
496
+ const c = await call(store, 'POST', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}/complete`, sandboxBody(), `checkout ${id}`);
497
+ const done = c.body;
498
+ if (done.status !== 'completed' || !done.order?.id) {
499
+ const said = (done.messages ?? []).map((m) => m.content).filter(Boolean).join(' ');
500
+ fail('store_refused', `${store.host} did not take the payment for checkout ${id}${said ? `: ${said}` : '.'}`, { details: { status: done.status, messages: done.messages ?? [] }, next_step: { action: 'human_step', url: done.continue_url ?? null } }, 'rejected');
501
+ }
502
+ return placed(store, done, true);
503
+ }
504
+ const sandboxBody = () => ({ ...sandboxPayment(), signals: { 'dev.ucp.user_agent': 'c11r' } });
package/dist/confirm.js CHANGED
@@ -40,18 +40,25 @@ export const terminal = {
40
40
  });
41
41
  },
42
42
  };
43
+ /**
44
+ * action names what needs a yes ("Destroy sandbox"). sentence, when given, is what the human is
45
+ * shown instead, whole ("Sharing with shop.example.com: Ada Lovelace, ..."), and details go with
46
+ * the error, so the agent can show the human the same.
47
+ */
43
48
  export async function confirm(action, command, opts) {
44
49
  if (opts.yes)
45
50
  return;
46
51
  const p = opts.prompter ?? terminal;
52
+ const said = opts.sentence ? `${opts.sentence.replace(/\.?$/, '.')} This` : action;
47
53
  if (!p.interactive) {
48
54
  // The agent asks the human; on a yes it repeats the command with --yes. next never carries the
49
55
  // --yes itself: only the human's answer adds it.
50
- fail('confirmation_required', `${action} needs confirmation, and there is no terminal to ask in. Ask the human first; on their yes, run it again with --yes.`, {
56
+ fail('confirmation_required', `${said} needs confirmation, and there is no terminal to ask in. Ask the human first; on their yes, run it again with --yes.`, {
51
57
  next_step: { action: 'fix_request', command },
58
+ ...(opts.details ? { details: opts.details } : {}),
52
59
  });
53
60
  }
54
- const answer = (await p.ask(`${action}. Go ahead? [y/N] `)).trim().toLowerCase();
61
+ const answer = (await p.ask(opts.sentence ? `${opts.sentence.replace(/\.?$/, '.')} Go ahead? [y/N] ` : `${action}. Go ahead? [y/N] `)).trim().toLowerCase();
55
62
  if (answer !== 'y' && answer !== 'yes')
56
- fail('confirmation_declined', `${action}: not confirmed, nothing was changed.`);
63
+ fail('confirmation_declined', opts.sentence ? `${opts.sentence.replace(/\.?$/, '')}: not confirmed, nothing was sent.` : `${action}: not confirmed, nothing was changed.`);
57
64
  }
package/dist/engine.js CHANGED
@@ -65,8 +65,11 @@ async function executeOnce(argv, opts) {
65
65
  const by = verb.refused.by === 'commercelayer' ? 'Commerce Layer' : verb.refused.by;
66
66
  fail('not_supported', `c11r ${schema.noun} ${verb.name} is refused by ${by}: ${verb.description || 'it cannot be done through the API.'}`, { details: { refused_by: verb.refused.by }, next_step: { action: 'none' } });
67
67
  }
68
- const positional = ext?.positional?.[verb.name];
69
- const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
68
+ // A bare value that is a flag's: the first for a verb with no id (sales-calls book <slot>), the
69
+ // second, after the id, for one with (shop order <store> <checkout>).
70
+ const positional = (builtins[schema.noun]?.positional ?? ext?.positional)?.[verb.name];
71
+ const bare = (i) => cmd.rest[i] !== undefined && !cmd.rest[i].startsWith('-');
72
+ const rest = !positional ? cmd.rest : !verb.id && bare(0) ? [`--${positional}`, ...cmd.rest] : verb.id && bare(0) && bare(1) ? [cmd.rest[0], `--${positional}`, ...cmd.rest.slice(1)] : cmd.rest;
70
73
  const parsed = parseFields(schema.noun, verb, rest);
71
74
  ext?.prepare?.(verb, parsed);
72
75
  // Files first, so a required document given with --file counts; a secret is typed only once
package/dist/errors.js CHANGED
@@ -62,6 +62,10 @@ const KIND = {
62
62
  no_credentials: 'auth',
63
63
  forbidden: 'rejected',
64
64
  claim_not_found: 'not_found',
65
+ // A store spoken to over UCP (c11r shop).
66
+ store_unavailable: 'unavailable',
67
+ store_refused: 'rejected',
68
+ payment_handler_unsupported: 'rejected',
65
69
  };
66
70
  /** For an API error whose code the CLI does not know: from the HTTP status. */
67
71
  export function kindOfStatus(status) {
package/dist/help.js CHANGED
@@ -30,7 +30,7 @@ export function indexHelp(builtins, api) {
30
30
  description: 'c11r is the Commerce Layer CLI for coding agents. Every answer, errors included, is one JSON object on stdout.',
31
31
  nouns: [...builtins, ...(api ?? [])].sort((a, b) => (a.noun < b.noun ? -1 : 1)),
32
32
  standard_verbs: 'list, get, create, update, delete. Nouns with a state machine add their own verbs.',
33
- flags: 'Fields in kebab or snake case (--price-list, --price_list). On list, --<field> <value> filters by equality and --<field>.<op> <value> by another operator (--created-at.gte 2026-11-01). in takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it, and a list that has one is sent as a JSON array. A ref flag, or a flag the schema marks nullable, given null or an empty value sends null and takes it off (--customer-group null, --coupon-code null). A dotted flag sets part of an object (--customer.email). Any other array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes key=value pairs (--items sku=COFFEE,quantity=2). --data takes the fields as one JSON object, or @ and a file name.',
33
+ flags: 'Fields in kebab or snake case (--price-list, --price_list). On list, --<field> <value> filters by equality and --<field>.<op> <value> by another operator (--created-at.gte 2026-11-01). in takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it, and a list that has one is sent as a JSON array. A ref flag, or a flag the schema marks nullable, given null or an empty value sends null and takes it off (--customer-group null, --coupon-code null). A dotted flag sets part of an object (--customer.email). Any other array takes the flag repeated or a JSON array, and the same rule holds: enums, numbers and dates also separated by commas, a name or text one value per flag (--payment-settings "Invoice, 60 days"); an array of objects takes key=value pairs (--items sku=COFFEE,quantity=2). --data takes the fields as one JSON object, or @ and a file name.',
34
34
  global_flags: GLOBAL_FLAG_HELP,
35
35
  exit_codes: EXIT_CODE_HELP,
36
36
  more: 'c11r <noun> --help describes a noun and its verbs; c11r <noun> <verb> --help a verb and its flags; c11r schema <noun> returns the full schema.',
package/dist/ucp.js ADDED
@@ -0,0 +1,207 @@
1
+ // A UCP platform client: what any shopping agent does to buy from a store that speaks UCP.
2
+ // The CLI talks to the store directly, as the agent CLI platform: its profile is the static
3
+ // https://commercelayer.ai/ucp/agent-cli/profile.json, it holds no keys, and stores verify no
4
+ // request signature. Every call carries UCP-Agent and Request-Id, a Content-Digest with a body,
5
+ // and an Idempotency-Key on writes. The store signs its answers (RFC 9421, ES256 over @status,
6
+ // content-digest and content-type): the CLI checks them against keys[] of its /.well-known/ucp.
7
+ import crypto from 'node:crypto';
8
+ import { CliError, fail } from './errors.js';
9
+ import { USER_AGENT } from './version.js';
10
+ export const UCP_VERSION = '2026-08-25';
11
+ export const AGENT_PROFILE_URL = 'https://commercelayer.ai/ucp/agent-cli/profile.json';
12
+ /** The sandbox payment handler: no money moves. The only one this CLI pays with. */
13
+ export const SANDBOX_HANDLER = 'ai.commercelayer.sandbox';
14
+ const SANDBOX_HANDLER_ID = 'cl_sandbox';
15
+ const SANDBOX_CREDENTIAL_TYPE = 'sandbox_token';
16
+ const SANDBOX_TOKEN_SUCCESS = 'sbx_tok_success';
17
+ const TIMEOUT_MS = 30_000;
18
+ /** A store from a host or a URL on it: https only (http for localhost, in tests). */
19
+ export function originOf(given) {
20
+ let url = null;
21
+ try {
22
+ url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(given) ? given : `https://${given}`);
23
+ }
24
+ catch {
25
+ url = null;
26
+ }
27
+ if (!url || !url.hostname)
28
+ fail('invalid_value', `${given} is not a store: give its host, as in shop.example.com, or an https URL on it.`, { path: 'id' });
29
+ const local = ['localhost', '127.0.0.1'].includes(url.hostname);
30
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local))
31
+ fail('invalid_value', `${given} is not https: UCP stores are served over https.`, { path: 'id' });
32
+ return url.origin;
33
+ }
34
+ /** GET /.well-known/ucp: what the store offers this CLI. */
35
+ export async function discover(origin) {
36
+ const res = await http('GET', `${origin}/.well-known/ucp`, { accept: 'application/json' });
37
+ const host = new URL(origin).host;
38
+ if (res.status === 404)
39
+ fail('not_found', `${host} serves no UCP profile (/.well-known/ucp answered 404): it does not sell to agents.`, { path: 'id', next_step: { action: 'human_step', url: origin } });
40
+ if (res.status !== 200)
41
+ fail('store_unavailable', `${host}/.well-known/ucp answered ${res.status}.`, { details: { status: res.status }, next_step: { action: 'retry_later' } });
42
+ const profile = parse(res.body);
43
+ if (!profile || typeof profile.ucp !== 'object')
44
+ fail('invalid_response', `${host}/.well-known/ucp is not a UCP profile.`, { next_step: { action: 'none' } });
45
+ const ucp = profile.ucp;
46
+ if (ucp.version !== UCP_VERSION)
47
+ fail('not_supported', `${host} speaks UCP ${ucp.version ?? '(no version)'}; this CLI speaks ${UCP_VERSION}.`, { details: { store_version: ucp.version ?? null, cli_version: UCP_VERSION }, next_step: { action: 'none' } });
48
+ const rest = (name) => (ucp.services?.[name] ?? []).find((s) => s.transport === 'rest')?.endpoint ?? null;
49
+ const endpoint = rest('dev.ucp.shopping');
50
+ if (!endpoint)
51
+ fail('not_supported', `${host}'s profile has no REST dev.ucp.shopping service.`, { next_step: { action: 'none' } });
52
+ const capabilities = new Set();
53
+ for (const [name, entries] of Object.entries((ucp.capabilities ?? {})))
54
+ if (Array.isArray(entries) && entries.some((e) => e.version === UCP_VERSION))
55
+ capabilities.add(name);
56
+ return { origin, host, profile, endpoint, commonEndpoint: rest('dev.ucp.common'), capabilities, keys: Array.isArray(profile.keys) ? profile.keys : [] };
57
+ }
58
+ /** The payment handlers the store offers, by name, and whether this CLI can pay there. */
59
+ export function handlersOf(store) {
60
+ const handlers = Object.keys(store.profile.ucp?.payment_handlers ?? {});
61
+ return { handlers, can_pay: handlers.includes(SANDBOX_HANDLER) && store.capabilities.has('dev.ucp.shopping.checkout') };
62
+ }
63
+ /** Before anything of the human's is sent: a store this CLI cannot pay at is refused. */
64
+ export function mustPay(store, extra = {}) {
65
+ if (!store.capabilities.has('dev.ucp.shopping.checkout')) {
66
+ fail('not_supported', `${store.host} offers no UCP checkout: an agent can browse it, not buy there.`, { next_step: { action: 'human_step', url: store.origin } });
67
+ }
68
+ const { handlers } = handlersOf(store);
69
+ if (!handlers.includes(SANDBOX_HANDLER)) {
70
+ fail('payment_handler_unsupported', `${store.host} takes ${handlers.length ? handlers.join(', ') : 'no payment handler'}; this CLI pays only with the ${SANDBOX_HANDLER} test handler, where no money moves. The human buys there in person.`, { details: { handlers }, next_step: { action: 'human_step', url: extra.continue_url ?? store.origin } }, 'rejected');
71
+ }
72
+ }
73
+ export const sandboxPayment = () => ({ payment: { instruments: [{ id: 'pi_1', handler_id: SANDBOX_HANDLER_ID, type: 'card', selected: true, credential: { type: SANDBOX_CREDENTIAL_TYPE, token: SANDBOX_TOKEN_SUCCESS } }] } });
74
+ /** A UCP call. The answer's digest and signature are checked; an error is a CliError. */
75
+ export async function call(store, method, url, body, what = 'the store') {
76
+ const raw = body === undefined ? undefined : JSON.stringify(body);
77
+ const headers = { accept: 'application/json', 'ucp-agent': `profile="${AGENT_PROFILE_URL}"`, 'request-id': crypto.randomUUID() };
78
+ if (raw !== undefined) {
79
+ headers['content-type'] = 'application/json';
80
+ headers['content-digest'] = contentDigest(raw);
81
+ }
82
+ if (method !== 'GET' && !/\/catalog\/|\/ask$/.test(url))
83
+ headers['idempotency-key'] = crypto.randomUUID();
84
+ const res = await http(method, url, headers, raw);
85
+ const signature = checkAnswer(store, res);
86
+ const parsed = parse(res.body);
87
+ if (res.status >= 400 || parsed?.ucp?.status === 'error')
88
+ refused(store, res.status, parsed, what);
89
+ if (!parsed || typeof parsed !== 'object')
90
+ fail('invalid_response', `${store.host} answered ${what} with no JSON.`, { details: { status: res.status }, next_step: { action: 'retry_later' } });
91
+ return { status: res.status, body: parsed, signature };
92
+ }
93
+ /** The store's own words, as an error the agent can show the human. */
94
+ function refused(store, status, body, what) {
95
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
96
+ const first = messages.find((m) => m.type === 'error') ?? messages[0];
97
+ const code = body?.code ?? first?.code;
98
+ const said = body?.content ?? first?.content ?? `it answered ${status}`;
99
+ const details = { status, store_code: code ?? null, messages: messages.length ? messages : undefined };
100
+ if (status === 404 || code === 'not_found')
101
+ fail('not_found', `${store.host} does not know ${what}: ${said}`, { details, next_step: { action: 'none' } });
102
+ if (status === 401 && /signature/i.test(`${code} ${said}`)) {
103
+ fail('not_supported', `${store.host} wants a signed request, and this CLI signs none: ${said}`, { details, next_step: { action: 'human_step', url: store.origin } });
104
+ }
105
+ if (status === 401 || code === 'identity_required')
106
+ fail('forbidden', `${store.host} refused ${what}: ${said} This CLI reads only orders it placed.`, { details, next_step: { action: 'none' } });
107
+ if (status === 429 || status >= 500)
108
+ fail('store_unavailable', `${store.host} could not answer ${what}: ${said}`, { details, next_step: { action: 'retry_later' } });
109
+ fail('store_refused', `${store.host} refused ${what}: ${said}`, { details, next_step: { action: 'human_step' } }, 'rejected');
110
+ }
111
+ async function http(method, url, headers, body) {
112
+ let res;
113
+ try {
114
+ res = await fetch(url, { method, headers: { 'user-agent': USER_AGENT, ...headers }, body, redirect: 'follow', signal: AbortSignal.timeout(TIMEOUT_MS) });
115
+ }
116
+ catch (e) {
117
+ const host = (() => {
118
+ try {
119
+ return new URL(url).host;
120
+ }
121
+ catch {
122
+ return url;
123
+ }
124
+ })();
125
+ throw new CliError({ code: 'store_unavailable', message: `${host} could not be reached: ${e.message}.`, required_state: null, required_plan: null, missing: null, path: null, next_step: { action: 'retry_later', command: null, url: null } }, 'unavailable');
126
+ }
127
+ const out = {};
128
+ res.headers.forEach((v, k) => (out[k.toLowerCase()] = v));
129
+ return { status: res.status, headers: out, body: await res.text() };
130
+ }
131
+ function parse(text) {
132
+ if (!text)
133
+ return null;
134
+ try {
135
+ const v = JSON.parse(text);
136
+ return v && typeof v === 'object' ? v : null;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ // ---------- digests and signatures (RFC 9530, RFC 9421)
143
+ export function contentDigest(body) {
144
+ return `sha-256=:${crypto.createHash('sha256').update(body).digest('base64')}:`;
145
+ }
146
+ function serializeParams(p) {
147
+ let s = `(${p.components.map((c) => `"${c}"`).join(' ')})`;
148
+ if (p.created !== undefined)
149
+ s += `;created=${p.created}`;
150
+ return `${s};keyid="${p.keyid}"`;
151
+ }
152
+ export function signatureBase(values, p) {
153
+ return [...p.components.map((c) => `"${c}": ${values[c] ?? ''}`), `"@signature-params": ${serializeParams(p)}`].join('\n');
154
+ }
155
+ function parseSignature(input, signature) {
156
+ if (!input || !signature)
157
+ return null;
158
+ const m = input.match(/^\s*([a-z0-9_-]+)=\(([^)]*)\)((?:;[a-z]+=[^;]*)*)\s*$/i);
159
+ if (!m)
160
+ return null;
161
+ const [, label, list, rest] = m;
162
+ const params = { components: [...list.matchAll(/"([^"]+)"/g)].map((x) => x[1]), keyid: '' };
163
+ for (const [, k, v] of rest.matchAll(/;([a-z]+)=("[^"]*"|[^;]*)/g)) {
164
+ if (k === 'keyid')
165
+ params.keyid = v.replace(/^"|"$/g, '');
166
+ if (k === 'created')
167
+ params.created = Number(v);
168
+ }
169
+ const sig = signature.match(new RegExp(`${label}=:([A-Za-z0-9+/=]+):`));
170
+ return sig ? { params, signature: sig[1] } : null;
171
+ }
172
+ function verify(base, signature, jwk) {
173
+ try {
174
+ const { kid: _k, use: _u, alg: _a, key_ops: _o, ...key } = jwk;
175
+ return crypto.verify('sha256', Buffer.from(base), { key: crypto.createPublicKey({ key: key, format: 'jwk' }), dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64'));
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ }
181
+ /** A digest that does not match, or a signature that does not verify: the answer is not trusted. */
182
+ function checkAnswer(store, res) {
183
+ const digest = res.headers['content-digest'];
184
+ if (digest && digest !== contentDigest(res.body))
185
+ fail('invalid_response', `${store.host}'s answer does not match its Content-Digest: it was changed on the way, and is not used.`, { next_step: { action: 'retry_later' } });
186
+ const sig = parseSignature(res.headers['signature-input'], res.headers.signature);
187
+ if (!sig)
188
+ return 'unsigned';
189
+ const key = store.keys.find((k) => k.kid === sig.params.keyid);
190
+ const values = { '@status': String(res.status), 'content-digest': digest ?? '', 'content-type': res.headers['content-type'] ?? '' };
191
+ if (!key || !verify(signatureBase(values, sig.params), sig.signature, key)) {
192
+ fail('invalid_response', `${store.host}'s answer is signed with ${key ? 'a signature that does not verify' : `a key its profile does not publish (${sig.params.keyid})`}: it is not used.`, { next_step: { action: 'none' } });
193
+ }
194
+ return 'verified';
195
+ }
196
+ export function money(amount, currency) {
197
+ let digits = 2;
198
+ try {
199
+ digits = new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ?? 2;
200
+ }
201
+ catch {
202
+ digits = 2;
203
+ }
204
+ const major = amount / 10 ** digits;
205
+ return { amount, currency, display: `${amount < 0 ? '-' : ''}${currency} ${Math.abs(major).toFixed(digits)}` };
206
+ }
207
+ export const minorOf = (major, currency) => Math.round(major * 10 ** (money(0, currency).display.split('.')[1]?.length ?? 0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "1.0.0-dev.12",
3
+ "version": "1.0.0-dev.13",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. One grammar, c11r <noun> <verb>, from a sandbox to a running organization.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [