@commercelayer/agent-cli 1.0.0-dev.13 → 1.0.0-dev.15

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
@@ -34,8 +34,9 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
34
34
  | `c11r loops run <name>` | One pass of a loop. The CLI is not a daemon: a scheduler repeats the pass. |
35
35
  | `c11r report sales [--from] [--to] [--by] [--interval] [--market] [--top]` | Sales in a window, from the Metrics API. A plain noun: its flags come from the API's schema. |
36
36
  | `c11r auth login [--client-id] [--secret-file] [--keep-file]` | The human's own Commerce Layer login, from their Provisioning API application. See below. |
37
- | `c11r auth logout`, `auth switch <slug> [--global]` | Forgetting the login; the organization commands act on. |
37
+ | `c11r auth logout [--live --org <slug>]`, `auth switch <slug> [--global]` | Forgetting the login, or with `--live` revoking the API's live credential for an organization; the organization commands act on. |
38
38
  | `c11r shop profile\|search\|get\|ask <store>` | Reading a store that speaks UCP, as a shopping agent: what it offers, its catalog, a variant, a question. No organization. See below. |
39
+ | `c11r skills sync` | Local copies of the Commerce Layer skills in `.claude/skills`, from the index at commercelayer.ai, listed in `AGENTS.md`. No organization. See below. |
39
40
  | `c11r shop checkout <store> [<checkout>]`, `shop order <store> <checkout>` | Buying there for the human: their details after their yes on what is shared, then the payment after their yes on the total, with the sandbox test handler only. |
40
41
 
41
42
  A loop runs a skill a Manage or Report noun's schema names in `loop.skill`, or in `loop.skills` for a noun with several (`manage-orders`, `manage-stock`, `report-sales`, `report-pending`, ...); a report loop allows no verb and only reports. create and update refuse any other skill, and any verb that loop does not list, with the allowed values. A loop policy allows verbs: a bare verb is the skill's own noun (`approve`), `<noun>.<verb>` another Manage noun's verb on the same item (`payments.capture`). Verbs that delete or return money are never open to a policy.
