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

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
@@ -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
@@ -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: (() => {
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/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/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.22",
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": [