@commercelayer/agent-cli 1.0.0-dev.19 → 1.0.0-dev.21

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
@@ -28,9 +28,9 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
28
28
  | --- | --- |
29
29
  | `c11r status` | Where this machine and folder stand: the organization picked and from where, its state, plan, mode, limits, usage and what is waiting; the stored login, if any. |
30
30
  | `c11r schema <noun>` | The noun's schema document: fields, filters, verbs, which verbs are destructive and which a loop may automate. |
31
- | `c11r sandbox create [--name] [--seed]` | An anonymous sandbox. Its token is stored on this machine and never printed; the folder is linked to it. |
31
+ | `c11r sandbox create [--name] [--seed [coffee\|tshirts\|games\|custom]] [--catalogue <file>]` | An anonymous sandbox. Its token is stored on this machine and never printed; the folder is linked to it. With `--seed`, a demo shop of that type with its storefront on, and the shop link in `data.shop_url` and `next`. See below. |
32
32
  | `c11r sandbox get`, `reset`, `claim`, `destroy` | The sandbox's details; a fresh organization behind the same token; a claim URL for the human; the end of the sandbox. |
33
- | `c11r loops create <name> --skill --every [--allow] [--max-total] [--market]` | A loop in `loops.yml`: a skill, a policy and a cadence; with no `--allow` it only reports what is waiting. Also `list`, `get`, `update`, `delete`. |
33
+ | `c11r loops create <name> --skill --every [--allow] [--max-total] [--market]` | A loop in `loops.yml`: a skill, a policy and a cadence; with no `--allow` it only reports what is waiting. A policy that allows a verb with no `--max-total` or no `--market` is saved with a `no_limit` warning naming what is unbounded. Also `list`, `get`, `update`, `delete`. |
34
34
  | `c11r loops run <name>` | One pass of a loop. The CLI is not a daemon: a scheduler repeats the pass. |
35
35
  | `c11r report sales [--from] [--to] [--by] [--interval] [--market] [--top]` | Sales in a window, from the Metrics API. A plain noun: its flags come from the API's schema. |
36
36
  | `c11r auth login [--client-id] [--secret-file] [--keep-file]` | The human's own Commerce Layer login, from their Provisioning API application. See below. |
@@ -68,13 +68,13 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
68
68
  | `--live` | Live data, on a claimed organization named with `--org` (or `C11R_ORG`), with the human's login: `?mode=live&org=` on every request. Without it, test mode. See below. |
69
69
  | `--help` | Describes the noun or verb it follows, from its schema. |
70
70
 
71
- Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sandbox reset` and `destroy`), and any verb the schema marks `confirm`, ask for confirmation in a terminal, then send `confirm=true`. With no terminal, as when an agent or a schedule runs the CLI, they never wait: without `--yes` they stop at once with `confirmation_required` and exit code 3. Its `next_step.command` is the same command without `--yes`: the agent asks the human first, and only on their yes runs it again with `--yes`. The API's own `confirmation_required` works the same way.
71
+ Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sandbox reset` and `destroy`), and any verb the schema marks `confirm`, ask for confirmation in a terminal, then send `confirm=true`. With no terminal, as when an agent or a schedule runs the CLI, they never wait: without `--yes` they stop at once with `confirmation_required` and exit code 3. Its `next_step` is `{ "action": "confirm", "command": "c11r markets delete eu --yes" }`: the same command with `--yes`, and the action is the guard. The agent asks the human first, and runs the command only on their yes, never on its own. The API's own `confirmation_required` works the same way; when it names no command, the CLI fills in this one with `--yes`.
72
72
 
73
73
  ## Live mode
74
74
 
75
- `--live` acts on a claimed organization's live data. It always sends the human's login, never an organization token, and names the organization with `?org=`: from `--org`, or `C11R_ORG`, never from the folder's link or the default, which are a sandbox's. Without one it is `missing_value` before any call, with the command and `--org ORG_SLUG --live`.
75
+ `--live` acts on a claimed organization's live data. It sends the human's login and names the organization with `?org=`: from `--org`, or `C11R_ORG`, never from the folder's link or the default, which are a sandbox's. Without one it is `missing_value` before any call, with the command and `--org ORG_SLUG --live`; in a linked folder, the message and the command name the linked organization instead of `ORG_SLUG`.
76
76
 
77
- - Without a login, nothing is checked on this machine: the request goes without a token, and the API's answer (`login_required`, or `state_required` for an organization not claimed) comes through as it is. So do `live_unavailable` (a plan without live), `forbidden` (the login is not an active admin of it) and `not_supported` for a verb that comes with a later step of go-live.
77
+ - Without a login, nothing is checked on this machine: the request goes with the organization token this machine holds for the organization named, or with no token when it holds none (never `C11R_TOKEN`), and the API's answer (`login_required`, or `state_required` for an organization not claimed) comes through as it is. So do `live_unavailable` (a plan without live), `forbidden` (the login is not an active admin of it) and `not_supported` for a verb that comes with a later step of go-live.
78
78
  - A verb that never runs live (sandbox, plans, sales calls, `auth login`, `auth switch`, `shop`) stops on this machine with `state_required`.
79
79
  - `c11r events writes --live --org <slug>` (`--since 2026-11-12T00:00:00Z`, an ISO time, `--noun`, `--cursor`, `--limit`) is the audit of live writes, and runs only in live: without `--live` it is `missing_value` with the full command, before any call.
80
80
  - `c11r status --live --org <slug>` is the organization's live status, with `live_credential` and the warning `live_credential_held` while the API holds one.
