@commercelayer/agent-cli 1.0.0-dev.12 → 1.0.0-dev.14

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,349 @@
1
+ // c11r skills sync: local copies of the skills, from the index the site publishes (start.md), so
2
+ // any coding agent in the project finds them. State none: no token, no organization, no call to
3
+ // the API; only commercelayer.ai.
4
+ //
5
+ // <project>/.claude/skills/<name>/SKILL.md one per skill, as the site serves it
6
+ // <project>/.claude/skills/.c11r-skills.json the manifest: what sync wrote, by version and hash
7
+ // <project>/AGENTS.md a marked block listing them, for other agents
8
+ //
9
+ // Only what the index version changed is fetched, with the index itself a conditional GET. A copy
10
+ // whose bytes are not what sync wrote is the person's edit: it is never replaced or deleted
11
+ // without --yes. Folders sync did not write are never touched.
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { findUp, LINK_FILE } from '../config.js';
16
+ import { CliError, fail } from '../errors.js';
17
+ import { envelope } from '../http.js';
18
+ import { parseSchema } from '../schema.js';
19
+ import { USER_AGENT } from '../version.js';
20
+ const INDEX_URL = 'https://commercelayer.ai/start.md';
21
+ const SKILLS_DIR = path.join('.claude', 'skills');
22
+ const MANIFEST = '.c11r-skills.json';
23
+ const AGENTS_FILE = 'AGENTS.md';
24
+ const START = '<!-- c11r skills: start -->';
25
+ const END = '<!-- c11r skills: end -->';
26
+ const NAME = /^[a-z0-9][a-z0-9-]*$/;
27
+ const TIMEOUT_MS = 30_000;
28
+ export const skills = {
29
+ schema: parseSchema({
30
+ noun: 'skills',
31
+ group: 'setup',
32
+ skill: 'start',
33
+ kind: 'task',
34
+ 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.',
35
+ verbs: [
36
+ {
37
+ verb: 'sync',
38
+ method: 'POST',
39
+ path: '/skills/sync',
40
+ state: 'none',
41
+ live: false,
42
+ body: [
43
+ { name: 'except', type: 'array', items: 'string', description: 'A skill not to keep a copy of, repeated. Remembered: later syncs leave it out too.' },
44
+ { name: 'include', type: 'array', items: 'string', description: 'A skill left out with --except to keep a copy of again, repeated.' },
45
+ ],
46
+ effect: 'Brings the copies up to date with the index at commercelayer.ai: fetches only the skills whose version changed, adds new ones, removes the ones the index dropped, and keeps the list in AGENTS.md (creating the file when there is none). A copy edited on this machine is kept as it is; replacing or removing it needs the human\'s yes. Answers added, updated, removed, edited and how many are unchanged. With --dry-run it reads only the index and writes nothing.',
47
+ },
48
+ ],
49
+ }),
50
+ defaultVerb: 'sync',
51
+ async run(run) {
52
+ return sync(run);
53
+ },
54
+ };
55
+ // ---------- the site
56
+ function indexUrl() {
57
+ const given = process.env.C11R_SKILLS_URL;
58
+ if (!given)
59
+ return INDEX_URL;
60
+ let u;
61
+ try {
62
+ u = new URL(given);
63
+ }
64
+ catch {
65
+ fail('invalid_config', `C11R_SKILLS_URL ${given} is not a URL.`);
66
+ }
67
+ if (!['localhost', '127.0.0.1'].includes(u.hostname))
68
+ fail('invalid_config', `C11R_SKILLS_URL is for a test site on localhost only, not ${u.host}.`);
69
+ return u.toString();
70
+ }
71
+ const unavailable = (message) => new CliError({ code: 'site_unavailable', message, required_state: null, required_plan: null, missing: null, path: null, next_step: { action: 'retry_later', command: null, url: null } }, 'unavailable');
72
+ async function get(url, etag) {
73
+ let res;
74
+ try {
75
+ res = await fetch(url, { headers: { 'user-agent': USER_AGENT, accept: 'text/markdown', ...(etag ? { 'if-none-match': etag } : {}) }, signal: AbortSignal.timeout(TIMEOUT_MS) });
76
+ }
77
+ catch (e) {
78
+ throw unavailable(`${new URL(url).host} could not be reached: ${e.message}. The local copies are unchanged and still usable.`);
79
+ }
80
+ if (res.status !== 200 && res.status !== 304)
81
+ throw unavailable(`${url} answered ${res.status}. The local copies are unchanged and still usable.`);
82
+ return { status: res.status, etag: res.headers.get('etag'), text: res.status === 200 ? await res.text() : '' };
83
+ }
84
+ /** The index lines: - [name](url) VERSION, state ..., plan ...[, requires ...]: description. */
85
+ function parseIndex(text, from) {
86
+ const origin = new URL(from).origin;
87
+ const entries = [];
88
+ const skipped = [];
89
+ for (const line of text.split('\n')) {
90
+ const m = line.match(/^- \[([^\]]+)\]\((\S+)\) (\d+\.\d+\.\d+\S*?),[^:]*: (.*)$/);
91
+ if (!m)
92
+ continue;
93
+ const [, name, link, version, description] = m;
94
+ let u = null;
95
+ try {
96
+ u = new URL(link, from);
97
+ }
98
+ catch {
99
+ u = null;
100
+ }
101
+ if (!NAME.test(name) || !u || u.origin !== origin || !u.pathname.startsWith('/skills/')) {
102
+ skipped.push(name);
103
+ continue;
104
+ }
105
+ if (!entries.some((e) => e.name === name))
106
+ entries.push({ name, version, url: u.toString(), description: description.trim() });
107
+ }
108
+ return { entries, version: frontmatter(text).version ?? null, skipped };
109
+ }
110
+ function frontmatter(text) {
111
+ const m = text.match(/^---\n([\s\S]*?)\n---/);
112
+ const out = {};
113
+ for (const line of m?.[1].split('\n') ?? []) {
114
+ const kv = line.match(/^([a-z_]+):\s*(.*)$/);
115
+ if (kv)
116
+ out[kv[1]] = kv[2].replace(/^"(.*)"$/, '$1').trim();
117
+ }
118
+ return out;
119
+ }
120
+ // ---------- this machine
121
+ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
122
+ const readOr = (file) => {
123
+ try {
124
+ return fs.readFileSync(file, 'utf8');
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ };
130
+ function readManifest(file) {
131
+ const raw = readOr(file);
132
+ if (raw === null)
133
+ return null;
134
+ try {
135
+ const m = JSON.parse(raw);
136
+ return { index: { url: m.index?.url ?? INDEX_URL, version: m.index?.version ?? null, etag: m.index?.etag ?? null, entries: m.index?.entries ?? [] }, skills: m.skills ?? {}, except: m.except ?? [] };
137
+ }
138
+ catch {
139
+ fail('invalid_config', `${file} is not JSON: c11r skills sync writes it, and it was changed by hand.`);
140
+ }
141
+ }
142
+ function writeAtomic(file, text) {
143
+ fs.mkdirSync(path.dirname(file), { recursive: true });
144
+ const tmp = `${file}.${process.pid}.tmp`;
145
+ fs.writeFileSync(tmp, text);
146
+ fs.renameSync(tmp, file);
147
+ }
148
+ const firstSentence = (d) => {
149
+ const i = d.indexOf('. ');
150
+ return i === -1 ? d : d.slice(0, i + 1);
151
+ };
152
+ function agentsBlock(entries, rel) {
153
+ const link = (n) => `${rel}/${n}/SKILL.md`;
154
+ const start = entries.find((e) => e.name === 'start');
155
+ const lines = [
156
+ START,
157
+ '## Commerce Layer skills',
158
+ '',
159
+ `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
+ '',
161
+ ...entries.map((e) => `- [${e.name}](${link(e.name)}): ${firstSentence(e.description)}`),
162
+ END,
163
+ ];
164
+ return lines.join('\n');
165
+ }
166
+ /** AGENTS.md with the block put in or replaced; nothing outside the markers changes. */
167
+ function withBlock(current, block) {
168
+ if (current === null)
169
+ return `${block}\n`;
170
+ const a = current.indexOf(START);
171
+ const b = current.indexOf(END);
172
+ if (a !== -1 && b > a)
173
+ return current.slice(0, a) + block + current.slice(b + END.length);
174
+ return `${current.replace(/\n*$/, '')}\n\n${block}\n`;
175
+ }
176
+ // ---------- sync
177
+ async function sync(run) {
178
+ const v = run.parsed.values;
179
+ const dryRun = run.globals.dryRun;
180
+ // The project root: where the copies already are, the folder's link or store files, a git
181
+ // repository's root, else here.
182
+ const root = findUp([path.join(SKILLS_DIR, MANIFEST)]) ?? findUp([LINK_FILE]) ?? findUp(['store.yml', 'loops.yml']) ?? findUp(['.git']) ?? process.cwd();
183
+ const dir = path.join(root, SKILLS_DIR);
184
+ const manifestFile = path.join(dir, MANIFEST);
185
+ const from = indexUrl();
186
+ const before = readManifest(manifestFile);
187
+ const manifest = before ?? { index: { url: from, version: null, etag: null, entries: [] }, skills: {}, except: [] };
188
+ const warnings = [];
189
+ const except = v.except ?? [];
190
+ const include = v.include ?? [];
191
+ for (const n of [...except, ...include])
192
+ if (!NAME.test(n))
193
+ fail('invalid_value', `${n} is not a skill name: lowercase letters, digits and dashes, as in manage-orders.`, { path: except.includes(n) ? 'except' : 'include' });
194
+ const excluded = new Set([...manifest.except.filter((n) => !include.includes(n)), ...except]);
195
+ // The index: a 304 means the one sync last read, whose entries the manifest keeps.
196
+ const sameUrl = manifest.index.url === from;
197
+ const idx = await get(from, sameUrl && manifest.index.entries.length ? manifest.index.etag : null);
198
+ let entries;
199
+ let indexVersion;
200
+ if (idx.status === 304) {
201
+ entries = manifest.index.entries;
202
+ indexVersion = manifest.index.version;
203
+ }
204
+ else {
205
+ const parsed = parseIndex(idx.text, from);
206
+ if (!parsed.entries.length)
207
+ throw unavailable(`${from} lists no skills: it may be mid-deploy. The local copies are unchanged.`);
208
+ entries = parsed.entries;
209
+ indexVersion = parsed.version;
210
+ for (const n of parsed.skipped)
211
+ warnings.push({ code: 'skill_skipped', message: `${n} links outside ${new URL(from).origin}/skills/ in the index: not copied.` });
212
+ }
213
+ for (const n of include)
214
+ if (!entries.some((e) => e.name === n))
215
+ warnings.push({ code: 'unknown_skill', message: `${n} is not in the index.` });
216
+ for (const n of except)
217
+ if (!entries.some((e) => e.name === n) && !manifest.skills[n])
218
+ warnings.push({ code: 'unknown_skill', message: `${n} is not in the index.` });
219
+ const wanted = entries.filter((e) => !excluded.has(e.name));
220
+ const fileOf = (n) => path.join(dir, n, 'SKILL.md');
221
+ const editedNow = (n) => {
222
+ const kept = manifest.skills[n];
223
+ const text = readOr(fileOf(n));
224
+ return Boolean(kept && text !== null && sha(text) !== kept.sha256);
225
+ };
226
+ const added = [];
227
+ const updated = [];
228
+ const removed = [];
229
+ const edited = [];
230
+ let unchanged = 0;
231
+ const toFetch = [];
232
+ for (const e of wanted) {
233
+ const kept = manifest.skills[e.name];
234
+ const present = readOr(fileOf(e.name)) !== null;
235
+ if (kept && present && kept.version === e.version) {
236
+ // Same version: still checked, since an edit there is not what the index says either.
237
+ if (editedNow(e.name))
238
+ edited.push({ name: e.name, local: kept.version, latest: e.version });
239
+ else
240
+ unchanged++;
241
+ continue;
242
+ }
243
+ if (kept && present && editedNow(e.name)) {
244
+ edited.push({ name: e.name, local: kept.version, latest: e.version });
245
+ continue;
246
+ }
247
+ // A file there that sync never wrote is the person's: kept, as an edit.
248
+ if (!kept && present) {
249
+ edited.push({ name: e.name, local: frontmatter(readOr(fileOf(e.name))).version ?? 'unknown', latest: e.version });
250
+ continue;
251
+ }
252
+ toFetch.push(e);
253
+ }
254
+ const gone = Object.values(manifest.skills).filter((k) => !wanted.some((e) => e.name === k.name));
255
+ const toRemove = [];
256
+ for (const k of gone) {
257
+ if (readOr(fileOf(k.name)) === null)
258
+ continue;
259
+ if (editedNow(k.name))
260
+ edited.push({ name: k.name, local: k.version, latest: null });
261
+ else
262
+ toRemove.push(k);
263
+ }
264
+ // Edits are replaced or removed only on the human's yes.
265
+ const replaceEdits = edited.length > 0 && !dryRun && run.globals.yes;
266
+ if (dryRun) {
267
+ for (const e of toFetch)
268
+ (manifest.skills[e.name] && readOr(fileOf(e.name)) !== null ? updated.push({ name: e.name, from: manifest.skills[e.name].version, to: e.version }) : added.push({ name: e.name, version: e.version }));
269
+ for (const k of toRemove)
270
+ removed.push({ name: k.name, version: k.version });
271
+ const agentsNow = readOr(path.join(root, AGENTS_FILE));
272
+ const agentsNext = withBlock(agentsNow, agentsBlock(wanted, SKILLS_DIR.split(path.sep).join('/')));
273
+ return answer({ dry_run: true, dir, index: { url: from, version: indexVersion }, added, updated, removed, edited, unchanged, agents_md: agentsNow === null ? 'created' : agentsNow === agentsNext ? 'unchanged' : 'updated' }, warnings, edited, replaceEdits);
274
+ }
275
+ // Fetch first, write after: a cut-off sync writes nothing.
276
+ if (replaceEdits)
277
+ for (const e of edited)
278
+ if (e.latest)
279
+ toFetch.push(wanted.find((w) => w.name === e.name));
280
+ const bodies = new Map();
281
+ for (const e of toFetch) {
282
+ const r = await get(e.url);
283
+ const fm = frontmatter(r.text);
284
+ if (fm.name !== e.name || fm.version !== e.version) {
285
+ warnings.push({ code: 'skill_invalid', message: `${e.url} is ${fm.name ?? 'no skill'} ${fm.version ?? ''}, not ${e.name} ${e.version} as the index says: the local copy is unchanged.`.replace(' ', ' ') });
286
+ continue;
287
+ }
288
+ bodies.set(e.name, r.text);
289
+ }
290
+ for (const e of toFetch) {
291
+ const text = bodies.get(e.name);
292
+ if (text === undefined)
293
+ continue;
294
+ const kept = manifest.skills[e.name];
295
+ const had = readOr(fileOf(e.name)) !== null;
296
+ writeAtomic(fileOf(e.name), text);
297
+ if (kept && had)
298
+ updated.push({ name: e.name, from: kept.version, to: e.version });
299
+ else
300
+ added.push({ name: e.name, version: e.version });
301
+ manifest.skills[e.name] = { ...e, sha256: sha(text) };
302
+ }
303
+ const removeOne = (name, version) => {
304
+ fs.rmSync(fileOf(name), { force: true });
305
+ try {
306
+ fs.rmdirSync(path.join(dir, name));
307
+ }
308
+ catch {
309
+ // The folder holds something of the person's: it stays.
310
+ }
311
+ removed.push({ name, version });
312
+ delete manifest.skills[name];
313
+ };
314
+ for (const k of toRemove)
315
+ removeOne(k.name, k.version);
316
+ for (const k of gone)
317
+ if (readOr(fileOf(k.name)) === null && manifest.skills[k.name])
318
+ delete manifest.skills[k.name];
319
+ if (replaceEdits)
320
+ for (const e of edited)
321
+ if (!e.latest)
322
+ removeOne(e.name, e.local);
323
+ // Unchanged entries take the index's description, which AGENTS.md lists.
324
+ for (const e of wanted)
325
+ if (manifest.skills[e.name] && manifest.skills[e.name].version === e.version)
326
+ manifest.skills[e.name] = { ...manifest.skills[e.name], url: e.url, description: e.description };
327
+ manifest.index = { url: from, version: indexVersion, etag: idx.status === 304 ? manifest.index.etag : idx.etag, entries };
328
+ manifest.except = [...excluded].sort();
329
+ writeAtomic(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
330
+ const listed = wanted.filter((e) => manifest.skills[e.name] || readOr(fileOf(e.name)) !== null);
331
+ const agentsFile = path.join(root, AGENTS_FILE);
332
+ const agentsNow = readOr(agentsFile);
333
+ const agentsNext = withBlock(agentsNow, agentsBlock(listed, SKILLS_DIR.split(path.sep).join('/')));
334
+ const agents_md = agentsNow === null ? 'created' : agentsNow === agentsNext ? 'unchanged' : 'updated';
335
+ if (agents_md !== 'unchanged')
336
+ writeAtomic(agentsFile, agentsNext);
337
+ const stillEdited = replaceEdits ? [] : edited;
338
+ return answer({ dir, index: { url: from, version: indexVersion }, added, updated, removed, edited: stillEdited, unchanged, agents_md }, warnings, stillEdited, replaceEdits);
339
+ }
340
+ function answer(data, warnings, edited, replaced) {
341
+ if (!replaced) {
342
+ for (const e of edited) {
343
+ warnings.push({ code: 'skill_edited', message: `${e.name} was edited on this machine: kept as it is${e.latest ? `, while the index has ${e.latest}` : ', though the index dropped it'}. Replacing it loses those edits: ask the human first, and on their yes run the sync again with --yes.` });
344
+ }
345
+ }
346
+ if (data.agents_md === 'created')
347
+ warnings.push({ code: 'agents_md_created', message: 'AGENTS.md was created, holding only the list of skills: tell the human in one line.' });
348
+ return envelope(data, { warnings, next: [] });
349
+ }
package/dist/config.js CHANGED
@@ -161,7 +161,7 @@ export function storeBooking(id, booking) {
161
161
  /** The last four characters, as the API shows them. */
162
162
  export const hintOf = (token) => `${token.slice(0, 4)}...${token.slice(-4)}`;
163
163
  /** The nearest folder, from the current one up, that holds one of the files. */
164
- function findUp(names, from = process.cwd()) {
164
+ export function findUp(names, from = process.cwd()) {
165
165
  let dir = path.resolve(from);
166
166
  for (;;) {
167
167
  if (names.some((n) => fs.existsSync(path.join(dir, n))))
package/dist/confirm.js CHANGED
@@ -40,18 +40,25 @@ export const terminal = {
40
40
  });
41
41
  },
42
42
  };
43
+ /**
44
+ * action names what needs a yes ("Destroy sandbox"). sentence, when given, is what the human is
45
+ * shown instead, whole ("Sharing with shop.example.com: Ada Lovelace, ..."), and details go with
46
+ * the error, so the agent can show the human the same.
47
+ */
43
48
  export async function confirm(action, command, opts) {
44
49
  if (opts.yes)
45
50
  return;
46
51
  const p = opts.prompter ?? terminal;
52
+ const said = opts.sentence ? `${opts.sentence.replace(/\.?$/, '.')} This` : action;
47
53
  if (!p.interactive) {
48
54
  // The agent asks the human; on a yes it repeats the command with --yes. next never carries the
49
55
  // --yes itself: only the human's answer adds it.
50
- fail('confirmation_required', `${action} needs confirmation, and there is no terminal to ask in. Ask the human first; on their yes, run it again with --yes.`, {
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.`, {
51
57
  next_step: { action: 'fix_request', command },
58
+ ...(opts.details ? { details: opts.details } : {}),
52
59
  });
53
60
  }
54
- const answer = (await p.ask(`${action}. Go ahead? [y/N] `)).trim().toLowerCase();
61
+ const answer = (await p.ask(opts.sentence ? `${opts.sentence.replace(/\.?$/, '.')} Go ahead? [y/N] ` : `${action}. Go ahead? [y/N] `)).trim().toLowerCase();
55
62
  if (answer !== 'y' && answer !== 'yes')
56
- fail('confirmation_declined', `${action}: not confirmed, nothing was changed.`);
63
+ fail('confirmation_declined', opts.sentence ? `${opts.sentence.replace(/\.?$/, '')}: not confirmed, nothing was sent.` : `${action}: not confirmed, nothing was changed.`);
57
64
  }
