@commercelayer/agent-cli 0.5.0 → 0.5.2

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
@@ -10,6 +10,7 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
10
10
  - **The website comes with the store.** Every store has a hosted theme at its preview URL, set by a few `theme:` settings and the product data: `apply` is all it takes. A custom storefront (your own web app) is opt-in: `storefront init`, `publish` and `check`.
11
11
  - **JSON only.** Every command prints one JSON object. A failure exits 1 with `{ "error": { "code", "message", "hint" } }`; the hint is the fix.
12
12
  - **Safe to re-run.** Every command is idempotent; a failed step can be fixed and run again.
13
+ - **Always current.** Run it as `npx -y @commercelayer/agent-cli@latest`, even where a `c11r` binary is installed; the note `init` writes in `AGENTS.md` and `CLAUDE.md` says so. The CLI sends `User-Agent: c11r/<version> (node/<major>)`, and an outdated one gets a `cli_outdated` entry in `warnings`, with `current`, `latest` and a `hint`.
13
14
  - **No secrets in git.** `init` writes the sandbox token to `.commercelayer/credentials` (`C11R_ORG`, `C11R_TOKEN`) and gitignores it. The keys of a catalog source go in the same file.
14
15
  ## Commands
15
16
  | Command | Does |
@@ -34,15 +35,15 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
34
35
  | `fulfillment list [--status <status>\|all] [--limit <n>]` · `show <shipment>` | The shipments, by default the ones not shipped yet. A shipment is its number (`1001/S/001`), its id, or its order's number. |
35
36
  | `fulfillment hold` · `pick` · `pack` · `ready` · `ship <shipment> [--carrier] [--tracking] [--tracking-url]` · `deliver` | Move a shipment; each prints the shipment after it. |
