@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 +8 -5
- package/dist/api/client.js +15 -1
- package/dist/api/stub/index.js +26 -1
- package/dist/api/stub/orders.js +13 -9
- package/dist/api/stub/theme.js +16 -1
- package/dist/api/stub/ucp.js +2 -2
- package/dist/api/transport.js +27 -7
- package/dist/bin.js +5 -4
- package/dist/commands.js +35 -18
- package/dist/files.js +14 -2
- package/dist/output.js +14 -1
- package/dist/ucp/images.js +62 -0
- package/dist/ucp/shop.js +2 -0
- package/dist/version.js +9 -0
- package/package.json +1 -1
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
|
|
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
|
|
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`).
|
package/dist/api/client.js
CHANGED
|
@@ -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;
|
package/dist/api/stub/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/api/stub/orders.js
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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 &&
|
|
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',
|
|
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);
|
package/dist/api/stub/theme.js
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
158
|
-
/**
|
|
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;
|
package/dist/api/stub/ucp.js
CHANGED
|
@@ -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) {
|
package/dist/api/transport.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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:
|
|
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
|
-
|
|
23
|
-
export
|
|
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
|
|
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
|
|
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
|
|
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':
|
|
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}).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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),
|
package/dist/version.js
ADDED
|
@@ -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.
|
|
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": [
|