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

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
@@ -15,7 +15,7 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
15
15
  - Flags are the schema's fields, in kebab or snake case: `--price-list` and `--price_list` are the same.
16
16
  - On `list`, `--<field> <value>` filters by equality and `--<field>.<op> <value>` by another operator: `--placed-at.gte 2026-11-01`, `--status.in placed,approved`. `--waiting` lists what needs a decision now.
17
17
  - A ref flag, or one the schema marks `nullable`, given `null` or an empty value sends `null` and takes it off: `c11r customers update ada@example.com --customer-group null` sends `customer_group: null`, `c11r orders update 1001 --coupon-code null` sends `coupon_code: null`. A ref the schema marks `nullable: false`, and any other field, sends the text.
18
- - A dotted flag sets part of an object: `--customer.email ada@example.com`. An `in` filter 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 (`--tax-calculator.in "UK VAT, manual"`), and a list that has one is sent as a JSON array. 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, one object per flag: `--items sku=COFFEE,quantity=2 --items sku=TEA,quantity=1`. Each array flag's help has a `repeat` line with its own form. `--data` takes the fields as one JSON object, or `@` and a file name.
18
+ - A dotted flag sets part of an object: `--customer.email ada@example.com`. An `in` filter 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 (`--tax-calculator.in "UK VAT, manual"`), and a list that has one is sent as a JSON array. 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"`). On `markets update` and `stores update` the payment settings are the whole list: `markets update eu --payment-settings "Wire transfer" --payment-settings Stripe` replaces the market's settings with those two, so adding one means naming the ones it has too; an array of objects takes `key=value` pairs, one object per flag: `--items sku=COFFEE,quantity=2 --items sku=TEA,quantity=1`. Each array flag's help has a `repeat` line with its own form. `--data` takes the fields as one JSON object, or `@` and a file name.
19
19
  - Amounts are in major units: `--amount 12.50`.
20
20
  - A secret field the schema marks `secret_input` (provider credentials) has no flag of its own: `--credentials-file <file>` reads it, as JSON or `KEY=value` lines, from a plain file only its owner can read (mode 600). Without the file, a terminal asks for it without echo; with neither, the command stops with `secret_required`. It is refused as a flag value or in `--data`, and never printed.
21
21
  - A field with documented values the API does not enforce (`known`: webhook topics) takes any value. One outside them is sent anyway, with the warning `unknown_value` and `did_you_mean`. Help lists up to 20 of them, and past that says how many and that `c11r schema <noun>` has them all.