package/dist/engine.js CHANGED
@@ -65,8 +65,11 @@ async function executeOnce(argv, opts) {
65
65
  const by = verb.refused.by === 'commercelayer' ? 'Commerce Layer' : verb.refused.by;
66
66
  fail('not_supported', `c11r ${schema.noun} ${verb.name} is refused by ${by}: ${verb.description || 'it cannot be done through the API.'}`, { details: { refused_by: verb.refused.by }, next_step: { action: 'none' } });
67
67
  }
68
- const positional = ext?.positional?.[verb.name];
69
- const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
68
+ // A bare value that is a flag's: the first for a verb with no id (sales-calls book <slot>), the
69
+ // second, after the id, for one with (shop order <store> <checkout>).
70
+ const positional = (builtins[schema.noun]?.positional ?? ext?.positional)?.[verb.name];
71
+ const bare = (i) => cmd.rest[i] !== undefined && !cmd.rest[i].startsWith('-');
72
+ const rest = !positional ? cmd.rest : !verb.id && bare(0) ? [`--${positional}`, ...cmd.rest] : verb.id && bare(0) && bare(1) ? [cmd.rest[0], `--${positional}`, ...cmd.rest.slice(1)] : cmd.rest;
70
73
  const parsed = parseFields(schema.noun, verb, rest);
