@commercelayer/agent-cli 0.5.2 → 0.7.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/dist/commands.js CHANGED
@@ -9,6 +9,11 @@ import { diff, hashStore, languageWarnings, loadStore, storefrontMode, storefron
9
9
  import { storefrontFiles } from './storefront.js';
10
10
  import { imageNotes, planAssets, uploadAssets } from './assets.js';
11
11
  import { pull } from './catalog.js';
12
+ import * as ops from './ops.js';
13
+ import { checkOrderRequest, lineFlag, sourceFlag } from './orderRequest.js';
14
+ import { readInboxCursor, seenKey, writeInboxCursor } from './inbox/cursor.js';
15
+ import { openMailbox } from './inbox/mailbox.js';
16
+ import { parseMessage, writeMessage } from './inbox/message.js';
12
17
  import { checkLink } from './links.js';
13
18
  import { ACTIVE_LIMIT, activeWithoutCoupon, checkPromotion } from './promotions.js';
14
19
  import YAML from 'yaml';
@@ -43,11 +48,13 @@ export const HELP = {
43
48
  'links disable': 'Turn a link off without deleting it: its url stops opening. c11r links disable <id>.',
44
49
  'links delete': 'Delete a link from Commerce Layer: c11r links delete <id>. Ask the human first; to pause one, disable it.',
45
50
  'orders test': 'Buy from the store over UCP as a shopping agent would, paying with the sandbox handler: discover, search, checkout, address, pay, read the order. Options: --sku <id> --quantity <n> --country <CC> --code <discount> --via-cart --decline. The order is placed, payment authorized; approve and capture it with `c11r orders approve <id> --capture`. discounts lists what promotions and codes took off; a code that applied nothing is a discount_code_invalid warning in messages.',
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.",
51
+ '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 pending|placed|approved|cancelled|editing|all] [--channel message|link] [--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. pending orders were made from emails or messages (orders create) and are not placed yet: placed_at is null, created_at says when; --status pending lists them (all is every status but pending: --status all,pending for both), --channel message every order from a message, --channel link those from checkout links (agent and web: insights --by channel). A pending order cancelled stays archived with them: --status pending --archived.",
47
52
  '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.',
53
+ 'orders create': "Make an order from an email or a message the human received (an order by email, a chat, a PDF purchase order the agent read): c11r orders create -f <file> (or the request on stdin) --source email:<Message-ID> [--place | --hold] [--force], or with flags: --email --first-name --last-name [--company] --phone --item <sku>:<qty> (repeat it) --query \"<words>:<qty>\" (repeat it) --street [--line-2] --city --postal-code [--region] --country [--reference <PO number>] [--notes <text>] [--language <ll>] --source <kind>:<id>. source is required: email:<Message-ID> for an email, message:<id> for anything else (a chat or form message id); it keeps a message from making two orders. The request: { source: { kind: email|message, id, from, subject, received_at }, customer: { email, first_name, last_name, company, phone }, ship_to: { line_1, line_2, city, postal_code, region, country, phone }, bill_to, items: [{ sku, quantity } | { query, quantity }], notes, reference, language }. Everything comes from the message or the human, never invented: a missing phone, postal code or country is invalid_argument with path naming it (ask the human). quantity is 1 to 10,000; two lines of the same sku become one. Prices are always the catalog's: never put the email's prices in; tell the human when they differ. A query line is resolved by the store's search; a line it cannot match surely leaves status needs_review with nothing created (exit 0): resolution lists each line { requested, sku, name, confidence, candidates: [{ sku, name, price }], status: matched|ambiguous|not_found|unavailable }; ask the human which product, then run it again with --item <sku>:<qty>, or with --force to leave those lines out (line_dropped in warnings). The order is created pending (not placed) for the human's yes, then c11r orders place <number>; mail_orders: { auto_place: true } in .commercelayer/ops.yml (or --place) places it at once, --hold keeps it pending whatever the policy says. The same --source again returns the order it made (duplicate_source in warnings), never a second one. Other errors: country_not_served (with the countries the store ships to), too_many_items (25 lines at most), store_unavailable (the store's search failed: nothing created, try again). Warnings: duplicate_source, line_dropped, no_shipping_method. Returns { status: pending|placed|needs_review, order, resolution, warnings, next }.",
54
+ 'orders place': "Place a pending order made by orders create (channel message), once the human said yes to its lines and total: c11r orders place <number>. A shopper's own checkout in progress is theirs to finish: invalid_transition. It is paid by wire transfer: placed with the payment authorized, then approved and captured like any other order. Returns { status, order, next }.",
48
55
  '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
56
  '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 }.",
57
+ '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 }. A pending order (from orders create) is dropped: it reads cancelled and archived, and lists with --status pending --archived.",
51
58
  '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
59
  '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
60
  'orders unarchive': 'Bring an archived order back into orders list: c11r orders unarchive <number>. Idempotent.',
@@ -59,6 +66,14 @@ export const HELP = {
59
66
  'fulfillment ready': 'Mark a shipment ready to ship: c11r fulfillment ready <shipment>. Needs the order approved. Returns { status, shipment, next }.',
60
67
  '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
68
  'fulfillment deliver': 'Mark a shipped shipment delivered: c11r fulfillment deliver <shipment>. Returns { status, shipment, next }.',
69
+ 'ops check': "Run the store on a schedule, step 1: what is new and what the policy would do, without changing anything. c11r ops check [--since 24h|7d|<ISO>]. Returns { checked_at, since, policy, new_orders, actions, held, waiting, awaiting_payment, stuck, counts, truncated, next }: new_orders are placed orders not reported before (each check remembers what it reported, so the next one does not repeat them; --since looks back from a time instead and remembers nothing); each decision is { order: { id, number, placed_at, country (the shipping country), channel, email, items, total, payment_status }, action: approve_capture | approve | capture | hold | wait, reason }; held are orders the policy keeps for the human (review_above, hold_countries), waiting are those it leaves to them (auto_approve or auto_capture off); awaiting_payment are orders with no payment yet; stuck are shipments picking, packing or ready to ship longer than stuck_days, or shipped longer than transit_days and not delivered, with { shipment, days, since, reason } (on hold is the human's choice, never stuck); counts are the real totals, and truncated says a list was cut. Ages are by the store's clock (checked_at). The policy is .commercelayer/ops.yml, agreed with the human and committed with the store, never sent to the API (c11r plan checks it): auto_approve and auto_capture (true or false, default false: set them only when the human says so), review_above (hold orders above it, in major units of the order's currency), hold_countries (hold orders shipping there, ISO codes), stuck_days (2), transit_days (7), and for orders from emails and messages mail_orders: { auto_place (false: they stay pending for the human), labels: { pending, ordered, declined } (the email labels, those names by default) }. Without the file nothing is approved: every order waits for the human. Pass next on: it says what ops run would do and what to tell the human.",
70
+ 'ops run': "Run the store on a schedule, step 2: apply the policy in .commercelayer/ops.yml (see ops check). c11r ops run [--dry-run]. Approves, and captures when auto_capture is on, every placed order the policy does not hold; captures approved orders when auto_capture is on. It never ships, cancels or refunds: those stay with the human. Returns { ran_at, policy, new_orders, done: [{ action, order }], failed, held, waiting, awaiting_payment, stuck, next }; it acts on every order, beyond the 50 a check shows; with --dry-run, would_do instead of done, and nothing changes. failed lists orders that changed meanwhile (invalid_transition): nothing else stops. Tell the human about held and stuck, with the reasons.",
71
+ 'ops digest': "A summary for the human of how the store is doing: c11r ops digest [--since 24h|7d|<ISO>] (default 24h). Returns { since, orders: { count, approved, cancelled }, revenue: [Money per currency], refunds, held, waiting, stuck, top_products: [{ sku, name, units, revenue }], summary, next }: count is the orders placed in the span that are placed or approved now, cancelled the ones cancelled since; refunds are apart from revenue. summary is plain text written for the human: give it to them as it is.",
72
+ 'insights': "Sales over a time span, by one dimension: c11r insights [--since 24h|7d|30d|<ISO>] [--until <ISO>] [--by product|day|market|country|channel|promotion] (default 30d, at most 366 days). Returns { since, until, by, as_of, totals: { orders, units, revenue, average_order, refunds, cancellations }, rows: [{ key, label, orders, units, revenue, discount }], top_products: [{ sku, name, orders, units, revenue }], channel: { agent, link, web }, partial, summary, next }: orders are those placed in the span that are placed or approved now; revenue includes taxes, shipping and discounts, refunds are apart, cancellations count the orders cancelled since; rows only with --by (day has every day, country the shipping country, promotion its discount); partial: only the 1,000 newest orders are counted; money is per currency, never summed across currencies. Give the human the summary; the rows have the numbers.",
73
+ 'inbox check': 'The inbox orders arrive in, over IMAP: c11r inbox check. Returns { host, user, folder, exists, unseen, next }. The connection is in .commercelayer/credentials (gitignored): C11R_IMAP_HOST, C11R_IMAP_USER, C11R_IMAP_PASSWORD (for Gmail an app password, never the account password), optionally C11R_IMAP_PORT (993) and C11R_IMAP_FOLDER (INBOX). Missing ones answer missing_credentials with how to set them: the human writes the password in the file, never in the chat; no output ever shows it. A mail connector the agent already has does the same job: use it first. The CLI never sends email: there is no inbox reply, replies stay with the human.',
74
+ 'inbox list': 'The messages in the inbox, newest first: c11r inbox list [--unseen] [--since 24h|7d|<ISO>] [--limit 20]. Returns { folder, count, new, messages: [{ id, message_id, from, subject, date, seen, labels, new }], next }: new marks the messages not pulled before. Nothing is marked read.',
75
+ 'inbox pull': 'Save messages for the agent to read: c11r inbox pull <id> | --unseen (every unseen message not pulled yet, --limit 20). Each goes to .commercelayer/inbox/<id>/ (gitignored): message.json { id, message_id, from, to, subject, date, labels, text, html, attachments: [{ file, name, content_type, size }] }, body.txt, and attachments/ with each file as it came (25 MB at most; PDFs, sheets and documents are not converted: open them yourself). Nothing is marked read. Returns { count, pulled: [{ id, dir, message, body, subject, from, message_id, attachments }], next }.',
76
+ 'inbox tag': 'Mark what was done with an email: c11r inbox tag <id> [--label <name>]... [--unlabel <name>]... [--move <folder>] [--seen], for example --label ordered --unlabel pending --seen. On Gmail labels are labels; elsewhere keyword flags when the server keeps them, else labels_unsupported: use --move <folder>. The label names are mail_orders.labels in .commercelayer/ops.yml (pending, ordered, declined by default). Returns { status: tagged, id, labels_as: gmail_label|keyword|null, labeled, unlabeled, moved_to, seen, notes }.',
62
77
  'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
63
78
  '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
79
  '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.",
@@ -124,6 +139,8 @@ export async function init(values) {
124
139
  export async function plan() {
125
140
  const api = createApi(requireCredentials());
126
141
  const desired = loadStore();
142
+ // The ops policy is checked here too, so a mistake shows before a scheduled run trips on it.
143
+ ops.loadPolicy();
127
144
  const state = await api.getStore();
128
145
  const assets = planAssets(state, desired);
129
146
  const changes = [...diff(state, desired), ...assets.changes];
@@ -321,6 +338,10 @@ const LIST_LIMIT = (raw) => {
321
338
  function orderNext(o) {
322
339
  const n = o.number;
323
340
  const shipment = o.shipments?.[0];
341
+ if (o.status === 'pending')
342
+ return `Order ${n} is pending: made from an email or a message, not placed yet. Show the human its lines and total (c11r orders show ${n}); on their yes, place it: c11r orders place ${n}. If they don't want it: c11r orders cancel ${n}.`;
343
+ if (o.status === 'cancelled' && o.archived)
344
+ return `Order ${n} is cancelled and archived (payment ${o.payment_status}). Nothing more to do.`;
324
345
  if (o.status === 'cancelled')
325
346
  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
347
  if (o.status === 'placed' && o.payment_status !== 'authorized')
@@ -338,14 +359,15 @@ export async function ordersList(values, flags) {
338
359
  const archived = values.archived ?? (flags.has('--archived') ? 'true' : undefined);
339
360
  if (archived !== undefined && !['true', 'false', 'all'].includes(archived))
340
361
  fail('invalid_argument', `--archived ${archived} is not a choice.`, 'Use --archived for only the archived orders, or --archived all for every order.');
341
- const query = { status: values.status, payment: values.payment, fulfillment: values.fulfillment, archived, limit: LIST_LIMIT(values.limit) };
362
+ const query = { status: values.status, payment: values.payment, fulfillment: values.fulfillment, channel: values.channel, archived, limit: LIST_LIMIT(values.limit) };
342
363
  const { orders } = await api.listMerchantOrders(query);
343
364
  const waiting = orders.filter((o) => o.status === 'placed').length;
365
+ const pending = orders.filter((o) => o.status === 'pending').length;
344
366
  return {
345
367
  count: orders.length,
346
368
  orders,
347
369
  next: orders.length
348
- ? `Show the human the orders (number, status, payment_status, total.display, email).${waiting ? ` ${waiting} placed order(s) wait for the human to approve them: c11r orders show <number> for one, then c11r orders approve <number> --capture once they say so.` : ''}`
370
+ ? `Show the human the orders (number, status, payment_status, total.display, email).${pending ? ` ${pending} pending order(s) from emails or messages wait for the human's yes: c11r orders show <number>, then c11r orders place <number>.` : ''}${waiting ? ` ${waiting} placed order(s) wait for the human to approve them: c11r orders show <number> for one, then c11r orders approve <number> --capture once they say so.` : ''}`
349
371
  : 'No orders match. Without --status the list has placed and approved orders only: --status all shows every one, --archived the archived ones.',
350
372
  };
351
373
  }
@@ -370,6 +392,61 @@ export async function ordersAct(action, id, values, flags) {
370
392
  const order = await createApi(requireCredentials()).orderAction(id, action, body);
371
393
  return { status: action === 'archive' ? 'archived' : action === 'unarchive' ? 'unarchived' : order.status, order, next: orderNext(order) };
372
394
  }
395
+ // Orders from emails and messages: the agent reads the message, the API resolves the lines
396
+ // against the catalog (catalog prices only) and creates the order pending, or placed under the
397
+ // policy; a line it cannot resolve comes back for the human, with nothing created.
398
+ /** The flags that write the request itself; with -f or stdin only --source may be added. */
399
+ const REQUEST_FLAGS = ['email', 'first-name', 'last-name', 'company', 'phone', 'item', 'query', 'street', 'line-2', 'city', 'postal-code', 'region', 'country', 'reference', 'notes', 'language'];
400
+ const CREATE_USAGE = "Give the order as YAML or JSON with -f <file> or on stdin (c11r orders create <<'EOF' ... EOF), or with flags: --email --first-name --last-name --phone --item <sku>:<qty> --street --city --postal-code --country.";
401
+ function requestFromFlags(values, lists) {
402
+ const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
403
+ return drop({
404
+ customer: drop({ email: values.email, first_name: values['first-name'], last_name: values['last-name'], company: values.company, phone: values.phone }),
405
+ ship_to: drop({ line_1: values.street, line_2: values['line-2'], city: values.city, postal_code: values['postal-code'], region: values.region, country: values.country }),
406
+ items: [...(lists.item ?? []).map((x) => lineFlag(x, 'sku')), ...(lists.query ?? []).map((x) => lineFlag(x, 'query'))],
407
+ notes: values.notes,
408
+ reference: values.reference,
409
+ language: values.language,
410
+ });
411
+ }
412
+ const RESOLUTION_WORDS = { ambiguous: 'could be several products', not_found: 'matches nothing in the catalog', unavailable: 'is not sold in that country' };
413
+ function createdNext(r, placedBy) {
414
+ const order = r.order;
415
+ if (r.status === 'needs_review' || !order) {
416
+ const open = r.resolution.filter((x) => x.status !== 'matched');
417
+ const lines = open.map((x) => `"${x.requested}" ${RESOLUTION_WORDS[x.status] ?? x.status}${x.candidates.length ? ` (${x.candidates.map((c) => `${c.name}, ${c.sku}`).join('; ')})` : ''}`);
418
+ return `Nothing was created: ${ops.plural(open.length, 'line needs', 'lines need')} the human: ${lines.join('; ')}. Show them the candidates and ask which they meant; then run orders create again with --item <sku>:<quantity> for those lines (the same --source), or with --force to create the order without them.`;
419
+ }
420
+ const dup = r.warnings?.find((w) => w.code === 'duplicate_source');
421
+ const dropped = r.warnings?.filter((w) => w.code === 'line_dropped') ?? [];
422
+ const before = [
423
+ dup ? `This source already made order ${order.number}: nothing new was created.` : '',
424
+ dropped.length ? `Tell the human ${ops.plural(dropped.length, 'line was', 'lines were')} left out (warnings).` : '',
425
+ !dup && order.status === 'placed' && placedBy ? `Placed at once (${placedBy}).` : '',
426
+ ].filter(Boolean);
427
+ return [...before, orderNext(order)].join(' ');
428
+ }
429
+ export async function ordersCreate(values, lists, flags) {
430
+ if (flags.has('--place') && flags.has('--hold'))
431
+ fail('invalid_argument', '--place and --hold say opposite things.', 'Use --place to place it at once, --hold to leave it pending whatever the policy says, or neither to follow mail_orders.auto_place.');
432
+ const byFlags = REQUEST_FLAGS.some((k) => values[k] !== undefined);
433
+ if (byFlags && values.file)
434
+ fail('invalid_argument', 'The order is given both in a file and with flags.', 'Give it in the file (-f) or with flags, not both; --source, --place, --hold and --force go with either.');
435
+ const raw = byFlags ? requestFromFlags(values, lists) : await readText(values, 'order request', CREATE_USAGE);
436
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && values.source !== undefined)
437
+ raw.source = sourceFlag(values.source);
438
+ const request = checkOrderRequest(raw);
439
+ const policy = ops.loadPolicy();
440
+ // --hold wins; then --place or place: true in the request; then the policy.
441
+ const placedBy = flags.has('--hold') ? null : flags.has('--place') ? '--place' : request.place ? 'place: true in the request' : policy.mail_orders.auto_place ? 'mail_orders.auto_place in .commercelayer/ops.yml' : null;
442
+ delete request.place;
443
+ if (placedBy)
444
+ request.place = true;
445
+ if (flags.has('--force'))
446
+ request.force = true;
447
+ const r = await createApi(requireCredentials()).createOrder(request);
448
+ return { status: r.status, order: r.order, resolution: r.resolution, warnings: r.warnings?.length ? r.warnings : undefined, next: createdNext(r, placedBy) };
449
+ }
373
450
  function shipmentNext(s) {
374
451
  const n = s.number;
375
452
  if (s.status === 'delivered')
@@ -776,3 +853,384 @@ export async function linksDelete(id) {
776
853
  await createApi(requireCredentials()).deleteLink(id);
777
854
  return { status: 'deleted', id };
778
855
  }
856
+ // ---------- ops: the merchant agent on a schedule, and insights
857
+ /** Pages read at most when a snapshot list is cut (50) and the run needs every order. */
858
+ const MAX_PAGES = 10;
859
+ /** Every order a list query matches, oldest first, page by page (at most MAX_PAGES of 100). */
860
+ async function allOrders(api, query) {
861
+ const out = [];
862
+ for (let page = 1; page <= MAX_PAGES; page++) {
863
+ const r = await api.listMerchantOrders({ ...query, direction: 'asc', limit: '100', page: String(page) });
864
+ out.push(...r.orders);
865
+ if (!r.has_more)
866
+ return { orders: out, truncated: false };
867
+ }
868
+ return { orders: out, truncated: true };
869
+ }
870
+ /**
871
+ * What the loop works on: the API's snapshot (one call, server time), each cut list read in full
872
+ * when the run needs it; on an API without the snapshot, one page of each list.
873
+ */
874
+ async function opsSnapshot(api, policy, full) {
875
+ try {
876
+ const r = await api.getOps({ stuck_days: String(policy.stuck_days), transit_days: String(policy.transit_days) });
877
+ const placed = full && r.has_more.placed_unapproved ? await allOrders(api, { status: 'placed' }) : { orders: r.orders.placed_unapproved, truncated: r.has_more.placed_unapproved };
878
+ const uncaptured = full && r.has_more.approved_uncaptured ? await allOrders(api, { status: 'approved', payment: 'authorized,partially_authorized' }) : { orders: r.orders.approved_uncaptured, truncated: r.has_more.approved_uncaptured };
879
+ return {
880
+ as_of: r.as_of,
881
+ placed: placed.orders,
882
+ uncaptured: uncaptured.orders,
883
+ awaiting_payment: r.orders.awaiting_payment,
884
+ stuck: r.shipments.stuck.map((x) => ops.stuckView(x, x.since_days, policy)),
885
+ counts: { placed: r.counts.placed_unapproved, uncaptured: r.counts.approved_uncaptured, awaiting_payment: r.counts.awaiting_payment, stuck: r.counts.stuck },
886
+ truncated: placed.truncated || uncaptured.truncated || r.has_more.awaiting_payment || r.has_more.stuck,
887
+ };
888
+ }
889
+ catch (e) {
890
+ if (!(e instanceof CliError) || e.code !== 'ops_unsupported')
891
+ throw e;
892
+ }
893
+ // An API before the snapshot: one page of 25 of each list, ages by this machine's clock.
894
+ const [placed, uncaptured, shipments] = await Promise.all([
895
+ api.listMerchantOrders({ status: 'placed', limit: '25' }),
896
+ api.listMerchantOrders({ status: 'approved', payment: 'authorized', limit: '25' }),
897
+ api.listShipments({ status: 'picking,packing,ready_to_ship,shipped', limit: '25' }),
898
+ ]);
899
+ const stuck = ops.stuckShipments(shipments.shipments, policy);
900
+ const oldest = (l) => [...l].sort((a, b) => Date.parse(a.placed_at) - Date.parse(b.placed_at));
901
+ return {
902
+ as_of: new Date().toISOString(),
903
+ placed: oldest(placed.orders),
904
+ uncaptured: oldest(uncaptured.orders),
905
+ awaiting_payment: [],
906
+ stuck,
907
+ counts: { placed: placed.orders.length, uncaptured: uncaptured.orders.length, awaiting_payment: 0, stuck: stuck.length },
908
+ truncated: [placed.orders, uncaptured.orders, shipments.shipments].some((l) => l.length >= 25),
909
+ };
910
+ }
911
+ function opsPlan(snap, policy) {
912
+ const placed = snap.placed.map((o) => ops.decidePlaced(o, policy));
913
+ const uncaptured = snap.uncaptured.map((o) => ops.decideApproved(o, policy));
914
+ const all = [...placed, ...uncaptured];
915
+ return {
916
+ placed,
917
+ actions: all.filter((d) => d.action !== 'hold' && d.action !== 'wait'),
918
+ held: all.filter((d) => d.action === 'hold'),
919
+ waiting: all.filter((d) => d.action === 'wait'),
920
+ stuck: snap.stuck,
921
+ unpaid: snap.awaiting_payment,
922
+ };
923
+ }
924
+ const VERB = { approve_capture: ['approve and capture', 'approved and captured'], approve: ['approve', 'approved'], capture: ['capture', 'captured'] };
925
+ const heldText = (held) => held.map((d) => `#${d.order.number} (${d.order.total.display}): ${d.reason}`).join('; ');
926
+ const stuckText = (stuck) => stuck.map((s) => `${s.shipment.number} ${s.reason}`).join('; ');
927
+ /** What the actions do, grouped by verb: "approve and capture 3 orders (#1, #2, #3)". */
928
+ function actionsText(actions, past = false) {
929
+ return Object.entries(VERB)
930
+ .map(([k, [now, then]]) => {
931
+ const verb = past ? then : now;
932
+ const these = actions.filter((d) => d.action === k);
933
+ return these.length ? `${verb} ${ops.plural(these.length, 'order')} (${ops.numbers(these)})` : '';
934
+ })
935
+ .filter(Boolean)
936
+ .join(', ');
937
+ }
938
+ /** The rest of a next: what waits for the human, what is held, what is stuck. */
939
+ function forTheHuman(p) {
940
+ const out = [];
941
+ if (p.held.length)
942
+ out.push(`Tell the human ${ops.plural(p.held.length, 'order is', 'orders are')} held for them: ${heldText(p.held)}. If they agree, c11r orders approve <number> --capture.`);
943
+ const approve = p.waiting.filter((d) => p.placed.includes(d));
944
+ const capture = p.waiting.filter((d) => !p.placed.includes(d));
945
+ if (approve.length)
946
+ out.push(`${ops.plural(approve.length, 'placed order waits', 'placed orders wait')} for the human to approve (auto_approve is off): ${ops.numbers(approve)}; c11r orders approve <number> --capture once they say so.`);
947
+ if (capture.length)
948
+ out.push(`${ops.plural(capture.length, 'approved order has', 'approved orders have')} the payment still to take (auto_capture is off): ${ops.numbers(capture)}; c11r orders capture <number> when the human says so.`);
949
+ if (p.unpaid.length)
950
+ out.push(`${ops.plural(p.unpaid.length, 'order has', 'orders have')} no payment yet: ${p.unpaid.map((o) => `#${o.number}`).join(', ')}.`);
951
+ if (p.stuck.length)
952
+ out.push(`${ops.plural(p.stuck.length, 'shipment looks', 'shipments look')} stuck: ${stuckText(p.stuck)}. Tell the human; shipping and cancelling are theirs to decide.`);
953
+ return out;
954
+ }
955
+ const policyView = (p) => ({ auto_approve: p.auto_approve, auto_capture: p.auto_capture, review_above: p.review_above, hold_countries: p.hold_countries, stuck_days: p.stuck_days, transit_days: p.transit_days });
956
+ const brief = (o) => ({ id: o.id, number: o.number, placed_at: o.placed_at, total: o.total, payment_status: o.payment_status });
957
+ export async function opsCheck(values) {
958
+ const policy = ops.loadPolicy();
959
+ const api = createApi(requireCredentials());
960
+ const cursor = ops.readCursor();
961
+ const snap = await opsSnapshot(api, policy, false);
962
+ const now = Date.parse(snap.as_of);
963
+ const since = values.since === undefined ? undefined : ops.parseSince(values.since, 'since', now);
964
+ const p = opsPlan(snap, policy);
965
+ const fresh = p.placed.filter((d) => (since ? Date.parse(d.order.placed_at) >= since.getTime() : !cursor.seen.includes(d.order.id)));
966
+ // --since looks back without moving the cursor; a plain check marks what it reported as seen.
967
+ if (!since)
968
+ ops.writeCursor({ last_run: snap.as_of, seen: [...fresh.map((d) => d.order.id), ...cursor.seen] });
969
+ const parts = [];
970
+ if (p.actions.length)
971
+ parts.push(`Run c11r ops run to ${actionsText(p.actions)}.`);
972
+ parts.push(...forTheHuman(p));
973
+ return {
974
+ checked_at: snap.as_of,
975
+ since: since?.toISOString() ?? cursor.last_run,
976
+ policy: policyView(policy),
977
+ new_orders: fresh,
978
+ actions: p.actions,
979
+ held: p.held,
980
+ waiting: p.waiting,
981
+ awaiting_payment: p.unpaid.length ? p.unpaid.map(brief) : undefined,
982
+ stuck: p.stuck,
983
+ counts: snap.counts,
984
+ truncated: snap.truncated || undefined,
985
+ next: parts.length ? parts.join(' ') : `Nothing to do: ${fresh.length ? ops.plural(fresh.length, 'new order') + ', all handled' : 'no new orders'}, nothing held or stuck.`,
986
+ };
987
+ }
988
+ export async function opsRun(flags) {
989
+ const policy = ops.loadPolicy();
990
+ const api = createApi(requireCredentials());
991
+ const dry = flags.has('--dry-run');
992
+ const cursor = ops.readCursor();
993
+ const snap = await opsSnapshot(api, policy, true);
994
+ const p = opsPlan(snap, policy);
995
+ const fresh = p.placed.filter((d) => !cursor.seen.includes(d.order.id));
996
+ const done = [];
997
+ const failed = [];
998
+ if (!dry) {
999
+ // One at a time: a refusal (the human, or another run, moved the order meanwhile) stops nothing else.
1000
+ for (const d of p.actions) {
1001
+ const [action, body] = d.action === 'approve_capture' ? ['approve', { capture: true }] : d.action === 'approve' ? ['approve', {}] : ['capture', {}];
1002
+ try {
1003
+ const o = await api.orderAction(d.order.number, action, body);
1004
+ done.push({ action: d.action, order: { id: o.id, number: o.number, status: o.status, payment_status: o.payment_status, total: o.total } });
1005
+ }
1006
+ catch (e) {
1007
+ if (!(e instanceof CliError) || !['invalid_transition', 'not_found'].includes(e.code))
1008
+ throw e;
1009
+ failed.push({ action: d.action, order: d.order, error: { code: e.code, message: e.message } });
1010
+ }
1011
+ }
1012
+ ops.writeCursor({ last_run: snap.as_of, seen: [...fresh.map((d) => d.order.id), ...cursor.seen] });
1013
+ }
1014
+ const parts = [];
1015
+ if (dry)
1016
+ parts.push(p.actions.length ? `Dry run: c11r ops run would ${actionsText(p.actions)}. Run it without --dry-run to do it.` : 'Dry run: nothing to approve or capture.');
1017
+ else if (done.length)
1018
+ parts.push(`Done: ${actionsText(p.actions.filter((d) => !failed.some((f) => f.order.id === d.order.id)), true)}. Ship them when they are ready: c11r fulfillment list.`);
1019
+ if (failed.length)
1020
+ parts.push(`${ops.plural(failed.length, 'order was', 'orders were')} refused (see failed): the order changed since; c11r orders show <number> for where it is now.`);
1021
+ parts.push(...forTheHuman(p));
1022
+ return {
1023
+ ran_at: snap.as_of,
1024
+ dry_run: dry || undefined,
1025
+ policy: policyView(policy),
1026
+ new_orders: fresh.length,
1027
+ ...(dry ? { would_do: p.actions } : { done, failed: failed.length ? failed : undefined }),
1028
+ held: p.held,
1029
+ waiting: p.waiting,
1030
+ awaiting_payment: p.unpaid.length ? p.unpaid.map(brief) : undefined,
1031
+ stuck: p.stuck,
1032
+ truncated: snap.truncated || undefined,
1033
+ next: parts.length ? parts.join(' ') : 'Nothing to do: nothing to approve or capture, nothing held or stuck.',
1034
+ };
1035
+ }
1036
+ /** "The last 24 hours", "The last 7 days", "Since 2026-11-01", "From 2026-11-01 to 2026-11-05". */
1037
+ function spanText(raw, until) {
1038
+ if (until)
1039
+ return `From ${raw.trim()} to ${until.trim()}`;
1040
+ const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*([hdw])$/i);
1041
+ if (!m)
1042
+ return `Since ${raw.trim()}`;
1043
+ const n = Number(m[1]);
1044
+ const unit = { h: 'hour', d: 'day', w: 'week' }[m[2].toLowerCase()];
1045
+ return n === 1 ? `The last ${unit}` : `The last ${n} ${unit}s`;
1046
+ }
1047
+ const INSIGHTS_BY = ['product', 'day', 'market', 'country', 'channel', 'promotion'];
1048
+ /** The period's numbers from insights (one call), or from one page of the order list on an API without it. */
1049
+ async function periodNumbers(api, since) {
1050
+ try {
1051
+ const r = await api.insights({ since: since.toISOString() });
1052
+ return { orders: r.totals.orders, revenue: r.totals.revenue, refunds: r.totals.refunds, cancellations: r.totals.cancellations, top: r.top_products.map((t) => ({ sku: t.sku, name: t.name, units: t.units, revenue: t.revenue })), partial: r.partial, note: undefined };
1053
+ }
1054
+ catch (e) {
1055
+ if (!(e instanceof CliError) || e.code !== 'insights_unsupported')
1056
+ throw e;
1057
+ }
1058
+ const all = await api.listMerchantOrders({ status: 'all', archived: 'all', limit: '25' });
1059
+ const inWindow = all.orders.filter((o) => Date.parse(o.placed_at) >= since.getTime());
1060
+ const kept = inWindow.filter((o) => o.status !== 'cancelled');
1061
+ return { orders: kept.length, revenue: ops.totalsByCurrency(kept.map((o) => o.total)), refunds: [], cancellations: inWindow.length - kept.length, top: undefined, partial: all.orders.length >= 25, note: 'Top products and refunds need insights, which this API does not report yet.' };
1062
+ }
1063
+ export async function opsDigest(values) {
1064
+ const policy = ops.loadPolicy();
1065
+ const api = createApi(requireCredentials());
1066
+ const raw = values.since ?? '24h';
1067
+ const since = ops.parseSince(raw, 'since');
1068
+ const [n, snap, approved] = await Promise.all([periodNumbers(api, since), opsSnapshot(api, policy, false), api.listMerchantOrders({ status: 'approved', since: since.toISOString(), limit: '1' }).catch(() => null)]);
1069
+ const p = opsPlan(snap, policy);
1070
+ // total is every match; an API before paging gives only the page.
1071
+ const approvedCount = approved ? (approved.total ?? approved.orders.length) : undefined;
1072
+ const at = n.partial ? 'at least ' : '';
1073
+ const s = [];
1074
+ s.push(`${spanText(raw)}: ${at}${ops.plural(n.orders, 'order')}${n.orders ? `, ${ops.moneyText(n.revenue)}` : ''}${n.cancellations ? ` (${n.cancellations} more cancelled)` : ''}.`);
1075
+ if (approvedCount)
1076
+ s.push(`${approvedCount} approved.`);
1077
+ if (n.refunds.length)
1078
+ s.push(`Refunded: ${ops.moneyText(n.refunds)}.`);
1079
+ const waitingApproval = p.waiting.filter((d) => p.placed.includes(d));
1080
+ const toCapture = p.waiting.filter((d) => !p.placed.includes(d));
1081
+ if (p.held.length)
1082
+ s.push(`Held for you: ${heldText(p.held)}.`);
1083
+ if (waitingApproval.length)
1084
+ s.push(`Waiting for your approval: ${ops.numbers(waitingApproval)}.`);
1085
+ if (toCapture.length)
1086
+ s.push(`Approved, payment still to take: ${ops.numbers(toCapture)}.`);
1087
+ if (p.stuck.length)
1088
+ s.push(`Stuck: ${stuckText(p.stuck)}.`);
1089
+ if (n.top?.length)
1090
+ s.push(`Top products: ${n.top.map((t) => `${t.name} (${t.units})`).join(', ')}.`);
1091
+ if (!p.held.length && !waitingApproval.length && !toCapture.length && !p.stuck.length)
1092
+ s.push('Nothing waits for you.');
1093
+ return {
1094
+ since: since.toISOString(),
1095
+ orders: { count: n.orders, approved: approvedCount, cancelled: n.cancellations, partial: n.partial || undefined },
1096
+ revenue: n.revenue,
1097
+ refunds: n.refunds,
1098
+ held: p.held,
1099
+ waiting: p.waiting,
1100
+ stuck: p.stuck,
1101
+ top_products: n.top,
1102
+ notes: n.note ? [n.note] : undefined,
1103
+ summary: s.join(' '),
1104
+ next: 'Give the human the summary as it is (it is written for them); act only on what they answer.',
1105
+ };
1106
+ }
1107
+ /** A summary of insights: totals, refunds and cancellations, channels, top products, then the rows. */
1108
+ function insightsSummary(r, raw, until) {
1109
+ const t = r.totals;
1110
+ const out = [];
1111
+ out.push(`${spanText(raw, until)}: ${r.partial ? 'at least ' : ''}${ops.plural(t.orders, 'order')}, ${ops.plural(t.units, 'unit')}, ${ops.moneyText(t.revenue)}${t.orders ? ` (average ${ops.moneyText(t.average_order)})` : ''}.`);
1112
+ const apart = [t.refunds.length ? `${ops.moneyText(t.refunds)} refunded` : '', t.cancellations ? `${ops.plural(t.cancellations, 'order')} cancelled` : ''].filter(Boolean);
1113
+ if (apart.length)
1114
+ out.push(`Apart from that: ${apart.join(', ')}.`);
1115
+ const ch = r.channel;
1116
+ if (ch && ch.agent + ch.link + ch.web + (ch.message ?? 0) && r.by !== 'channel')
1117
+ out.push(`By channel: ${[['agent', 'by agents'], ['link', 'through links'], ['web', 'on the website'], ['message', 'from emails and messages']].filter(([k]) => ch[k]).map(([k, w]) => `${ch[k]} ${w}`).join(', ')}.`);
1118
+ if (r.top_products?.length && r.by !== 'product')
1119
+ out.push(`Top products: ${r.top_products.map((x) => `${x.name} (${x.units})`).join(', ')}.`);
1120
+ const rows = r.rows.filter((x) => x.orders);
1121
+ if (r.by && rows.length)
1122
+ out.push(`By ${r.by}: ${rows.slice(0, 5).map((x) => `${x.label} ${ops.plural(x.orders, 'order')}, ${ops.plural(x.units, 'unit')}, ${ops.moneyText(x.revenue)}`).join('; ')}${rows.length > 5 ? `; and ${rows.length - 5} more` : ''}.`);
1123
+ return out.join(' ');
1124
+ }
1125
+ export async function insights(values) {
1126
+ const raw = values.since ?? '30d';
1127
+ const since = ops.parseSince(raw, 'since');
1128
+ const until = values.until === undefined ? undefined : ops.parseSince(values.until, 'until');
1129
+ if (until && until <= since)
1130
+ fail('invalid_argument', `--until ${values.until} is not after --since ${raw}.`, 'Give an --until later than --since, or leave it out for now.');
1131
+ const by = values.by;
1132
+ if (by !== undefined && !INSIGHTS_BY.includes(by))
1133
+ fail('invalid_argument', `--by ${by} is not a choice.`, `Use --by with one of ${INSIGHTS_BY.join(', ')}.`);
1134
+ const r = await createApi(requireCredentials()).insights({ since: since.toISOString(), ...(until ? { until: until.toISOString() } : {}), ...(by ? { by } : {}) });
1135
+ return {
1136
+ ...r,
1137
+ summary: insightsSummary(r, raw, values.until),
1138
+ next: `Give the human the summary.${by ? ` The rows have the numbers per ${by}.` : ' For a breakdown, --by product, day, market, country, channel or promotion.'}${r.partial ? ' partial: only the 1,000 newest orders of the period are counted; ask for a shorter period for exact numbers.' : ''}`,
1139
+ };
1140
+ }
1141
+ // ---------- inbox: order emails over IMAP
1142
+ /** Run with the mailbox open; it is closed (logged out) whatever happens. */
1143
+ async function withMailbox(run) {
1144
+ const mb = await openMailbox();
1145
+ try {
1146
+ return await run(mb);
1147
+ }
1148
+ finally {
1149
+ await mb.close();
1150
+ }
1151
+ }
1152
+ const INBOX_LIMIT = (raw) => (raw === undefined ? 20 : Number(LIST_LIMIT(raw)));
1153
+ export async function inboxCheck() {
1154
+ return withMailbox(async (mb) => {
1155
+ const c = await mb.check();
1156
+ return {
1157
+ host: mb.host,
1158
+ user: mb.user,
1159
+ folder: mb.folder,
1160
+ exists: c.exists,
1161
+ unseen: c.unseen,
1162
+ next: c.unseen ? `${ops.plural(c.unseen, 'message is', 'messages are')} unseen: c11r inbox list --unseen.` : 'Nothing unseen in the inbox.',
1163
+ };
1164
+ });
1165
+ }
1166
+ export async function inboxList(values, flags) {
1167
+ const limit = INBOX_LIMIT(values.limit);
1168
+ const since = values.since === undefined ? undefined : ops.parseSince(values.since, 'since');
1169
+ return withMailbox(async (mb) => {
1170
+ const list = await mb.list({ unseen: flags.has('--unseen'), since, limit });
1171
+ const cursor = readInboxCursor();
1172
+ const messages = list.map((m) => ({ ...m, new: !cursor.seen.includes(seenKey(m)) }));
1173
+ writeInboxCursor({ ...cursor, last_check: new Date().toISOString() });
1174
+ const fresh = messages.filter((m) => m.new).length;
1175
+ return {
1176
+ folder: mb.folder,
1177
+ count: messages.length,
1178
+ new: fresh,
1179
+ messages,
1180
+ next: fresh
1181
+ ? 'Read the senders and subjects. For each new message that may be an order (someone asking to buy items), c11r inbox pull <id> and read it. Leave the others alone; tell the human about questions and quote requests.'
1182
+ : `No new messages${flags.has('--unseen') ? ' unseen' : ''}: nothing to do.`,
1183
+ };
1184
+ });
1185
+ }
1186
+ export async function inboxPull(id, values, flags) {
1187
+ const all = flags.has('--unseen');
1188
+ if (!id && !all)
1189
+ fail('missing_argument', 'Which message?', 'Use `c11r inbox pull <id>` with an id from inbox list, or `c11r inbox pull --unseen` for every unseen message not pulled yet.');
1190
+ const labels = ops.loadPolicy().mail_orders.labels;
1191
+ return withMailbox(async (mb) => {
1192
+ const cursor = readInboxCursor();
1193
+ let ids = id ? [id] : [];
1194
+ if (all)
1195
+ ids = (await mb.list({ unseen: true, limit: INBOX_LIMIT(values.limit) })).filter((m) => !cursor.seen.includes(seenKey(m))).map((m) => m.id);
1196
+ const pulled = [];
1197
+ for (const one of ids) {
1198
+ const got = await mb.fetch(one);
1199
+ if (!got)
1200
+ fail('not_found', `There is no message ${one} in ${mb.folder}.`, 'Run `c11r inbox list` for the ids.');
1201
+ const p = writeMessage(one, await parseMessage(got.raw), got.labels);
1202
+ pulled.push(p);
1203
+ const key = seenKey({ id: one, message_id: p.message_id });
1204
+ cursor.seen = [key, ...cursor.seen.filter((k) => k !== key)];
1205
+ }
1206
+ writeInboxCursor(cursor);
1207
+ const first = pulled[0];
1208
+ return {
1209
+ count: pulled.length,
1210
+ pulled,
1211
+ next: !first
1212
+ ? 'Nothing to pull: every unseen message was pulled before.'
1213
+ : `Read ${pulled.length === 1 ? first.body : 'each body.txt'} and the files in attachments (PDFs, sheets, documents: open them yourself). If it is an order, write the request (customer, ship_to, items by sku or by the words used) to a file and run c11r orders create -f <file> --source email:<message_id>; then c11r inbox tag <id> --label ${labels.ordered} --unlabel ${labels.pending} --seen. If something is missing or unclear, ask the human; if it is not an order, leave the email for the human.`,
1214
+ };
1215
+ });
1216
+ }
1217
+ export async function inboxTag(id, lists, values, flags) {
1218
+ if (!id)
1219
+ fail('missing_argument', 'Which message?', 'Use `c11r inbox tag <id> --label <name> [--unlabel <name>] [--move <folder>] [--seen]`.');
1220
+ const label = lists.label ?? [];
1221
+ const unlabel = lists.unlabel ?? [];
1222
+ for (const l of [...label, ...unlabel]) {
1223
+ if (!l.trim() || l.length > 50)
1224
+ fail('invalid_argument', `"${l}" is not a label name.`, 'A label is a name of 1 to 50 characters.');
1225
+ if (l.startsWith('\\'))
1226
+ fail('invalid_argument', `${l} is an IMAP flag, not a label.`, 'Use --seen to mark the email read; labels are plain names (ordered, pending).');
1227
+ }
1228
+ if (values.move !== undefined && !values.move.trim())
1229
+ fail('invalid_argument', '--move needs a folder.', 'Use --move <folder>, an existing folder of the mailbox.');
1230
+ if (!label.length && !unlabel.length && values.move === undefined && !flags.has('--seen'))
1231
+ fail('missing_argument', 'Nothing to do to the email.', 'Use --label <name>, --unlabel <name>, --move <folder> or --seen (any of them together).');
1232
+ return withMailbox(async (mb) => {
1233
+ const r = await mb.tag(id, { label, unlabel, move: values.move, seen: flags.has('--seen') });
1234
+ return { status: 'tagged', ...r };
1235
+ });
1236
+ }
package/dist/cursor.js ADDED
@@ -0,0 +1,31 @@
1
+ // A small JSON state file under .commercelayer/ that a command rewrites on every run (the ops
2
+ // cursor, the inbox cursor): read with defaults when missing or broken, kept out of git from
3
+ // its first write, like the credentials.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { ignorePath } from './files.js';
7
+ /** At most this many ids are remembered, newest first. */
8
+ export const MAX_SEEN = 500;
9
+ /** A cursor in `file`: parse turns what was read (or undefined when unreadable) into T. */
10
+ export function jsonCursor(file, parse) {
11
+ return {
12
+ file,
13
+ read() {
14
+ try {
15
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
16
+ return parse(raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : undefined);
17
+ }
18
+ catch {
19
+ return parse(undefined);
20
+ }
21
+ },
22
+ write(value) {
23
+ fs.mkdirSync(path.dirname(file), { recursive: true });
24
+ if (!fs.existsSync(file))
25
+ ignorePath(file);
26
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
27
+ },
28
+ };
29
+ }
30
+ /** The strings of a list read back from a cursor file, at most MAX_SEEN. */
31
+ export const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string').slice(0, MAX_SEEN) : []);
package/dist/files.js CHANGED
@@ -53,12 +53,14 @@ export function requireCredentials() {
53
53
  return c;
54
54
  }
55
55
  /** Make sure .gitignore lists the credentials file. Returns true if it changed. */
56
- export function ignoreCredentials() {
56
+ export const ignoreCredentials = () => ignorePath(paths.credentials);
57
+ /** Add a path to .gitignore unless it is there; whether it was added. */
58
+ export function ignorePath(file) {
57
59
  const existing = fs.existsSync('.gitignore') ? fs.readFileSync('.gitignore', 'utf8') : '';
58
- if (existing.split('\n').includes(paths.credentials))
60
+ if (existing.split('\n').includes(file))
59
61
  return false;
60
62
  const sep = existing && !existing.endsWith('\n') ? '\n' : '';
61
- fs.appendFileSync('.gitignore', `${sep}${paths.credentials}\n`);
63
+ fs.appendFileSync('.gitignore', `${sep}${file}\n`);
62
64
  return true;
63
65
  }
64
66
  /** The note that sends a future agent in this repo to the skills. No skill names: it never goes stale. */
@@ -0,0 +1,11 @@
1
+ // What the inbox commands remember between runs, in .commercelayer/inbox.json (gitignored):
2
+ // when the inbox was last listed, and the messages already pulled, by Message-ID (or by id
3
+ // when a message has none), so inbox list marks only the others as new.
4
+ import path from 'node:path';
5
+ import { jsonCursor, MAX_SEEN, strings } from '../cursor.js';
6
+ import { paths } from '../files.js';
7
+ const cursor = () => jsonCursor(path.join(paths.dir, 'inbox.json'), (c) => ({ last_check: typeof c?.last_check === 'string' ? c.last_check : null, seen: strings(c?.seen) }));
8
+ export const readInboxCursor = () => cursor().read();
9
+ export const writeInboxCursor = (c) => cursor().write({ last_check: c.last_check, seen: c.seen.slice(0, MAX_SEEN) });
10
+ /** The key a message is remembered by. */
11
+ export const seenKey = (m) => m.message_id ?? `id:${m.id}`;