@commercelayer/agent-cli 0.5.1 → 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 +5 -3
- package/dist/api/stub/index.js +1 -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 +24 -7
- package/dist/bin.js +2 -2
- package/dist/commands.js +29 -13
- package/dist/ucp/images.js +62 -0
- package/dist/ucp/shop.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
35
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. |
|
|
36
36
|
| `fulfillment hold` · `pick` · `pack` · `ready` · `ship <shipment> [--carrier] [--tracking] [--tracking-url]` · `deliver` | Move a shipment; each prints the shipment after it. |
|
|
37
37
|
| `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
|
|
38
|
-
| `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. |
|
|
39
39
|
| `ucp checkout create --item <variant id>[:<qty>] … [--code <discount>] [--country <CC>]` | Open a checkout; `messages` say what is still missing (email, shipping address). |
|
|
40
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. |
|
|
41
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`. |
|
|
@@ -160,9 +160,9 @@ A custom storefront replaces the theme: `storefront.path` in `store.yml` names i
|
|
|
160
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`.
|
|
161
161
|
## Orders and fulfillment
|
|
162
162
|
|
|
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), 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.
|
|
164
164
|
|
|
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. `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`.
|
|
166
166
|
|
|
167
167
|
## The store speaks UCP
|
|
168
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:
|
|
@@ -182,6 +182,8 @@ The CLI is a UCP **platform**: its profile is served at `https://commercelayer.a
|
|
|
182
182
|
|
|
183
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`.
|
|
184
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
|
+
|
|
185
187
|
## Requirements
|
|
186
188
|
|
|
187
189
|
Node 20 or later. One runtime dependency (`yaml`).
|
package/dist/api/stub/index.js
CHANGED
|
@@ -44,7 +44,7 @@ function outdated(req, res) {
|
|
|
44
44
|
const current = req.headers['user-agent']?.match(/^c11r\/(\d+\.\d+\.\d+)/)?.[1];
|
|
45
45
|
if (!latest || !current || !older(current, latest) || !res.body)
|
|
46
46
|
return res;
|
|
47
|
-
const body = JSON.parse(res.body);
|
|
47
|
+
const body = JSON.parse(res.body.toString());
|
|
48
48
|
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
49
49
|
return res;
|
|
50
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.` };
|
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
|
@@ -8,6 +8,16 @@ import { handle } from './stub/index.js';
|
|
|
8
8
|
import { USER_AGENT } from '../version.js';
|
|
9
9
|
export const DEFAULT_API_URL = 'https://api.commercelayer.ai';
|
|
10
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
|
+
}
|
|
11
21
|
export async function send(method, url, headers = {}, body) {
|
|
12
22
|
const base = apiUrl();
|
|
13
23
|
const lower = {};
|
|
@@ -17,14 +27,11 @@ export async function send(method, url, headers = {}, body) {
|
|
|
17
27
|
lower['user-agent'] ??= USER_AGENT;
|
|
18
28
|
if (body !== undefined && !lower['content-type'])
|
|
19
29
|
lower['content-type'] = 'application/json';
|
|
20
|
-
if (base === 'stub')
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const baseUrl = new URL(base);
|
|
24
|
-
const targetUrl = new URL(url);
|
|
25
|
-
if (base !== DEFAULT_API_URL && targetUrl.host !== baseUrl.host && !url.startsWith(base)) {
|
|
26
|
-
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() };
|
|
27
33
|
}
|
|
34
|
+
const target = targetOf(url);
|
|
28
35
|
let res;
|
|
29
36
|
try {
|
|
30
37
|
// Never follow redirects: UCP forbids it for profiles, and the 302/303 of
|
|
@@ -38,3 +45,13 @@ export async function send(method, url, headers = {}, body) {
|
|
|
38
45
|
res.headers.forEach((v, k) => (resHeaders[k] = v));
|
|
39
46
|
return { status: res.status, headers: resHeaders, body: await res.text() };
|
|
40
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
|
@@ -114,9 +114,9 @@ async function run() {
|
|
|
114
114
|
if (sub === 'check')
|
|
115
115
|
return cmd.ucpCheck();
|
|
116
116
|
if (sub === 'search')
|
|
117
|
-
return cmd.ucpSearch(args.slice(2).join(' '), values, lists);
|
|
117
|
+
return cmd.ucpSearch(args.slice(2).join(' '), values, lists, flags);
|
|
118
118
|
if (sub === 'product')
|
|
119
|
-
return cmd.ucpProduct(args[2], values);
|
|
119
|
+
return cmd.ucpProduct(args[2], values, flags);
|
|
120
120
|
if (sub === 'order')
|
|
121
121
|
return cmd.ucpOrder(args[2], values);
|
|
122
122
|
if (sub === 'ask')
|
package/dist/commands.js
CHANGED
|
@@ -16,6 +16,7 @@ import { storefrontCheck } from './check.js';
|
|
|
16
16
|
import { buy } from './ucp/buy.js';
|
|
17
17
|
import { check } from './ucp/check.js';
|
|
18
18
|
import { discover } from './ucp/client.js';
|
|
19
|
+
import { MAX_IMAGES, withImages } from './ucp/images.js';
|
|
19
20
|
import * as shop from './ucp/shop.js';
|
|
20
21
|
import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
|
|
21
22
|
import { LATEST_HINT, VERSION } from './version.js';
|
|
@@ -45,8 +46,8 @@ export const HELP = {
|
|
|
45
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.",
|
|
46
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.',
|
|
47
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.",
|
|
48
|
-
'orders capture': 'Take the authorized payment of an
|
|
49
|
-
'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 }.",
|
|
50
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.",
|
|
51
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.',
|
|
52
53
|
'orders unarchive': 'Bring an archived order back into orders list: c11r orders unarchive <number>. Idempotent.',
|
|
@@ -56,18 +57,18 @@ export const HELP = {
|
|
|
56
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.',
|
|
57
58
|
'fulfillment pack': 'Mark a shipment as being packed: c11r fulfillment pack <shipment>. Needs the order approved. Returns { status, shipment, next }.',
|
|
58
59
|
'fulfillment ready': 'Mark a shipment ready to ship: c11r fulfillment ready <shipment>. Needs the order approved. Returns { status, shipment, next }.',
|
|
59
|
-
'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 }.",
|
|
60
61
|
'fulfillment deliver': 'Mark a shipped shipment delivered: c11r fulfillment deliver <shipment>. Returns { status, shipment, next }.',
|
|
61
62
|
'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
|
|
62
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.',
|
|
63
|
-
'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.",
|
|
64
|
-
'ucp product':
|
|
65
|
-
'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.",
|
|
66
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>.",
|
|
67
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.',
|
|
68
69
|
'ucp checkout cancel': 'Cancel a checkout the human no longer wants: c11r ucp checkout cancel <id> [--store <host>]. Returns the checkout, status canceled.',
|
|
69
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.",
|
|
70
|
-
'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 }.',
|
|
71
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.',
|
|
72
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.',
|
|
73
74
|
claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
|
|
@@ -304,7 +305,7 @@ export async function ordersTest(flags, values) {
|
|
|
304
305
|
messages: p.checkout.messages ?? p.ready.messages,
|
|
305
306
|
payment_handler: 'ai.commercelayer.sandbox (test payments only)',
|
|
306
307
|
steps: p.steps,
|
|
307
|
-
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,
|
|
308
309
|
};
|
|
309
310
|
}
|
|
310
311
|
// Orders and shipments, live in Commerce Layer through the API. An order lands placed with its
|
|
@@ -322,13 +323,15 @@ function orderNext(o) {
|
|
|
322
323
|
const shipment = o.shipments?.[0];
|
|
323
324
|
if (o.status === 'cancelled')
|
|
324
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).`;
|
|
325
328
|
if (o.status === 'placed')
|
|
326
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}.`;
|
|
327
330
|
if (o.payment_status === 'authorized')
|
|
328
331
|
return `Order ${n} is approved; the payment is authorized but not taken. Take it when the human says so: c11r orders capture ${n}.`;
|
|
329
332
|
if (o.fulfillment_status === 'fulfilled')
|
|
330
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>].`;
|
|
331
|
-
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).`;
|
|
332
335
|
}
|
|
333
336
|
export async function ordersList(values, flags) {
|
|
334
337
|
const api = createApi(requireCredentials());
|
|
@@ -377,6 +380,8 @@ function shipmentNext(s) {
|
|
|
377
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}.`;
|
|
378
381
|
if (s.order?.status === 'placed')
|
|
379
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>].`;
|
|
380
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}.`;
|
|
381
386
|
}
|
|
382
387
|
export async function fulfillmentList(values) {
|
|
@@ -510,7 +515,14 @@ const checkoutNext = (ck) => {
|
|
|
510
515
|
const errors = (ck.messages ?? []).filter((m) => m.type === 'error').map((m) => m.content);
|
|
511
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.`;
|
|
512
517
|
};
|
|
513
|
-
|
|
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()) {
|
|
514
526
|
const price = (flag) => {
|
|
515
527
|
const raw = values[flag];
|
|
516
528
|
if (raw === undefined)
|
|
@@ -528,12 +540,16 @@ export async function ucpSearch(query, values, lists = {}) {
|
|
|
528
540
|
const next = r.count
|
|
529
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).'
|
|
530
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.';
|
|
531
|
-
return { ...r, next };
|
|
543
|
+
return flags.has('--images') ? { ...r, ...(await addImages(r, r.products)), next } : { ...r, next };
|
|
532
544
|
}
|
|
533
|
-
export async function ucpProduct(id, values) {
|
|
545
|
+
export async function ucpProduct(id, values, flags = new Set()) {
|
|
534
546
|
if (!id)
|
|
535
547
|
fail('missing_argument', 'ucp product needs a product id.', 'Use a product id from c11r ucp search.');
|
|
536
|
-
|
|
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 };
|
|
537
553
|
}
|
|
538
554
|
export async function ucpCheckoutCreate(values, lists) {
|
|
539
555
|
const items = (lists.item ?? []).map(shop.parseItem);
|
|
@@ -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/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": [
|