@@ -101,6 +101,24 @@ Records that share a key (two shipping methods with the same name in one market,
101
101
 
102
102
  After a Configure write that went through (create, update, delete or a domain verb), in a folder whose `store.yml` names the same organization, the answer warns `config_drift` and `.c11r/drift` notes the write; `config plan` repeats the warning, and `config export` clears it.
103
103
 
104
+ `c11r sandbox create --seed` loads a demo shop: `coffee` (also `--seed` alone, sent as `true`), `tshirts` or `games`, six products each, or `custom`, the person's own from `--catalogue shop.yml`. Each has the markets `eu` and `us`, shipping, a manual payment setting and the storefront on. `sandbox reset --seed <type>` takes the same values, and `--seed false` reseeds with nothing. The catalogue file is YAML:
105
+
106
+ ```yaml
107
+ name: Fern & Clay
108
+ products:
109
+ - name: Speckled mug
110
+ description: Stoneware, 350 ml.
111
+ prices: { EUR: 18, USD: 20 }
112
+ code: FC-MUG
113
+ look:
114
+ color: "#7a4b2a"
115
+ font: rounded
116
+ ```
117
+
118
+ `name` has 1 to 40 characters; `products` 1 to 24, each with a unique `name` (1 to 80), an optional `description` (up to 500), `prices` in EUR and USD (above 0, at most 100000, two decimals at most; a quoted amount such as "9.50" is taken too) and an optional unique `code` (2 to 24 capitals, digits and dashes). `look` takes `color`, `background`, `font`, `corners`, `tagline` and `badge`, as `scaffold storefront` does. The CLI checks the file before anything is sent: a refusal is `invalid_value` with the field in `path`, as the API names it (`catalogue.products[1].prices.USD`, counted from 0), and the file and line in `details`. `--catalogue` goes only with `--seed custom`, and `--seed custom` needs it. When the answer has a store, `data.shop_url` is its link and the first `next` is `c11r scaffold storefront`.
119
+
120
+ `c11r scaffold storefront` sets the shop's look: `--name` (the shop's name in its title and header, 1 to 40 characters after trimming; the organization's name and slug stay), `--color` (`#rrggbb`), `--background` (`#rgb` or `#rrggbb`), `--font classic|modern|rounded|mono`, `--corners round|square`, `--tagline` (up to 120 characters), `--badge` (up to 20), `--logo`, `--hero`, and `--product-image SKU=FILE`, once per sku. An image is a local file or an https URL; a local file is read here and sent as `{ type, content_base64, sha256 }`. It must be svg, png, jpg or webp by its name and its first bytes, at most 512 KB, and one call carries at most 2 MB of files. An SVG the store would refuse is refused here, with the reason and the way out: a DOCTYPE or entity, script or embedded content (`<script>`, `<foreignObject>` and the like), an `on...` attribute, a script URL, a link or `url()` other than `#id` or a `data:image/...;base64` image, or an `@import`. `null` (or an empty value) takes a field off: `--logo null`, `--product-image SKU=null`. These checks run before anything is sent. The answer has the shop link in `data.shop_url` and in `next`. `config export` does not carry the look.
121
+
104
122
  `c11r scaffold drop-in --out <file>` writes the drop-in.js snippet as one HTML file: the part for the page's `<head>`, then the part for its `<body>`, each under a comment saying where it goes. The file must be inside the project folder: a path that leads out of it, by `..`, an absolute path or a symlink, is refused with `invalid_value` on `out` before anything is sent. It never replaces a different file without `--yes`. Without `--out`, the snippet stays in the JSON answer.
105
123
 
106
124
  `c11r exports get <id> --out <file>` downloads a completed export while its link lasts (5 minutes), unzipped unless the name ends in `.gz`; `--wait` reads the job again until it completes.
@@ -130,7 +148,7 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
130
148
  }
131
149
  ```
132
150
 
133
- `code` is stable. `missing` is `{ noun, why }`, `path` the flag or field at fault, `next_step.action` one of `create_sandbox`, `claim`, `upgrade`, `contact_sales`, `create_dependency`, `fix_request`, `retry_later`, `human_step`, `none`. `human_step` is something only the human can do, such as saving a secret to a file, with a page in `url` when there is one. The CLI may add `did_you_mean` and `details`.
151
+ `code` is stable. `missing` is `{ noun, why }`, `path` the flag or field at fault, `next_step.action` one of `create_sandbox`, `claim`, `upgrade`, `contact_sales`, `create_dependency`, `fix_request`, `retry_later`, `human_step`, `confirm`, `none`. `confirm` comes with every `confirmation_required`: its command carries `--yes`, run only on the human's yes. `human_step` is something only the human can do, such as saving a secret to a file, with a page in `url` when there is one. The CLI may add `did_you_mean` and `details`.
134
152
 
135
153
  | Exit | Meaning | Codes |
136
154
  | --- | --- | --- |
@@ -161,7 +179,7 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
161
179
 
162
180
  `c11r skills sync` keeps copies of the skills in the project folder, where coding agents load them: `.claude/skills/<name>/SKILL.md`, one per skill as https://commercelayer.ai serves it, and in `AGENTS.md` a block between `<!-- c11r skills: start -->` and `<!-- c11r skills: end -->` listing them, for agents that read that file. Nothing outside the markers changes; without an `AGENTS.md`, sync creates one holding only the block, and says so with `agents_md: "created"`. The project folder is the one already holding the copies, else the one with `.c11r.json`, `store.yml` or `loops.yml`, else the git repository's root, else the current folder. It needs no organization and calls only commercelayer.ai, not the API.
163
181
 
164
- - It reads the index (`start.md`) with the ETag of the last sync, and fetches only the skills whose version changed. A skill the index dropped is removed. `.claude/skills/.c11r-skills.json` records what sync wrote, with each file's sha256; a folder sync did not write is never touched.
182
+ - It reads the list of skills (`https://commercelayer.ai/skills.md`) with the ETag of the last sync, and fetches only the skills whose version changed. A skill the index dropped is removed. `.claude/skills/.c11r-skills.json` records what sync wrote, with each file's sha256; a folder sync did not write is never touched.
165
183
  - Every copy's sha256 is checked on each run, also when nothing new is in the index. A copy that is not what sync wrote is an edit: it is kept, neither replaced nor removed, and listed in `edited` with a `skill_edited` warning. `--yes`, on the human's yes, replaces or removes it.
166
184
  - `--except <skill>` (repeated) leaves a skill out, and later syncs remember it; `--include <skill>` takes it back.
167
185
  - A file whose name or version is not what the index says is not used (`skill_invalid`); a link off `commercelayer.ai/skills/` is skipped (`skill_skipped`).
@@ -178,6 +196,6 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
178
196
  | `C11R_AUTH_URL` | Commerce Layer's token endpoint, `https://auth.commercelayer.io/oauth/token` by default: for tests. |
179
197
  | `C11R_TEST` | `1` marks a sandbox as a test organization, like a name starting `zztest`. |
180
198
  | `C11R_SCHEMA_CACHE` | `0` turns off the 10 minute schema cache in `~/.cache/c11r`. The cache records the `C11R-Schema` hash it was read under; an answer with another hash drops it, and a command the API refused on the old schema runs once more on the fresh one (without the header, after `confirmation_required`, `unknown_field` or `unknown_verb`). The second run asks what the fresh schema asks: it never adds `confirm=true` by itself. |
181
- | `C11R_SKILLS_URL` | The skills index, for a test site on localhost only: `https://commercelayer.ai/start.md` otherwise. |
199
+ | `C11R_SKILLS_URL` | The skills index, for a test site on localhost only: `https://commercelayer.ai/skills.md` otherwise. |
182
200
  | `C11R_RETRY_CAP_S` | The longest the CLI waits before one retry, 30 by default. |
183
201
  | `C11R_TIMEOUT_MS` | How long a request may take, 30000 by default (sandbox create waits longer). |
