@commercelayer/agent-cli 0.5.1 → 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.
@@ -0,0 +1,141 @@
1
+ // An order request from an email or a message (orders create), checked before it is sent: the
2
+ // same rules the API applies, so a request missing what only the human knows fails here, naming
3
+ // the path. Nothing is ever made up: no placeholder names, phones or addresses.
4
+ import { fail } from './output.js';
5
+ export const MAX_ITEMS = 25;
6
+ const KEYS = ['source', 'customer', 'ship_to', 'bill_to', 'items', 'notes', 'reference', 'language', 'place', 'force'];
7
+ const ASK = 'Ask the human for it; never invent it.';
8
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
9
+ const bad = (path, what, hint = ASK) => fail('invalid_argument', `${path}: ${what}`, hint, path);
10
+ /** A text field: absent is undefined; present, it is a non-empty string of at most max characters. */
11
+ function text(o, key, path, opts = {}) {
12
+ const v = o[key];
13
+ if (v === undefined || v === null || (typeof v === 'string' && !v.trim())) {
14
+ if (opts.required)
15
+ bad(`${path}.${key}`, 'is missing.');
16
+ return undefined;
17
+ }
18
+ if (typeof v !== 'string' && typeof v !== 'number')
19
+ bad(`${path}.${key}`, `is ${JSON.stringify(v)}, not text.`, 'Give it as text.');
20
+ const s = String(v).trim();
21
+ // A dash or a question mark stands for "unknown": the human has to say it.
22
+ if (/^[-?.]+$/.test(s))
23
+ bad(`${path}.${key}`, `is "${s}", a placeholder.`);
24
+ if (s.length > (opts.max ?? 255))
25
+ bad(`${path}.${key}`, `is longer than ${opts.max ?? 255} characters.`, 'Shorten it.');
26
+ return s;
27
+ }
28
+ function unknownKeys(o, allowed, path) {
29
+ const extra = Object.keys(o).filter((k) => !allowed.includes(k));
30
+ if (extra.length)
31
+ bad(`${path ? `${path}.` : ''}${extra[0]}`, `is not a field${path ? ` of ${path}` : ''}.`, `The fields are ${allowed.join(', ')}.`);
32
+ }
33
+ function address(o, path, withNames) {
34
+ if (!isObj(o))
35
+ return bad(path, 'is missing: the address the order ships to.');
36
+ const fields = ['line_1', 'line_2', 'city', 'postal_code', 'region', 'country', 'phone', ...(withNames ? ['first_name', 'last_name', 'company'] : [])];
37
+ unknownKeys(o, fields, path);
38
+ const country = text(o, 'country', path, { required: true }).toUpperCase();
39
+ if (!/^[A-Z]{2}$/.test(country))
40
+ bad(`${path}.country`, `is "${country}", not an ISO 3166 country code.`, 'Give the two-letter code: IT, FR, US.');
41
+ const out = {
42
+ line_1: text(o, 'line_1', path, { required: true }),
43
+ line_2: text(o, 'line_2', path),
44
+ city: text(o, 'city', path, { required: true }),
45
+ postal_code: text(o, 'postal_code', path, { required: true, max: 20 }),
46
+ region: text(o, 'region', path, { max: 50 }),
47
+ country,
48
+ phone: text(o, 'phone', path, { max: 40 }),
49
+ ...(withNames ? { first_name: text(o, 'first_name', path), last_name: text(o, 'last_name', path), company: text(o, 'company', path) } : {}),
50
+ };
51
+ return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined));
52
+ }
53
+ /** The request, checked and trimmed; invalid_argument naming the path, or too_many_items. */
54
+ export function checkOrderRequest(raw) {
55
+ if (!isObj(raw))
56
+ return bad('request', 'is not a set of fields.', `Give it as YAML or JSON with ${KEYS.join(', ')}.`);
57
+ unknownKeys(raw, KEYS, '');
58
+ if (!isObj(raw.customer))
59
+ bad('customer', 'is missing: who the order is for (email, first_name, last_name or company, phone).');
60
+ const c = raw.customer;
61
+ unknownKeys(c, ['email', 'first_name', 'last_name', 'company', 'phone'], 'customer');
62
+ const email = text(c, 'email', 'customer', { required: true });
63
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
64
+ bad('customer.email', `is "${email}", not an email address.`);
65
+ const customer = { email, first_name: text(c, 'first_name', 'customer'), last_name: text(c, 'last_name', 'customer'), company: text(c, 'company', 'customer'), phone: text(c, 'phone', 'customer', { max: 40 }) };
66
+ if (!customer.company && !(customer.first_name && customer.last_name))
67
+ bad(customer.first_name ? 'customer.last_name' : 'customer.first_name', 'is missing: an order needs the first and last name, or a company.');
68
+ const ship_to = address(raw.ship_to, 'ship_to', false);
69
+ if (!customer.phone && !ship_to.phone)
70
+ bad('customer.phone', 'is missing: Commerce Layer needs a phone number for the shipping address.');
71
+ const bill_to = raw.bill_to === undefined || raw.bill_to === null ? undefined : address(raw.bill_to, 'bill_to', true);
72
+ if (!Array.isArray(raw.items) || raw.items.length === 0)
73
+ bad('items', 'is missing: at least one line, { sku, quantity } or { query, quantity }.', 'Give each line a sku, or the words that name the product as query.');
74
+ const list = raw.items;
75
+ if (list.length > MAX_ITEMS)
76
+ fail('too_many_items', `items: ${list.length} lines, more than the ${MAX_ITEMS} one order takes.`, `Split it into orders of at most ${MAX_ITEMS} lines, with the human.`, 'items');
77
+ const items = list.map((it, i) => {
78
+ const path = `items[${i}]`;
79
+ if (!isObj(it))
80
+ return bad(path, `is ${JSON.stringify(it)}, not { sku, quantity } or { query, quantity }.`, 'Give each line a sku or a query, and a quantity.');
81
+ unknownKeys(it, ['sku', 'query', 'quantity'], path);
82
+ const sku = text(it, 'sku', path, { max: 100 });
83
+ const query = text(it, 'query', path, { max: 200 });
84
+ if (!sku && !query)
85
+ bad(path, 'has neither sku nor query.', 'Give the sku, or the words that name the product as query.');
86
+ const q = it.quantity ?? 1;
87
+ if (typeof q !== 'number' || !Number.isInteger(q) || q < 1 || q > 10_000)
88
+ bad(`${path}.quantity`, `is ${JSON.stringify(q)}, not a whole number from 1 to 10,000.`, 'Give how many, as the message says; ask the human if it looks wrong.');
89
+ return { ...(sku ? { sku } : {}), ...(query ? { query } : {}), quantity: q };
90
+ });
91
+ // Required: it makes the request idempotent, so a message never makes two orders.
92
+ let source;
93
+ {
94
+ const how = 'Give where the order came from: --source email:<Message-ID> for an email, --source message:<id> for anything else (the chat message or form id), or source: { kind, id } in the request.';
95
+ if (raw.source === undefined || raw.source === null)
96
+ bad('source', 'is missing: where the order came from.', how);
97
+ if (!isObj(raw.source))
98
+ return bad('source', 'is not { kind, id }.', how);
99
+ const s = raw.source;
100
+ unknownKeys(s, ['kind', 'id', 'from', 'subject', 'received_at'], 'source');
101
+ const kind = text(s, 'kind', 'source', { required: true });
102
+ if (kind !== 'email' && kind !== 'message')
103
+ bad('source.kind', `is "${kind}", not email or message.`, 'Use email for an email, message for anything else (a chat, a form, a call).');
104
+ source = { kind: kind, id: text(s, 'id', 'source', { required: true }), from: text(s, 'from', 'source'), subject: text(s, 'subject', 'source', { max: 500 }), received_at: text(s, 'received_at', 'source', { max: 40 }) };
105
+ source = Object.fromEntries(Object.entries(source).filter(([, v]) => v !== undefined));
106
+ }
107
+ const language = text(raw, 'language', 'request', { max: 5 })?.toLowerCase();
108
+ if (language && !/^[a-z]{2}$/.test(language))
109
+ bad('language', `is "${language}", not an ISO 639-1 code.`, 'Give the two-letter code: en, it, fr.');
110
+ for (const k of ['place', 'force'])
111
+ if (raw[k] !== undefined && typeof raw[k] !== 'boolean')
112
+ bad(k, `is ${JSON.stringify(raw[k])}, not true or false.`, `Write ${k}: true or leave it out.`);
113
+ const out = {
114
+ source,
115
+ customer: Object.fromEntries(Object.entries(customer).filter(([, v]) => v !== undefined)),
116
+ ship_to,
117
+ ...(bill_to ? { bill_to } : {}),
118
+ items,
119
+ ...(text(raw, 'notes', 'request', { max: 1000 }) ? { notes: text(raw, 'notes', 'request', { max: 1000 }) } : {}),
120
+ ...(text(raw, 'reference', 'request') ? { reference: text(raw, 'reference', 'request') } : {}),
121
+ ...(language ? { language } : {}),
122
+ ...(raw.place === true ? { place: true } : {}),
123
+ ...(raw.force === true ? { force: true } : {}),
124
+ };
125
+ return out;
126
+ }
127
+ /** "SKU:3" → { sku, quantity }, "dark roast:2" → { query, quantity }; no ":<n>" is 1. */
128
+ export function lineFlag(raw, kind) {
129
+ const m = raw.match(/^(.*?)(?::\s*(\d+))?$/s);
130
+ const value = m[1].trim();
131
+ if (!value)
132
+ fail('invalid_argument', `--${kind === 'sku' ? 'item' : 'query'} ${raw} names nothing.`, kind === 'sku' ? 'Use --item <sku>:<quantity>, for example --item ETH:3.' : 'Use --query "<words>:<quantity>", for example --query "dark roast:2".');
133
+ return { [kind]: value, quantity: m[2] === undefined ? 1 : Number(m[2]) };
134
+ }
135
+ /** "email:<id>" or "message:<id>": the kind is everything before the first colon. */
136
+ export function sourceFlag(raw) {
137
+ const i = raw.indexOf(':');
138
+ if (i < 1 || !raw.slice(i + 1).trim())
139
+ fail('invalid_argument', `--source ${raw} is not kind:id.`, 'Use --source email:<Message-ID> for an email, --source message:<id> for anything else.');
140
+ return { kind: raw.slice(0, i), id: raw.slice(i + 1).trim() };
141
+ }
package/dist/output.js CHANGED
@@ -6,14 +6,18 @@
6
6
  export class CliError extends Error {
7
7
  code;
8
8
  hint;
9
- constructor(code, message, hint) {
9
+ path;
10
+ constructor(code, message, hint,
11
+ /** The field of a request the error is about: customer.phone, items[0].quantity. */
12
+ path) {
10
13
  super(message);
11
14
  this.code = code;
12
15
  this.hint = hint;
16
+ this.path = path;
13
17
  }
14
18
  }
15
- export function fail(code, message, hint) {
16
- throw new CliError(code, message, hint);
19
+ export function fail(code, message, hint, path) {
20
+ throw new CliError(code, message, hint, path);
17
21
  }
18
22
  export function progress(data) {
19
23
  process.stderr.write(JSON.stringify(data) + '\n');
@@ -0,0 +1,62 @@
1
+ // --images: each product's image_url saved as a file, for agents that can read images and
2
+ // want to look at or describe a product. Files go under <tmpdir>/c11r/images/<store host>/,
3
+ // named by product id and a hash of the URL, so a file already there is reused and a new
4
+ // image gets a new file. A chat thumbnail needs only image_url, never this.
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { download } from '../api/transport.js';
10
+ export const MAX_IMAGES = 24;
11
+ const TIMEOUT_MS = 5000;
12
+ const MAX_BYTES = 10 * 1024 * 1024;
13
+ const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif', 'image/svg+xml': 'svg' };
14
+ /** The folder of a store's images: C11R_IMAGES_DIR (tests) or the OS temp dir. */
15
+ export const imagesDir = (origin) => path.join(process.env.C11R_IMAGES_DIR ?? path.join(os.tmpdir(), 'c11r', 'images'), new URL(origin).host);
16
+ const safe = (s) => s.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80) || 'product';
17
+ /** One product's image: the path of its file, or why there is none. */
18
+ async function fetchImage(dir, p) {
19
+ const url = p.image_url;
20
+ if (typeof url !== 'string' || !url)
21
+ return { image_error: 'The product has no image_url.' };
22
+ let parsed;
23
+ try {
24
+ parsed = new URL(url);
25
+ }
26
+ catch {
27
+ return { image_error: `image_url ${url} is not a URL.` };
28
+ }
29
+ if (!['https:', 'http:'].includes(parsed.protocol))
30
+ return { image_error: `image_url ${url} is not an http(s) address.` };
31
+ const stem = `${safe(String(p.id ?? ''))}-${crypto.createHash('sha256').update(url).digest('hex').slice(0, 10)}`;
32
+ const kept = fs.existsSync(dir) ? fs.readdirSync(dir).find((f) => f.startsWith(`${stem}.`) && Object.values(EXT).includes(f.slice(stem.length + 1))) : undefined;
33
+ if (kept)
34
+ return { image_file: path.join(dir, kept) };
35
+ try {
36
+ const r = await download(url, TIMEOUT_MS);
37
+ if (r.status !== 200)
38
+ return { image_error: `The store answered ${r.status} for ${url}.` };
39
+ const ext = EXT[r.type.split(';')[0].trim().toLowerCase()];
40
+ if (!ext)
41
+ return { image_error: `${url} is not an image (content-type ${r.type || 'missing'}).` };
42
+ if (r.bytes.length > MAX_BYTES)
43
+ return { image_error: `${url} is over 10 MB.` };
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ const file = path.join(dir, `${stem}.${ext}`);
46
+ // Written aside then renamed, so a parallel run never reads half a file.
47
+ const tmp = `${file}.${process.pid}.part`;
48
+ fs.writeFileSync(tmp, r.bytes);
49
+ fs.renameSync(tmp, file);
50
+ return { image_file: file };
51
+ }
52
+ catch (e) {
53
+ const err = e;
54
+ return { image_error: err.name === 'TimeoutError' ? `${url} took more than ${TIMEOUT_MS / 1000} s.` : `Could not download ${url}: ${err.message}` };
55
+ }
56
+ }
57
+ /** Products with image_file or image_error, the first MAX_IMAGES in parallel; how many were left out. */
58
+ export async function withImages(origin, products) {
59
+ const dir = imagesDir(origin);
60
+ const got = await Promise.all(products.slice(0, MAX_IMAGES).map((p) => fetchImage(dir, p)));
61
+ return { products: products.map((p, i) => (i < MAX_IMAGES ? { ...p, ...got[i] } : p)), skipped: Math.max(0, products.length - MAX_IMAGES) };
62
+ }
package/dist/ucp/shop.js CHANGED
@@ -128,6 +128,7 @@ export function checkoutView(ck, origin, call) {
128
128
  id: l.id,
129
129
  item_id: l.item?.id,
130
130
  title: l.item?.title,
131
+ image_url: l.item?.image_url,
131
132
  quantity: l.quantity,
132
133
  unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur),
133
134
  total: money((l.totals ?? []).find((t) => t.type === 'total')?.amount ?? 0, cur),
@@ -160,6 +161,7 @@ export function orderView(o, origin, call) {
160
161
  line_items: (o.line_items ?? []).map((l) => ({
161
162
  item_id: l.item?.id,
162
163
  title: l.item?.title,
164
+ image_url: l.item?.image_url,
163
165
  quantity: l.quantity?.total ?? l.quantity,
164
166
  status: l.status,
165
167
  total: money((l.totals ?? []).find((t) => t.type === 'total')?.amount ?? 0, cur),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [
@@ -31,6 +31,8 @@
31
31
  "prepublishOnly": "npm run build"
32
32
  },
33
33
  "dependencies": {
34
+ "imapflow": "^2.10.2",
35
+ "postal-mime": "^4.2.0",
34
36
  "yaml": "^2.6.0"
35
37
  },
36
38
  "devDependencies": {