@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 +29 -3
- package/dist/builtins/auth.js +17 -2
- package/dist/builtins/extensions.js +26 -6
- package/dist/builtins/index.js +3 -2
- package/dist/builtins/skills.js +349 -0
- package/dist/config.js +1 -1
- package/dist/engine.js +72 -14
- package/dist/errors.js +2 -0
- package/dist/help.js +1 -1
- package/dist/project.js +21 -11
- package/package.json +1 -1
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
|
|
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). |
|
package/dist/builtins/auth.js
CHANGED
|
@@ -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
|
-
{
|
|
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,
|
|
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 =
|
|
206
|
+
const here = storeFileIs(run.org.org, run.globals.live ? 'live' : 'test');
|
|
187
207
|
const drift = readDrift();
|
|
188
|
-
if (name === 'plan' && drift.length && here
|
|
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
|
|
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';
|
package/dist/builtins/index.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
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
|
-
//
|
|
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
|
|
61
|
-
export function
|
|
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 (
|
|
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.
|
|
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": [
|