71
74
  ext?.prepare?.(verb, parsed);
72
75
  // Files first, so a required document given with --file counts; a secret is typed only once
package/dist/errors.js CHANGED
@@ -62,6 +62,12 @@ const KIND = {
62
62
  no_credentials: 'auth',
63
63
  forbidden: 'rejected',
64
64
  claim_not_found: 'not_found',
65
+ // A store spoken to over UCP (c11r shop).
66
+ store_unavailable: 'unavailable',
67
+ store_refused: 'rejected',
68
+ payment_handler_unsupported: 'rejected',
69
+ // The site that publishes the skills (c11r skills sync).
70
+ site_unavailable: 'unavailable',
65
71
  };
66
72
  /** For an API error whose code the CLI does not know: from the HTTP status. */
67
73
  export function kindOfStatus(status) {
package/dist/help.js CHANGED
@@ -30,7 +30,7 @@ export function indexHelp(builtins, api) {
30
30
  description: 'c11r is the Commerce Layer CLI for coding agents. Every answer, errors included, is one JSON object on stdout.',
31
31
  nouns: [...builtins, ...(api ?? [])].sort((a, b) => (a.noun < b.noun ? -1 : 1)),
32
32
  standard_verbs: 'list, get, create, update, delete. Nouns with a state machine add their own verbs.',
33
- flags: 'Fields in kebab or snake case (--price-list, --price_list). On list, --<field> <value> filters by equality and --<field>.<op> <value> by another operator (--created-at.gte 2026-11-01). in takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it, and a list that has one is sent as a JSON array. A ref flag, or a flag the schema marks nullable, given null or an empty value sends null and takes it off (--customer-group null, --coupon-code null). A dotted flag sets part of an object (--customer.email). Any other array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes key=value pairs (--items sku=COFFEE,quantity=2). --data takes the fields as one JSON object, or @ and a file name.',
33
+ flags: 'Fields in kebab or snake case (--price-list, --price_list). On list, --<field> <value> filters by equality and --<field>.<op> <value> by another operator (--created-at.gte 2026-11-01). in takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it, and a list that has one is sent as a JSON array. A ref flag, or a flag the schema marks nullable, given null or an empty value sends null and takes it off (--customer-group null, --coupon-code null). A dotted flag sets part of an object (--customer.email). Any other array takes the flag repeated or a JSON array, and the same rule holds: enums, numbers and dates also separated by commas, a name or text one value per flag (--payment-settings "Invoice, 60 days"); an array of objects takes key=value pairs (--items sku=COFFEE,quantity=2). --data takes the fields as one JSON object, or @ and a file name.',
34
34
  global_flags: GLOBAL_FLAG_HELP,
35
35
  exit_codes: EXIT_CODE_HELP,
36
36
  more: 'c11r <noun> --help describes a noun and its verbs; c11r <noun> <verb> --help a verb and its flags; c11r schema <noun> returns the full schema.',
package/dist/ucp.js ADDED
@@ -0,0 +1,207 @@
1
+ // A UCP platform client: what any shopping agent does to buy from a store that speaks UCP.
2
+ // The CLI talks to the store directly, as the agent CLI platform: its profile is the static
3
+ // https://commercelayer.ai/ucp/agent-cli/profile.json, it holds no keys, and stores verify no
4
+ // request signature. Every call carries UCP-Agent and Request-Id, a Content-Digest with a body,
5
+ // and an Idempotency-Key on writes. The store signs its answers (RFC 9421, ES256 over @status,
6
+ // content-digest and content-type): the CLI checks them against keys[] of its /.well-known/ucp.
7
+ import crypto from 'node:crypto';
8
+ import { CliError, fail } from './errors.js';
9
+ import { USER_AGENT } from './version.js';
10
+ export const UCP_VERSION = '2026-08-25';
11
+ export const AGENT_PROFILE_URL = 'https://commercelayer.ai/ucp/agent-cli/profile.json';
12
+ /** The sandbox payment handler: no money moves. The only one this CLI pays with. */
13
+ export const SANDBOX_HANDLER = 'ai.commercelayer.sandbox';
14
+ const SANDBOX_HANDLER_ID = 'cl_sandbox';
15
+ const SANDBOX_CREDENTIAL_TYPE = 'sandbox_token';
16
+ const SANDBOX_TOKEN_SUCCESS = 'sbx_tok_success';
17
+ const TIMEOUT_MS = 30_000;
18
+ /** A store from a host or a URL on it: https only (http for localhost, in tests). */
19
+ export function originOf(given) {
20
+ let url = null;
21
+ try {
22
+ url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(given) ? given : `https://${given}`);
23
+ }
24
+ catch {
25
+ url = null;
26
+ }
27
+ if (!url || !url.hostname)
28
+ fail('invalid_value', `${given} is not a store: give its host, as in shop.example.com, or an https URL on it.`, { path: 'id' });
29
+ const local = ['localhost', '127.0.0.1'].includes(url.hostname);
30
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local))
31
+ fail('invalid_value', `${given} is not https: UCP stores are served over https.`, { path: 'id' });
32
+ return url.origin;
33
+ }
34
+ /** GET /.well-known/ucp: what the store offers this CLI. */
35
+ export async function discover(origin) {
36
+ const res = await http('GET', `${origin}/.well-known/ucp`, { accept: 'application/json' });
37
+ const host = new URL(origin).host;
38
+ if (res.status === 404)
39
+ fail('not_found', `${host} serves no UCP profile (/.well-known/ucp answered 404): it does not sell to agents.`, { path: 'id', next_step: { action: 'human_step', url: origin } });
40
+ if (res.status !== 200)
41
+ fail('store_unavailable', `${host}/.well-known/ucp answered ${res.status}.`, { details: { status: res.status }, next_step: { action: 'retry_later' } });
42
+ const profile = parse(res.body);
43
+ if (!profile || typeof profile.ucp !== 'object')
44
+ fail('invalid_response', `${host}/.well-known/ucp is not a UCP profile.`, { next_step: { action: 'none' } });
45
+ const ucp = profile.ucp;
46
+ if (ucp.version !== UCP_VERSION)
47
+ fail('not_supported', `${host} speaks UCP ${ucp.version ?? '(no version)'}; this CLI speaks ${UCP_VERSION}.`, { details: { store_version: ucp.version ?? null, cli_version: UCP_VERSION }, next_step: { action: 'none' } });
48
+ const rest = (name) => (ucp.services?.[name] ?? []).find((s) => s.transport === 'rest')?.endpoint ?? null;
49
+ const endpoint = rest('dev.ucp.shopping');
50
+ if (!endpoint)
51
+ fail('not_supported', `${host}'s profile has no REST dev.ucp.shopping service.`, { next_step: { action: 'none' } });
52
+ const capabilities = new Set();
53
+ for (const [name, entries] of Object.entries((ucp.capabilities ?? {})))
54
+ if (Array.isArray(entries) && entries.some((e) => e.version === UCP_VERSION))
55
+ capabilities.add(name);
56
+ return { origin, host, profile, endpoint, commonEndpoint: rest('dev.ucp.common'), capabilities, keys: Array.isArray(profile.keys) ? profile.keys : [] };
57
+ }
58
+ /** The payment handlers the store offers, by name, and whether this CLI can pay there. */
59
+ export function handlersOf(store) {
60
+ const handlers = Object.keys(store.profile.ucp?.payment_handlers ?? {});
61
+ return { handlers, can_pay: handlers.includes(SANDBOX_HANDLER) && store.capabilities.has('dev.ucp.shopping.checkout') };
62
+ }
63
+ /** Before anything of the human's is sent: a store this CLI cannot pay at is refused. */
64
+ export function mustPay(store, extra = {}) {
65
+ if (!store.capabilities.has('dev.ucp.shopping.checkout')) {
66
+ fail('not_supported', `${store.host} offers no UCP checkout: an agent can browse it, not buy there.`, { next_step: { action: 'human_step', url: store.origin } });
67
+ }
68
+ const { handlers } = handlersOf(store);
69
+ if (!handlers.includes(SANDBOX_HANDLER)) {
70
+ fail('payment_handler_unsupported', `${store.host} takes ${handlers.length ? handlers.join(', ') : 'no payment handler'}; this CLI pays only with the ${SANDBOX_HANDLER} test handler, where no money moves. The human buys there in person.`, { details: { handlers }, next_step: { action: 'human_step', url: extra.continue_url ?? store.origin } }, 'rejected');
71
+ }
72
+ }
73
+ export const sandboxPayment = () => ({ payment: { instruments: [{ id: 'pi_1', handler_id: SANDBOX_HANDLER_ID, type: 'card', selected: true, credential: { type: SANDBOX_CREDENTIAL_TYPE, token: SANDBOX_TOKEN_SUCCESS } }] } });
74
+ /** A UCP call. The answer's digest and signature are checked; an error is a CliError. */
75
+ export async function call(store, method, url, body, what = 'the store') {
76
+ const raw = body === undefined ? undefined : JSON.stringify(body);
77
+ const headers = { accept: 'application/json', 'ucp-agent': `profile="${AGENT_PROFILE_URL}"`, 'request-id': crypto.randomUUID() };
78
+ if (raw !== undefined) {
79
+ headers['content-type'] = 'application/json';
80
+ headers['content-digest'] = contentDigest(raw);
81
+ }
82
+ if (method !== 'GET' && !/\/catalog\/|\/ask$/.test(url))
83
+ headers['idempotency-key'] = crypto.randomUUID();
84
+ const res = await http(method, url, headers, raw);
85
+ const signature = checkAnswer(store, res);
86
+ const parsed = parse(res.body);
87
+ if (res.status >= 400 || parsed?.ucp?.status === 'error')
88
+ refused(store, res.status, parsed, what);
89
+ if (!parsed || typeof parsed !== 'object')
90
+ fail('invalid_response', `${store.host} answered ${what} with no JSON.`, { details: { status: res.status }, next_step: { action: 'retry_later' } });
91
+ return { status: res.status, body: parsed, signature };
92
+ }
93
+ /** The store's own words, as an error the agent can show the human. */
94
+ function refused(store, status, body, what) {
95
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
96
+ const first = messages.find((m) => m.type === 'error') ?? messages[0];
97
+ const code = body?.code ?? first?.code;
98
+ const said = body?.content ?? first?.content ?? `it answered ${status}`;
99
+ const details = { status, store_code: code ?? null, messages: messages.length ? messages : undefined };
100
+ if (status === 404 || code === 'not_found')
101
+ fail('not_found', `${store.host} does not know ${what}: ${said}`, { details, next_step: { action: 'none' } });
102
+ if (status === 401 && /signature/i.test(`${code} ${said}`)) {
103
+ fail('not_supported', `${store.host} wants a signed request, and this CLI signs none: ${said}`, { details, next_step: { action: 'human_step', url: store.origin } });
104
+ }
105
+ if (status === 401 || code === 'identity_required')
106
+ fail('forbidden', `${store.host} refused ${what}: ${said} This CLI reads only orders it placed.`, { details, next_step: { action: 'none' } });
107
+ if (status === 429 || status >= 500)
108
+ fail('store_unavailable', `${store.host} could not answer ${what}: ${said}`, { details, next_step: { action: 'retry_later' } });
109
+ fail('store_refused', `${store.host} refused ${what}: ${said}`, { details, next_step: { action: 'human_step' } }, 'rejected');
110
+ }
111
+ async function http(method, url, headers, body) {
112
+ let res;
113
+ try {
114
+ res = await fetch(url, { method, headers: { 'user-agent': USER_AGENT, ...headers }, body, redirect: 'follow', signal: AbortSignal.timeout(TIMEOUT_MS) });
115
+ }
116
+ catch (e) {
117
+ const host = (() => {
118
+ try {
119
+ return new URL(url).host;
120
+ }
121
+ catch {
122
+ return url;
123
+ }
124
+ })();
125
+ throw new CliError({ code: 'store_unavailable', message: `${host} could not be reached: ${e.message}.`, required_state: null, required_plan: null, missing: null, path: null, next_step: { action: 'retry_later', command: null, url: null } }, 'unavailable');
126
+ }
127
+ const out = {};
128
+ res.headers.forEach((v, k) => (out[k.toLowerCase()] = v));
129
+ return { status: res.status, headers: out, body: await res.text() };
130
+ }
131
+ function parse(text) {
132
+ if (!text)
133
+ return null;
134
+ try {
135
+ const v = JSON.parse(text);
136
+ return v && typeof v === 'object' ? v : null;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ // ---------- digests and signatures (RFC 9530, RFC 9421)
143
+ export function contentDigest(body) {
144
+ return `sha-256=:${crypto.createHash('sha256').update(body).digest('base64')}:`;
145
+ }
146
+ function serializeParams(p) {
147
+ let s = `(${p.components.map((c) => `"${c}"`).join(' ')})`;
148
+ if (p.created !== undefined)
149
+ s += `;created=${p.created}`;
150
+ return `${s};keyid="${p.keyid}"`;
151
+ }
152
+ export function signatureBase(values, p) {
153
+ return [...p.components.map((c) => `"${c}": ${values[c] ?? ''}`), `"@signature-params": ${serializeParams(p)}`].join('\n');
154
+ }
155
+ function parseSignature(input, signature) {
156
+ if (!input || !signature)
157
+ return null;
158
+ const m = input.match(/^\s*([a-z0-9_-]+)=\(([^)]*)\)((?:;[a-z]+=[^;]*)*)\s*$/i);
159
+ if (!m)
160
+ return null;
161
+ const [, label, list, rest] = m;
162
+ const params = { components: [...list.matchAll(/"([^"]+)"/g)].map((x) => x[1]), keyid: '' };
163
+ for (const [, k, v] of rest.matchAll(/;([a-z]+)=("[^"]*"|[^;]*)/g)) {
164
+ if (k === 'keyid')
165
+ params.keyid = v.replace(/^"|"$/g, '');
166
+ if (k === 'created')
167
+ params.created = Number(v);
168
+ }
169
+ const sig = signature.match(new RegExp(`${label}=:([A-Za-z0-9+/=]+):`));
170
+ return sig ? { params, signature: sig[1] } : null;
171
+ }
172
+ function verify(base, signature, jwk) {
173
+ try {
174
+ const { kid: _k, use: _u, alg: _a, key_ops: _o, ...key } = jwk;
175
+ return crypto.verify('sha256', Buffer.from(base), { key: crypto.createPublicKey({ key: key, format: 'jwk' }), dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64'));
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ }
181
+ /** A digest that does not match, or a signature that does not verify: the answer is not trusted. */
182
+ function checkAnswer(store, res) {
183
+ const digest = res.headers['content-digest'];
184
+ if (digest && digest !== contentDigest(res.body))
185
+ fail('invalid_response', `${store.host}'s answer does not match its Content-Digest: it was changed on the way, and is not used.`, { next_step: { action: 'retry_later' } });
186
+ const sig = parseSignature(res.headers['signature-input'], res.headers.signature);
187
+ if (!sig)
188
+ return 'unsigned';
189
+ const key = store.keys.find((k) => k.kid === sig.params.keyid);
190
+ const values = { '@status': String(res.status), 'content-digest': digest ?? '', 'content-type': res.headers['content-type'] ?? '' };
191
+ if (!key || !verify(signatureBase(values, sig.params), sig.signature, key)) {
192
+ fail('invalid_response', `${store.host}'s answer is signed with ${key ? 'a signature that does not verify' : `a key its profile does not publish (${sig.params.keyid})`}: it is not used.`, { next_step: { action: 'none' } });
193
+ }
194
+ return 'verified';
195
+ }
196
+ export function money(amount, currency) {
197
+ let digits = 2;
198
+ try {
199
+ digits = new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ?? 2;
200
+ }
201
+ catch {
202
+ digits = 2;
203
+ }
204
+ const major = amount / 10 ** digits;
205
+ return { amount, currency, display: `${amount < 0 ? '-' : ''}${currency} ${Math.abs(major).toFixed(digits)}` };
206
+ }
207
+ export const minorOf = (major, currency) => Math.round(major * 10 ** (money(0, currency).display.split('.')[1]?.length ?? 0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "1.0.0-dev.12",
3
+ "version": "1.0.0-dev.14",
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": [