36
37
  | `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
37
- | `ucp search [<words>] [--category <name>] [--min-price <n>] [--max-price <n>] [--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 with their categories, prices in major units in `price.display`, sale prices, shipping and the market the prices are for. |
38
+ | `ucp search [<words>] [--category <name>] [--min-price <n>] [--max-price <n>] [--country <CC>] [--images]` · `ucp product <id> [--images]` | Buy for the human, step by step (see [Buying for the human](#buying-for-the-human)): the store's products with their categories, prices in major units in `price.display`, sale prices, shipping and the market the prices are for. |
38
39
  | `ucp checkout create --item <variant id>[:<qty>] … [--code <discount>] [--country <CC>]` | Open a checkout; `messages` say what is still missing (email, shipping address). |
39
40
  | `ucp checkout update <id> [--email …] [--street …] [--item <variant id>:<qty>] [--code …] [--shipping <option>]` | Give the buyer's details (flags, or YAML or JSON on stdin or `-f`), change items or codes; `ready_for_complete` once nothing is missing, with shipping in the total. |
40
41
  | `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`. |
41
42
  | `ucp order <id>` · `ucp ask "<question>"` | Read an order the CLI placed; ask the store a question. Every `ucp` buying command takes `--store <host>` to buy from any store that speaks UCP. |
42
43
  | `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. |
43
- | `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. |
44
+ | `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 store's semantic search index (`search`: model, status, vectors, embedded, updated_at, error; null before its first run), the UCP profile URL. |
44
45
  | `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
45
- | `help [<command>]` | Describe the commands. `--version` prints the version. |
46
+ | `help [<command>]` | Describe the commands. `--version` prints the version and `latest_hint`, how to always run the newest. |
46
47
  ## store.yml
47
48
  `init` writes a commented template. A filled-in store looks like this:
48
49
  ```yaml
@@ -159,9 +160,9 @@ A custom storefront replaces the theme: `storefront.path` in `store.yml` names i
159
160
  Error codes: `invalid_theme`, `invalid_storefront`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_unsupported`, `invalid_link`, `link_exists`, `link_not_managed` (reserved), `links_unsupported`, `missing_images`, `asset_too_large`, `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
160
161
  ## Orders and fulfillment
161
162
 
162
- Orders land **placed** with the payment **authorized**, not taken: the store no longer approves or captures them. The merchant, through the agent, accepts an order with `orders approve <n> --capture` (or approves now and runs `orders capture <n>` later), cancels one it doesn't want (`orders cancel`, which voids the authorization; a shipped order can't be cancelled, a captured one is refunded instead), and gives money back with `orders refund <n> [--amount <major units>]` (the last of it cancels the order). `orders archive` hides an order from the list, whatever its status; `orders list --archived` shows them. Every command prints the order after it and a `next` that says what it waits for; a move the order doesn't allow answers `invalid_transition`, with the commands it allows now in the hint.
163
+ Orders land **placed** with the payment **authorized**, not taken: the store no longer approves or captures them. The merchant, through the agent, accepts an order with `orders approve <n> --capture` (or approves now and runs `orders capture <n>` later; Commerce Layer also captures a placed order), cancels one it doesn't want before it ships (`orders cancel`, which voids an authorization or refunds a captured payment; a shipped order is refunded, not cancelled), and gives money back with `orders refund <n> [--amount <major units>]` (the last of it cancels the order). `orders archive` hides an order from the list, whatever its status; `orders list --archived` shows them. Every command prints the order after it and a `next` that says what it waits for; a move the order doesn't allow answers `invalid_transition`, with the commands it allows now in the hint.
163
164
 
164
- Each order has one shipment, `upcoming` when placed. `fulfillment hold` works on a placed order; `pick`, `pack`, `ready` and `ship` need the order approved, and `on_hold`, `picking`, `packing` and `ready_to_ship` move freely between each other. `ship` takes the carrier's `--carrier`, `--tracking` and `--tracking-url` (https); `deliver` follows `ship`. An API older than these routes answers `orders_unsupported` or `fulfillment_unsupported`.
165
+ Each order has one shipment, `upcoming` when placed. `fulfillment hold` works on a placed order; `pick`, `pack`, `ready` and `ship` need the order approved, and `on_hold`, `picking`, `packing` and `ready_to_ship` move freely between each other and on to `shipped`. An `upcoming` shipment goes only to `hold` or `pick`, as Commerce Layer refuses to ship it straight away. `ship` takes the carrier's `--carrier`, `--tracking` and `--tracking-url` (https); `deliver` follows `ship`. An API older than these routes answers `orders_unsupported` or `fulfillment_unsupported`.
165
166
 
166
167
  ## The store speaks UCP
167
168
  Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
@@ -181,6 +182,8 @@ The CLI is a UCP **platform**: its profile is served at `https://commercelayer.a
181
182
 
182
183
  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 (words in any order; `--category`, which includes the subcategories, and `--min-price`/`--max-price` in major units narrow it; the `messages` the store sends, such as its categories when nothing matched, are passed on), `ucp checkout create --item <variant id>:<qty>` for what they picked (the `variants[].id` search lists; the sku on Commerce Layer stores), `ucp checkout update <id>` with the email and shipping address they give (never invented), then, once they said yes to the total, `ucp checkout complete <id>`, and the order's `label` and `permalink_url` back to them. Money comes as `{ amount, currency, major, display }`: `amount` in minor units as UCP has it, `display` for the human. The store is `--store <hostname or origin>`, or this folder's store when there is one; checkout and order ids are remembered with their store (in `~/.local/state/c11r/ucp.json`), so later steps need no `--store`. Paying needs the store's sandbox handler (`ai.commercelayer.sandbox`, test payments only): any store without it (every store taking real payments) answers `payment_handler_unsupported`, and the human pays at the checkout's `continue_url`. Responses are signature-checked like `orders test`: `signature` is `verified`, `invalid`, `unknown_key` or `unsigned`.
183
184
 
185
+ `--images` is for agents that can read image files and want to look at or describe a product: it saves each product's `image_url` under `<tmpdir>/c11r/images/<store host>/` (the first 24, in parallel, 5 s each, a file already there reused) and adds `image_file`, the absolute path, or `image_error`. Showing a thumbnail in a chat needs only `image_url`, which checkout and order line items carry too.
186
+
184
187
  ## Requirements
185
188
 
186
189
  Node 20 or later. One runtime dependency (`yaml`).
@@ -1,5 +1,6 @@
1
1
  // The client for the management API at api.commercelayer.ai.
2
- import { CliError, fail } from '../output.js';
2
+ import { CliError, fail, notice } from '../output.js';
3
+ import { NPX } from '../version.js';
3
4
  import { apiUrl, DEFAULT_API_URL, send } from './transport.js';
4
5
  export function createApi(auth) {
5
6
  const base = apiUrl() === 'stub' ? DEFAULT_API_URL : apiUrl().replace(/\/$/, '');
@@ -20,6 +21,19 @@ export function createApi(auth) {
20
21
  catch {
21
22
  fail('api_error', `${base} answered ${res.status} with something that is not JSON.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
22
23
  }
24
+ // The API flags an outdated CLI (from the User-Agent) in warnings, on any answer, errors included.
25
+ const warnings = data && typeof data === 'object' && !Array.isArray(data) ? data.warnings : undefined;
26
+ if (Array.isArray(warnings)) {
27
+ const isOutdated = (w) => !!w && typeof w === 'object' && w.code === 'cli_outdated';
28
+ for (const n of warnings.filter(isOutdated))
29
+ notice({ ...n, hint: n.hint ?? `Run the commands with ${NPX}. If c11r is installed globally, tell the human it is on ${n.current} and ${n.latest} exists.` });
30
+ // Printed once, at the top of the output, not inside the order or store it came with.
31
+ const rest = warnings.filter((w) => !isOutdated(w));
32
+ if (rest.length)
33
+ data.warnings = rest;
34
+ else
35
+ delete data.warnings;
36
+ }
23
37
  if (res.status < 400)
24
38
  return data;
25
39
  const err = data?.error;
@@ -16,7 +16,7 @@ export function handle(req) {
16
16
  url = new URL(`https://${prefixed[1]}${prefixed[2] ?? '/'}${url.search}`);
17
17
  const org = orgOfHost(url.hostname);
18
18
  if (org === null)
19
- return handleManagement({ ...req, url });
19
+ return outdated(req, handleManagement({ ...req, url }));
20
20
  const db = readDb();
21
21
  const store = db[org];
22
22
  if (!store)
@@ -25,3 +25,28 @@ export function handle(req) {
25
25
  writeDb(db);
26
26
  return res;
27
27
  }
28
+ /** "0.5.1" → [0, 5, 1], for comparing versions. */
29
+ const parts = (v) => v.split('.').map((n) => Number.parseInt(n, 10) || 0);
30
+ const older = (a, b) => {
31
+ const [x, y] = [parts(a), parts(b)];
32
+ for (let i = 0; i < 3; i++)
33
+ if ((x[i] ?? 0) !== (y[i] ?? 0))
34
+ return (x[i] ?? 0) < (y[i] ?? 0);
35
+ return false;
36
+ };
37
+ /**
38
+ * Like the API: a CLI whose User-Agent (c11r/<version>) is older than the latest gets a
39
+ * cli_outdated warning on every JSON object it is answered. The stub's latest is
40
+ * C11R_STUB_LATEST_CLI; unset, every CLI is current.
41
+ */
42
+ function outdated(req, res) {
43
+ const latest = process.env.C11R_STUB_LATEST_CLI;
44
+ const current = req.headers['user-agent']?.match(/^c11r\/(\d+\.\d+\.\d+)/)?.[1];
45
+ if (!latest || !current || !older(current, latest) || !res.body)
46
+ return res;
47
+ const body = JSON.parse(res.body.toString());
48
+ if (!body || typeof body !== 'object' || Array.isArray(body))
49
+ return res;
50
+ const warning = { code: 'cli_outdated', current, latest, hint: `Run the commands with npx -y @commercelayer/agent-cli@latest. If c11r is installed globally, tell the human it is on ${current} and ${latest} exists.` };
51
+ return { ...res, body: JSON.stringify({ ...body, warnings: [...(Array.isArray(body.warnings) ? body.warnings : []), warning] }) };
52
+ }
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // An order lands placed with its payment authorized. approve (optionally with
6
6
  // capture), capture, cancel, refund, archive and unarchive move it; its one shipment
7
- // goes upcoming → on_hold | picking | packing | ready_to_ship → shipped → delivered.
7
+ // goes upcoming → on_hold | picking → packing → ready_to_ship → shipped → delivered; as in
8
+ // Commerce Layer, an upcoming shipment ships only once it is picking (or past it).
8
9
  import { money } from '../../ucp/shop.js';
9
10
  import { minorUnits } from '../../ucp/platform.js';
10
11
  import { baseLanguage } from './locale.js';
@@ -147,9 +148,9 @@ function orderCommands(o) {
147
148
  const out = [];
148
149
  if (m.status === 'placed')
149
150
  out.push(`c11r orders approve ${n} [--capture]`);
150
- if (m.status === 'approved' && pay === 'authorized')
151
+ if (m.status !== 'cancelled' && pay === 'authorized')
151
152
  out.push(`c11r orders capture ${n}`);
152
- if (m.status !== 'cancelled' && !['shipped', 'delivered'].includes(m.shipment.status) && ['authorized', 'free'].includes(pay))
153
+ if (m.status !== 'cancelled' && !['shipped', 'delivered'].includes(m.shipment.status))
153
154
  out.push(`c11r orders cancel ${n}`);
154
155
  if (['paid', 'partially_refunded'].includes(pay))
155
156
  out.push(`c11r orders refund ${n} [--amount <major>]`);
@@ -206,8 +207,9 @@ export function orderAction(r, id, action, body) {
206
207
  capture();
207
208
  break;
208
209
  case 'capture':
209
- if (m.status !== 'approved' || paymentStatus(o) !== 'authorized')
210
- refuse(`Order ${number(o)} is ${m.status} with payment ${paymentStatus(o)}: only an approved order with an authorized payment can be captured.`, orderCommands(o));
210
+ // As in Commerce Layer: a placed order can be captured before it is approved.
211
+ if (m.status === 'cancelled' || paymentStatus(o) !== 'authorized')
212
+ refuse(`Order ${number(o)} is ${m.status} with payment ${paymentStatus(o)}: only an authorized payment can be captured.`, orderCommands(o));
211
213
  capture();
212
214
  break;
213
215
  case 'cancel':
@@ -215,10 +217,12 @@ export function orderAction(r, id, action, body) {
215
217
  refuse(`Order ${number(o)} is already cancelled.`, orderCommands(o));
216
218
  if (['shipped', 'delivered'].includes(m.shipment.status))
217
219
  refuse(`Order ${number(o)} has shipped: it cannot be cancelled.`, orderCommands(o));
218
- if (!['authorized', 'free'].includes(paymentStatus(o)))
219
- refuse(`Order ${number(o)} has payment ${paymentStatus(o)}: refund it to cancel it.`, orderCommands(o));
220
+ // Commerce Layer voids an authorized payment and refunds a captured one.
220
221
  if (paymentStatus(o) === 'authorized')
221
222
  m.transactions.push({ type: 'void', amount: total(o), succeeded: true, at });
223
+ const left = sum(o, 'capture') - sum(o, 'refund');
224
+ if (left > 0)
225
+ m.transactions.push({ type: 'refund', amount: left, succeeded: true, at });
222
226
  m.status = 'cancelled';
223
227
  m.shipment.status = 'cancelled';
224
228
  break;
@@ -286,7 +290,7 @@ function shipmentCommands(o) {
286
290
  can('pick', approved && ['upcoming', 'on_hold', 'packing', 'ready_to_ship'].includes(s.status));
287
291
  can('pack', approved && ['on_hold', 'picking', 'ready_to_ship'].includes(s.status));
288
292
  can('ready', approved && ['on_hold', 'picking', 'packing'].includes(s.status));
289
- can('ship', approved && OPEN_SHIPMENTS.includes(s.status));
293
+ can('ship', approved && WORKING.includes(s.status));
290
294
  can('deliver', s.status === 'shipped');
291
295
  if (!out.length && merchant(o).status === 'placed')
292
296
  out.push(`c11r orders approve ${number(o)} [--capture]`);
@@ -346,7 +350,7 @@ export function shipmentAction(r, id, action, body) {
346
350
  }
347
351
  if (body?.tracking_url !== undefined && !URL_RE.test(body.tracking_url))
348
352
  throw new OrderError(422, 'invalid_argument', `tracking_url ${body.tracking_url} is not an https URL.`, 'Give --tracking-url the carrier\'s tracking page, starting with https://.');
349
- move('shipped', OPEN_SHIPMENTS, true);
353
+ move('shipped', WORKING, true);
350
354
  if (body?.carrier || body?.tracking || body?.tracking_url)
351
355
  s.tracking = { carrier: body.carrier, number: body.tracking, url: body.tracking_url };
352
356
  return showShipment(r, s.id);
@@ -155,8 +155,23 @@ function renderPage(r, path, search, locale) {
155
155
  return page(r, { lang: locale ? tag(locale) : 'en', title, head: locale ? alternates(localesOf(r), path) : '', header, body, description: tagline, home: `${prefix}/${keep}` });
156
156
  }
157
157
  const xml = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]);
158
- /** /tiles/<sku>.svg and /tiles/hero.svg: a plain stand-in for the store host's generated tiles. */
158
+ /** A 1×1 PNG: the stand-in for a generated PNG tile. */
159
+ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkqF9fDwAEAQG/jvkFNQAAAABJRU5ErkJggg==', 'base64');
160
+ /** /tiles/<sku>.svg, /tiles/<sku>.png and /tiles/hero.svg: a plain stand-in for the store host's generated tiles. */
159
161
  export function renderTile(r, path, search) {
162
+ const png = path.match(/^\/tiles\/([^/]+)\.png$/);
163
+ if (png) {
164
+ let sku;
165
+ try {
166
+ sku = decodeURIComponent(png[1]);
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ if (!r.applied?.products[sku])
172
+ return null;
173
+ return { status: 200, headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' }, body: PNG };
174
+ }
160
175
  const m = path.match(/^\/tiles\/([^/]+)\.svg$/);
161
176
  if (!m)
162
177
  return null;
@@ -68,7 +68,7 @@ function negotiate(platformUrl) {
68
68
  // ---------- the REST service
69
69
  const protocolError = (status, code, content, headers = {}) => json(status, { code, content }, headers);
70
70
  function withUcpHeaders(r, res) {
71
- const digest = contentDigest(res.body);
71
+ const digest = contentDigest(res.body.toString());
72
72
  const values = { '@status': String(res.status), 'content-digest': digest, 'content-type': res.headers['content-type'] ?? 'application/json' };
73
73
  const sig = signHeaders('sig1', values, { components: ['@status', 'content-digest', 'content-type'], keyid: r.ucp.kid, created: Math.floor(Date.now() / 1000) }, r.ucp.private_jwk);
74
74
  return { ...res, headers: { ...res.headers, 'content-digest': digest, ...sig } };
@@ -114,7 +114,7 @@ function handleService(req, r, origin, path) {
114
114
  const res = dispatch(ctx, req.method, path, parsed);
115
115
  res.headers['content-language'] = ctx.language;
116
116
  if (mutating && key)
117
- r.ucp.idempotency[`${req.method} ${path} ${key}`] = { digest, status: res.status, headers: res.headers, body: res.body };
117
+ r.ucp.idempotency[`${req.method} ${path} ${key}`] = { digest, status: res.status, headers: res.headers, body: res.body.toString() };
118
118
  return res;
119
119
  }
120
120
  function dispatch(ctx, method, path, body) {
@@ -5,23 +5,33 @@
5
5
  // stands in for the whole platform.
6
6
  import { fail } from '../output.js';
7
7
  import { handle } from './stub/index.js';
8
+ import { USER_AGENT } from '../version.js';
8
9
  export const DEFAULT_API_URL = 'https://api.commercelayer.ai';
9
10
  export const apiUrl = () => process.env.C11R_API_URL ?? DEFAULT_API_URL;
11
+ /** Where a request goes: to a local API, other hosts through its /-/<host>/ prefix. */
12
+ function targetOf(url) {
13
+ const base = apiUrl();
14
+ const baseUrl = new URL(base);
15
+ const targetUrl = new URL(url);
16
+ if (base !== DEFAULT_API_URL && targetUrl.host !== baseUrl.host && !url.startsWith(base)) {
17
+ return `${base.replace(/\/$/, '')}/-/${targetUrl.host}${targetUrl.pathname}${targetUrl.search}`;
18
+ }
19
+ return url;
20
+ }
10
21
  export async function send(method, url, headers = {}, body) {
11
22
  const base = apiUrl();
12
23
  const lower = {};
13
24
  for (const [k, v] of Object.entries(headers))
14
25
  lower[k.toLowerCase()] = v;
26
+ // On every request, to the API and to stores: the API tells an outdated CLI so (cli_outdated).
27
+ lower['user-agent'] ??= USER_AGENT;
15
28
  if (body !== undefined && !lower['content-type'])
16
29
  lower['content-type'] = 'application/json';
17
- if (base === 'stub')
18
- return handle({ method, url: new URL(url), headers: lower, body });
19
- let target = url;
20
- const baseUrl = new URL(base);
21
- const targetUrl = new URL(url);
22
- if (base !== DEFAULT_API_URL && targetUrl.host !== baseUrl.host && !url.startsWith(base)) {
23
- target = `${base.replace(/\/$/, '')}/-/${targetUrl.host}${targetUrl.pathname}${targetUrl.search}`;
30
+ if (base === 'stub') {
31
+ const r = handle({ method, url: new URL(url), headers: lower, body });
32
+ return { ...r, body: r.body.toString() };
24
33
  }
34
+ const target = targetOf(url);
25
35
  let res;
26
36
  try {
27
37
  // Never follow redirects: UCP forbids it for profiles, and the 302/303 of
@@ -35,3 +45,13 @@ export async function send(method, url, headers = {}, body) {
35
45
  res.headers.forEach((v, k) => (resHeaders[k] = v));
36
46
  return { status: res.status, headers: resHeaders, body: await res.text() };
37
47
  }
48
+ /** A file over GET, as bytes, within timeoutMs; redirects are followed (images sit behind CDNs). Throws on failure. */
49
+ export async function download(url, timeoutMs) {
50
+ const headers = { 'user-agent': USER_AGENT, accept: 'image/*' };
51
+ if (apiUrl() === 'stub') {
52
+ const r = handle({ method: 'GET', url: new URL(url), headers });
53
+ return { status: r.status, type: r.headers['content-type'] ?? '', bytes: Buffer.from(r.body) };
54
+ }
55
+ const res = await fetch(targetOf(url), { headers, signal: AbortSignal.timeout(timeoutMs) });
56
+ return { status: res.status, type: res.headers.get('content-type') ?? '', bytes: Buffer.from(await res.arrayBuffer()) };
57
+ }
package/dist/bin.js CHANGED
@@ -4,6 +4,7 @@
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
+ import { LATEST_HINT, NPX } from './version.js';
7
8
  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', '--category', '--min-price', '--max-price', '--status', '--payment', '--fulfillment', '--limit', '--archived', '--amount', '--carrier', '--tracking', '--tracking-url']);
8
9
  const argv = process.argv.slice(2);
9
10
  const flags = new Set();
@@ -36,7 +37,7 @@ for (let i = 0; i < argv.length; i++) {
36
37
  const [name, sub] = args;
37
38
  async function run() {
38
39
  if (flags.has('--version') || name === 'version')
39
- return { version: cmd.VERSION };
40
+ return { version: cmd.VERSION, latest_hint: LATEST_HINT };
40
41
  switch (name) {
41
42
  case undefined:
42
43
  case 'help':
@@ -113,9 +114,9 @@ async function run() {
113
114
  if (sub === 'check')
114
115
  return cmd.ucpCheck();
115
116
  if (sub === 'search')
116
- return cmd.ucpSearch(args.slice(2).join(' '), values, lists);
117
+ return cmd.ucpSearch(args.slice(2).join(' '), values, lists, flags);
117
118
  if (sub === 'product')
118
- return cmd.ucpProduct(args[2], values);
119
+ return cmd.ucpProduct(args[2], values, flags);
119
120
  if (sub === 'order')
120
121
  return cmd.ucpOrder(args[2], values);
121
122
  if (sub === 'ask')
@@ -151,7 +152,7 @@ run()
151
152
  }
152
153
  else {
153
154
  const message = e instanceof Error ? e.message : String(e);
154
- print({ error: { code: 'internal', message, hint: 'This is a bug in the CLI. Tell the human, with this output, and try again with npx -y @commercelayer/agent-cli@latest in case it is already fixed.' } });
155
+ print({ error: { code: 'internal', message, hint: `This is a bug in the CLI. Tell the human, with this output, and try again with ${NPX} in case it is already fixed.` } });
155
156
  }
156
157
  process.exit(1);
157
158
  });
package/dist/commands.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // One function per command. Each returns the object the CLI prints, or throws a
2
2
  // CliError. Every command is safe to re-run.
3
3
  import fs from 'node:fs';
4
- import { createRequire } from 'node:module';
5
4
  import path from 'node:path';
6
5
  import { createApi } from './api/client.js';
7
6
  import { ignoreCredentials, paths, readCredentials, requireCredentials, writeAgentNotes, writeCredentials } from './files.js';
@@ -17,10 +16,11 @@ import { storefrontCheck } from './check.js';
17
16
  import { buy } from './ucp/buy.js';
18
17
  import { check } from './ucp/check.js';
19
18
  import { discover } from './ucp/client.js';
19
+ import { MAX_IMAGES, withImages } from './ucp/images.js';
20
20
  import * as shop from './ucp/shop.js';
21
21
  import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
22
- /** The package version, read from package.json so a release bumps it in one place. */
23
- export const VERSION = createRequire(import.meta.url)('../package.json').version;
22
+ import { LATEST_HINT, VERSION } from './version.js';
23
+ export { VERSION } from './version.js';
24
24
  export const HELP = {
25
25
  init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials, and a section in AGENTS.md and CLAUDE.md (created, or appended once) that points later agents in this repo to the skills: commit them. Reuses the store if store.yml already exists.',
26
26
  plan: 'Show what apply would create, update or delete: markets, products, images, and the store\'s name, language, policies and theme; missing_images lists references with no file in .commercelayer/img/ (apply refuses them), unused_images files nothing references, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku). Products may carry other languages: name, description and category as { <ll>: text } maps (the base language, store.yml language:, default en, among them), or name_<ll>, description_<ll>, category_<ll> CSV columns; at most 6 languages. warnings lists a language no country of the store speaks.',
@@ -46,8 +46,8 @@ export const HELP = {
46
46
  'orders list': "The store's orders, live in Commerce Layer, newest first: by default the placed and approved ones, not archived, 25 at most. c11r orders list [--status placed|approved|cancelled|editing|all] [--payment authorized|paid|refunded|partially_refunded|voided|...] [--fulfillment unfulfilled|in_progress|fulfilled|not_required] [--archived [all]] [--limit 1-100]; a comma list takes several (--status placed,cancelled). Returns { count, orders: [{ id, number, status, payment_status, fulfillment_status, placed_at, market, country, currency, total: { amount, currency, major, display }, email, items, shipments: [{ id, number, status, tracking }], archived }], next }. Orders arrive placed with the payment authorized, not taken: they wait for the human to accept them.",
47
47
  'orders show': 'One order as the merchant sees it: c11r orders show <number, #number or id>. Returns { order: { ...as in orders list, line_items: [{ sku, name, quantity, unit_price, total }], totals: { subtotal, discount, shipping, taxes, total, codes }, shipping_address, billing_address, shipping_method, payment: { method, source, status, captured, refundable }, transactions: [{ type, amount, succeeded, at }], language, archived_at, ucp: { id, permalink_url } }, next }. next says what the order is waiting for.',
48
48
  'orders approve': "Accept a placed order, only once the human said to: c11r orders approve <number> [--capture]. With --capture the authorized payment is taken too; without, take it later with orders capture. Returns { status, order, next } with the order after it. An order that cannot be approved answers invalid_transition: its hint lists what it allows now.",
49
- 'orders capture': 'Take the authorized payment of an approved order, once the human said to: c11r orders capture <number>. Returns { status, order, next }; invalid_transition when it is not approved or already captured.',
50
- 'orders cancel': "Cancel an order the human does not want, before it ships: c11r orders cancel <number>. An authorized payment is voided. A shipped order cannot be cancelled, and a captured one is refunded instead (orders refund): both answer invalid_transition, with what is allowed in the hint. Returns { status, order, next }.",
49
+ 'orders capture': 'Take the authorized payment of an order, once the human said to: c11r orders capture <number>. Usually after approving it (or at once with orders approve --capture); a placed order can be captured too. Returns { status, order, next }; invalid_transition when nothing authorized is left to capture.',
50
+ 'orders cancel': "Cancel an order the human does not want, before it ships: c11r orders cancel <number>. An authorized payment is voided, a captured one refunded in full. A shipped order cannot be cancelled (refund it with orders refund): invalid_transition, with what is allowed in the hint. Returns { status, order, next }.",
51
51
  'orders refund': "Give money back on a captured order, only the amount the human said: c11r orders refund <number> [--amount <n>]. --amount is in major units (4.50); without it, everything still refundable. Refunding the last of the payment cancels the order. Returns { status, order, next }; refund_exceeds when the amount is more than payment.refundable.",
52
52
  'orders archive': 'Hide an order from orders list (any status), for orders that need nothing more: c11r orders archive <number>. Idempotent. orders list --archived shows them.',
53
53
  'orders unarchive': 'Bring an archived order back into orders list: c11r orders unarchive <number>. Idempotent.',
@@ -57,20 +57,20 @@ export const HELP = {
57
57
  'fulfillment pick': 'Mark a shipment as being picked: c11r fulfillment pick <shipment>. Needs the order approved. on_hold, picking, packing and ready_to_ship move freely between each other. Returns { status, shipment, next }; invalid_transition says what is allowed now.',
58
58
  'fulfillment pack': 'Mark a shipment as being packed: c11r fulfillment pack <shipment>. Needs the order approved. Returns { status, shipment, next }.',
59
59
  'fulfillment ready': 'Mark a shipment ready to ship: c11r fulfillment ready <shipment>. Needs the order approved. Returns { status, shipment, next }.',
60
- 'fulfillment ship': "Mark a shipment shipped, with the carrier's tracking when the human has it: c11r fulfillment ship <shipment> [--carrier <name>] [--tracking <number>] [--tracking-url <https url>]. Needs the order approved; works from upcoming or any step before. Ask the human for the tracking, never invent it. Returns { status, shipment, next }.",
60
+ 'fulfillment ship': "Mark a shipment shipped, with the carrier's tracking when the human has it: c11r fulfillment ship <shipment> [--carrier <name>] [--tracking <number>] [--tracking-url <https url>]. Needs the order approved and the shipment picked first (c11r fulfillment pick <shipment>): an upcoming shipment cannot ship. Ask the human for the tracking, never invent it. Returns { status, shipment, next }.",
61
61
  'fulfillment deliver': 'Mark a shipped shipment delivered: c11r fulfillment deliver <shipment>. Returns { status, shipment, next }.',
62
62
  'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
63
63
  '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.',
64
- 'ucp search': "Buy for the human, step 1: search a store's catalog over UCP. c11r ucp search [<words>] [--category <name>] [--min-price <n>] [--max-price <n>] [--country CC] [--store <host>]. Search with the words that matter, in any order (\"dark roast coffee\" or \"coffee dark\"): the store ranks products with every word first; no words lists the whole catalog (up to 50). --category narrows to a category or its subcategories (\"Coffee\" includes \"Coffee > Single origin\"), repeat it for any of several; --min-price and --max-price are in major units of the store's currency (15 means 15.00); --country is the human's country: prices, shipping and availability for it. Returns { store, query, prices: { market, market_name, currency, countries, tax_included }, filters, count, total_count, has_more, products: [{ id, title, description, url, image_url, categories: [\"Coffee > Single origin\"], partial, matched_on, variants: [{ id, sku, title, price: { amount, currency, major, display }, list_price, available, availability, shipping: { to, option, price, earliest, latest, ships_to_country } }] }], messages, signature }. list_price is the price before a sale, only when there is one; partial: true marks a product with only some of the words. Show the human the titles, categories and price.display (amount is in minor units: never show it), with shipping.price.display and the arrival dates when they ask what it costs delivered; let them pick, then c11r ucp checkout create --item <variant id>. Read messages and pass on what they say: with no results or a partial match they list the store's categories, and prices says which market the prices are for. --store is any store that speaks UCP (hostname or https origin); the default is this folder's store.",
65
- 'ucp product': 'Look at one product over UCP, with every variant and the full description: c11r ucp product <product id> [--country CC] [--store <host>]. Returns { store, product: { id, title, description, url, image_url, variants: [...] }, signature }, variants as in ucp search.',
66
- 'ucp checkout create': "Buy for the human, step 2: open a checkout with the items the human picked. c11r ucp checkout create --item <variant id>[:<qty>] [--item ...] [--code <discount>] [--country CC] [--store <host>]. Returns the checkout: { id, status, store, currency, line_items: [{ id, item_id, title, quantity, unit_price, total }], buyer, ship_to, shipping, discounts, codes, totals: [{ type, label, amount, major, display }], total, messages, continue_url, expires_at, links, signature }. status is incomplete until the buyer's email and shipping address are given: messages say what is missing. Ask the human for them, never invent them, then c11r ucp checkout update <id>. The id is remembered with its store: later steps need no --store.",
64
+ 'ucp search': "Buy for the human, step 1: search a store's catalog over UCP. c11r ucp search [<words>] [--category <name>] [--min-price <n>] [--max-price <n>] [--country CC] [--images] [--store <host>]. Search with the words that matter, in any order (\"dark roast coffee\" or \"coffee dark\"): the store ranks products with every word first; no words lists the whole catalog (up to 50). --category narrows to a category or its subcategories (\"Coffee\" includes \"Coffee > Single origin\"), repeat it for any of several; --min-price and --max-price are in major units of the store's currency (15 means 15.00); --country is the human's country: prices, shipping and availability for it. Returns { store, query, prices: { market, market_name, currency, countries, tax_included }, filters, count, total_count, has_more, products: [{ id, title, description, url, image_url, categories: [\"Coffee > Single origin\"], partial, matched_on, variants: [{ id, sku, title, price: { amount, currency, major, display }, list_price, available, availability, shipping: { to, option, price, earliest, latest, ships_to_country } }] }], messages, signature }. list_price is the price before a sale, only when there is one; partial: true marks a product with only some of the words. Show the human the titles, categories and price.display (amount is in minor units: never show it), with shipping.price.display and the arrival dates when they ask what it costs delivered; let them pick, then c11r ucp checkout create --item <variant id>. Read messages and pass on what they say: with no results or a partial match they list the store's categories, and prices says which market the prices are for. --store is any store that speaks UCP (hostname or https origin); the default is this folder's store. --images is for agents that can read image files and want to look at or describe a product: it saves each product's image (the first 24) and adds image_file, the file's absolute path, or image_error when the download failed; showing a thumbnail in a chat needs only image_url.",
65
+ 'ucp product': "Look at one product over UCP, with every variant and the full description: c11r ucp product <product id> [--country CC] [--images] [--store <host>]. Returns { store, product: { id, title, description, url, image_url, variants: [...] }, signature }, variants as in ucp search. --images is for agents that can read image files and want to look at or describe a product: it saves the product's image and adds image_file, the file's absolute path, or image_error when the download failed; showing a thumbnail in a chat needs only image_url.",
66
+ 'ucp checkout create': "Buy for the human, step 2: open a checkout with the items the human picked. c11r ucp checkout create --item <variant id>[:<qty>] [--item ...] [--code <discount>] [--country CC] [--store <host>]. Returns the checkout: { id, status, store, currency, line_items: [{ id, item_id, title, image_url, 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.",
67
67
  'ucp checkout update': "Buy for the human, step 3: give the buyer's details, change items or codes. c11r ucp checkout update <id> [--email --first-name --last-name --phone] [--street --city --postal-code --region --country] [--item <variant id>:<qty> (0 removes)] [--code <discount> (adds)] [--shipping <option id>]; or the same as YAML or JSON on stdin or -f <file>: { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: [\"ID:2\"], codes: [...] (replaces), shipping }. Only what is given changes. Returns the checkout as in create: status ready_for_complete once nothing is missing, with shipping and the total including it; or incomplete with messages (address_undeliverable says where the store ships). Show the human the line items, shipping and total.display, ask for an explicit yes, then c11r ucp checkout complete <id>.",
68
68
  '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.',
69
69
  'ucp checkout cancel': 'Cancel a checkout the human no longer wants: c11r ucp checkout cancel <id> [--store <host>]. Returns the checkout, status canceled.',
70
70
  'ucp checkout complete': "Buy for the human, step 4, only after the human said yes to the total: pay the checkout. c11r ucp checkout complete <id> [--decline] [--store <host>]. Pays with the ai.commercelayer.sandbox test handler (no money moves): only stores that advertise it can be paid; --decline uses the declined test card. Returns the checkout with status completed and order: { id, label, permalink_url }, plus payment_handler. Tell the human the order label and give them permalink_url. A store without that handler (any store taking real payments) answers payment_handler_unsupported: give the human continue_url to pay there. A declined payment leaves status ready_for_complete with a payment_failed message.",
71
- 'ucp order': 'Read an order this CLI placed: c11r ucp order <id> [--store <host>]. Returns { id, label, permalink_url, store, checkout_id, currency, line_items: [{ item_id, title, quantity, status, total }], fulfillment: [{ method, destination, description }], events, totals, signature }.',
71
+ 'ucp order': 'Read an order this CLI placed: c11r ucp order <id> [--store <host>]. Returns { id, label, permalink_url, store, checkout_id, currency, line_items: [{ item_id, title, image_url, quantity, status, total }], fulfillment: [{ method, destination, description }], events, totals, signature }.',
72
72
  '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.',
73
- status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists).',
73
+ status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists). search is the store\'s semantic search index: { model, status: indexing|ready|failed|too_large, vectors, embedded, updated_at, error }, or null before it first runs; until it is ready the store\'s search matches words only.',
74
74
  claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
75
75
  help: 'Describe the commands. `c11r help <command>` for one command, `c11r help <group>` (catalog, fulfillment, links, orders, promotions, storefront, ucp) for a group.',
76
76
  };
@@ -87,7 +87,7 @@ export function help(name) {
87
87
  const showsFormat = name === 'apply' || name === 'plan';
88
88
  return { command: name, description: HELP[name], store_format: showsFormat ? template('<name>') : undefined };
89
89
  }
90
- return { version: VERSION, commands: HELP, note: 'Every command prints JSON. The store format is documented in the comments of .commercelayer/store.yml; large catalogs go in .commercelayer/products/ as .yml or .csv files.' };
90
+ return { version: VERSION, latest_hint: LATEST_HINT, commands: HELP, note: 'Every command prints JSON. The store format is documented in the comments of .commercelayer/store.yml; large catalogs go in .commercelayer/products/ as .yml or .csv files.' };
91
91
  }
92
92
  export async function init(values) {
93
93
  const existing = readCredentials();
@@ -305,7 +305,7 @@ export async function ordersTest(flags, values) {
305
305
  messages: p.checkout.messages ?? p.ready.messages,
306
306
  payment_handler: 'ai.commercelayer.sandbox (test payments only)',
307
307
  steps: p.steps,
308
- next: p.checkout.status === 'completed' ? `Placed, payment authorized: approve and capture it with c11r orders approve ${String(p.order?.label ?? p.order?.id).replace(/^#/, '')} --capture, then ship it with c11r fulfillment ship.` : undefined,
308
+ next: p.checkout.status === 'completed' ? `Placed, payment authorized: approve and capture it with c11r orders approve ${String(p.order?.label ?? p.order?.id).replace(/^#/, '')} --capture, then ship it with c11r fulfillment pick and ship.` : undefined,
309
309
  };
310
310
  }
311
311
  // Orders and shipments, live in Commerce Layer through the API. An order lands placed with its
@@ -323,13 +323,15 @@ function orderNext(o) {
323
323
  const shipment = o.shipments?.[0];
324
324
  if (o.status === 'cancelled')
325
325
  return `Order ${n} is cancelled (payment ${o.payment_status}). Nothing more to do; archive it to keep the list short: c11r orders archive ${n}.`;
326
+ if (o.status === 'placed' && o.payment_status !== 'authorized')
327
+ return `Order ${n} is placed; its payment is ${o.payment_status?.replace(/_/g, ' ')} (taken before approving). When the human accepts it, approve it: c11r orders approve ${n}. If they don't want it: c11r orders cancel ${n} (Commerce Layer refunds what was taken).`;
326
328
  if (o.status === 'placed')
327
329
  return `Order ${n} is placed and its payment is authorized, not taken. When the human accepts it, approve it and take the payment: c11r orders approve ${n} --capture (without --capture to take it later). If they don't want it: c11r orders cancel ${n}.`;
328
330
  if (o.payment_status === 'authorized')
329
331
  return `Order ${n} is approved; the payment is authorized but not taken. Take it when the human says so: c11r orders capture ${n}.`;
330
332
  if (o.fulfillment_status === 'fulfilled')
331
333
  return `Order ${n} has shipped${shipment?.tracking?.number ? ` (tracking ${shipment.tracking.number})` : ''}. Mark it delivered when it arrives: c11r fulfillment deliver ${shipment?.number ?? n}; refund all or part if the human asks: c11r orders refund ${n} [--amount <n>].`;
332
- return `Order ${n} is approved and paid (${o.total?.display}). Ship it: c11r fulfillment ship ${shipment?.number ?? n} [--carrier <name> --tracking <number> --tracking-url <url>], or move it through picking and packing first (c11r fulfillment pick, pack, ready).`;
334
+ return `Order ${n} is approved and paid (${o.total?.display}). Pick it, then ship it: c11r fulfillment pick ${shipment?.number ?? n}, then c11r fulfillment ship ${shipment?.number ?? n} [--carrier <name> --tracking <number> --tracking-url <url>] (pack and ready in between if the human tracks them).`;
333
335
  }
334
336
  export async function ordersList(values, flags) {
335
337
  const api = createApi(requireCredentials());
@@ -378,6 +380,8 @@ function shipmentNext(s) {
378
380
  return `Shipment ${n} is on its way${s.tracking?.number ? ` (tracking ${s.tracking.number})` : ''}. Mark it delivered when it arrives: c11r fulfillment deliver ${n}.`;
379
381
  if (s.order?.status === 'placed')
380
382
  return `Shipment ${n} waits for its order ${s.order.number} to be approved: c11r orders approve ${s.order.number} --capture, once the human accepts it. It can be held meanwhile: c11r fulfillment hold ${n}.`;
383
+ if (s.status === 'upcoming')
384
+ return `Shipment ${n} is upcoming. Pick it first: c11r fulfillment pick ${n}; then ship it when it leaves: c11r fulfillment ship ${n} [--carrier <name> --tracking <number> --tracking-url <url>].`;
381
385
  return `Shipment ${n} is ${s.status.replace(/_/g, ' ')}. Ship it when it leaves: c11r fulfillment ship ${n} [--carrier <name> --tracking <number> --tracking-url <url>]; or move it on: c11r fulfillment pick, pack, ready or hold ${n}.`;
382
386
  }
383
387
  export async function fulfillmentList(values) {
@@ -462,6 +466,8 @@ export async function status() {
462
466
  markets: s.applied ? Object.keys(s.applied.markets) : [],
463
467
  products: s.applied ? Object.keys(s.applied.products).length : 0,
464
468
  orders: s.orders.map((o) => ({ id: o.id, label: o.label })),
469
+ // Semantic search: until it is ready, the store's search matches words only.
470
+ search: s.search ? { model: s.search.model, status: s.search.status, vectors: s.search.vectors, embedded: s.search.embedded, updated_at: s.search.updated_at, error: s.search.error } : null,
465
471
  ucp_profile: `${s.preview_url}/.well-known/ucp`,
466
472
  next: s.apply?.status === 'in_progress' ? 'An apply is running: run `c11r status` again to follow it, or `c11r apply` to wait for it.' : storeError ? 'Fix the store files (see store_error), then run `c11r plan`.' : pending.length ? 'Run `c11r plan` to see the changes, then `c11r apply`.' : undefined,
467
473
  };
@@ -509,7 +515,14 @@ const checkoutNext = (ck) => {
509
515
  const errors = (ck.messages ?? []).filter((m) => m.type === 'error').map((m) => m.content);
510
516
  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.`;
511
517
  };
512
- export async function ucpSearch(query, values, lists = {}) {
518
+ /** --images: each product's image saved to a file (image_file, or image_error), at most MAX_IMAGES. */
519
+ async function addImages(r, list) {
520
+ const { products, skipped } = await withImages(r.store, list);
521
+ return skipped
522
+ ? { products, warnings: [{ code: 'images_capped', message: `Images were saved for the first ${MAX_IMAGES} products only; ${skipped} more have none.`, hint: 'Narrow the search (words, --category, prices) to get the images of the others.' }] }
523
+ : { products };
524
+ }
525
+ export async function ucpSearch(query, values, lists = {}, flags = new Set()) {
513
526
  const price = (flag) => {
514
527
  const raw = values[flag];
515
528
  if (raw === undefined)
@@ -527,12 +540,16 @@ export async function ucpSearch(query, values, lists = {}) {
527
540
  const next = r.count
528
541
  ? 'Show the human the products (title, categories, price.display, and list_price.display when it is on sale), let them pick, then c11r ucp checkout create --item <variant id>[:<qty>] (variants[].id).'
529
542
  : 'Nothing matched: read messages (the store may list its categories there), then try other words, a --category, wider prices, or no query for the whole catalog.';
530
- return { ...r, next };
543
+ return flags.has('--images') ? { ...r, ...(await addImages(r, r.products)), next } : { ...r, next };
531
544
  }
532
- export async function ucpProduct(id, values) {
545
+ export async function ucpProduct(id, values, flags = new Set()) {
533
546
  if (!id)
534
547
  fail('missing_argument', 'ucp product needs a product id.', 'Use a product id from c11r ucp search.');
535
- return shop.product(await shopOrigin(values), id, values.country);
548
+ const r = await shop.product(await shopOrigin(values), id, values.country);
549
+ if (!flags.has('--images'))
550
+ return r;
551
+ const [product] = (await withImages(r.store, [r.product])).products;
552
+ return { ...r, product };
536
553
  }
537
554
  export async function ucpCheckoutCreate(values, lists) {
538
555
  const items = (lists.item ?? []).map(shop.parseItem);
package/dist/files.js CHANGED
@@ -63,18 +63,30 @@ export function ignoreCredentials() {
63
63
  }
64
64
  /** The note that sends a future agent in this repo to the skills. No skill names: it never goes stale. */
65
65
  const AGENT_NOTE_HEADING = '# Store on Commerce Layer';
66
+ const SKILLS = 'Before any task about the store (products, prices, markets, storefront, orders, promotions, agents), fetch `https://commercelayer.ai/skills.md` with `curl -fsSL` and load the skill for the task from `skills/<name>.md`; follow it rather than calling the Commerce Layer API directly. If `.commercelayer/store.yml` is missing, start from `https://commercelayer.ai/start.md`.';
66
67
  export const AGENT_NOTE = `${AGENT_NOTE_HEADING}
67
- This store is managed with \`c11r\` (\`npx @commercelayer/agent-cli\`), configured in \`.commercelayer/store.yml\`. Before any task about the store (products, prices, markets, storefront, orders, promotions, agents), fetch \`https://commercelayer.ai/skills.md\` with \`curl -fsSL\` and load the skill for the task from \`skills/<name>.md\`; follow it rather than calling the Commerce Layer API directly. If \`.commercelayer/store.yml\` is missing, start from \`https://commercelayer.ai/start.md\`.
68
+ This store is managed with \`c11r\`, run as \`npx -y @commercelayer/agent-cli@latest\`, configured in \`.commercelayer/store.yml\`. Always use that form, even if a \`c11r\` binary is installed: it stays on the current version. ${SKILLS}
69
+ `;
70
+ /** The note CLIs before 0.5.1 wrote: replaced by the current one when a file has it word for word. */
71
+ const OLD_AGENT_NOTE = `${AGENT_NOTE_HEADING}
72
+ This store is managed with \`c11r\` (\`npx @commercelayer/agent-cli\`), configured in \`.commercelayer/store.yml\`. ${SKILLS}
68
73
  `;
69
74
  /**
70
75
  * Write the note to AGENTS.md and CLAUDE.md (Claude Code reads that name) in the repo root:
71
76
  * created when absent, appended as a section when the file has other content, left alone
72
- * when the section is already there. Returns the files written.
77
+ * when the section is already there (replaced when it is an older CLI's, word for word).
78
+ * Returns the files written.
73
79
  */
74
80
  export function writeAgentNotes() {
75
81
  const written = [];
76
82
  for (const file of ['AGENTS.md', 'CLAUDE.md']) {
77
83
  const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
84
+ // A note an older CLI wrote, untouched since: bring it up to date. An edited one stays as it is.
85
+ if (before.includes(OLD_AGENT_NOTE)) {
86
+ fs.writeFileSync(file, before.replace(OLD_AGENT_NOTE, AGENT_NOTE));
87
+ written.push(file);
88
+ continue;
89
+ }
78
90
  if (before.split('\n').some((line) => line.trim() === AGENT_NOTE_HEADING))
79
91
  continue;
80
92
  fs.writeFileSync(file, before.trim() ? `${before.replace(/\s*$/, '')}\n\n${AGENT_NOTE}` : AGENT_NOTE);
package/dist/output.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // A failure is { error: { code, message, hint } } with exit code 1. The hint is
3
3
  // the fix: an agent reads it and acts on it before trying anything else.
4
4
  // A command that waits (apply) writes progress to stderr, one JSON object per line.
5
+ // Warnings the API sends about the CLI itself (cli_outdated) go into whatever is printed.
5
6
  export class CliError extends Error {
6
7
  code;
7
8
  hint;
@@ -17,6 +18,18 @@ export function fail(code, message, hint) {
17
18
  export function progress(data) {
18
19
  process.stderr.write(JSON.stringify(data) + '\n');
19
20
  }
21
+ const notices = [];
22
+ /** Keep a warning to print with the result, once per code. */
23
+ export function notice(n) {
24
+ if (!notices.some((x) => x.code === n.code))
25
+ notices.push(n);
26
+ }
20
27
  export function print(data) {
21
- process.stdout.write(JSON.stringify(data, null, 2) + '\n');
28
+ let out = data;
29
+ if (notices.length && data && typeof data === 'object' && !Array.isArray(data)) {
30
+ const own = data.warnings;
31
+ // A command's own warnings are strings or objects; the API's are added after them.
32
+ out = { ...data, warnings: [...(Array.isArray(own) ? own : own === undefined ? [] : [own]), ...notices] };
33
+ }
34
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
22
35
  }
@@ -0,0 +1,62 @@
1
+ // --images: each product's image_url saved as a file, for agents that can read images and
2
+ // want to look at or describe a product. Files go under <tmpdir>/c11r/images/<store host>/,
3
+ // named by product id and a hash of the URL, so a file already there is reused and a new
4
+ // image gets a new file. A chat thumbnail needs only image_url, never this.
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { download } from '../api/transport.js';
10
+ export const MAX_IMAGES = 24;
11
+ const TIMEOUT_MS = 5000;
12
+ const MAX_BYTES = 10 * 1024 * 1024;
13
+ const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif', 'image/svg+xml': 'svg' };
14
+ /** The folder of a store's images: C11R_IMAGES_DIR (tests) or the OS temp dir. */
15
+ export const imagesDir = (origin) => path.join(process.env.C11R_IMAGES_DIR ?? path.join(os.tmpdir(), 'c11r', 'images'), new URL(origin).host);
16
+ const safe = (s) => s.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80) || 'product';
17
+ /** One product's image: the path of its file, or why there is none. */
18
+ async function fetchImage(dir, p) {
19
+ const url = p.image_url;
20
+ if (typeof url !== 'string' || !url)
21
+ return { image_error: 'The product has no image_url.' };
22
+ let parsed;
23
+ try {
24
+ parsed = new URL(url);
25
+ }
26
+ catch {
27
+ return { image_error: `image_url ${url} is not a URL.` };
28
+ }
29
+ if (!['https:', 'http:'].includes(parsed.protocol))
30
+ return { image_error: `image_url ${url} is not an http(s) address.` };
31
+ const stem = `${safe(String(p.id ?? ''))}-${crypto.createHash('sha256').update(url).digest('hex').slice(0, 10)}`;
32
+ const kept = fs.existsSync(dir) ? fs.readdirSync(dir).find((f) => f.startsWith(`${stem}.`) && Object.values(EXT).includes(f.slice(stem.length + 1))) : undefined;
33
+ if (kept)
34
+ return { image_file: path.join(dir, kept) };
35
+ try {
36
+ const r = await download(url, TIMEOUT_MS);
37
+ if (r.status !== 200)
38
+ return { image_error: `The store answered ${r.status} for ${url}.` };
39
+ const ext = EXT[r.type.split(';')[0].trim().toLowerCase()];
40
+ if (!ext)
41
+ return { image_error: `${url} is not an image (content-type ${r.type || 'missing'}).` };
42
+ if (r.bytes.length > MAX_BYTES)
43
+ return { image_error: `${url} is over 10 MB.` };
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ const file = path.join(dir, `${stem}.${ext}`);
46
+ // Written aside then renamed, so a parallel run never reads half a file.
47
+ const tmp = `${file}.${process.pid}.part`;
48
+ fs.writeFileSync(tmp, r.bytes);
49
+ fs.renameSync(tmp, file);
50
+ return { image_file: file };
51
+ }
52
+ catch (e) {
53
+ const err = e;
54
+ return { image_error: err.name === 'TimeoutError' ? `${url} took more than ${TIMEOUT_MS / 1000} s.` : `Could not download ${url}: ${err.message}` };
55
+ }
56
+ }
57
+ /** Products with image_file or image_error, the first MAX_IMAGES in parallel; how many were left out. */
58
+ export async function withImages(origin, products) {
59
+ const dir = imagesDir(origin);
60
+ const got = await Promise.all(products.slice(0, MAX_IMAGES).map((p) => fetchImage(dir, p)));
61
+ return { products: products.map((p, i) => (i < MAX_IMAGES ? { ...p, ...got[i] } : p)), skipped: Math.max(0, products.length - MAX_IMAGES) };
62
+ }
package/dist/ucp/shop.js CHANGED
@@ -128,6 +128,7 @@ export function checkoutView(ck, origin, call) {
128
128
  id: l.id,
129
129
  item_id: l.item?.id,
130
130
  title: l.item?.title,
131
+ image_url: l.item?.image_url,
131
132
  quantity: l.quantity,
132
133
  unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur),
133
134
  total: money((l.totals ?? []).find((t) => t.type === 'total')?.amount ?? 0, cur),
@@ -160,6 +161,7 @@ export function orderView(o, origin, call) {
160
161
  line_items: (o.line_items ?? []).map((l) => ({
161
162
  item_id: l.item?.id,
162
163
  title: l.item?.title,
164
+ image_url: l.item?.image_url,
163
165
  quantity: l.quantity?.total ?? l.quantity,
164
166
  status: l.status,
165
167
  total: money((l.totals ?? []).find((t) => t.type === 'total')?.amount ?? 0, cur),
@@ -0,0 +1,9 @@
1
+ // The CLI's version and how it names itself on the wire.
2
+ import { createRequire } from 'node:module';
3
+ /** The package version, read from package.json so a release bumps it in one place. */
4
+ export const VERSION = createRequire(import.meta.url)('../package.json').version;
5
+ /** The User-Agent of every request: the API answers cli_outdated when a newer CLI exists. */
6
+ export const USER_AGENT = `c11r/${VERSION} (node/${process.versions.node.split('.')[0]})`;
7
+ /** The one way to run the CLI that is always current, whatever is installed. */
8
+ export const NPX = 'npx -y @commercelayer/agent-cli@latest';
9
+ export const LATEST_HINT = `${NPX} always runs the newest version, even where a c11r binary is installed.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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": [