@commercelayer/agent-cli 0.5.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,8 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
18
18
  | `init [--name <business name>]` | Create a sandbox store, with no login. The name (the folder name when absent) names the organization; the org slug and store hostname derive from it, numbered when taken. Writes `store.yml` (a commented template) and `credentials`, plus a short `AGENTS.md` and `CLAUDE.md` section (appended to an existing file, never twice) that sends later agents in the repo to [commercelayer.ai/skills.md](https://commercelayer.ai/skills.md). Reuses the store if they exist. |
19
19
  | `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, images, and the store's name, policies and theme. `missing_images` lists `img/<file>` references with no file in `.commercelayer/img/` (`apply` refuses them); `unused_images` lists files there that nothing references. |
20
20
  | `apply [--allow-delete] [--full]` | Make the store match the files, the website included: the theme settings, and the images in `.commercelayer/img/` that the theme and products reference, uploaded when new or changed (unused ones are dropped from the store host). Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused. While Commerce Layer is busy or unavailable, `apply` keeps reading (backing off on busy) and writes a `retrying` line. `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
21
- | `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet or a Google Merchant Center feed) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. |
21
+ | `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet, a Google Merchant Center feed, or a brand's website for a demo store) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. `--currency <code>` gives a website's currency when the site does not say it. |
22
+ | `storefront inspect <url>` | Read a brand's website, read-only: title, languages, theme colour, icons, `og:image`, logo candidates, the colours its CSS uses most, the currencies and countries it sells in, the platform (Shopify, WooCommerce or unknown) and whether `robots.txt` lets `catalog pull` read its products. For a demo store that looks like the site: the agent picks `theme.color` and `theme.logo` (a URL, hot-linked) from it. Writes no file. |
22
23
  | `storefront init [--force]` | Custom storefronts only. Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. Product images are the store's, at `/img/<file>` (from `.commercelayer/img/`, uploaded by `apply`); products with no image show the tile the store host draws for them, at `/tiles/<sku>.svg`. The site's own files go in `storefront/assets/`. Refuses to overwrite without the flag; with it, rewrites the three files. |
23
24
  | `storefront publish` | Custom storefronts only. Upload the folder at `storefront.path` (default `storefront`) to the store host, where it replaces the hosted theme, leaving out dotfiles. `/img/` on the store host is always the store's images, so files under the folder's `img/` are not served: publish lists them as `not_served`. |
24
25
  | `storefront check` | Custom storefronts only. Read the published storefront at the preview URL and check it: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product and none for unknown skus, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides (no dead `::part` rules), title, `lang`, viewport, description, and whether the folder changed since publish. Prints `{ status, summary, checks: [{ id, kind, status: pass\|warn\|fail, message, fix, items }], render }` and exits 1 when a check fails; warnings do not fail. `kind` is `static` today; a later rendering phase adds `render` checks and fills `render`. |
@@ -29,11 +30,18 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
29
30
  | `links create` | Create a shareable link to a microstore (sku, skus or category) or an order's checkout, from a spec in YAML or JSON on stdin or `-f <file>` (see [Links](#links)). Prints the link, `url` first. |
30
31
  | `links enable <id>` · `disable <id>` · `delete <id>` | Turn a link on or off, or delete it (ask the human first). |
31
32
  | `orders test [--sku <id>] [--quantity <n>] [--country <CC>] [--code <discount>] [--via-cart] [--decline]` | Buy from the store over UCP as a shopping agent would: discover, search, (cart,) checkout, address, pay with the sandbox handler, read the order back. Prints the order, the `discounts` applied (promotions and codes, in minor units like the totals) and every step. |
32
- | `orders list [--status placed\|approved\|cancelled\|editing\|all] [--payment <status>] [--fulfillment <status>] [--archived [all]] [--limit <n>]` | The store's orders, live in Commerce Layer, newest first; by default placed and approved, not archived, 25 at most (see [Orders and fulfillment](#orders-and-fulfillment)). |
33
+ | `orders list [--status pending\|placed\|approved\|cancelled\|editing\|all] [--channel message\|link] [--payment <status>] [--fulfillment <status>] [--archived [all]] [--limit <n>]` | The store's orders, live in Commerce Layer, newest first; by default placed and approved, not archived, 25 at most (see [Orders and fulfillment](#orders-and-fulfillment)). Pending orders (from emails and messages) only with `--status pending`. |
34
+ | `orders create -f <file> \| <flags> --source email:<id> [--place\|--hold] [--force]` | An order from an email or a message: the agent writes the request (customer, address, lines by sku or by the words used); the API resolves the products at catalog prices and makes it pending, or answers `needs_review` with nothing created (see [Orders from emails and messages](#orders-from-emails-and-messages)). |
35
+ | `orders place <n>` | Place a pending order once the human said yes. |
33
36
  | `orders show <number or id>` | One order as the merchant sees it: line items, totals, addresses, payment and transactions, shipments, and `next`, what it waits for. |
34
37
  | `orders approve <n> [--capture]` · `capture` · `cancel` · `refund <n> [--amount <major>]` · `archive` · `unarchive` | Move an order; each prints the order after it. |
35
38
  | `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
39
  | `fulfillment hold` · `pick` · `pack` · `ready` · `ship <shipment> [--carrier] [--tracking] [--tracking-url]` · `deliver` | Move a shipment; each prints the shipment after it. |
40
+ | `ops check [--since 24h\|7d\|<ISO>]` | What is new since the last check and what the policy in `.commercelayer/ops.yml` would do, without changing anything: new orders, what it would approve and capture, what it holds for the human and why, orders with no payment yet, stuck shipments (see [Running the store on a schedule](#running-the-store-on-a-schedule)). |
41
+ | `ops run [--dry-run]` | Apply the policy: approve, and capture if it says so, every placed order it does not hold. Never ships, cancels or refunds. |
42
+ | `ops digest [--since 24h\|7d\|<ISO>]` | A summary for the human: orders, revenue per currency, what was approved, what is held or stuck, top products, as `summary` text. |
43
+ | `insights [--since 24h\|7d\|30d\|<ISO>] [--until <ISO>] [--by product\|day\|market\|country\|channel\|promotion]` | Sales over a span (default 30 days): orders, units, revenue and average order per currency, refunds and cancellations apart, top products, channels, and with `--by` one row per product, day, market, country, channel or promotion; a `summary` to read. |
44
+ | `inbox check` · `list [--unseen] [--since 24h] [--limit 20]` · `pull <id>\|--unseen` · `tag <id> [--label <name>]… [--unlabel <name>]… [--move <folder>] [--seen]` | The inbox orders arrive in, over IMAP: list it, save messages and their attachments under `.commercelayer/inbox/` for the agent to read, label or move them when done. No sending: replies stay with the human. |
37
45
  | `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
38
46
  | `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
47
  | `ucp checkout create --item <variant id>[:<qty>] … [--code <discount>] [--country <CC>]` | Open a checkout; `messages` say what is still missing (email, shipping address). |
@@ -139,14 +147,18 @@ A store has at most 6 languages, the base one included. Every country gets its o
139
147
  When the products live in a tool the human edits too, `store.yml` names it and `c11r catalog pull` copies it into `.commercelayer/products/catalog.csv`, sorted by sku: same source, same file. Pull applies the product rules above before it writes, with errors naming the row in the source, and reports the skus added, changed and removed. Removed products leave the store only through `apply --allow-delete`. Every source has the same columns: `sku`, `name`, `description`, `image_url`, `category`, and one price column per market currency, named by its ISO 4217 code in lowercase (`eur`, `usd`).
140
148
  ```yaml
141
149
  catalog:
142
- source: sheets # sheets | supabase | merchant
150
+ source: sheets # sheets | supabase | merchant | website
143
151
  url: https://docs.google.com/spreadsheets/d/e/2PACX-.../pub?output=csv
144
152
  # table: products # supabase only
153
+ # limit: 100 # website only: how many products (at most 500)
145
154
  ```
146
155
  - `sheets`: a Google Sheet, by its CSV link (File > Share > Publish to web > Comma-separated values) or by its own link when anyone with the link can view it. No key. Translations are `name_<ll>`, `description_<ll>` and `category_<ll>` columns, in sheets and Supabase tables alike.
147
156
  - `supabase`: a table (default `products`), read over the REST API with the project's anon key: `C11R_SUPABASE_URL=https://<project>.supabase.co` and `C11R_SUPABASE_KEY=<anon key>` in `.commercelayer/credentials`, or the environment. The key needs a row level security policy that allows select. Never use the `service_role` key. Columns `id`, `created_at` and `updated_at` are skipped.
148
157
  - `merchant`: a Google Merchant Center product feed URL, RSS 2.0 (XML, `g:` namespace) or TSV. `id` is the sku, `title` the name, `description`, `image_link` the image, `price` (`14.50 EUR`) the price in its currency, `product_type` the category. An id listed once per currency is one product with several prices. Other attributes, `sale_price` included, are ignored. Other languages: `catalog.translations: { it: <the Italian feed URL> }`, one feed per language, merged by `id` (`title`, `description`, `product_type`).
149
- Error codes: `no_catalog_source`, `invalid_catalog`, `missing_credentials`, `catalog_unreachable`, `catalog_unauthorized`, `catalog_not_found`, `catalog_not_csv`, `catalog_error`, `empty_catalog`, plus `invalid_product` and `missing_price`.
158
+ - `website`: a brand's own website, for a demo store (the human's site, or a brand they are pitching to). `url` is the site, `limit` how many products (default 100, at most 500). Pull tries, in order, and the first that yields products wins: Shopify's public `products.json` (also `/collections/all/products.json`), WooCommerce's Store API (`/wp-json/wc/store/v1/products`), then the product pages its sitemap lists (`/products/`, `/product/`, `/p/`, `/shop/`, `/item/`) and their schema.org Product JSON-LD. One row per product: the first available variant's sku and price (or the handle in upper snake case), the title, the description as plain text (paragraphs kept, at most 2,000 characters), the first image, the product type or breadcrumb as category. Products without a price are left out, and a sku seen twice keeps the first. Prices are the site's, in the site's currency, never converted: the store's markets must sell in it (`currency_mismatch` otherwise), and when the site does not say it, `currency_unknown` asks for `--currency <code>`. Translations are not pulled yet; `source_site.languages` lists the site's languages. **Images and the logo stay on the brand's servers: `image_url` links them, nothing is copied.** Pull reads politely: `robots.txt` is honoured for every path (`robots_skipped` counts what it disallows), one request at a time and at most 2 a second, 15 s per request and 60 s in all (`budget_exhausted` when it stopped early, with what it read), as `c11r/<version> (+https://commercelayer.ai)`. It prints `strategy`, `source_site: { url, title, platform, product_count_seen, currency, languages }`, `pulled`, `variants_skipped`, `robots_skipped`, `no_price_skipped`, `duplicates_skipped`, `other_currency_skipped`, `tried: [{ strategy, result }]` and `requests`, so the agent can tell the human "I found 340 products, pulled 100".
159
+ Error codes: `no_catalog_source`, `invalid_catalog`, `missing_credentials`, `catalog_unreachable`, `catalog_unauthorized`, `catalog_not_found`, `catalog_not_csv`, `catalog_error`, `empty_catalog`, plus `invalid_product` and `missing_price`; for a website `site_unreachable`, `no_products_found` (ask the human for a feed or a sheet instead), `currency_unknown` and `currency_mismatch`.
160
+
161
+ `storefront inspect <url>` reads the same way, one page and up to 3 of its stylesheets, and writes nothing: `{ url, title, site_name, description, language, languages, theme_color, icons, og_image, logo_candidates: [{ url, source: json_ld|header_img|img|header_svg, alt, width, height }], colors: [{ hex, source: theme_color|css, count, neutral }], currencies_seen, countries_seen, platform, robots: { products_allowed, paths }, next }`. An inline SVG logo has `url: null`: there is nothing to link. Errors: `missing_argument`, `site_unreachable`, `site_error`, `robots_disallowed`.
150
162
 
151
163
  ### The website
152
164
  The theme renders the home page (logo, hero with name and tagline, products by top-level category), a page per category at `/categories/<slug>/...` and per product at `/products/<sku>`, with the market from `?market=<code>`, cart and checkout. Images written `img/<file>` (`theme.logo`, `theme.hero`, a product's `image_url`) are files in `.commercelayer/img/`, served at `<preview_url>/img/<file>`: paths of letters, digits, `.`, `_`, `-` and `/`, a lowercase `png`, `jpg`, `jpeg`, `webp`, `gif`, `avif` or `svg` extension, at most 5 MB each. `apply` uploads the new and changed ones (by SHA-256) before anything else, and the store host drops the ones nothing references any more.
@@ -157,13 +169,49 @@ One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`,
157
169
 
158
170
  A custom storefront replaces the theme: `storefront.path` in `store.yml` names its folder, `storefront init` writes a drop-in.js scaffold, `storefront publish` uploads it, `storefront check` checks it. Its pages link product images at `/img/`; its own files (fonts, icons, styles) go in `assets/`, not `img/`.
159
171
 
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`.
172
+ 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`, `country_not_served`, `too_many_items`, `store_unavailable`, `orders_unsupported`, `missing_credentials`, `invalid_credentials`, `inbox_unreachable`, `labels_unsupported`, `invalid_policy`, `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
173
  ## Orders and fulfillment
162
174
 
163
175
  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
176
 
165
177
  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
178
 
179
+ ### Running the store on a schedule
180
+
181
+ An agent can run the store on a schedule (a cron job, a CI workflow, a loop) with `ops check` and `ops run`, under a policy the human agrees to. The policy is `.commercelayer/ops.yml`, committed with the store; the CLI reads it and never sends it to the API, and `plan` checks it:
182
+
183
+ ```yaml
184
+ auto_approve: true # approve placed orders (default false: every order waits for the human)
185
+ auto_capture: true # take the payment when approving, and of orders the human approved (default false)
186
+ review_above: 500 # hold orders above this total, in major units of the order's currency
187
+ hold_countries: [US] # hold orders shipping to these countries
188
+ stuck_days: 2 # a shipment picking, packing or ready to ship longer is stuck (on hold never is)
189
+ transit_days: 7 # a shipment shipped longer and not delivered is stuck in transit
190
+ mail_orders: # orders made from emails and messages (orders create)
191
+ auto_place: false # place them at once (default false: pending until the human says yes)
192
+ labels: { pending: pending, ordered: ordered, declined: declined } # what the agent labels the emails (these by default)
193
+ ```
194
+
195
+ `ops check` remembers what it reported in `.commercelayer/ops.json` (machine state, gitignored), so the next check does not repeat an order; `--since` looks back without moving it. `ops run` approves and captures what the policy allows, one order at a time, and reports what it did, what it held and why, and what is stuck; with `--dry-run` it only says what it would do. Shipping, cancelling and refunding stay with the human. `ops digest` and `insights` summarise for the human, in plain text they can read as is.
196
+
197
+ ### Orders from emails and messages
198
+
199
+ Merchants get orders by email: a sentence in the body, a purchase order as PDF, a list in a sheet. The agent reads the message (the CLI saves attachments as they came; the agent opens PDFs and sheets itself) and writes the request:
200
+
201
+ ```yaml
202
+ source: { kind: email, id: "<po-114@bar.example>" } # required: the same source never makes a second order
203
+ customer: { email: buyer@bar.example, company: Bar Centrale, phone: "+33 1 23 45 67 89" }
204
+ ship_to: { line_1: 3 rue de la Paix, city: Paris, postal_code: "75002", country: FR }
205
+ items:
206
+ - { sku: ETH, quantity: 6 }
207
+ - { query: white mug, quantity: 2 } # the words the customer used
208
+ reference: PO-2026-114 # their purchase order number
209
+ ```
210
+
211
+ `orders create -f request.yml` sends it; the API finds each line in the catalog (a sku, or the store's search for the words), at the catalog's prices, never the email's. A line it is not sure of comes back in `resolution` with up to three candidates and status `needs_review`, and nothing is created: the agent asks the human which product and runs it again with `--item <sku>:<qty>`, or with `--force` to leave those lines out. Nothing is invented: a missing phone, postal code or country is `invalid_argument` with the `path`. The order is made **pending**, shown to the human, and placed on their yes with `orders place <n>`; `mail_orders.auto_place: true` places it at once, `--hold` keeps it pending anyway. `orders cancel` drops a pending order (it stays archived).
212
+
213
+ `inbox` reads the mailbox when the agent has no mail connector of its own. The connection goes in `.commercelayer/credentials` (gitignored), written by the human: `C11R_IMAP_HOST`, `C11R_IMAP_USER`, `C11R_IMAP_PASSWORD` (for Gmail an [app password](https://myaccount.google.com/apppasswords), never the account password), and optionally `C11R_IMAP_PORT` (993) and `C11R_IMAP_FOLDER` (INBOX); no output ever shows the password. `inbox pull` writes `.commercelayer/inbox/<id>/` (gitignored): `message.json`, `body.txt` and `attachments/`; `.commercelayer/inbox.json` remembers what was pulled. `inbox tag` labels the email (Gmail labels, else IMAP keywords, else `--move` to a folder) and `--seen` marks it read.
214
+
167
215
  ## The store speaks UCP
168
216
  Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
169
217
  | What | Where |
@@ -186,7 +234,7 @@ An agent can buy from any UCP store on behalf of its human, one step per command
186
234
 
187
235
  ## Requirements
188
236
 
189
- Node 20 or later. One runtime dependency (`yaml`).
237
+ Node 20 or later. Runtime dependencies: `yaml`, and for `inbox` only `imapflow` and `postal-mime`, loaded when an inbox command runs (about 9 MB more on install).
190
238
 
191
239
  ## Links and license
192
240
 
@@ -38,7 +38,7 @@ export function createApi(auth) {
38
38
  return data;
39
39
  const err = data?.error;
40
40
  if (err)
41
- fail(err.code, err.message, err.hint);
41
+ fail(err.code, err.message, err.hint, err.path);
42
42
  fail('api_error', `The API answered ${res.status}.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
43
43
  };
44
44
  // Promotions and links: on an API older than them, the collection route is an unknown route (404 not_found).
@@ -120,8 +120,46 @@ export function createApi(auth) {
120
120
  ordersUnsupported();
121
121
  return o;
122
122
  }),
123
+ createOrder: async (body) => {
124
+ try {
125
+ return (await request('POST', `${org()}/orders`, body));
126
+ }
127
+ catch (e) {
128
+ // An API before orders from messages has no POST /orders.
129
+ if (e instanceof CliError && e.code === 'not_found')
130
+ fail('orders_unsupported', 'This API cannot create orders from messages yet.', 'Try again later; tell the human if it persists.');
131
+ throw e;
132
+ }
133
+ },
123
134
  orderAction: (id, action, body) => orderCall(() => request('POST', `${orderPath(id)}/${action}`, body ?? {})),
124
135
  listShipments,
136
+ // An API before the snapshot answers not_found: ops_unsupported, and the CLI reads the lists instead.
137
+ getOps: async (query) => {
138
+ try {
139
+ const r = (await request('GET', `${org()}/ops${qs(query).replace(/^&/, '?')}`));
140
+ if (!r || typeof r !== 'object' || !r.orders || !r.shipments)
141
+ fail('ops_unsupported', 'This API has no operations snapshot yet.', 'The CLI reads the order and shipment lists instead.');
142
+ return r;
143
+ }
144
+ catch (e) {
145
+ if (e instanceof CliError && e.code === 'not_found')
146
+ fail('ops_unsupported', 'This API has no operations snapshot yet.', 'The CLI reads the order and shipment lists instead.');
147
+ throw e;
148
+ }
149
+ },
150
+ insights: async (query) => {
151
+ try {
152
+ const r = (await request('GET', `${org()}/insights${qs(query).replace(/^&/, '?')}`));
153
+ if (!r || typeof r !== 'object' || !('totals' in r))
154
+ fail('insights_unsupported', 'This API does not report insights yet.', 'Try again later; tell the human if it persists.');
155
+ return r;
156
+ }
157
+ catch (e) {
158
+ if (e instanceof CliError && e.code === 'not_found')
159
+ fail('insights_unsupported', 'This API does not report insights yet.', 'Try again later; tell the human if it persists.');
160
+ throw e;
161
+ }
162
+ },
125
163
  getShipment: (id) => shipmentCall(() => request('GET', shipmentPath(id))),
126
164
  shipmentAction: (id, action, body) => shipmentCall(() => request('POST', `${shipmentPath(id)}/${action}`, body ?? {})),
127
165
  listPromotions: () => unsupported('promotions', request('GET', collection('promotions'))),
@@ -5,8 +5,8 @@ export function json(status, body, headers = {}) {
5
5
  return { status, headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) };
6
6
  }
7
7
  /** The management API's error shape, the same the CLI prints. */
8
- export function error(status, code, message, hint) {
9
- return json(status, { error: { code, message, hint } });
8
+ export function error(status, code, message, hint, path) {
9
+ return json(status, { error: { code, message, hint, ...(path ? { path } : {}) } });
10
10
  }
11
11
  export function readJson(req) {
12
12
  if (!req.body)
@@ -6,7 +6,8 @@ import { generateKey } from '../../ucp/signing.js';
6
6
  import { localesOf } from '../../locales.js';
7
7
  import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
8
8
  import { bearer, error, json, readJson } from './http.js';
9
- import { listOrders, listShipments, OrderError, orderAction, shipmentAction, showOrder, showShipment } from './orders.js';
9
+ import { createOrder } from './messages.js';
10
+ import { insights, listOrders, opsSnapshot, listShipments, OrderError, orderAction, shipmentAction, showOrder, showShipment } from './orders.js';
10
11
  function publicState(r) {
11
12
  const { token: _token, ucp: _ucp, pending: _pending, asset_files: _files, ...state } = r;
12
13
  return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, storefront_mode: r.storefront_mode ?? 'hosted', locales: r.locales ?? [], assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
@@ -363,11 +364,27 @@ export function handleManagement(req) {
363
364
  const merchantView = q.get('view') === 'merchant';
364
365
  if (req.method === 'GET' && rest === '/orders' && merchantView)
365
366
  return merchant(() => listOrders(record, q));
366
- const act = rest.match(/^\/orders\/([^/]+)\/(approve|capture|cancel|refund|archive|unarchive)$/);
367
+ if (req.method === 'POST' && rest === '/orders') {
368
+ try {
369
+ const out = createOrder(record, readJson(req));
370
+ writeDb(db);
371
+ return json(out.status, out.body);
372
+ }
373
+ catch (e) {
374
+ if (e instanceof OrderError)
375
+ return error(e.status, e.code, e.message, e.hint, e.path);
376
+ throw e;
377
+ }
378
+ }
379
+ const act = rest.match(/^\/orders\/([^/]+)\/(place|approve|capture|cancel|refund|archive|unarchive)$/);
367
380
  if (req.method === 'POST' && act)
368
381
  return merchant(() => orderAction(record, decodeURIComponent(act[1]), act[2], readJson(req) ?? {}), true);
369
382
  if (req.method === 'GET' && rest === '/shipments')
370
383
  return merchant(() => listShipments(record, q));
384
+ if (req.method === 'GET' && rest === '/insights')
385
+ return merchant(() => insights(record, q));
386
+ if (req.method === 'GET' && rest === '/ops')
387
+ return merchant(() => opsSnapshot(record, q));
371
388
  const ship = rest.match(/^\/shipments\/(.+?)(?:\/(hold|pick|pack|ready|ship|deliver))?$/);
372
389
  if (ship && req.method === 'POST' && ship[2])
373
390
  return merchant(() => shipmentAction(record, decodeURIComponent(ship[1]), ship[2], readJson(req) ?? {}), true);
@@ -0,0 +1,126 @@
1
+ // POST /stores/:org/orders, as the API does it (its messages.ts): an order from an email or a
2
+ // message, made pending (or placed with place: true) from a checked request. Lines by sku are
3
+ // looked up; lines by words go through the store's search (stub/search.ts rank), with the API's
4
+ // confidence rule: every word 0.9, else 0.6 × the share of words (the stub has no embeddings for
5
+ // the 0.4 × cosine part), 0.2 less when the second candidate scores within 10%; 0.75 to match.
6
+ // A line under the bar and no force: needs_review, nothing created. Catalog prices only.
7
+ import { checkOrderRequest } from '../../orderRequest.js';
8
+ import { CliError } from '../../output.js';
9
+ import { money } from '../../ucp/shop.js';
10
+ import { toMinor } from '../../ucp/platform.js';
11
+ import { OrderError, showOrder } from './orders.js';
12
+ import { queryWords, rank } from './search.js';
13
+ const BAR = 0.75;
14
+ const round = (n) => Math.round(n * 100) / 100;
15
+ function resolveLine(products, market, line) {
16
+ const priced = (p) => typeof p.prices?.[market.currency] === 'number';
17
+ const card = (p) => ({ sku: p.sku, name: p.name, price: priced(p) ? money(toMinor(p.prices[market.currency], market.currency), market.currency) : null });
18
+ const requested = line.sku ?? line.query;
19
+ if (line.sku) {
20
+ const p = products.find((x) => x.sku === line.sku);
21
+ if (p)
22
+ return priced(p) ? { requested, quantity: line.quantity, sku: p.sku, name: p.name, confidence: 1, candidates: [card(p)], status: 'matched', product: p } : { requested, quantity: line.quantity, sku: p.sku, name: p.name, confidence: 1, candidates: [], status: 'unavailable' };
23
+ // An unknown sku is searched like words: "ETH-250" may still name a product.
24
+ }
25
+ const ranked = rank(products, requested);
26
+ const sellable = ranked.filter((x) => priced(x.product));
27
+ if (!ranked.length)
28
+ return { requested, quantity: line.quantity, sku: null, name: null, confidence: 0, candidates: [], status: 'not_found' };
29
+ if (!sellable.length)
30
+ return { requested, quantity: line.quantity, sku: ranked[0].product.sku, name: ranked[0].product.name, confidence: 0, candidates: [], status: 'unavailable' };
31
+ const [top, second] = sellable;
32
+ const asked = Math.max(1, queryWords(requested).length);
33
+ let confidence = top.match.all ? 0.9 : 0.6 * (top.match.words.length / asked);
34
+ if (second && second.match.score >= top.match.score * 0.9)
35
+ confidence -= 0.2;
36
+ confidence = round(Math.max(0, confidence));
37
+ const candidates = sellable.slice(0, 3).map((x) => card(x.product));
38
+ if (confidence >= BAR)
39
+ return { requested, quantity: line.quantity, sku: top.product.sku, name: top.product.name, confidence, candidates, status: 'matched', product: top.product };
40
+ return { requested, quantity: line.quantity, sku: null, name: null, confidence, candidates, status: 'ambiguous' };
41
+ }
42
+ export function createOrder(r, body) {
43
+ let req;
44
+ try {
45
+ req = checkOrderRequest(body);
46
+ }
47
+ catch (e) {
48
+ if (e instanceof CliError)
49
+ throw new OrderError(422, e.code, e.message, e.hint, e.path);
50
+ throw e;
51
+ }
52
+ const markets = Object.values(r.applied?.markets ?? {});
53
+ const market = markets.find((m) => m.countries.includes(req.ship_to.country));
54
+ if (!market) {
55
+ const served = [...new Set(markets.flatMap((m) => m.countries))].sort();
56
+ throw new OrderError(422, 'country_not_served', `The store does not ship to ${req.ship_to.country}.`, served.length ? `It ships to ${served.join(', ')}: ask the human what to do with this order.` : 'The store has no markets yet: run c11r apply.', 'ship_to.country');
57
+ }
58
+ // The same source again: the order it made, unless that one was cancelled.
59
+ if (req.source) {
60
+ const existing = Object.values(r.ucp.orders).reverse().find((o) => o._source?.kind === req.source.kind && o._source?.id === req.source.id && o._merchant?.status !== 'cancelled');
61
+ if (existing) {
62
+ const order = showOrder(r, existing.id);
63
+ return { status: 200, body: { status: order.status, order, resolution: [], warnings: [{ code: 'duplicate_source', message: `${req.source.kind} ${req.source.id} already made order ${order.number}.` }] } };
64
+ }
65
+ }
66
+ const products = Object.values(r.applied?.products ?? {});
67
+ const resolved = req.items.map((it) => resolveLine(products, market, it));
68
+ const resolution = resolved.map(({ product: _p, ...x }) => x);
69
+ const open = resolved.filter((x) => x.status !== 'matched');
70
+ // Two lines of the same sku become one.
71
+ const kept = [];
72
+ for (const x of resolved.filter((y) => y.status === 'matched')) {
73
+ const same = kept.find((k) => k.sku === x.sku);
74
+ if (same)
75
+ same.quantity = same.quantity + x.quantity;
76
+ else
77
+ kept.push({ ...x });
78
+ }
79
+ if ((open.length && !req.force) || !kept.length)
80
+ return { status: 200, body: { status: 'needs_review', order: null, resolution, warnings: [] } };
81
+ const warnings = open.map((x) => ({ code: 'line_dropped', message: `"${x.requested}" (${x.quantity}) was left out: ${x.status.replace('_', ' ')}.` }));
82
+ const orders = r.ucp.orders;
83
+ const id = `ord_${Math.random().toString(16).slice(2, 14)}`;
84
+ const at = new Date().toISOString();
85
+ const cur = market.currency;
86
+ const lines = kept.map((x, i) => {
87
+ const price = toMinor(x.product.prices[cur], cur);
88
+ return { id: `li_${i + 1}`, item: { id: x.product.sku, title: x.product.name, price, ...(x.product.image_url ? { image_url: x.product.image_url } : {}) }, quantity: { original: x.quantity, total: x.quantity, fulfilled: 0 }, totals: [{ type: 'total', amount: price * x.quantity }], status: 'processing' };
89
+ });
90
+ const subtotal = lines.reduce((s, l) => s + l.totals[0].amount, 0);
91
+ const ship = toMinor(market.shipping?.price ?? 0, cur);
92
+ const c = req.customer;
93
+ const to = req.ship_to;
94
+ const destination = { first_name: c.first_name, last_name: c.last_name, company: c.company, street_address: to.line_1, extended_address: to.line_2, address_locality: to.city, postal_code: to.postal_code, address_region: to.region, address_country: to.country, phone: to.phone ?? c.phone };
95
+ const number = `${1001 + Object.keys(orders).length}`;
96
+ const order = {
97
+ id,
98
+ label: `#${number}`,
99
+ permalink_url: `${r.preview_url}/orders/${id}`,
100
+ currency: cur,
101
+ line_items: lines,
102
+ fulfillment: { expectations: [{ id: 'exp_1', line_items: lines.map((l) => ({ id: l.id, quantity: l.quantity.total })), method_type: 'shipping', destination, description: market.shipping?.method ?? 'Standard', fulfillable_on: 'now' }], events: [] },
103
+ adjustments: [],
104
+ totals: [{ type: 'subtotal', display_text: 'Subtotal', amount: subtotal }, { type: 'fulfillment', display_text: 'Shipping', amount: ship }, { type: 'total', display_text: 'Total', amount: subtotal + ship }],
105
+ _platform: 'message',
106
+ _buyer: { email: c.email, first_name: c.first_name, last_name: c.last_name, company: c.company, phone_number: c.phone ?? to.phone },
107
+ _market: market.code,
108
+ _language: req.language,
109
+ _codes: [],
110
+ _source: req.source ?? null,
111
+ _notes: req.notes,
112
+ _reference: req.reference,
113
+ ...(req.bill_to ? { _bill_to: { first_name: req.bill_to.first_name, last_name: req.bill_to.last_name, company: req.bill_to.company, street_address: req.bill_to.line_1, extended_address: req.bill_to.line_2, address_locality: req.bill_to.city, postal_code: req.bill_to.postal_code, address_region: req.bill_to.region, address_country: req.bill_to.country, phone: req.bill_to.phone } } : {}),
114
+ _merchant: {
115
+ status: req.place ? 'placed' : 'pending',
116
+ created_at: at,
117
+ placed_at: req.place ? at : null,
118
+ archived_at: null,
119
+ transactions: req.place ? [{ type: 'authorization', amount: subtotal + ship, succeeded: true, at }] : [],
120
+ shipment: { id: `shp_${id.slice(4)}`, number: `${number}/S/001`, status: 'upcoming', tracking: null, dates: { created_at: at, on_hold_at: null, picking_at: null, packing_at: null, ready_to_ship_at: null, shipped_at: null, delivered_at: null } },
121
+ },
122
+ };
123
+ orders[id] = order;
124
+ const shown = showOrder(r, id);
125
+ return { status: 201, body: { status: shown.status, order: shown, resolution, warnings } };
126
+ }