@@ -53,7 +53,7 @@ Two kinds of bearer. An **organization token** (`sbx_...` from `sandbox create`,
53
53
 
54
54
  Never from a flag or the environment. c11r exchanges the pair for a two hour token at Commerce Layer and sends only that token to the c11r API; the client secret never leaves this machine. Both are kept in `credentials.json`, mode 600, and never printed. `c11r status` shows the stored login as `cli.login`, `{ client_id, email, stored_at }`, or null: read on this machine, never the secret. `auth logout` forgets the login; its last token ends by itself within two hours. Organization tokens stay.
55
55
 
56
- `organizations create` and `organizations connect <slug>` answer an organization token once: it is stored like a sandbox token. `connect --revoke-others` also ends every other token of that organization, and asks for confirmation. With a login and no organization token for the organization named, an organization noun stops with `no_credentials` and the next step `c11r organizations connect <slug>`. `members` and `roles` with an organization token and no login are sent with that token, and the API says what is needed: a claim for a sandbox, a login for a claimed organization. `organizations` with no login stops here with `login_required`.
56
+ `organizations create` and `organizations connect <slug>` answer an organization token once: it is stored like a sandbox token. `connect --revoke-others` also ends every other token of that organization, and asks for confirmation. On an organization c11r does not know (one of the human's own, made outside c11r), `connect` gives commercelayer.ai admin access to it in test mode: the API answers `428 confirmation_required` with the sentence naming that access until the command runs with `--yes` (the schema marks it `confirm_when: ["unknown_organization"]`); `--dry-run` answers the same sentence. The answer carries `connected: true`, `access`, `mode: test` and `payments.flow`, and `auth login` keeps `connected_with_c11r` beside `claimed_with_c11r` and `created_with_c11r` for each organization. With a login and no organization token for the organization named, an organization noun stops with `no_credentials` and the next step `c11r organizations connect <slug>`. `members` and `roles` with an organization token and no login are sent with that token, and the API says what is needed: a claim for a sandbox, a login for a claimed organization. `organizations` with no login stops here with `login_required`.
57
57
 
58
58
  `credentials create` and `credentials token` answer a secret once. c11r writes it to a file with mode 600 and prints the file, the format and a hint, never the value: by default `~/.config/c11r/secrets/<org>/<name>.env` (`<id>-token.env` for a token; a record with no name, such as a gift card, `gift-card-<id>.env` after the id it is given), or `--out <path>`, as `KEY=value` lines or with `--format json` one JSON object. The file is checked before the request is sent; one there already is replaced only with `--yes`. A file inside a git repository that git does not ignore gets the warning `secret_not_ignored`. With `--dry-run` nothing is written and `secret_file` says where it would go. Each file records the record it belongs to. A `delete` of a record whose noun answers secrets (`credentials`, `webhooks`) removes the files c11r wrote for it in `~/.config/c11r/secrets/<org>/` (a credential's secret and tokens, a webhook's shared secret), found by the id, name or code given, and lists them in `removed_files`; `sandbox destroy` removes the whole folder of that organization and names it in `forgotten.secrets`. A file written with `--out` is not tracked and stays: the warning `out_files_not_tracked` says so on the delete of the record whose secret went to `--out` on this machine, and on `sandbox destroy` of an organization that had one. Any other delete has no such warning. A token file carries `CL_BASE_ENDPOINT` beside the token. `sales-calls book` answers a cancel token once: it is kept in `credentials.json` and `sales-calls cancel <booking>` sends it.
59
59
 
@@ -77,7 +77,7 @@ Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sand
77
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
- - `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.
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. On a connected organization the first live call answers `428 confirmation_required` before the live credential is made; `c11r status --live --org <slug> --yes` is the yes, sent as `confirm=true` on that read. A write's `--yes` never stands for it.
81
81
  - `c11r auth logout --live --org <slug>` revokes that credential in Commerce Layer (`DELETE /v1/auth/live`): `{ org, revoked, credential }`. The login stays on this machine. `--dry-run` says what it would revoke.
82
82
  - A verb that needs a yes is confirmed by the API in live, with a sentence naming the action, the amount and the mode (`Refunds EUR 1.00 on order N, in LIVE mode, real money.`): the CLI sends it without `confirm=true` and shows that `confirmation_required` as it is, with the API's next command, or asks it with those words in a terminal. `--yes` sends `confirm=true` on every live write, `payments capture` included.
83
83
  - A verb that answers a secret (`credentials create`) writes it in live only to the file the human chose with `--out`: without it, `missing_value` with the command and `--out FILE`. Never to c11r's secrets folder, never printed. A live delete leaves the test mode's secret files of the same organization alone.
@@ -90,7 +90,9 @@ Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sand
90
90
  `--org`, then `C11R_ORG`, then `.c11r.json` in the project folder (found by walking up, like `.git`), then the default set by `auth switch --global`, then the stored organization when exactly one is stored (never for a login noun's `?org=`). With several stored and nothing naming one, the command stops with `org_required` and lists them.
91
91
 
92
92
  - Tokens are only in `~/.config/c11r/credentials.json` (or under `XDG_CONFIG_HOME`), mode 600. The CLI never prints one, only its hint. Each token records the host that issued it: organization and cancel tokens the API base, the login the auth endpoint. One is used, listed and forgotten only while that host is the one in effect (`C11R_API_URL`, `C11R_AUTH_URL`), so a token never goes to another host. An entry that records no host is not used, and goes at the next write.
93
- - `.c11r.json` holds only `{ "org": "<slug>" }`: no secret, safe to commit. `sandbox create`, `organizations create` and `auth switch` write it, and `organizations connect` when the folder has no link; `reset` follows the new slug; `destroy` removes it when it names the destroyed organization.
93
+ - `.c11r.json` holds only `{ "org": "<slug>" }`: no secret, safe to commit. `sandbox create` and `organizations create` write it in the current folder: a link found above is another project's, stays as it is (warning `parent_link_kept`), and its token is not sent. `auth switch` writes it too, and `organizations connect` when the folder has no link; `reset` follows the new slug; `destroy` removes it when it names the destroyed organization.
94
+ - `c11r status` shows the link in `cli.link`, with `here`: true when the `.c11r.json` is in the current folder, false when it was found above.
95
+ - `sandbox create` sends a token only when this folder's own link, `--org`, `C11R_ORG` or `C11R_TOKEN` names the sandbox: the API then finishes a failed seed there, or refuses a second sandbox with `409 conflict`. The default of `auth switch --global` and the one stored token are not sent.
94
96
  - The project folder is the one with `.c11r.json`, else the nearest with `store.yml` or `loops.yml`, else the current one. `loops.yml` goes there.
95
97
 
96
98
  ## The store's setup in files
@@ -172,7 +174,10 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
172
174
  - `profile`, `search`, `get` and `ask` only read. `get` and `ask` take the variant or the question as the bare value after the store.
173
175
  - `checkout` opens a checkout with `--item ID=QTY` (repeated) and the buyer's `--email`, `--first-name`, `--last-name`, `--phone` and `--address.street`, `--address.line-2`, `--address.locality`, `--address.postal-code`, `--address.region`, `--address.country`, all but the line 2 and the region required. Each is the human's, never invented: a missing one is `missing_value` with a human step. Before any of them is sent, `confirmation_required` shows every value and the host (`Sharing with shop.example.com: Ada Lovelace, ada@example.com, ...`); the agent asks the human and, on their yes, runs it again with `--yes`. `checkout <store> <checkout>` changes one (items, the buyer, the address, `--shipping <option>`), or with nothing to change reads it again. The answer has the totals with shipping and, when it is ready, `next` is the order.
174
176
  - `order <store> <checkout>` shows the total and asks the same way, then pays with the sandbox test handler (`ai.commercelayer.sandbox`), where no money moves, and answers the order. On a checkout already paid it answers the order and pays nothing. A checkout that is not ready is `invalid_transition`: when the store's messages name the fields it still needs, `next_step` is the command with their flags (`--phone PHONE`), and when they do not, it is a human step at the store's checkout page; a declined payment is `store_refused` with the store's words.
175
- - The CLI has no flag for card data. A store without the sandbox handler is `payment_handler_unsupported` before anything of the human's is sent, with the store's URL for the human to buy there in person.
177
+ - The CLI has no flag for card data. A store without the sandbox handler is `payment_handler_unsupported` before anything of the human's is sent, with the store's URL for the human to buy on the shop's site. That includes a live shop taking Stripe's `com.stripe.payments`: Stripe wallet tokens come from agents Stripe has enrolled, and this CLI is not one yet. A shop that offers no UCP checkout (a live shop without Stripe ready keeps its catalog) is `not_supported` for checkout; `shop profile` says either with `can_pay: false` and the warning `cannot_pay_here`, not an error.
178
+ - A checkout the store marks `payment_unavailable` (its market takes no payment from agents) answers with the warning `cannot_pay_here` and no next step, and `shop order` refuses it with `payment_handler_unsupported` before anything is completed.
179
+ - Each checkout's own `ucp.payment_handlers`, the handlers its market takes, decides over the profile's union: without the sandbox handler there, `shop order` refuses before anything is completed. A checkout paid whose order the store could not place (`order_not_placed`) is refused with a human step every time it is run, and nothing offers to pay it again until the human has reviewed it: the store keeps the checkout `incomplete` with that note until an update, the buyer's review, clears it.
180
+ - A payment the store has taken and not yet confirmed (`complete_in_progress`) answers `payment_in_progress`, exit 7, with `retry_later` and the same `shop order` command. Run again, it reads the checkout first and never completes it a second time: it answers the order once the store has settled it. While a payment is under way, `shop checkout` takes no changes to it (`payment_in_progress`, nothing sent). Two runs at once are safe too: the store answers a second complete with the first payment's state, and the CLI says `payment_in_progress` again.
176
181
  - With `--dry-run`, checkout answers what it would send and order the total it would pay: nothing is sent.
177
182
 
178
183
  ## Skills
@@ -70,6 +70,9 @@ export const auth = {
70
70
  return undefined;
71
71
  },
72
72
  };
73
+ /** How the organization came to c11r, as the API marks it: at most one of the three is true. */
74
+ const MARKERS = ['claimed_with_c11r', 'created_with_c11r', 'connected_with_c11r'];
75
+ const markers = (o) => Object.fromEntries(MARKERS.filter((k) => o[k] !== undefined).map((k) => [k, o[k]]));
73
76
  async function login(run) {
74
77
  const v = run.parsed.values;
75
78
  const prompter = run.prompter ?? terminal;
@@ -114,7 +117,7 @@ async function login(run) {
114
117
  token: minted.token,
115
118
  expires_at: minted.expires_at,
116
119
  user: who.data?.user ?? null,
117
- organizations: items.map((o) => ({ slug: o.slug, name: o.name, plan: o.plan ?? null, ...(o.claimed_with_c11r !== undefined ? { claimed_with_c11r: o.claimed_with_c11r } : {}) })),
120
+ organizations: items.map((o) => ({ slug: o.slug, name: o.name, plan: o.plan ?? null, ...markers(o) })),
118
121
  created_at: new Date().toISOString(),
119
122
  });
120
123
  }
@@ -168,7 +171,7 @@ async function switchOrg(run) {
168
171
  reached = items.map((o) => o.slug);
169
172
  const l = storedLogin();
170
173
  if (l)
171
- storeLogin({ ...l, organizations: items.map((o) => ({ slug: o.slug, name: o.name, plan: o.plan ?? null })) });
174
+ storeLogin({ ...l, organizations: items.map((o) => ({ slug: o.slug, name: o.name, plan: o.plan ?? null, ...markers(o) })) });
172
175
  }
173
176
  if (!tokens[slug] && !reached.includes(slug)) {
174
177
  const known = [...new Set([...Object.keys(tokens), ...reached])];
@@ -11,7 +11,7 @@ import crypto from 'node:crypto';
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import zlib from 'node:zlib';
14
- import { credentialsFile, forgetOrg, forgetOutWritten, hintOf, projectDir, readLink, removeLink, storeBooking, storedBooking, storedOrgs, storeOrg, writeLink } from '../config.js';
14
+ import { credentialsFile, forgetOrg, forgetOutWritten, hintOf, projectDir, readLink, removeLink, parentLinkKept, storeBooking, storedBooking, storedOrgs, storeOrg, writeLink } from '../config.js';
15
15
  import { OUT_NOT_TRACKED, removeOrgSecrets } from '../secrets.js';
16
16
  import { confirm, terminal } from '../confirm.js';
17
17
  import { fail } from '../errors.js';
@@ -64,15 +64,20 @@ export const organizations = {
64
64
  })();
65
65
  let linked = null;
66
66
  if (run.verb.name === 'create' || !existing || existing.org === org) {
67
- const link = writeLink(org);
67
+ // A create links this folder; a link found above stays as it is.
68
+ const create = run.verb.name === 'create';
69
+ const link = writeLink(org, { here: create });
68
70
  linked = link.file;
71
+ if (create && existing && !existing.here)
72
+ warnings.push(parentLinkKept(existing));
69
73
  if (link.previous)
70
74
  warnings.push({ code: 'relinked', message: `${link.file} named ${link.previous}; it now names ${org}. The token of ${link.previous} stays stored.` });
71
75
  }
72
76
  else {
73
77
  warnings.push({ code: 'not_linked', message: `${existing.file} names ${existing.org}, so this folder stays linked to it. --org ${org}, or c11r auth switch ${org}, acts on ${org}.` });
74
78
  }
75
- if (typeof data.revoked === 'number') {
79
+ // Only when --revoke-others asked: a first connect answers revoked: 0 as well.
80
+ if (typeof data.revoked === 'number' && run.parsed.values.revoke_others) {
76
81
  warnings.push({ code: 'tokens_revoked', message: data.revoked ? `${data.revoked} other organization token${data.revoked === 1 ? '' : 's'} of ${org} no longer work${data.revoked === 1 ? 's' : ''}, on whatever machine held them.` : `No other organization token of ${org} was in use.` });
77
82
  }
78
83
  return { ...r, data: { ...data, org, token_hint: hint, credentials: credentialsFile(), linked }, warnings };
@@ -4,7 +4,7 @@
4
4
  // reset moves both to the new organization; destroy forgets them. The token is printed never,
5
5
  // not even by create: only its hint.
6
6
  import crypto from 'node:crypto';
7
- import { credentialsFile, defaultOrg, forgetOutWritten, storedLogin, forgetOrg, LINK_FILE, readLink, removeLink, renameOrg, storedOrgs, storeOrg, writeLink, hintOf } from '../config.js';
7
+ import { credentialsFile, defaultOrg, forgetOutWritten, storedLogin, forgetOrg, LINK_FILE, readLink, removeLink, renameOrg, parentLinkKept, storedOrgs, storeOrg, writeLink, hintOf } from '../config.js';
8
8
  import { CliError, fail } from '../errors.js';
9
9
  import { apiUrl, envelope, request } from '../http.js';
10
10
  import { parseSchema } from '../schema.js';
@@ -44,7 +44,7 @@ export const sandbox = {
44
44
  SEED,
45
45
  CATALOGUE,
46
46
  ],
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.',
47
+ effect: 'Creates a sandbox, stores its token on this machine (never printed) and links this folder to it with .c11r.json, in this folder: a link in a folder above is another project\'s and stays as it is. With --seed it answers once the seed and its storefront are in place, in about 20 seconds, with the shop link.',
48
48
  },
49
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.' },
50
50
  {
@@ -92,10 +92,12 @@ async function create(run, values) {
92
92
  }
93
93
  const { seed, catalogue } = seedBody(values);
94
94
  const test = process.env.C11R_TEST === '1' || Boolean(name?.startsWith('zztest'));
95
- // The token goes only when this folder, --org or C11R_ORG names a sandbox: the API then
96
- // finishes a failed seed there, or answers 409 conflict naming it, rather than make a second
97
- // one for the same folder. A token picked only because it is the one stored stays home.
98
- const named = run.org.source === 'link' || run.org.source === 'flag' || run.org.source === 'env' || run.org.source === 'token_env';
95
+ // The token goes only when this folder's own link, --org, C11R_ORG or C11R_TOKEN names a
96
+ // sandbox: the API then finishes a failed seed there, or answers 409 conflict naming it, rather
97
+ // than make a second one for the same folder. A link found above is another project's, and a
98
+ // token picked as the default or as the only one stored stays home: the create makes a new one.
99
+ const above = readLink();
100
+ const named = (run.org.source === 'link' && above?.here === true) || run.org.source === 'flag' || run.org.source === 'env' || run.org.source === 'token_env';
99
101
  // A fresh key per create: after a 5xx or a lost connection the same create is repeated with it,
100
102
  // and the API answers the sandbox (and token) it made, rather than leave one with a lost token.
101
103
  const idempotencyKey = `c11r-${crypto.randomBytes(16).toString('base64url')}`;
@@ -107,9 +109,11 @@ async function create(run, values) {
107
109
  fail('invalid_response', 'The API created a sandbox without returning its org and token.', { next_step: { action: 'none' } });
108
110
  const hint = typeof data.token_hint === 'string' ? data.token_hint : hintOf(token);
109
111
  storeOrg(data.org, { token, token_hint: hint, expires_at: data.expires_at ?? null, created_at: new Date().toISOString(), api_url: apiUrl() });
110
- const link = writeLink(data.org);
112
+ const link = writeLink(data.org, { here: true });
111
113
  // The API says to store the token: the CLI has, and printed none of it.
112
114
  const warnings = r.warnings.filter((w) => w.code !== 'token_shown_once');
115
+ if (above && !above.here)
116
+ warnings.push(parentLinkKept(above));
113
117
  if (link.previous)
114
118
  warnings.push({ code: 'relinked', message: `${link.file} named ${link.previous}; it now names ${data.org}. The token of ${link.previous} stays stored.` });
115
119
  return withShop({ ...r, data: { ...data, token_hint: hint, credentials: credentialsFile(), linked: link.file }, warnings }, data.store);
@@ -178,12 +182,14 @@ export const status = {
178
182
  noun: 'status',
179
183
  kind: 'task',
180
184
  description: 'c11r status: where this machine and folder stand. The organization picked and from where, its lifecycle state, plan, mode, limits, usage and what is waiting.',
181
- verbs: [{ verb: 'get', method: 'GET', path: '/v1/status', state: 'none', effect: 'Returns the status. c11r status is the same as c11r status get.' }],
185
+ verbs: [{ verb: 'get', method: 'GET', path: '/v1/status', state: 'none', effect: 'Returns the status. c11r status is the same as c11r status get. With --live --yes it sends confirm=true: on an organization connected to c11r, the first live call asks for it once, before the live credential is made.' }],
182
186
  }),
183
187
  defaultVerb: 'get',
184
188
  async run(run) {
185
189
  // Status works at every state: an unresolved organization is shown, not raised.
186
- const r = await run.send({ method: 'GET', path: '/status' });
190
+ // In live, --yes is the human's yes to the live credential of a connected organization
191
+ // (contract 49.4): only this read, confirmed, makes it, never a write's --yes.
192
+ const r = await run.send({ method: 'GET', path: '/status', ...(run.globals.live && run.globals.yes && !run.globals.dryRun ? { confirm: true } : {}) });
187
193
  const link = (() => {
188
194
  try {
189
195
  return readLink();
@@ -198,7 +204,7 @@ export const status = {
198
204
  api_url: apiUrl(),
199
205
  org: run.org.org ? { slug: run.org.org, from: run.org.source } : null,
200
206
  ...(run.org.problem ? { org_problem: { code: run.org.problem.code, message: run.org.problem.message } } : {}),
201
- link: link ? { file: link.file, org: link.org } : { file: null, name: LINK_FILE },
207
+ link: link ? { file: link.file, org: link.org, here: link.here } : { file: null, name: LINK_FILE, here: false },
202
208
  credentials: credentialsFile(),
203
209
  stored,
204
210
  login: (() => {
@@ -13,7 +13,7 @@ import { confirm } from '../confirm.js';
13
13
  import { fail } from '../errors.js';
14
14
  import { envelope } from '../http.js';
15
15
  import { parseSchema } from '../schema.js';
16
- import { call, discover, handlersOf, minorOf, money, mustPay, originOf, sandboxPayment } from '../ucp.js';
16
+ import { call, cannotPay, discover, SANDBOX_HANDLER, handlersOf, minorOf, money, mustPay, originOf, sandboxPayment } from '../ucp.js';
17
17
  const ADDRESS = [
18
18
  { name: 'street', type: 'string', description: 'Street and number: Via Roma 1.' },
19
19
  { name: 'line_2', type: 'string', description: 'Apartment, floor, care of.' },
@@ -151,7 +151,7 @@ function profile(store) {
151
151
  signed: store.keys.length > 0,
152
152
  }, {
153
153
  next: [{ command: cmd(store, 'search', '--query WORDS'), why: 'what the store sells' }],
154
- warnings: can_pay ? [] : [{ code: 'cannot_pay_here', message: `This CLI pays only with the sandbox test handler; ${store.host} ${handlers.length ? `takes ${handlers.join(', ')}` : 'takes no payment over UCP'}. The human buys there in person.` }],
154
+ warnings: can_pay ? [] : [{ code: 'cannot_pay_here', message: cannotPay(store).message }],
155
155
  });
156
156
  }
157
157
  // ---------- reading
@@ -352,7 +352,31 @@ function checkoutView(ck, store) {
352
352
  expires_at: ck.expires_at,
353
353
  });
354
354
  }
355
+ const hasCode = (ck, code) => (ck.messages ?? []).some((x) => x.code === code);
356
+ /**
357
+ * Why this checkout takes no payment from this CLI: the store's payment_unavailable (its market
358
+ * takes no payment from agents), or its own handler list, the authority over the profile's union,
359
+ * without the sandbox handler.
360
+ */
361
+ function unpayable(ck, store) {
362
+ const m = (ck.messages ?? []).find((x) => x.code === 'payment_unavailable');
363
+ if (m)
364
+ return `${store.host} can't take a payment from agents for checkout ${ck.id}${m.content ? `: ${m.content}` : '.'} The human buys on the shop's site.`;
365
+ const own = ck.ucp?.payment_handlers;
366
+ if (own && typeof own === 'object' && !Object.keys(own).includes(SANDBOX_HANDLER)) {
367
+ return cannotPay(store, { id: ck.id, handlers: Object.keys(own) })?.message ?? null;
368
+ }
369
+ return null;
370
+ }
371
+ /** The store took the payment and Commerce Layer would not place the order: nothing is paid again until the human has reviewed the checkout. */
372
+ function notPlaced(store, ck) {
373
+ const m = (ck.messages ?? []).find((x) => x.code === 'order_not_placed');
374
+ fail('order_not_placed', `${store.host} took the payment for checkout ${ck.id}, but could not place the order${m?.content ? `: ${m.content}` : '.'} Don't pay again until the human has reviewed the checkout, at its page.`, { details: { status: ck.status, messages: ck.messages ?? [] }, next_step: { action: 'human_step', url: ck.continue_url ?? store.origin } }, 'rejected');
375
+ }
355
376
  function nextOf(ck, store) {
377
+ // Nothing to pay here, or paid already with the order not placed: no command offers to pay.
378
+ if (unpayable(ck, store) || hasCode(ck, 'order_not_placed'))
379
+ return [];
356
380
  if (ck.status === 'ready_for_complete')
357
381
  return [{ command: cmd(store, 'order', ck.id), why: `pay ${ck.totals ? money(ck.totals.find((t) => t.type === 'total')?.amount ?? 0, ck.currency).display : 'the total'} with the sandbox test handler, after the human's yes on it` }];
358
382
  if (ck.status === 'completed' && ck.order?.id)
@@ -368,6 +392,9 @@ async function checkout(run, store) {
368
392
  const lines = items(v.item);
369
393
  const { buyer, address } = given(v);
370
394
  const current = id ? (await call(store, 'GET', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}`, undefined, `checkout ${id}`)).body : null;
395
+ // A payment under way takes no changes (the store answers not_allowed): read it again with order.
396
+ if (current?.status === 'complete_in_progress' && (lines.length || Object.keys(buyer).length || Object.keys(address).length || v.shipping))
397
+ inProgress(store, id);
371
398
  if (current && (current.status === 'completed' || current.status === 'canceled')) {
372
399
  if (lines.length || Object.keys(buyer).length || Object.keys(address).length || v.shipping) {
373
400
  fail('invalid_transition', `Checkout ${id} is ${current.status}: it takes no changes.`, { details: { status: current.status }, next_step: current.status === 'completed' ? { action: 'fix_request', command: cmd(store, 'order', id) } : { action: 'fix_request', command: cmd(store, 'checkout', `--item VARIANT=QTY ${HUMAN_FLAGS}`) } }, 'rejected');
@@ -450,7 +477,8 @@ async function checkout(run, store) {
450
477
  c = await call(store, 'PUT', `${store.endpoint}/checkout-sessions/${encodeURIComponent(c.body.id)}`, again, `checkout ${c.body.id}`);
451
478
  }
452
479
  const ck = c.body;
453
- return answer(checkoutView(ck, store), c, { next: nextOf(ck, store) });
480
+ const why = unpayable(ck, store);
481
+ return answer(checkoutView(ck, store), c, { next: nextOf(ck, store), warnings: why ? [{ code: 'cannot_pay_here', message: why }] : [] });
454
482
  }
455
483
  // ---------- order
456
484
  function orderView(o, store, checkoutId) {
@@ -482,7 +510,16 @@ async function order(run, store) {
482
510
  return placed(store, ck, false);
483
511
  if (ck.status === 'canceled')
484
512
  fail('invalid_transition', `Checkout ${id} was canceled: it cannot be paid.`, { details: { status: ck.status }, next_step: { action: 'fix_request', command: cmd(store, 'checkout', `--item VARIANT=QTY ${HUMAN_FLAGS}`) } }, 'rejected');
513
+ // A payment under way is read again, never completed a second time.
514
+ if (ck.status === 'complete_in_progress')
515
+ inProgress(store, id);
516
+ // Paid, and the order not placed: not paid again until the human's review (an update) clears the store's note.
517
+ if (hasCode(ck, 'order_not_placed'))
518
+ notPlaced(store, ck);
485
519
  mustPay(store, { continue_url: ck.continue_url ?? null });
520
+ const why = unpayable(ck, store);
521
+ if (why)
522
+ fail('payment_handler_unsupported', why, { details: { status: ck.status, messages: ck.messages ?? [] }, next_step: { action: 'human_step', url: ck.continue_url ?? store.origin } }, 'rejected');
486
523
  if (ck.status !== 'ready_for_complete') {
487
524
  const said = (ck.messages ?? []).map((m) => m.content).filter(Boolean).join(' ');
488
525
  fail('invalid_transition', `Checkout ${id} is ${ck.status}, not ready to pay${said ? `: ${said}` : '.'}`, { details: { status: ck.status, messages: ck.messages ?? [] }, next_step: stillNeeds(ck, store) ? { action: 'fix_request', command: stillNeeds(ck, store) } : { action: 'human_step', url: ck.continue_url ?? null } }, 'rejected');
@@ -495,10 +532,18 @@ async function order(run, store) {
495
532
  await confirm(`Pay checkout ${id}`, run.line, { yes: run.globals.yes, prompter: run.prompter, sentence, details: { store: store.host, checkout: id, total } });
496
533
  const c = await call(store, 'POST', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}/complete`, sandboxBody(), `checkout ${id}`);
497
534
  const done = c.body;
535
+ if (done.status === 'complete_in_progress')
536
+ inProgress(store, id);
537
+ if (hasCode(done, 'order_not_placed'))
538
+ notPlaced(store, done);
498
539
  if (done.status !== 'completed' || !done.order?.id) {
499
540
  const said = (done.messages ?? []).map((m) => m.content).filter(Boolean).join(' ');
500
541
  fail('store_refused', `${store.host} did not take the payment for checkout ${id}${said ? `: ${said}` : '.'}`, { details: { status: done.status, messages: done.messages ?? [] }, next_step: { action: 'human_step', url: done.continue_url ?? null } }, 'rejected');
501
542
  }
502
543
  return placed(store, done, true);
503
544
  }
545
+ /** The store took the payment and has not finished it: the agent reads it again, and pays nothing more. */
546
+ function inProgress(store, id) {
547
+ fail('payment_in_progress', `${store.host} is still taking the payment for checkout ${id}: it is not confirmed yet. Run the same command again in a moment: it reads the checkout first and never pays twice. Until it answers the order, don't tell the human it is paid.`, { details: { status: 'complete_in_progress' }, next_step: { action: 'retry_later', command: cmd(store, 'order', id) } });
548
+ }
504
549
  const sandboxBody = () => ({ ...sandboxPayment(), signals: { 'dev.ucp.user_agent': 'c11r' } });
package/dist/config.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // A project folder links to its organization with .c11r.json, { "org": "<slug>" }: no secret,
7
7
  // safe to commit, found by walking up from the current folder like .git. The project folder is
8
8
  // the one with .c11r.json, else the nearest with store.yml or loops.yml, else the current one.
9
+ // A link found above is the folder's for every command but a create: sandbox create and
10
+ // organizations create link the current folder, and leave the link above as it is.
9
11
  //
10
12
  // The organization: --org, then C11R_ORG, then .c11r.json, then the stored one when exactly one
11
13
  // is stored. With several stored and nothing naming one, the command stops and names them.
@@ -191,15 +193,23 @@ export function readLink() {
191
193
  const org = raw?.org;
192
194
  if (typeof org !== 'string' || !org)
193
195
  fail('invalid_config', `${file} has no org.`, { path: `${LINK_FILE}.org` });
194
- return { file, org };
196
+ return { file, org, here: dir === path.resolve(process.cwd()) };
195
197
  }
196
- /** Links the project folder to the organization; returns the file and the org it linked before. */
197
- export function writeLink(org) {
198
- const existing = readLink();
198
+ /**
199
+ * Links the project folder to the organization; returns the file and the org it linked before.
200
+ * With here, the current folder, whatever a folder above links: a create makes a new project.
201
+ */
202
+ export function writeLink(org, opts = {}) {
203
+ const found = readLink();
204
+ const existing = opts.here && !found?.here ? null : found;
199
205
  const file = existing?.file ?? path.join(process.cwd(), LINK_FILE);
200
206
  fs.writeFileSync(file, JSON.stringify({ org }, null, 2) + '\n');
201
207
  return { file, previous: existing && existing.org !== org ? existing.org : null };
202
208
  }
209
+ /** The warning of a create that linked this folder and left the link of a folder above as it is. */
210
+ export function parentLinkKept(above) {
211
+ return { code: 'parent_link_kept', message: `The folder above links ${above.org} (${above.file}); it stays as it is, and this folder now has its own link. Tell the human only that the shop in the folder above stays as it is.` };
212
+ }
203
213
  /** Removes the link, only when it names this organization. */
204
214
  export function removeLink(org) {
205
215
  const link = readLink();
package/dist/engine.js CHANGED
@@ -132,8 +132,10 @@ async function executeOnce(argv, opts) {
132
132
  // amount and the mode ("Refunds EUR 1.00 on order N, in LIVE mode, real money."): the CLI sends
133
133
  // without confirm=true and shows that 428 as it is, or asks it in a terminal. --yes sends
134
134
  // confirm=true on every live write, payments capture included.
135
- // So does a login verb (organizations disconnect), whose sentence names what stops answering.
136
- const apiAsks = (live || verb.credential === 'login') && (verb.confirm || confirmFlags.length > 0);
135
+ // So does a login verb (organizations disconnect), whose sentence names what stops answering,
136
+ // and one the schema marks confirm_when: the API asks only in that state of the target
137
+ // (connect on an organization c11r does not know), with a sentence naming the access given.
138
+ const apiAsks = (live || verb.credential === 'login') && (verb.confirm || confirmFlags.length > 0 || verb.confirmWhen.length > 0);
137
139
  if (apiAsks && cmd.globals.yes && !cmd.globals.dryRun)
138
140
  confirmed = true;
139
141
  if (live && cmd.globals.yes && verb.method !== 'GET')
package/dist/errors.js CHANGED
@@ -66,6 +66,10 @@ const KIND = {
66
66
  store_unavailable: 'unavailable',
67
67
  store_refused: 'rejected',
68
68
  payment_handler_unsupported: 'rejected',
69
+ // The store took the payment and has not confirmed it: read again, never paid twice.
70
+ payment_in_progress: 'unavailable',
71
+ // Paid at the store, and the order not placed: the human reviews it; never paid again.
72
+ order_not_placed: 'rejected',
69
73
  // The site that publishes the skills (c11r skills sync).
70
74
  site_unavailable: 'unavailable',
71
75
  };
package/dist/schema.js CHANGED
@@ -63,6 +63,7 @@ export function parseSchema(raw, expected) {
63
63
  secret: Boolean(v.secret),
64
64
  idAlternative: Array.isArray(v.id_alternative) ? v.id_alternative.filter((x) => typeof x === 'string' && flags[x]) : [],
65
65
  confirmIf: Array.isArray(v.confirm_if) ? v.confirm_if.filter((x) => typeof x === 'string') : [],
66
+ confirmWhen: Array.isArray(v.confirm_when) ? v.confirm_when.filter((x) => typeof x === 'string') : [],
66
67
  refused: v.served === false ? { by: v.refused_by ?? 'the API' } : null,
67
68
  path: v.path ? v.path.replace(/^\/v\d+(?=\/)/, '') : null,
68
69
  flags,
package/dist/ucp.js CHANGED
@@ -55,20 +55,41 @@ export async function discover(origin) {
55
55
  capabilities.add(name);
56
56
  return { origin, host, profile, endpoint, commonEndpoint: rest('dev.ucp.common'), capabilities, keys: Array.isArray(profile.keys) ? profile.keys : [] };
57
57
  }
58
+ /** Stripe's UCP handler: agents pay with a Stripe wallet token (spt_), which this CLI does not hold. */
59
+ export const STRIPE_HANDLER = 'com.stripe.payments';
58
60
  /** The payment handlers the store offers, by name, and whether this CLI can pay there. */
59
61
  export function handlersOf(store) {
60
62
  const handlers = Object.keys(store.profile.ucp?.payment_handlers ?? {});
61
63
  return { handlers, can_pay: handlers.includes(SANDBOX_HANDLER) && store.capabilities.has('dev.ucp.shopping.checkout') };
62
64
  }
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
+ /** Why this CLI cannot pay at the store, in words for the human; null when it can. */
66
+ export function cannotPay(store, checkout) {
67
+ const handlers = checkout?.handlers ?? handlersOf(store).handlers;
68
+ if (handlers.includes(SANDBOX_HANDLER) && store.capabilities.has('dev.ucp.shopping.checkout'))
69
+ return null;
70
+ // A checkout's own list, the authority over the profile's union: named, so the human knows which.
71
+ const who = checkout ? `${store.host}, for checkout ${checkout.id},` : store.host;
72
+ const elsewhere = `The human buys on the shop's site.`;
65
73
  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 } });
74
+ return { code: 'not_supported', message: `${who} doesn't take orders from agents yet: an agent can show its products, not buy them. ${elsewhere}` };
67
75
  }
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');
76
+ if (!handlers.length)
77
+ return { code: 'payment_handler_unsupported', message: `${who} takes no payment from agents yet. ${elsewhere}` };
78
+ const only = 'This CLI pays only in test shops, with the sandbox test handler, where no money moves.';
79
+ if (handlers.includes(STRIPE_HANDLER)) {
80
+ return { code: 'payment_handler_unsupported', message: `${who} takes agent payments through Stripe, from agents Stripe has enrolled, and this CLI is not one of them yet. ${only} ${elsewhere}` };
71
81
  }
82
+ return { code: 'payment_handler_unsupported', message: `${who} takes ${handlers.join(', ')}, which this CLI can't pay with. ${only} ${elsewhere}` };
83
+ }
84
+ /** Before anything of the human's is sent: a store this CLI cannot pay at is refused. */
85
+ export function mustPay(store, extra = {}) {
86
+ const why = cannotPay(store);
87
+ if (!why)
88
+ return;
89
+ const { handlers } = handlersOf(store);
90
+ if (why.code === 'not_supported')
91
+ fail('not_supported', why.message, { details: { handlers }, next_step: { action: 'human_step', url: store.origin } });
92
+ fail(why.code, why.message, { details: { handlers }, next_step: { action: 'human_step', url: extra.continue_url ?? store.origin } }, 'rejected');
72
93
  }
73
94
  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
95
  /** A UCP call. The answer's digest and signature are checked; an error is a CliError. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "1.0.0-dev.21",
3
+ "version": "1.0.0-dev.23",
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": [