@@ -64,11 +65,25 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
64
65
  | `--dry-run` | On a write, the CLI sends it with `?dry_run=true`: the API checks it all (state, plan, limits, Commerce Layer's current status) and answers what it would do, `would: [...]`, and nothing is written. No confirmation is needed. Reads ignore it. |
65
66
  | `--yes` | Confirms a destructive verb in advance. |
66
67
  | `--org <slug>` | The organization to act on, by its stored token. |
67
- | `--live` | Live data, on a claimed organization: `?mode=live` on every request. Without it, test mode. |
68
+ | `--live` | Live data, on a claimed organization named with `--org` (or `C11R_ORG`), with the human's login: `?mode=live&org=` on every request. Without it, test mode. See below. |
68
69
  | `--help` | Describes the noun or verb it follows, from its schema. |
69
70
 
70
71
  Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sandbox reset` and `destroy`), and any verb the schema marks `confirm`, ask for confirmation in a terminal, then send `confirm=true`. With no terminal, as when an agent or a schedule runs the CLI, they never wait: without `--yes` they stop at once with `confirmation_required` and exit code 3. Its `next_step.command` is the same command without `--yes`: the agent asks the human first, and only on their yes runs it again with `--yes`. The API's own `confirmation_required` works the same way.
71
72
 
73
+ ## Live mode
74
+
75
+ `--live` acts on a claimed organization's live data. It always sends the human's login, never an organization token, and names the organization with `?org=`: from `--org`, or `C11R_ORG`, never from the folder's link or the default, which are a sandbox's. Without one it is `missing_value` before any call, with the command and `--org ORG_SLUG --live`.
76
+
77
+ - Without a login, nothing is checked on this machine: the request goes without a token, and the API's answer (`login_required`, or `state_required` for an organization not claimed) comes through as it is. So do `live_unavailable` (a plan without live), `forbidden` (the login is not an active admin of it) and `not_supported` for a verb that comes with a later step of go-live.
78
+ - A verb that never runs live (sandbox, plans, sales calls, `auth login`, `auth switch`, `shop`) stops on this machine with `state_required`.
79
+ - `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 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.
81
+ - A destructive verb's confirmation names the mode and the organization: `Delete markets eu, in LIVE mode on S`. When the API supplies its own sentence (money, in a later step), that one is shown.
82
+ - 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.
83
+ - `store.yml`, `.c11r/export.json` and the drift notes are of an organization in a mode: a live write marks a live `store.yml` behind, never a test one, and the other way round.
84
+ - `c11r organizations disconnect <slug>` (confirmed) has the API revoke its credentials and the organization tokens; this machine then forgets the token, its secret files and a `.c11r.json` naming it, as `sandbox destroy` does, and answers `forgotten`.
85
+ - A secret read from a file (`--credentials-file`) is never printed: an answer or an error that holds it, a dry run's included, shows `[secret]` in its place. No live key is stored on this machine.
86
+
72
87
  ## Which organization
73
88
 
74
89
  `--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.
@@ -79,7 +94,7 @@ Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sand
79
94
 
80
95
  ## The store's setup in files
81
96
 
82
- `c11r config export` writes the organization's setup into the project folder: `store.yml` (the Configure nouns, refs by key, no ids, no secrets; its first key, `meta`, names the organization and the time) and `price_lists/<code>.csv` (in a row, an empty `compare_at_amount` means equal to `amount`, and one below `amount` is refused). It records what it wrote in `.c11r/export.json`, and never overwrites a file changed since the last export (or one no export wrote) without `--yes`: the answer lists them. A price list file the organization no longer has, unchanged since, goes. `c11r config plan` sends the files and answers what `config apply` would change; `apply` makes the organization match them, and with `--prune` (confirmed) also deletes what the files lack. While imports run, `apply` answers `status: running` with each noun's state, `retry_after` and the run's token, `run`; `--wait` applies again with that token and the same files until done or failed, and the done answer counts everything the run wrote. The `next` an unfinished apply suggests carries `--run <token> --wait`. `plan` lists in `needs` the new records the files can't make alone (a webhook without its `callback_url`); `apply` skips them and warns `needs_input` with the command that creates each.
97
+ `c11r config export` writes the organization's setup into the project folder: `store.yml` (the Configure nouns, refs by key, no ids, no secrets; its first key, `meta`, names the organization, the mode (`test`, or `live` with `--live`) and the time) and `price_lists/<code>.csv` (in a row, an empty `compare_at_amount` means equal to `amount`, and one below `amount` is refused). It records what it wrote in `.c11r/export.json`, and never overwrites a file another organization or mode exported, nor a file changed since the last export (or one no export wrote) without `--yes`: the answer lists them. A price list file the organization no longer has, unchanged since, goes. `c11r config plan` sends the files and answers what `config apply` would change; `apply` makes the organization match them, and with `--prune` (confirmed) also deletes what the files lack. While imports run, `apply` answers `status: running` with each noun's state, `retry_after` and the run's token, `run`; `--wait` applies again with that token and the same files until done or failed, and the done answer counts everything the run wrote. The `next` an unfinished apply suggests carries `--run <token> --wait`. `plan` lists in `needs` the new records the files can't make alone (a webhook without its `callback_url`); `apply` skips them and warns `needs_input` with the command that creates each.
83
98
 
84
99
  Records that share a key (two shipping methods with the same name in one market, say) can't be matched to the files. `export` and `plan` list them in `ambiguous` (an empty list when there are none), each with the command that makes its key differ, and `export` puts the first one at the top of `next`. `apply` refuses with `conflict` and the same list in `error.details.ambiguous`, before it writes anything.
85
100
 
@@ -141,6 +156,16 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
141
156
  - 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.
142
157
  - With `--dry-run`, checkout answers what it would send and order the total it would pay: nothing is sent.
143
158
 
159
+ ## Skills
160
+
161
+ `c11r skills sync` keeps copies of the skills in the project folder, where coding agents load them: `.claude/skills/<name>/SKILL.md`, one per skill as https://commercelayer.ai serves it, and in `AGENTS.md` a block between `<!-- c11r skills: start -->` and `<!-- c11r skills: end -->` listing them, for agents that read that file. Nothing outside the markers changes; without an `AGENTS.md`, sync creates one holding only the block, and says so with `agents_md: "created"`. The project folder is the one already holding the copies, else the one with `.c11r.json`, `store.yml` or `loops.yml`, else the git repository's root, else the current folder. It needs no organization and calls only commercelayer.ai, not the API.
162
+
163
+ - It reads the index (`start.md`) with the ETag of the last sync, and fetches only the skills whose version changed. A skill the index dropped is removed. `.claude/skills/.c11r-skills.json` records what sync wrote, with each file's sha256; a folder sync did not write is never touched.
164
+ - Every copy's sha256 is checked on each run, also when nothing new is in the index. A copy that is not what sync wrote is an edit: it is kept, neither replaced nor removed, and listed in `edited` with a `skill_edited` warning. `--yes`, on the human's yes, replaces or removes it.
165
+ - `--except <skill>` (repeated) leaves a skill out, and later syncs remember it; `--include <skill>` takes it back.
166
+ - A file whose name or version is not what the index says is not used (`skill_invalid`); a link off `commercelayer.ai/skills/` is skipped (`skill_skipped`).
167
+ - The answer has `added`, `updated` (from and to), `removed`, `edited` and how many are `unchanged`. With `--dry-run` it reads only the index and writes nothing. When the site cannot be reached it is `site_unavailable` (exit 7), and the copies stay as they are.
168
+
144
169
  ## Environment
145
170
 
146
171
  | Variable | What it does |
@@ -152,5 +177,6 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
152
177
  | `C11R_AUTH_URL` | Commerce Layer's token endpoint, `https://auth.commercelayer.io/oauth/token` by default: for tests. |
153
178
  | `C11R_TEST` | `1` marks a sandbox as a test organization, like a name starting `zztest`. |
154
179
  | `C11R_SCHEMA_CACHE` | `0` turns off the 10 minute schema cache in `~/.cache/c11r`. The cache records the `C11R-Schema` hash it was read under; an answer with another hash drops it, and a command the API refused on the old schema runs once more on the fresh one (without the header, after `confirmation_required`, `unknown_field` or `unknown_verb`). The second run asks what the fresh schema asks: it never adds `confirm=true` by itself. |
180
+ | `C11R_SKILLS_URL` | The skills index, for a test site on localhost only: `https://commercelayer.ai/start.md` otherwise. |
155
181
  | `C11R_RETRY_CAP_S` | The longest the CLI waits before one retry, 30 by default. |
156
182
  | `C11R_TIMEOUT_MS` | How long a request may take, 30000 by default (sandbox create waits longer). |
@@ -30,6 +30,7 @@ export const auth = {
30
30
  path: '/v1/auth/login',
31
31
  state: 'none',
32
32
  credential: 'none',
33
+ live: false,
33
34
  body: [
34
35
  { name: 'client_id', type: 'string', description: 'The client id of the Provisioning API application. Not a secret. Also read from the secret file.' },
35
36
  { name: 'secret_file', type: 'string', description: `A file with the client secret, alone or as client_secret=..., readable only by its owner (mode 600). By default ${'~/.config/c11r/provisioning.secret'} when there is no terminal.` },
@@ -37,13 +38,21 @@ export const auth = {
37
38
  ],
38
39
  effect: 'Logs in with the human\'s Provisioning API application: mints a token at Commerce Layer, checks it with the API, keeps the client id and secret in credentials.json (mode 600). The API receives only the token.',
39
40
  },
40
- { verb: 'logout', method: 'POST', path: '/v1/auth/logout', state: 'none', credential: 'none', effect: 'Forgets the stored login on this machine. Its last token ends by itself within two hours. Organization tokens stay.' },
41
+ {
42
+ verb: 'logout',
43
+ method: 'POST',
44
+ path: '/v1/auth/logout',
45
+ state: 'none',
46
+ credential: 'none',
47
+ effect: 'Forgets the stored login on this machine. Its last token ends by itself within two hours. Organization tokens stay. With --live --org, instead revokes the API\'s own live credential for that organization in Commerce Layer, with the login: the login stays on this machine.',
48
+ },
41
49
  {
42
50
  verb: 'switch',
43
51
  method: 'POST',
44
52
  path: '/v1/auth/switch/{slug}',
45
53
  state: 'none',
46
54
  credential: 'none',
55
+ live: false,
47
56
  body: [{ name: 'global', type: 'boolean', description: 'Sets the default for this machine, used when no --org, C11R_ORG or .c11r.json names one; without it, links this project folder.' }],
48
57
  effect: 'Picks the organization commands act on: links this project folder to it (.c11r.json), or with --global sets the default for this machine.',
49
58
  },
@@ -54,7 +63,7 @@ export const auth = {
54
63
  case 'login':
55
64
  return login(run);
56
65
  case 'logout':
57
- return logout();
66
+ return run.globals.live ? logoutLive(run) : logout();
58
67
  case 'switch':
59
68
  return switchOrg(run);
60
69
  }
@@ -132,6 +141,12 @@ async function login(run) {
132
141
  next: who.next?.length ? who.next : [{ command: 'c11r organizations list', why: 'The organizations this login reaches.' }],
133
142
  };
134
143
  }
144
+ /** The API's live credential for the organization, revoked in Commerce Layer: DELETE /v1/auth/live?org=. */
145
+ async function logoutLive(run) {
146
+ // The path names live already: the request carries ?org= and not ?mode=live.
147
+ run.ctx.live = false;
148
+ return run.send({ method: 'DELETE', path: '/auth/live' });
149
+ }
135
150
  function logout() {
136
151
  const l = storedLogin();
137
152
  if (!l)
@@ -11,15 +11,18 @@ 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, hintOf, projectDir, readLink, storeBooking, storedBooking, storeOrg, writeLink } from '../config.js';
14
+ import { credentialsFile, forgetOrg, forgetOutWritten, hintOf, projectDir, readLink, removeLink, storeBooking, storedBooking, storedOrgs, storeOrg, writeLink } from '../config.js';
15
+ import { OUT_NOT_TRACKED, removeOrgSecrets } from '../secrets.js';
15
16
  import { confirm, terminal } from '../confirm.js';
16
17
  import { fail } from '../errors.js';
17
18
  import { apiUrl } from '../http.js';
18
- import { clearDrift, driftWarning, readDrift, readStoreFiles, storeFileOrg, writeStoreFiles } from '../project.js';
19
+ import { clearDrift, driftWarning, readDrift, readStoreFiles, storeFileIs, writeStoreFiles } from '../project.js';
19
20
  import { resourceRequest } from '../resources.js';
20
21
  const NAME_MAX = 25;
21
22
  export const organizations = {
22
23
  async run(run) {
24
+ if (run.verb.name === 'disconnect')
25
+ return disconnect(run);
23
26
  if (run.verb.name !== 'create' && run.verb.name !== 'connect')
24
27
  return undefined;
25
28
  const name = run.parsed.values.name;
@@ -73,6 +76,23 @@ export const organizations = {
73
76
  return { ...r, data: { ...data, org, token_hint: hint, credentials: credentialsFile(), linked }, warnings };
74
77
  },
75
78
  };
79
+ /**
80
+ * organizations disconnect: the API revokes its own credentials and the organization tokens, so
81
+ * this machine forgets them too, as sandbox destroy does: the token, its secret files, a link
82
+ * naming it.
83
+ */
84
+ async function disconnect(run) {
85
+ const r = await run.send(resourceRequest('organizations', run.verb, run.parsed.id, run.parsed.values));
86
+ if (run.globals.dryRun || r.data?.disconnected !== true)
87
+ return r;
88
+ const org = r.data.org ?? run.parsed.id;
89
+ const out = forgetOutWritten(org);
90
+ const credentials = Boolean(storedOrgs()[org]);
91
+ forgetOrg(org);
92
+ const link = removeLink(org);
93
+ const secrets = removeOrgSecrets(org);
94
+ return { ...r, data: { ...r.data, forgotten: { credentials, link, secrets } }, warnings: [...r.warnings, ...(out ? [OUT_NOT_TRACKED] : [])] };
95
+ }
76
96
  export const salesCalls = {
77
97
  positional: { book: 'slot' },
78
98
  prepare(verb, parsed) {
@@ -146,7 +166,7 @@ export const config = {
146
166
  const data = r.data ?? {};
147
167
  if (!data.store || typeof data.store !== 'object')
148
168
  fail('invalid_response', 'config export answered without store.', { next_step: { action: 'none' } });
149
- const out = writeStoreFiles({ store: data.store, price_lists: data.price_lists ?? {}, meta: { org: data.meta?.org ?? run.org.org ?? undefined, exported_at: data.meta?.exported_at } }, { overwrite: run.globals.yes });
169
+ const out = writeStoreFiles({ store: data.store, price_lists: data.price_lists ?? {}, meta: { org: data.meta?.org ?? run.org.org ?? undefined, exported_at: data.meta?.exported_at } }, { overwrite: run.globals.yes, mode: run.globals.live ? 'live' : 'test' });
150
170
  const ambiguous = Array.isArray(data.ambiguous) ? data.ambiguous : [];
151
171
  const counts = Object.fromEntries(Object.entries(data.store).map(([k, v]) => [k, Array.isArray(v) ? v.length : 0]));
152
172
  return {
@@ -183,12 +203,12 @@ export const config = {
183
203
  r = await run.send(resourceRequest('config', run.verb, undefined, values));
184
204
  }
185
205
  const warnings = [...r.warnings];
186
- const here = storeFileOrg();
206
+ const here = storeFileIs(run.org.org, run.globals.live ? 'live' : 'test');
187
207
  const drift = readDrift();
188
- if (name === 'plan' && drift.length && here === run.org.org)
208
+ if (name === 'plan' && drift.length && here)
189
209
  warnings.push(driftWarning(drift));
190
210
  // The organization now matches the files: the folder is no longer behind.
191
- if (name === 'apply' && !run.globals.dryRun && r.data?.status === 'done' && here === run.org.org)
211
+ if (name === 'apply' && !run.globals.dryRun && r.data?.status === 'done' && here)
192
212
  clearDrift();
193
213
  const data = r.data ?? {};
194
214
  const running = name === 'apply' && data.status === 'running';
@@ -1,9 +1,10 @@
1
1
  // The nouns with code of their own. Each brings a schema document in the API's format, so help,
2
2
  // validation and confirmation are the engine's. report is a plain noun: the API's schema drives
3
- // it. skills (c11r skills sync) comes with deliverable 4.
3
+ // it.
4
4
  import { auth } from './auth.js';
5
5
  import { loops } from './loops.js';
6
6
  import { sandbox, status } from './sandbox.js';
7
7
  import { shop } from './shop.js';
8
- export const builtins = { sandbox, status, loops, auth, shop };
8
+ import { skills } from './skills.js';
9
+ export const builtins = { sandbox, status, loops, auth, shop, skills };
9
10
  export { extensions } from './extensions.js';
@@ -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/engine.js CHANGED
@@ -81,13 +81,29 @@ async function executeOnce(argv, opts) {
81
81
  const line = ['c11r', ...argv.filter((a) => a !== '--yes' && a !== '-y')].map(quote).join(' ');
82
82
  const org = resolveOrg(cmd.globals.org);
83
83
  const login = hasLogin();
84
+ const live = cmd.globals.live;
85
+ // The command as typed: c11r status, not c11r status get, when the verb was left out.
86
+ const named = `c11r ${schema.noun}${cmd.verb ? ` ${verb.name}` : ''}`;
87
+ if (live && !verb.live) {
88
+ fail('state_required', `${named} does not run in live mode.`, { required_state: 'claimed', path: 'live', next_step: fixWith(line.replace(/\s--live\b/, '')) });
89
+ }
90
+ // Live mode acts on an organization named on the command, never the folder's link or the
91
+ // default, which are a sandbox's: --org, or C11R_ORG.
92
+ const liveOrg = live ? (cmd.globals.org ?? (process.env.C11R_ORG || null)) : null;
93
+ if (live && !liveOrg) {
94
+ fail('missing_value', `${named} --live acts on a claimed organization, named with --org.`, { path: 'org', next_step: fixWith(`${line.replace(/\s--live\b/, '')} --org ORG_SLUG --live`) });
95
+ }
84
96
  // The bearer, from the schema (contract section 23). An organization token for the organization
85
97
  // nouns, the login for the Provisioning ones; credentials take the login when there is one and
86
98
  // the organization token is not a sandbox's.
87
- let bearer = verb.credential === 'login' ? 'login' : verb.credential === 'organization|login' ? (login && !org.token?.startsWith('sbx_') ? 'login' : 'organization') : verb.credential === 'none' ? 'none' : 'organization';
99
+ // In live mode, always the login, never an organization token: the API answers login_required
100
+ // to anything else, and to no token at all.
101
+ let bearer = live
102
+ ? 'login'
103
+ : verb.credential === 'login' ? 'login' : verb.credential === 'organization|login' ? (login && !org.token?.startsWith('sbx_') ? 'login' : 'organization') : verb.credential === 'none' ? 'none' : 'organization';
88
104
  // members and roles with an organization token and no login: the API says what is needed (a
89
105
  // claim for a sandbox, a login for a claimed organization). organizations and auth stop here.
90
- if (bearer === 'login' && !login && org.token && schema.noun !== 'organizations' && schema.noun !== 'auth')
106
+ if (!live && bearer === 'login' && !login && org.token && schema.noun !== 'organizations' && schema.noun !== 'auth')
91
107
  bearer = 'organization';
92
108
  // A verb that needs an organization token: say which is missing before the API does.
93
109
  if (bearer === 'organization' && verb.state !== 'none' && !org.token) {
@@ -100,20 +116,23 @@ async function executeOnce(argv, opts) {
100
116
  // The login names its organization with ?org=, from --org, C11R_ORG, .c11r.json or the default:
101
117
  // never a guess from the only token stored, which may be a sandbox the login does not reach.
102
118
  const loginOrg = org.source && org.source !== 'only_stored' ? org.org : null;
103
- if (cmd.globals.live && !verb.live) {
104
- fail('state_required', `c11r ${schema.noun} ${verb.name} does not run in live mode.`, { required_state: 'claimed', path: 'live', next_step: fixWith(line.replace(/\s--live\b/, '')) });
105
- }
106
119
  let confirmed = false;
107
120
  const confirmFlags = verb.confirmIf.filter((k) => parsed.values[k] !== undefined && parsed.values[k] !== false);
108
121
  if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun) {
109
122
  const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
110
- await confirm(what.charAt(0).toUpperCase() + what.slice(1), line, { yes: cmd.globals.yes, prompter: opts.prompter });
123
+ // In live mode the sentence says so, and on which organization.
124
+ const sentence = `${what.charAt(0).toUpperCase() + what.slice(1)}${live ? `, in LIVE mode on ${liveOrg}` : ''}`;
125
+ await confirm(sentence, line, { yes: cmd.globals.yes, prompter: opts.prompter });
111
126
  confirmed = true;
112
127
  }
128
+ // A live secret goes only to a file the human chose: never the default folder, never printed.
129
+ if (live && verb.secret && parsed.values.out === undefined) {
130
+ fail('missing_value', `${named} --live answers a live secret: it is written only to a file the human chooses, with --out.`, { path: 'out', next_step: fixWith(`${line} --out FILE`) });
131
+ }
113
132
  // Where a secret goes, checked before the request makes one.
114
133
  const secretPlan = verb.secret ? planSecret({ org: org.org, noun: schema.noun, verb: verb.name, id: parsed.id, values: parsed.values, yes: cmd.globals.yes }) : null;
115
- const ctx = { live: cmd.globals.live, token: bearer === 'login' ? await loginToken() : org.token };
116
- const orgQuery = bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
134
+ const ctx = { live, token: live ? (login ? await loginToken() : null) : bearer === 'login' ? await loginToken() : org.token };
135
+ const orgQuery = liveOrg ? { org: liveOrg } : bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
117
136
  const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
118
137
  const run = {
119
138
  globals: cmd.globals,
@@ -139,16 +158,51 @@ async function executeOnce(argv, opts) {
139
158
  throw e;
140
159
  ctx.token = await loginToken({ fresh: bearer === 'login' });
141
160
  run.bearer = 'login';
142
- if (loginOrg && schema.noun !== 'organizations')
161
+ if (!live && loginOrg && schema.noun !== 'organizations')
143
162
  Object.assign(orgQuery, { org: loginOrg });
144
163
  return sendOnce(req);
145
164
  }
146
165
  },
147
166
  };
148
- const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
149
- const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
150
- const out = drifted(filesGone(secretOf(r, run, secretPlan), run), run);
151
- return unknown.length ? { ...out, warnings: [...unknown, ...out.warnings] } : out;
167
+ // A secret read from a file (provider or payment keys) is never printed, whatever the answer
168
+ // holds: a dry run or an error that echoes it shows [secret] in its place.
169
+ const secrets = secretValues(verb, parsed.values);
170
+ try {
171
+ const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
172
+ const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
173
+ const out = drifted(filesGone(secretOf(r, run, secretPlan), run), run);
174
+ return scrub(unknown.length ? { ...out, warnings: [...unknown, ...out.warnings] } : out, secrets);
175
+ }
176
+ catch (e) {
177
+ if (e instanceof CliError && secrets.length)
178
+ throw new CliError(scrub(e.body, secrets), e.kind, scrub(e.state, secrets));
179
+ throw e;
180
+ }
181
+ }
182
+ /** The string values of the secret fields given, longest first. */
183
+ function secretValues(verb, values) {
184
+ const out = [];
185
+ const walk = (v) => {
186
+ if (typeof v === 'string' && v.length >= 4)
187
+ out.push(v);
188
+ else if (v && typeof v === 'object')
189
+ for (const x of Object.values(v))
190
+ walk(x);
191
+ };
192
+ for (const spec of Object.values(verb.flags))
193
+ if (spec.input?.kind === 'secret')
194
+ walk(values[spec.input.target]);
195
+ return [...new Set(out)].sort((a, b) => b.length - a.length);
196
+ }
197
+ function scrub(v, secrets) {
198
+ if (!secrets.length || v === undefined)
199
+ return v;
200
+ let text = JSON.stringify(v);
201
+ if (text === undefined)
202
+ return v;
203
+ for (const s of secrets)
204
+ text = text.split(JSON.stringify(s).slice(1, -1)).join('[secret]');
205
+ return JSON.parse(text);
152
206
  }
153
207
  /**
154
208
  * A value outside a flag's documented values (known: webhook topics): not enforced, so it is sent,
@@ -185,7 +239,7 @@ function drifted(r, run) {
185
239
  return r;
186
240
  const data = (r.data && typeof r.data === 'object' ? r.data : {});
187
241
  const id = [run.parsed.id, data.code, data.name, data.id].find((k) => typeof k === 'string' && k !== '');
188
- const warning = markDrift(run.org.org, { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
242
+ const warning = markDrift(run.org.org, run.globals.live ? 'live' : 'test', { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
189
243
  return warning ? { ...r, warnings: [...r.warnings, warning] } : r;
190
244
  }
191
245
  /**
@@ -198,6 +252,10 @@ function filesGone(r, run) {
198
252
  return r;
199
253
  if (!Object.values(run.schema.verbs).some((v) => v.secret))
200
254
  return r;
255
+ // In live mode the secret went to a file the human chose, which is theirs to delete; the
256
+ // secrets folder holds the test mode's files of the same organization, which stay.
257
+ if (run.globals.live)
258
+ return { ...r, warnings: [...r.warnings, OUT_NOT_TRACKED] };
201
259
  const data = (r.data && typeof r.data === 'object' ? r.data : {});
202
260
  const keys = [run.parsed.id, data.id, data.name, data.code].filter((k) => typeof k === 'string' && k !== '');
203
261
  const removed = removeRecordFiles(run.org.org, run.schema.noun, keys);
package/dist/errors.js CHANGED
@@ -66,6 +66,8 @@ const KIND = {
66
66
  store_unavailable: 'unavailable',
67
67
  store_refused: 'rejected',
68
68
  payment_handler_unsupported: 'rejected',
69
+ // The site that publishes the skills (c11r skills sync).
70
+ site_unavailable: 'unavailable',
69
71
  };
70
72
  /** For an API error whose code the CLI does not know: from the HTTP status. */
71
73
  export function kindOfStatus(status) {
package/dist/help.js CHANGED
@@ -10,7 +10,7 @@ export const GLOBAL_FLAG_HELP = {
10
10
  '--dry-run': 'On a write, the API checks everything and says what would change, and changes nothing. Reads ignore it.',
11
11
  '--yes': 'Confirms a destructive verb in advance. Without it a destructive verb asks in the terminal, or, with no terminal, stops with confirmation_required.',
12
12
  '--org <slug>': 'The organization to act on, by its stored token. Without it: C11R_ORG, then the folder\'s .c11r.json, then the only stored organization.',
13
- '--live': 'Acts on live data, on a claimed organization. Without it every command works in test mode.',
13
+ '--live': 'Acts on live data, on a claimed organization named with --org, with the human\'s login. Without it every command works in test mode.',
14
14
  '--help': 'Describes the noun or verb it follows, from its schema.',
15
15
  };
16
16
  export const EXIT_CODE_HELP = {
package/dist/project.js CHANGED
@@ -7,7 +7,10 @@
7
7
  // .c11r/drift the Configure writes made since, in a folder whose store.yml names the
8
8
  // same organization: config plan mentions them, config export clears them.
9
9
  //
10
- // store.yml carries the export's meta ({ org, exported_at }) as its first key; the CLI takes it
10
+ // Live is a mode of the same organization, with the same slug: store.yml, the export record and
11
+ // the drift notes say which mode they are of, so test and live notes never mix.
12
+ //
13
+ // store.yml carries the export's meta ({ org, mode, exported_at }) as its first key; the CLI takes it
11
14
  // off before it sends the file, so the API sees { <noun>: [records] } only.
12
15
  import crypto from 'node:crypto';
13
16
  import fs from 'node:fs';
@@ -15,6 +18,7 @@ import path from 'node:path';
15
18
  import YAML from 'yaml';
16
19
  import { projectDir } from './config.js';
17
20
  import { fail } from './errors.js';
21
+ const sameSource = (a, b) => a.org !== null && a.org === b.org && a.mode === b.mode;
18
22
  export const STORE_FILE = 'store.yml';
19
23
  export const PRICE_LISTS_DIR = 'price_lists';
20
24
  const NOTES_DIR = '.c11r';
@@ -57,16 +61,18 @@ export function readStoreFiles(dir = projectDir()) {
57
61
  price_lists[n.slice(0, -4)] = fs.readFileSync(path.join(csvDir, n), 'utf8');
58
62
  return { store, price_lists, org };
59
63
  }
60
- /** The organization the folder's store.yml names, or null without one. */
61
- export function storeFileOrg(dir = projectDir()) {
64
+ /** The organization and mode the folder's store.yml names; org null without one. */
65
+ export function storeFileSource(dir = projectDir()) {
62
66
  try {
63
67
  const doc = YAML.parse(fs.readFileSync(path.join(dir, STORE_FILE), 'utf8'));
64
- return typeof doc?.meta?.org === 'string' ? doc.meta.org : null;
68
+ return { org: typeof doc?.meta?.org === 'string' ? doc.meta.org : null, mode: doc?.meta?.mode === 'live' ? 'live' : 'test' };
65
69
  }
66
70
  catch {
67
- return null;
71
+ return { org: null, mode: 'test' };
68
72
  }
69
73
  }
74
+ /** Whether the folder's store.yml is of this organization in this mode. */
75
+ export const storeFileIs = (org, mode, dir = projectDir()) => sameSource(storeFileSource(dir), { org, mode });
70
76
  /**
71
77
  * Writes an export: store.yml (meta first) and price_lists/<code>.csv. A file there that differs
72
78
  * from what the last export wrote (or that no export wrote) is the user's: without overwrite,
@@ -76,15 +82,19 @@ export function storeFileOrg(dir = projectDir()) {
76
82
  export function writeStoreFiles(data, opts) {
77
83
  const dir = opts.dir ?? projectDir();
78
84
  const last = readRecord(dir);
79
- const meta = { org: data.meta?.org ?? null, exported_at: data.meta?.exported_at ?? new Date().toISOString() };
85
+ const meta = { org: data.meta?.org ?? null, mode: opts.mode, exported_at: data.meta?.exported_at ?? new Date().toISOString() };
80
86
  const yaml = `# The organization's setup, written by c11r config export. Edit it, then c11r config plan and c11r config apply.\n${YAML.stringify({ meta, ...data.store }, { lineWidth: 0 })}`;
81
87
  const files = { [STORE_FILE]: yaml };
82
88
  for (const [code, csv] of Object.entries(data.price_lists ?? {}))
83
89
  files[path.join(PRICE_LISTS_DIR, `${safeName(code)}.csv`)] = csv;
90
+ // Files of another organization or mode are not the last export's to replace: --yes first.
91
+ const other = last.org !== null && !sameSource(last, meta);
84
92
  const changed = (rel) => {
85
93
  const abs = path.join(dir, rel);
86
94
  if (!fs.existsSync(abs))
87
95
  return false;
96
+ if (other)
97
+ return true;
88
98
  const now = hashOf(fs.readFileSync(abs, 'utf8'));
89
99
  return last.files[rel] !== now;
90
100
  };
@@ -110,7 +120,7 @@ export function writeStoreFiles(data, opts) {
110
120
  fs.rmSync(path.join(dir, rel), { force: true });
111
121
  removed.push(path.join(dir, rel));
112
122
  }
113
- writeRecord(dir, { org: meta.org, exported_at: meta.exported_at, files: Object.fromEntries(Object.entries(files).map(([rel, text]) => [rel, hashOf(text)])) });
123
+ writeRecord(dir, { org: meta.org, mode: meta.mode, exported_at: meta.exported_at, files: Object.fromEntries(Object.entries(files).map(([rel, text]) => [rel, hashOf(text)])) });
114
124
  clearDrift(dir);
115
125
  return { written, removed, dir };
116
126
  }
@@ -118,8 +128,8 @@ export function writeStoreFiles(data, opts) {
118
128
  * After a Configure write: in a folder whose store.yml names this organization, notes it in
119
129
  * .c11r/drift and answers the warning. Elsewhere, nothing.
120
130
  */
121
- export function markDrift(org, entry, dir = projectDir()) {
122
- if (storeFileOrg(dir) !== org)
131
+ export function markDrift(org, mode, entry, dir = projectDir()) {
132
+ if (!storeFileIs(org, mode, dir))
123
133
  return null;
124
134
  const list = readDrift(dir);
125
135
  list.push({ ...entry, at: new Date().toISOString() });
@@ -153,10 +163,10 @@ export function driftWarning(list) {
153
163
  function readRecord(dir) {
154
164
  try {
155
165
  const v = JSON.parse(fs.readFileSync(path.join(notes(dir), EXPORT_FILE), 'utf8'));
156
- return { org: v.org ?? null, exported_at: v.exported_at ?? null, files: v.files && typeof v.files === 'object' ? v.files : {} };
166
+ return { org: v.org ?? null, mode: v.mode === 'live' ? 'live' : 'test', exported_at: v.exported_at ?? null, files: v.files && typeof v.files === 'object' ? v.files : {} };
157
167
  }
158
168
  catch {
159
- return { org: null, exported_at: null, files: {} };
169
+ return { org: null, mode: 'test', exported_at: null, files: {} };
160
170
  }
161
171
  }
162
172
  function writeRecord(dir, r) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "1.0.0-dev.13",
3
+ "version": "1.0.0-dev.15",
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": [