package/dist/argv.js CHANGED
@@ -96,6 +96,10 @@ export function parseFields(noun, verb, rest) {
96
96
  if (spec.type === 'boolean' && !sub.length) {
97
97
  raw = next === 'true' || next === 'false' ? rest[++i] : 'true';
98
98
  }
99
+ else if (spec.bare && !sub.length && (next === undefined || /^--[a-z]/i.test(next))) {
100
+ values[top] = true;
101
+ continue;
102
+ }
99
103
  else {
100
104
  if (next === undefined || /^--[a-z]/i.test(next))
101
105
  fail('missing_value', `${toFlag(key)} needs a value.`, { path: key });
@@ -112,6 +116,11 @@ export function parseFields(noun, verb, rest) {
112
116
  target[sub[sub.length - 1]] = part in (spec.properties ?? {}) ? coerce(key, spec.properties[part], raw) : raw;
113
117
  continue;
114
118
  }
119
+ // A flag that may stand alone takes true and false as values too (--seed false).
120
+ if (spec.bare && !sub.length && (raw === 'true' || raw === 'false')) {
121
+ values[top] = raw === 'true';
122
+ continue;
123
+ }
115
124
  const value = coerce(key, spec, raw);
116
125
  values[top] = spec.type === 'array' ? [...(values[top] ?? []), ...value] : value;
117
126
  }
@@ -14,7 +14,7 @@ import { credentialsFile, readLink, setDefaultOrg, storedLogin, storedOrgs, stor
14
14
  import { terminal } from '../confirm.js';
15
15
  import { closest, errorBody, CliError, fail } from '../errors.js';
16
16
  import { envelope, request } from '../http.js';
17
- import { loginToken, mint, PROVISIONING_URL, readSecretFile, secretFileDefault, writeSecretTemplate } from '../login.js';
17
+ import { hasLogin, loginToken, mint, PROVISIONING_URL, readSecretFile, secretFileDefault, writeSecretTemplate } from '../login.js';
18
18
  import { parseSchema } from '../schema.js';
19
19
  export const auth = {
20
20
  schema: parseSchema({
@@ -145,6 +145,9 @@ async function login(run) {
145
145
  async function logoutLive(run) {
146
146
  // The path names live already: the request carries ?org= and not ?mode=live.
147
147
  run.ctx.live = false;
148
+ // Revoking the live credential is the login's: never an organization token.
149
+ if (!hasLogin())
150
+ run.ctx.token = null;
148
151
  return run.send({ method: 'DELETE', path: '/auth/live' });
149
152
  }
150
153
  function logout() {
@@ -18,6 +18,7 @@ import { fail } from '../errors.js';
18
18
  import { apiUrl } from '../http.js';
19
19
  import { clearDrift, driftWarning, readDrift, readStoreFiles, storeFileIs, writeStoreFiles } from '../project.js';
20
20
  import { resourceRequest } from '../resources.js';
21
+ import { CALL_MAX, kb, lookImage, lookProblem, withShop } from '../storefront.js';
21
22
  const NAME_MAX = 25;
22
23
  export const organizations = {
23
24
  async run(run) {
@@ -330,8 +331,19 @@ export const scaffold = {
330
331
  'drop-in': {
331
332
  out: { type: 'string', to: 'local', description: 'Write the snippet to this HTML file: the part for the page\'s <head>, then the part for its <body>, each under a comment saying where it goes. It must be inside the project folder. A different file there is replaced only with --yes.' },
332
333
  },
334
+ storefront: {
335
+ product_image: {
336
+ type: 'array',
337
+ items: 'string',
338
+ to: 'local',
339
+ repeat: '--product-image SKU=FILE --product-image SKU=FILE, once per sku: a local svg, png, jpg or webp file, or an https URL; SKU=null takes the image off.',
340
+ description: 'The image of one sku in the shop, as SKU=FILE or SKU=https://...: a local file is read here and sent with its sha256. At most 512 KB a file and 2 MB a call.',
341
+ },
342
+ },
333
343
  },
334
344
  async run(run) {
345
+ if (run.verb.name === 'storefront')
346
+ return storefront(run);
335
347
  const out = run.parsed.values.out;
336
348
  if (run.verb.name !== 'drop-in' || !out)
337
349
  return undefined;
@@ -363,4 +375,57 @@ export const scaffold = {
363
375
  return { ...r, data: { ...rest, file, replaced: before !== null && before !== html } };
364
376
  },
365
377
  };
378
+ /**
379
+ * scaffold storefront (48.3): the look checked here, the image files read into { type,
380
+ * content_base64, sha256 }, then one call; the answer carries the shop link.
381
+ */
382
+ async function storefront(run) {
383
+ const v = { ...run.parsed.values };
384
+ for (const key of ['name', 'color', 'background', 'font', 'corners', 'tagline', 'badge']) {
385
+ if (v[key] === undefined)
386
+ continue;
387
+ const why = lookProblem(key, v[key]);
388
+ if (why)
389
+ fail('invalid_value', `--${key} ${JSON.stringify(v[key])} ${why}. Nothing was sent.`, { path: key, next_step: { action: 'fix_request', command: null } });
390
+ }
391
+ const pairs = v.product_image ?? [];
392
+ delete v.product_image;
393
+ if (pairs.length) {
394
+ const images = { ...(v.product_images ?? {}) };
395
+ for (const pair of pairs) {
396
+ const eq = pair.indexOf('=');
397
+ if (eq < 1)
398
+ fail('invalid_value', `--product-image ${JSON.stringify(pair)} is not SKU=FILE: the sku code, then = and a file or an https URL.`, { path: 'product_image', next_step: { action: 'fix_request', command: null } });
399
+ const sku = pair.slice(0, eq).trim();
400
+ const value = pair.slice(eq + 1).trim();
401
+ if (sku in images)
402
+ fail('invalid_value', `--product-image names ${sku} twice.`, { path: `product_images.${sku}` });
403
+ images[sku] = value === 'null' || value === '' ? null : value;
404
+ }
405
+ v.product_images = images;
406
+ }
407
+ let bytes = 0;
408
+ for (const key of ['logo', 'hero']) {
409
+ if (v[key] === undefined)
410
+ continue;
411
+ const r = lookImage(key, `--${key}`, v[key]);
412
+ v[key] = r.value;
413
+ bytes += r.bytes;
414
+ }
415
+ if (v.product_images && typeof v.product_images === 'object') {
416
+ const images = v.product_images;
417
+ for (const [sku, x] of Object.entries(images)) {
418
+ const r = lookImage(`product_images.${sku}`, `--product-image ${sku}=`, x);
419
+ images[sku] = r.value;
420
+ bytes += r.bytes;
421
+ }
422
+ }
423
+ if (bytes > CALL_MAX) {
424
+ fail('invalid_value', `The images of this call add up to ${kb(bytes)}: one call carries at most 2 MB. Send them in more than one c11r scaffold storefront. Nothing was sent.`, { path: 'product_images', details: { bytes, max: CALL_MAX }, next_step: { action: 'fix_request', command: null } });
425
+ }
426
+ const r = await run.send(resourceRequest('scaffold', run.verb, undefined, v));
427
+ if (run.globals.dryRun)
428
+ return r;
429
+ return withShop(r, r.data?.store);
430
+ }
366
431
  export const extensions = { organizations, 'sales-calls': salesCalls, config, exports, scaffold };
@@ -193,7 +193,13 @@ async function checkPolicy(def) {
193
193
  fail('policy_forbidden', `A ${def.skill} loop may not allow ${verb}. ${allowed.verbs.length ? `It may allow ${allowed.verbs.join(', ')}.` : 'It allows no verb: it only reports what is waiting, so it takes no --allow.'}`, { path: 'allow', did_you_mean: closest(verb, allowed.verbs), details: { loopable: allowed.verbs }, next_step: fixWith(`c11r schema ${allowed.noun}`) });
194
194
  }
195
195
  }
196
- return [];
196
+ // Allowed and bounded by nothing: kept, and said, so the agent tells the human.
197
+ const open = [];
198
+ if (def.policy.allow.length && def.policy.max_total === undefined)
199
+ open.push('any order total (no --max-total)');
200
+ if (def.policy.allow.length && !def.policy.markets?.length)
201
+ open.push('every market (no --market)');
202
+ return open.length ? [{ code: 'no_limit', message: `This loop may ${def.policy.allow.join(', ')} for ${open.join(' and in ')}. Tell the human in one line.` }] : [];
197
203
  }
198
204
  /**
199
205
  * The skills a loop can run, from the schemas of the nouns that have a loop (Manage and Report):
@@ -1,4 +1,4 @@
1
- // c11r sandbox create [--name] [--seed], get, reset, claim, destroy (contract section 4), and
1
+ // c11r sandbox create [--name] [--seed [type]] [--catalogue file], get, reset, claim, destroy (contract sections 4 and 48), and
2
2
  // c11r status (section 5). They are built in for what happens on this machine: create stores the
3
3
  // sandbox token (mode 600, ~/.config/c11r/credentials.json) and links the folder (.c11r.json);
4
4
  // reset moves both to the new organization; destroy forgets them. The token is printed never,
@@ -10,8 +10,21 @@ import { apiUrl, envelope, request } from '../http.js';
10
10
  import { parseSchema } from '../schema.js';
11
11
  import { OUT_NOT_TRACKED, removeOrgSecrets } from '../secrets.js';
12
12
  import { VERSION } from '../version.js';
13
+ import { readCatalogue, SEED_TYPES, withShop } from '../storefront.js';
13
14
  /** The longest sandbox name the API takes (contract: 422 invalid_argument past it). */
14
15
  const NAME_MAX = 25;
16
+ const SEED = {
17
+ name: 'seed',
18
+ type: 'enum',
19
+ values: [...SEED_TYPES],
20
+ bare: true,
21
+ description: 'Loads a demo shop of this type: coffee, tshirts or games, six products each, or custom, the person\'s own from --catalogue. Each has two markets, shipping, a manual payment setting (Wire transfer) and a storefront on, enough to place a test order at once. --seed alone is coffee.',
22
+ };
23
+ const CATALOGUE = {
24
+ name: 'catalogue',
25
+ type: 'string',
26
+ description: 'With --seed custom: a YAML file with the shop\'s name, its products (name, description, prices in EUR and USD, code) and optionally its look. Checked here, then sent in the same call.',
27
+ };
15
28
  export const sandbox = {
16
29
  schema: parseSchema({
17
30
  noun: 'sandbox',
@@ -28,9 +41,10 @@ export const sandbox = {
28
41
  live: false,
29
42
  body: [
30
43
  { name: 'name', type: 'string', description: 'The organization name; without it, an invented one. A name starting zztest marks a test organization.' },
31
- { name: 'seed', type: 'boolean', description: 'Loads the demo dataset: two markets, six products, shipping and a manual payment setting (Wire transfer), enough to place a test order at once.' },
44
+ SEED,
45
+ CATALOGUE,
32
46
  ],
33
- effect: 'Creates a sandbox, stores its token on this machine (never printed) and links this folder to it with .c11r.json. With --seed it answers once the seed is applied, in about 10 seconds.',
47
+ effect: 'Creates a sandbox, stores its token on this machine (never printed) and links this folder to it with .c11r.json. With --seed it answers once the seed and its storefront are in place, in about 20 seconds, with the shop link.',
34
48
  },
35
49
  { verb: 'get', method: 'GET', path: '/v1/sandbox', live: false, effect: 'Returns the sandbox: organization, expiry, whether it is seeded, the token hint and the claim status.' },
36
50
  {
@@ -39,8 +53,8 @@ export const sandbox = {
39
53
  path: '/v1/sandbox/reset',
40
54
  live: false,
41
55
  destructive: true,
42
- body: [{ name: 'seed', type: 'boolean', description: 'Seeds the fresh organization, or not; without it, as before.' }],
43
- effect: 'Replaces the sandbox with a fresh organization behind the same token, seeded as before, and retires the old one. The slug changes; this machine and .c11r.json follow it.',
56
+ body: [{ ...SEED, description: 'Seeds the fresh organization with this type, or not (--seed false); without it, as before. custom needs --catalogue again.' }, CATALOGUE],
57
+ effect: 'Replaces the sandbox with a fresh organization behind the same token, seeded as before (its storefront on again), and retires the old one. The slug changes; this machine and .c11r.json follow it.',
44
58
  },
45
59
  { verb: 'claim', method: 'POST', path: '/v1/sandbox/claim', live: false, effect: 'Returns a one-time claim URL, valid 30 minutes, for the human to open: they sign in with Commerce Layer and pick a plan. The agent never opens it for them.' },
46
60
  {
@@ -76,7 +90,7 @@ async function create(run, values) {
76
90
  if (name !== undefined && (name.trim() === '' || name.length > NAME_MAX)) {
77
91
  fail('invalid_value', `--name ${JSON.stringify(name)} is ${name.trim() === '' ? 'empty' : `${name.length} characters`}: a sandbox name has 1 to ${NAME_MAX}.`, { path: 'name', next_step: { action: 'fix_request', command: null } });
78
92
  }
79
- const seed = Boolean(values.seed);
93
+ const { seed, catalogue } = seedBody(values);
80
94
  const test = process.env.C11R_TEST === '1' || Boolean(name?.startsWith('zztest'));
81
95
  // The token goes only when this folder, --org or C11R_ORG names a sandbox: the API then
82
96
  // finishes a failed seed there, or answers 409 conflict naming it, rather than make a second
@@ -85,7 +99,7 @@ async function create(run, values) {
85
99
  // A fresh key per create: after a 5xx or a lost connection the same create is repeated with it,
86
100
  // and the API answers the sandbox (and token) it made, rather than leave one with a lost token.
87
101
  const idempotencyKey = `c11r-${crypto.randomBytes(16).toString('base64url')}`;
88
- const r = await request({ method: 'POST', path: '/sandboxes', body: { ...(name ? { name } : {}), seed, test }, dryRun: run.globals.dryRun, timeoutMs: seed ? 120_000 : 60_000, idempotencyKey }, { live: false, token: named ? run.org.token : null });
102
+ const r = await request({ method: 'POST', path: '/sandboxes', body: { ...(name ? { name } : {}), seed, ...(catalogue ? { catalogue } : {}), test }, dryRun: run.globals.dryRun, timeoutMs: seed ? 180_000 : 60_000, idempotencyKey }, { live: false, token: named ? run.org.token : null });
89
103
  if (run.globals.dryRun)
90
104
  return r;
91
105
  const { token, ...data } = r.data ?? {};
@@ -98,10 +112,28 @@ async function create(run, values) {
98
112
  const warnings = r.warnings.filter((w) => w.code !== 'token_shown_once');
99
113
  if (link.previous)
100
114
  warnings.push({ code: 'relinked', message: `${link.file} named ${link.previous}; it now names ${data.org}. The token of ${link.previous} stays stored.` });
101
- return { ...r, data: { ...data, token_hint: hint, credentials: credentialsFile(), linked: link.file }, warnings };
115
+ return withShop({ ...r, data: { ...data, token_hint: hint, credentials: credentialsFile(), linked: link.file }, warnings }, data.store);
116
+ }
117
+ /** --seed and --catalogue as the API takes them: custom only with a catalogue, a catalogue only with custom. */
118
+ function seedBody(values) {
119
+ const seed = values.seed ?? false;
120
+ const file = values.catalogue;
121
+ if (seed === 'custom' && file === undefined) {
122
+ fail('missing_value', '--seed custom needs --catalogue: a YAML file with the shop\'s name and its products. Write it from what the human described, show it to them, then run the command with --catalogue <file>.', { path: 'catalogue', next_step: { action: 'fix_request', command: null } });
123
+ }
124
+ if (file !== undefined && seed !== 'custom')
125
+ fail('invalid_value', '--catalogue goes only with --seed custom: the other types bring their own products.', { path: 'catalogue', next_step: { action: 'fix_request', command: null } });
126
+ return { seed, catalogue: file === undefined ? undefined : readCatalogue(file) };
102
127
  }
103
128
  async function reset(run, values) {
104
- const r = await run.send({ method: 'POST', path: '/sandbox/reset', body: values.seed === undefined ? {} : { seed: values.seed } });
129
+ const body = {};
130
+ if (values.seed !== undefined || values.catalogue !== undefined) {
131
+ const { seed, catalogue } = seedBody(values);
132
+ body.seed = seed;
133
+ if (catalogue)
134
+ body.catalogue = catalogue;
135
+ }
136
+ const r = await run.send({ method: 'POST', path: '/sandbox/reset', body });
105
137
  if (run.globals.dryRun)
106
138
  return r;
107
139
  const { org, previous_org: previous } = r.data ?? {};
@@ -112,7 +144,7 @@ async function reset(run, values) {
112
144
  if (link?.org === before)
113
145
  writeLink(org);
114
146
  }
115
- return r;
147
+ return withShop(r, r.data?.store);
116
148
  }
117
149
  async function destroy(run) {
118
150
  let r;
@@ -1,4 +1,4 @@
1
- // c11r skills sync: local copies of the skills, from the index the site publishes (start.md), so
1
+ // c11r skills sync: local copies of the skills, from the list the site publishes (skills.md), so
2
2
  // any coding agent in the project finds them. State none: no token, no organization, no call to
3
3
  // the API; only commercelayer.ai.
4
4
  //
@@ -17,7 +17,7 @@ import { CliError, fail } from '../errors.js';
17
17
  import { envelope } from '../http.js';
18
18
  import { parseSchema } from '../schema.js';
19
19
  import { USER_AGENT } from '../version.js';
20
- const INDEX_URL = 'https://commercelayer.ai/start.md';
20
+ const INDEX_URL = 'https://commercelayer.ai/skills.md';
21
21
  const SKILLS_DIR = path.join('.claude', 'skills');
22
22
  const MANIFEST = '.c11r-skills.json';
23
23
  const AGENTS_FILE = 'AGENTS.md';
@@ -31,7 +31,6 @@ export const skills = {
31
31
  schema: parseSchema({
32
32
  noun: 'skills',
33
33
  group: 'setup',
34
- skill: 'start',
35
34
  kind: 'task',
36
35
  description: 'The local copies of the Commerce Layer skills, in the project folder, where coding agents load them: .claude/skills/<name>/SKILL.md, and a list of them in AGENTS.md.',
37
36
  verbs: [
@@ -154,12 +153,11 @@ const firstSentence = (d) => {
154
153
  };
155
154
  function agentsBlock(entries, rel) {
156
155
  const link = (n) => `${rel}/${n}/SKILL.md`;
157
- const start = entries.find((e) => e.name === 'start');
158
156
  const lines = [
159
157
  START,
160
158
  '## Commerce Layer skills',
161
159
  '',
162
- `Local copies of the Commerce Layer skills, kept up to date by \`c11r skills sync\`.${start ? ` Load [start](${link('start')}) first: it routes to the others.` : ''}`,
160
+ `Local copies of the Commerce Layer skills, from ${INDEX_URL}, kept up to date by \`c11r skills sync\`. When a skill's task comes, load its copy.`,
163
161
  '',
164
162
  ...entries.map((e) => `- [${e.name}](${link(e.name)}): ${firstSentence(e.description)}`),
165
163
  END,
package/dist/confirm.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Confirmation for destructive verbs. --yes skips it. With a terminal, it asks on stderr and
2
2
  // reads one line. With no terminal (an agent, a pipe, a schedule) it never waits: it fails with
3
- // confirmation_required, and the agent asks the human, then repeats the command with --yes.
3
+ // confirmation_required, next_step confirm with the command and --yes: the agent asks the human,
4
+ // and runs it only on their yes.
4
5
  import readline from 'node:readline';
5
6
  import { fail } from './errors.js';
6
7
  export const terminal = {
@@ -40,6 +41,8 @@ export const terminal = {
40
41
  });
41
42
  },
42
43
  };
44
+ /** The command the human's yes runs: the same, with --yes. */
45
+ export const withYes = (command) => (/\s(--yes|-y)(\s|$)/.test(command) ? command : `${command} --yes`);
43
46
  /**
44
47
  * action names what needs a yes ("Destroy sandbox"). sentence, when given, is what the human is
45
48
  * shown instead, whole ("Sharing with shop.example.com: Ada Lovelace, ..."), and details go with
@@ -51,10 +54,10 @@ export async function confirm(action, command, opts) {
51
54
  const p = opts.prompter ?? terminal;
52
55
  const said = opts.sentence ? `${opts.sentence.replace(/\.?$/, '.')} This` : action;
53
56
  if (!p.interactive) {
54
- // The agent asks the human; on a yes it repeats the command with --yes. next never carries the
55
- // --yes itself: only the human's answer adds it.
56
- fail('confirmation_required', `${said} needs confirmation, and there is no terminal to ask in. Ask the human first; on their yes, run it again with --yes.`, {
57
- next_step: { action: 'fix_request', command },
57
+ // The agent asks the human, and runs the command, which carries --yes, only on their yes: the
58
+ // action confirm is the guard (contract section 3), never fix_request.
59
+ fail('confirmation_required', `${said} needs the human's yes, and there is no terminal to ask in. Ask the human first, and run the command only on their yes.`, {
60
+ next_step: { action: 'confirm', command: withYes(command) },
58
61
  ...(opts.details ? { details: opts.details } : {}),
59
62
  });
60
63
  }
package/dist/engine.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // everything. Every answer is the API's envelope, { data, state, warnings, next }.
6
6
  import { parseCommand, parseFields, toFlag } from './argv.js';
7
7
  import { forgetOutWritten, raiseProblem, resolveOrg } from './config.js';
8
- import { confirm, terminal } from './confirm.js';
8
+ import { confirm, terminal, withYes } from './confirm.js';
9
9
  import { CliError, closest, fail, fixWith } from './errors.js';
10
10
  import { indexHelp, nounHelp, verbHelp } from './help.js';
11
11
  import { envelope, request } from './http.js';
@@ -99,7 +99,9 @@ async function executeOnce(argv, opts) {
99
99
  // default, which are a sandbox's: --org, or C11R_ORG.
100
100
  const liveOrg = live ? (cmd.globals.org ?? (process.env.C11R_ORG || null)) : null;
101
101
  if (live && !liveOrg) {
102
- fail('missing_value', `${named} --live acts on a claimed organization, named with --org.`, { path: 'org', next_step: fixWith(`${line.replace(/\s--live\b/, '')} --org ORG_SLUG --live`) });
102
+ // The folder's link is not taken in live mode; named, it is the value to pass when it is the one meant.
103
+ const linked = org.source === 'link' ? org.org : null;
104
+ fail('missing_value', `${named} --live acts on a claimed organization, named with --org.${linked ? ` This folder is linked to ${linked}: pass it with --org when it is the one meant.` : ''}`, { path: 'org', next_step: fixWith(`${line.replace(/\s--live\b/, '')} --org ${linked ?? 'ORG_SLUG'} --live`) });
103
105
  }
104
106
  // The bearer, from the schema (contract section 23). An organization token for the organization
105
107
  // nouns, the login for the Provisioning ones; credentials take the login when there is one and
@@ -137,8 +139,8 @@ async function executeOnce(argv, opts) {
137
139
  if (live && cmd.globals.yes && verb.method !== 'GET')
138
140
  confirmed = true;
139
141
  if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun && !apiAsks) {
140
- const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
141
- const sentence = what.charAt(0).toUpperCase() + what.slice(1);
142
+ // The command itself names the action ("c11r markets delete eu"): no verb to inflect.
143
+ const sentence = `c11r ${schema.noun} ${verb.name}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
142
144
  await confirm(sentence, line, { yes: cmd.globals.yes, prompter: opts.prompter });
143
145
  confirmed = true;
144
146
  }
@@ -148,7 +150,12 @@ async function executeOnce(argv, opts) {
148
150
  }
149
151
  // Where a secret goes, checked before the request makes one.
150
152
  const secretPlan = verb.secret ? planSecret({ org: org.org, noun: schema.noun, verb: verb.name, id: parsed.id, values: parsed.values, yes: cmd.globals.yes }) : null;
151
- const ctx = { live, token: live ? (login ? await loginToken() : null) : bearer === 'login' ? await loginToken() : org.token };
153
+ // Live without a login: the organization token this machine holds for the organization named,
154
+ // and no other (never C11R_TOKEN, whose organization is unknown). The API answers a sandbox's
155
+ // state_required with its claim step, a claimed one's login_required; a slug alone stays
156
+ // login_required, so no one learns which slugs exist.
157
+ const liveOwn = live && !login && org.org === liveOrg && (org.source === 'flag' || org.source === 'env') ? org.token : null;
158
+ const ctx = { live, token: live ? (login ? await loginToken() : liveOwn) : bearer === 'login' ? await loginToken() : org.token };
152
159
  const orgQuery = liveOrg ? { org: liveOrg } : bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
153
160
  const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
154
161
  const run = {
@@ -169,9 +176,9 @@ async function executeOnce(argv, opts) {
169
176
  // The API's own confirmation, in live: as it is with no terminal; asked with its words in one.
170
177
  if (apiAsks && !confirmed && e instanceof CliError && e.code === 'confirmation_required') {
171
178
  const p = opts.prompter ?? terminal;
172
- // Without a command of the API's, the next step is this one again, without --yes.
179
+ // Without a command of the API's, the next step is this one with --yes, under confirm.
173
180
  if (!p.interactive)
174
- throw e.body.next_step?.command ? e : new CliError({ ...e.body, next_step: { action: 'fix_request', command: line, url: null } }, e.kind, e.state);
181
+ throw e.body.next_step?.command ? e : new CliError({ ...e.body, next_step: { action: 'confirm', command: withYes(line), url: null } }, e.kind, e.state);
175
182
  await confirm(e.body.message, line, { yes: false, prompter: p, sentence: e.body.message });
176
183
  confirmed = true;
177
184
  return sendOnce(req);
package/dist/help.js CHANGED
@@ -93,6 +93,7 @@ function flagHelp(key, f, noun) {
93
93
  ...(f.required || (f.input?.kind === 'secret' && f.input.required) ? { required: true } : {}),
94
94
  ...(f.input ? { reads: f.input.kind === 'secret' ? `${f.input.target}, a secret: never a flag value, never printed` : `${f.input.target}, a JSON document` } : {}),
95
95
  ...(f.values ? { values: f.values } : {}),
96
+ ...(f.bare ? { alone: `${toFlag(key)} with no value sends true` } : {}),
96
97
  ...(f.known ? { known: f.known.length <= KNOWN_IN_FULL ? f.known : `one of ${f.known.length} documented values: c11r schema ${noun}` } : {}),
97
98
  ...(clearable(f) ? { clear: 'null, or an empty value, takes it off' } : {}),
98
99
  ...(f.filter ? { filter: f.filter.op } : {}),
package/dist/schema.js CHANGED
@@ -155,6 +155,7 @@ function flag(f, to, required) {
155
155
  ...(required ? { required: true } : {}),
156
156
  ...(f.description ? { description: f.description } : {}),
157
157
  ...(typeof f.repeat === 'string' && f.repeat ? { repeat: f.repeat } : {}),
158
+ ...(f.bare ? { bare: true } : {}),
158
159
  };
159
160
  }
160
161
  function bad(noun, why) {
@@ -0,0 +1,280 @@
1
+ // The starter storefront on this machine (contract section 48): the catalogue file of a custom
2
+ // seed, and the look files of scaffold storefront, both checked here before anything is sent.
3
+ //
4
+ // A catalogue is YAML: name, products (name, description, prices in EUR and USD, code) and an
5
+ // optional look. A refusal names the field as the API would (catalogue.products[2].prices.USD) and
6
+ // the line of the file. A look file (--logo ./logo.svg) goes as { type, content_base64, sha256 }:
7
+ // svg, png, jpg or webp, by its name and its first bytes, at most 512 KB, and 2 MB in one call. An
8
+ // https URL goes as it is, and null takes the image off.
9
+ import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import YAML, { LineCounter, isMap, isSeq } from 'yaml';
13
+ import { fail } from './errors.js';
14
+ export const SEED_TYPES = ['coffee', 'tshirts', 'games', 'custom'];
15
+ export const FONTS = ['classic', 'modern', 'rounded', 'mono'];
16
+ export const CORNERS = ['round', 'square'];
17
+ export const TAGLINE_MAX = 120;
18
+ export const BADGE_MAX = 20;
19
+ /** The shop's name in its title and header (scaffold storefront --name), trimmed. */
20
+ export const NAME_MAX = 40;
21
+ export const FILE_MAX = 512 * 1024;
22
+ export const CALL_MAX = 2 * 1024 * 1024;
23
+ const PRODUCTS_MAX = 24;
24
+ const CURRENCIES = ['EUR', 'USD'];
25
+ const LOOK_KEYS = ['color', 'background', 'font', 'corners', 'tagline', 'badge'];
26
+ /** The answer's next with the shop's entry first, and the API's own entry for the same command left out. */
27
+ export function withShop(r, store) {
28
+ const shop = shopNext(store);
29
+ if (!shop)
30
+ return r;
31
+ return { ...r, data: { ...r.data, shop_url: shop.url }, next: [shop.next, ...r.next.filter((n) => n.command !== shop.next.command)] };
32
+ }
33
+ /** The shop link of an answer's store, and the next entry that hands it to the human. */
34
+ export function shopNext(store) {
35
+ const host = store && typeof store === 'object' ? store.host : undefined;
36
+ if (typeof host !== 'string' || !host)
37
+ return null;
38
+ const given = store.url;
39
+ const url = typeof given === 'string' && given ? given : `https://${host}/`;
40
+ return { url, next: { command: 'c11r scaffold storefront', why: `The shop is at ${url}: give the human the link. Every change to its look goes through this command.` } };
41
+ }
42
+ /** A look value checked as the API checks it; a message, or null when it is right. */
43
+ export function lookProblem(key, v) {
44
+ if (v === null)
45
+ return null;
46
+ switch (key) {
47
+ case 'color':
48
+ return typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? null : 'is a colour as #rrggbb';
49
+ case 'background':
50
+ return typeof v === 'string' && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v) ? null : 'is a colour as #rgb or #rrggbb';
51
+ case 'font':
52
+ return typeof v === 'string' && FONTS.includes(v) ? null : `is one of ${FONTS.join(', ')}`;
53
+ case 'corners':
54
+ return typeof v === 'string' && CORNERS.includes(v) ? null : `is one of ${CORNERS.join(', ')}`;
55
+ case 'name':
56
+ return typeof v === 'string' && v.trim().length >= 1 && [...v.trim()].length <= NAME_MAX ? null : `has 1 to ${NAME_MAX} characters${typeof v === 'string' ? `, not ${[...v.trim()].length}` : ''}`;
57
+ case 'tagline':
58
+ case 'badge': {
59
+ const max = key === 'tagline' ? TAGLINE_MAX : BADGE_MAX;
60
+ return typeof v === 'string' && v.trim().length >= 1 && [...v].length <= max ? null : `has 1 to ${max} characters${typeof v === 'string' ? `, not ${[...v].length}` : ''}`;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+ /** The catalogue file read and checked: the object the API takes as body.catalogue. */
66
+ export function readCatalogue(file) {
67
+ let text;
68
+ try {
69
+ text = fs.readFileSync(file, 'utf8');
70
+ }
71
+ catch (e) {
72
+ fail('invalid_value', `--catalogue ${file}: ${e.code === 'ENOENT' ? 'no such file' : e.message}.`, { path: 'catalogue' });
73
+ }
74
+ const lines = new LineCounter();
75
+ const doc = YAML.parseDocument(text, { lineCounter: lines, prettyErrors: false });
76
+ if (doc.errors.length) {
77
+ const e = doc.errors[0];
78
+ const at = lines.linePos(e.pos[0]);
79
+ fail('invalid_value', `${file} is not YAML${at ? ` (line ${at.line})` : ''}: ${e.message.split('\n')[0]}`, { path: 'catalogue', details: { file, ...(at ? { line: at.line } : {}) } });
80
+ }
81
+ const v = doc.toJS();
82
+ const bad = (at, why) => {
83
+ const field = ['catalogue', ...at].reduce((acc, k) => (typeof k === 'number' ? `${acc}[${k}]` : acc ? `${acc}.${k}` : String(k)), '');
84
+ const line = lineOf(doc, lines, at);
85
+ fail('invalid_value', `${file}${line ? ` line ${line}` : ''}: ${field.replace(/^catalogue\.?/, '') || 'the file'} ${why}. Nothing was sent.`, {
86
+ path: field,
87
+ details: { file, ...(line ? { line } : {}) },
88
+ next_step: { action: 'fix_request', command: null },
89
+ });
90
+ };
91
+ if (!v || typeof v !== 'object' || Array.isArray(v))
92
+ bad([], 'is a mapping with name and products');
93
+ const c = v;
94
+ for (const k of Object.keys(c))
95
+ if (!['name', 'products', 'look'].includes(k))
96
+ bad([k], 'is not a catalogue field: it takes name, products and look');
97
+ text1(c.name, ['name'], 40, true, bad);
98
+ if (!Array.isArray(c.products) || !c.products.length)
99
+ bad(['products'], `is a list of 1 to ${PRODUCTS_MAX} products`);
100
+ const products = c.products;
101
+ if (products.length > PRODUCTS_MAX)
102
+ bad(['products'], `has ${products.length} products: at most ${PRODUCTS_MAX}`);
103
+ const names = new Map();
104
+ const codes = new Map();
105
+ products.forEach((p, i) => {
106
+ const at = ['products', i];
107
+ if (!p || typeof p !== 'object' || Array.isArray(p))
108
+ bad(at, 'is a product with name and prices');
109
+ const o = p;
110
+ for (const k of Object.keys(o))
111
+ if (!['name', 'description', 'prices', 'code'].includes(k))
112
+ bad([...at, k], 'is not a product field: it takes name, description, prices and code');
113
+ text1(o.name, [...at, 'name'], 80, true, bad);
114
+ const name = o.name.trim().toLowerCase();
115
+ if (names.has(name))
116
+ bad([...at, 'name'], `repeats the name of products[${names.get(name)}]: names are unique`);
117
+ names.set(name, i);
118
+ if (o.description !== undefined && o.description !== null)
119
+ text1(o.description, [...at, 'description'], 500, false, bad);
120
+ if (!o.prices || typeof o.prices !== 'object' || Array.isArray(o.prices))
121
+ bad([...at, 'prices'], 'has a price in EUR and one in USD, as { EUR: 18, USD: 20 }');
122
+ const prices = o.prices;
123
+ for (const k of Object.keys(prices))
124
+ if (!CURRENCIES.includes(k))
125
+ bad([...at, 'prices', k], 'is not a currency of the seed: it takes EUR and USD');
126
+ for (const cur of CURRENCIES) {
127
+ const given = prices[cur];
128
+ if (given === undefined)
129
+ bad([...at, 'prices'], `has no ${cur} price: it needs EUR and USD`);
130
+ // A quoted amount ("9.50") is taken as the API takes it: digits, a dot, digits.
131
+ const n = typeof given === 'string' && /^\d+(\.\d+)?$/.test(given.trim()) ? Number(given) : given;
132
+ if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0 || n > 100000)
133
+ bad([...at, 'prices', cur], 'is an amount above 0 and at most 100000');
134
+ if (Math.round(n * 100) !== Number((n * 100).toFixed(6)))
135
+ bad([...at, 'prices', cur], 'has two decimals at most');
136
+ }
137
+ if (o.code !== undefined) {
138
+ if (typeof o.code !== 'string' || !/^[A-Z0-9-]{2,24}$/.test(o.code))
139
+ bad([...at, 'code'], 'is 2 to 24 capitals, digits and dashes');
140
+ if (codes.has(o.code))
141
+ bad([...at, 'code'], `repeats the code of products[${codes.get(o.code)}]: codes are unique`);
142
+ codes.set(o.code, i);
143
+ }
144
+ });
145
+ if (c.look !== undefined && c.look !== null) {
146
+ if (typeof c.look !== 'object' || Array.isArray(c.look))
147
+ bad(['look'], `is a mapping of ${LOOK_KEYS.join(', ')}`);
148
+ for (const [k, x] of Object.entries(c.look)) {
149
+ if (!LOOK_KEYS.includes(k))
150
+ bad(['look', k], `is not a look field here: it takes ${LOOK_KEYS.join(', ')} (images come with c11r scaffold storefront)`);
151
+ const why = lookProblem(k, x);
152
+ if (why)
153
+ bad(['look', k], why);
154
+ }
155
+ }
156
+ return c;
157
+ }
158
+ function text1(v, at, max, required, bad) {
159
+ if (v === undefined || v === null) {
160
+ if (required)
161
+ bad(at, 'is missing');
162
+ return;
163
+ }
164
+ if (typeof v !== 'string')
165
+ bad(at, 'is text');
166
+ const n = [...v].length;
167
+ if ((required && !v.trim()) || n > max)
168
+ bad(at, `has ${required ? 1 : 0} to ${max} characters, not ${n}`);
169
+ }
170
+ /** The line of the field at the path: its key's line, or its item's; else the nearest parent's. */
171
+ function lineOf(doc, lines, at) {
172
+ for (let n = at.length; n > 0; n--) {
173
+ const parent = n === 1 ? doc.contents : doc.getIn(at.slice(0, n - 1), true);
174
+ const last = at[n - 1];
175
+ if (isMap(parent)) {
176
+ const pair = parent.items.find((p) => p.key?.value === last);
177
+ const range = pair?.key?.range;
178
+ if (range)
179
+ return lines.linePos(range[0]).line;
180
+ }
181
+ else if (isSeq(parent) && typeof last === 'number') {
182
+ const range = parent.items[last]?.range;
183
+ if (range)
184
+ return lines.linePos(range[0]).line;
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+ const EXT = { '.svg': 'svg', '.png': 'png', '.jpg': 'jpg', '.jpeg': 'jpg', '.webp': 'webp' };
190
+ /** What the first bytes say the file is, or null. */
191
+ function sniff(b) {
192
+ if (b.length >= 8 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
193
+ return 'png';
194
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
195
+ return 'jpg';
196
+ if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP')
197
+ return 'webp';
198
+ const head = b.subarray(0, 1024).toString('utf8').replace(/^/, '').trimStart();
199
+ if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(head))
200
+ return 'svg';
201
+ return null;
202
+ }
203
+ /**
204
+ * An image value as the API takes it: null as null, an https URL as it is, else a local file read
205
+ * into { type, content_base64, sha256 }. field names it in errors (logo, product_images.BB-ETH-250).
206
+ */
207
+ export function lookImage(field, flag, v) {
208
+ if (v === null)
209
+ return { value: null, bytes: 0 };
210
+ if (typeof v !== 'string' || !v.trim())
211
+ fail('invalid_value', `${flag} takes a file, an https URL, or null to take the image off.`, { path: field });
212
+ const s = v.trim();
213
+ if (/^https:\/\//i.test(s))
214
+ return { value: s, bytes: 0 };
215
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s))
216
+ fail('invalid_value', `${flag} ${s}: a link is https only. Or give a local file.`, { path: field });
217
+ const ext = EXT[path.extname(s).toLowerCase()];
218
+ if (!ext)
219
+ fail('invalid_value', `${flag} ${s} is not an svg, png, jpg or webp file, by its name.`, { path: field, details: { types: ['svg', 'png', 'jpg', 'webp'] } });
220
+ let stat;
221
+ try {
222
+ stat = fs.statSync(s);
223
+ }
224
+ catch (e) {
225
+ fail('invalid_value', `${flag} ${s}: ${e.code === 'ENOENT' ? 'no such file' : e.message}.`, { path: field });
226
+ }
227
+ if (!stat.isFile())
228
+ fail('invalid_value', `${flag} ${s} is not a file.`, { path: field });
229
+ if (stat.size > FILE_MAX)
230
+ fail('invalid_value', `${flag} ${s} has ${kb(stat.size)}: an image is at most 512 KB.`, { path: field, details: { bytes: stat.size, max: FILE_MAX } });
231
+ if (stat.size === 0)
232
+ fail('invalid_value', `${flag} ${s} is empty.`, { path: field });
233
+ const b = fs.readFileSync(s);
234
+ const is = sniff(b);
235
+ if (is !== ext)
236
+ fail('invalid_value', `${flag} ${s} is named ${ext}, but its first bytes ${is ? `are ${is}'s` : 'are none of svg, png, jpg or webp'}.`, { path: field });
237
+ if (ext === 'svg') {
238
+ let text = '';
239
+ try {
240
+ text = new TextDecoder('utf-8', { fatal: true }).decode(b);
241
+ }
242
+ catch {
243
+ fail('invalid_value', `${flag} ${s} is not UTF-8 text, as an svg file is. Save it again as UTF-8. Nothing was sent.`, { path: field, next_step: { action: 'fix_request', command: null } });
244
+ }
245
+ const why = svgRefusal(text);
246
+ if (why)
247
+ fail('invalid_value', `${flag} ${s} would be refused by the store: ${why.reason}. ${why.fix} Nothing was sent.`, { path: field, details: { refused: why.reason }, next_step: { action: 'fix_request', command: null } });
248
+ }
249
+ return { value: { type: ext, content_base64: b.toString('base64'), sha256: crypto.createHash('sha256').update(b).digest('hex') }, bytes: b.length };
250
+ }
251
+ /**
252
+ * Why the store would refuse an SVG, and the way out; null when it takes it. The store's rules
253
+ * (its assets.ts svgRefusal), checked here so the agent learns before the upload.
254
+ */
255
+ export function svgRefusal(text) {
256
+ const body = text.replace(/^\uFEFF/, '').replace(/<\?xml[\s\S]*?\?>/, '').replace(/<!--[\s\S]*?-->/g, '').trim();
257
+ if (/<!DOCTYPE|<!ENTITY/i.test(text))
258
+ return { reason: 'it declares a DOCTYPE or an entity', fix: 'Remove the DOCTYPE line (and any ENTITY) from the file.' };
259
+ if (!/^<svg[\s>]/i.test(body))
260
+ return { reason: 'it is not an <svg> document', fix: 'The file starts with <svg>, after an optional <?xml ...?> line and comments.' };
261
+ const embedded = body.match(/<\s*(script|foreignObject|iframe|embed|object|audio|video|handler|listener)\b/i);
262
+ if (embedded)
263
+ return { reason: `it has a <${embedded[1]}> element`, fix: `Remove the <${embedded[1]}> element: a shop image has no script or embedded content.` };
264
+ const event = body.match(/\s(on[a-z]+)\s*=/i);
265
+ if (event)
266
+ return { reason: `it has the event attribute ${event[1]}`, fix: 'Remove every on... attribute.' };
267
+ if (/javascript:|vbscript:/i.test(body))
268
+ return { reason: 'it has a script URL', fix: 'Remove every javascript: or vbscript: link.' };
269
+ for (const m of body.matchAll(/\b(?:xlink:)?href\s*=\s*(["'])(.*?)\1/gi))
270
+ if (!allowedRef(m[2]))
271
+ return { reason: `it links to ${m[2].slice(0, 60)}`, fix: 'Keep only links inside the file (#id) or images embedded as data:image/...;base64.' };
272
+ for (const m of body.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gi))
273
+ if (!allowedRef(m[2]))
274
+ return { reason: `it loads ${m[2].slice(0, 60)}`, fix: 'Keep only url(#id) or images embedded as data:image/...;base64.' };
275
+ if (/@import/i.test(body))
276
+ return { reason: 'it imports a stylesheet', fix: 'Remove the @import: put the styles in the file.' };
277
+ return null;
278
+ }
279
+ const allowedRef = (v) => /^#/.test(v.trim()) || /^data:image\/(png|jpe?g|webp|gif);base64,/i.test(v.trim());
280
+ export const kb = (n) => `${Math.ceil(n / 1024)} KB`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "1.0.0-dev.19",
3
+ "version": "1.0.0-dev.21",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. One grammar, c11r <noun> <verb>, from a sandbox to a running organization.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [