@commercelayer/agent-cli 1.0.0-dev.2 → 1.0.0-dev.4

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,7 +34,7 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
34
34
  | `c11r auth login [--client-id] [--secret-file] [--keep-file]` | The human's own Commerce Layer login, from their Provisioning API application. See below. |
35
35
  | `c11r auth logout`, `auth switch <slug> [--global]` | Forgetting the login; the organization commands act on. |
36
36
 
37
- 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.
37
+ A loop runs a skill a Manage noun's schema names in `loop.skill` (`manage-orders`, `manage-payments`, `playbook-mail-to-order` for carts); 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.
38
38
 
39
39
  ## Login, organization tokens and secrets
40
40
 
@@ -50,7 +50,7 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
50
50
 
51
51
  `organizations create` and `organizations connect <slug>` answer an organization token once: it is stored like a sandbox token. `connect --revoke-others` also ends every other token of that organization, and asks for confirmation. With a login and no organization token for the organization named, an organization noun stops with `no_credentials` and the next step `c11r organizations connect <slug>`. `members` and `roles` with an organization token and no login are sent with that token, and the API says what is needed: a claim for a sandbox, a login for a claimed organization. `organizations` with no login stops here with `login_required`.
52
52
 
53
- `credentials create` and `credentials token` answer a secret once. c11r writes it to a file with mode 600 and prints the file, the format and a hint, never the value: by default `~/.config/c11r/secrets/<org>/<name>.env` (`<id>-token.env` for a token), or `--out <path>`, as `KEY=value` lines or with `--format json` one JSON object. The file is checked before the request is sent; one there already is replaced only with `--yes`. A file inside a git repository that git does not ignore gets the warning `secret_not_ignored`. With `--dry-run` nothing is written and `secret_file` says where it would go. Each file records the record it belongs to. A `delete` of a record whose noun answers secrets (`credentials`, `webhooks`) removes the files c11r wrote for it in `~/.config/c11r/secrets/<org>/` (a credential's secret and tokens, a webhook's shared secret), found by the id, name or code given, and lists them in `removed_files`; `sandbox destroy` removes the whole folder of that organization and names it in `forgotten.secrets`. A file written with `--out` is not tracked and stays: the warning `out_files_not_tracked` says so, when a secret of that organization was written with `--out` on this machine, or when a delete found nothing to remove. A token file carries `CL_BASE_ENDPOINT` beside the token. `sales-calls book` answers a cancel token once: it is kept in `credentials.json` and `sales-calls cancel <booking>` sends it.
53
+ `credentials create` and `credentials token` answer a secret once. c11r writes it to a file with mode 600 and prints the file, the format and a hint, never the value: by default `~/.config/c11r/secrets/<org>/<name>.env` (`<id>-token.env` for a token), or `--out <path>`, as `KEY=value` lines or with `--format json` one JSON object. The file is checked before the request is sent; one there already is replaced only with `--yes`. A file inside a git repository that git does not ignore gets the warning `secret_not_ignored`. With `--dry-run` nothing is written and `secret_file` says where it would go. Each file records the record it belongs to. A `delete` of a record whose noun answers secrets (`credentials`, `webhooks`) removes the files c11r wrote for it in `~/.config/c11r/secrets/<org>/` (a credential's secret and tokens, a webhook's shared secret), found by the id, name or code given, and lists them in `removed_files`; `sandbox destroy` removes the whole folder of that organization and names it in `forgotten.secrets`. A file written with `--out` is not tracked and stays: the warning `out_files_not_tracked` says so on the delete of the record whose secret went to `--out` on this machine, and on `sandbox destroy` of an organization that had one. Any other delete has no such warning. A token file carries `CL_BASE_ENDPOINT` beside the token. `sales-calls book` answers a cancel token once: it is kept in `credentials.json` and `sales-calls cancel <booking>` sends it.
54
54
 
55
55
  ## Global flags
56
56
 
@@ -4,8 +4,9 @@
4
4
  // schedules the agent repeats it every `every`.
5
5
  //
6
6
  // A policy allows verbs: a bare verb is the skill's own noun (approve), <noun>.<verb> another
7
- // Manage noun's verb on the same item (payments.capture). Each must be in the noun's schema
8
- // loop.verbs; the API checks again on every pass.
7
+ // Manage noun's verb on the same item (payments.capture). The skill must be one a Manage noun's
8
+ // schema names in loop.skill (a noun's own skill is not always its loop's: carts loops run
9
+ // playbook-mail-to-order), and each verb in that loop.verbs; the API checks again on every pass.
9
10
  import fs from 'node:fs';
10
11
  import path from 'node:path';
11
12
  import YAML from 'yaml';
@@ -13,11 +14,11 @@ import { projectDir } from '../config.js';
13
14
  import { CliError, closest, fail, fixWith } from '../errors.js';
14
15
  import { envelope } from '../http.js';
15
16
  import { parseSchema } from '../schema.js';
16
- import { apiSchema } from '../schemas.js';
17
+ import { apiNouns, apiSchema } from '../schemas.js';
17
18
  const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
18
19
  const EVERY = /^[1-9]\d*(m|h|d)$/;
19
20
  const policyFlags = (required) => [
20
- { name: 'skill', type: 'string', required, description: 'The Manage skill or playbook the loop runs, as in manage-orders.' },
21
+ { name: 'skill', type: 'string', required, description: 'The skill or playbook the loop runs, as in manage-orders or playbook-mail-to-order: one a noun\'s schema names in loop.skill.' },
21
22
  { name: 'every', type: 'string', required, description: 'How often a pass should run, as in 15m, 2h or 1d. The scheduler reads it; the CLI does not wait.' },
22
23
  { name: 'allow', type: 'array', required, description: 'A verb the policy allows, repeated: approve, payments.capture. Only verbs the noun\'s schema lists in loop.verbs.' },
23
24
  { name: 'max_total', type: 'array', description: 'The largest order total acted on, in major units: 500 for every currency, or EUR=500, repeated per currency. Above it, the item waits for the human.' },
@@ -166,24 +167,45 @@ function maxTotal(items) {
166
167
  return [ccy, amount(n)];
167
168
  }));
168
169
  }
169
- /** The policy's verbs against the skill noun's schema; a playbook is checked by the API at the first pass. */
170
+ /**
171
+ * The skill and the policy's verbs against the same source loops run uses: each noun schema's loop,
172
+ * { skill, verbs }. A skill no schema's loop names is refused with the ones that are, and so is a
173
+ * verb its loop does not list.
174
+ */
170
175
  async function checkPolicy(def) {
171
176
  if (!def.policy.allow.length)
172
177
  fail('missing_argument', 'A loop needs at least one --allow.', { path: 'allow' });
173
- if (!def.skill.startsWith('manage-'))
174
- return [{ code: 'policy_unchecked', message: `${def.skill} is not a Manage skill: the API checks its policy at the first pass.` }];
175
- const noun = def.skill.slice('manage-'.length);
176
- const schema = await apiSchema(noun, []);
177
- const allowed = schema.loop?.verbs ?? [];
178
- if (!allowed.length)
179
- fail('policy_forbidden', `No loop acts on ${noun}: its schema has no loop.`, { path: 'skill' });
178
+ const skills = await loopSkills();
179
+ const allowed = skills.get(def.skill);
180
+ if (!allowed) {
181
+ const names = [...skills.keys()].sort();
182
+ fail('invalid_value', `--skill ${def.skill} runs no loop. A loop runs ${names.length ? names.join(', ') : 'no skill yet'}.`, {
183
+ path: 'skill',
184
+ did_you_mean: closest(def.skill, names),
185
+ details: { values: names },
186
+ next_step: fixWith('c11r loops create --help'),
187
+ });
188
+ }
180
189
  for (const verb of def.policy.allow) {
181
- if (!allowed.includes(verb)) {
182
- fail('policy_forbidden', `A ${def.skill} loop may not allow ${verb}. It may allow ${allowed.join(', ')}.`, { path: 'allow', did_you_mean: closest(verb, allowed), details: { loopable: allowed }, next_step: fixWith(`c11r schema ${noun}`) });
190
+ if (!allowed.verbs.includes(verb)) {
191
+ fail('policy_forbidden', `A ${def.skill} loop may not allow ${verb}. It may allow ${allowed.verbs.join(', ')}.`, { path: 'allow', did_you_mean: closest(verb, allowed.verbs), details: { loopable: allowed.verbs }, next_step: fixWith(`c11r schema ${allowed.noun}`) });
183
192
  }
184
193
  }
185
194
  return [];
186
195
  }
196
+ /** The skills a loop can run, from the schemas of the nouns that have a loop: skill to its noun and verbs. */
197
+ async function loopSkills() {
198
+ const nouns = (await apiNouns()).filter((n) => n.group === 'manage' && n.served !== false);
199
+ const schemas = await Promise.all(nouns.map((n) => apiSchema(n.noun, []).catch(() => null)));
200
+ const out = new Map();
201
+ for (const s of schemas) {
202
+ if (!s?.loop?.skill)
203
+ continue;
204
+ const seen = out.get(s.loop.skill);
205
+ out.set(s.loop.skill, { noun: seen?.noun ?? s.noun, verbs: [...new Set([...(seen?.verbs ?? []), ...s.loop.verbs])] });
206
+ }
207
+ return out;
208
+ }
187
209
  async function remove(run, name, file) {
188
210
  one(file, name);
189
211
  if (run.globals.dryRun)
@@ -4,7 +4,7 @@
4
4
  // reset moves both to the new organization; destroy forgets them. The token is printed never,
5
5
  // not even by create: only its hint.
6
6
  import crypto from 'node:crypto';
7
- import { credentialsFile, defaultOrg, markOutWritten, outWritten, storedLogin, forgetOrg, LINK_FILE, readLink, removeLink, renameOrg, storedOrgs, storeOrg, writeLink, hintOf } from '../config.js';
7
+ import { credentialsFile, defaultOrg, forgetOutWritten, storedLogin, forgetOrg, LINK_FILE, readLink, removeLink, renameOrg, storedOrgs, storeOrg, writeLink, hintOf } from '../config.js';
8
8
  import { CliError, fail } from '../errors.js';
9
9
  import { apiUrl, envelope, request } from '../http.js';
10
10
  import { parseSchema } from '../schema.js';
@@ -28,7 +28,7 @@ export const sandbox = {
28
28
  live: false,
29
29
  body: [
30
30
  { name: 'name', type: 'string', description: 'The organization name; without it, an invented one. A name starting zztest marks a test organization.' },
31
- { name: 'seed', type: 'boolean', description: 'Loads the demo dataset: two markets, six products, shipping and a test payment method, enough to place a test order at once.' },
31
+ { name: 'seed', type: 'boolean', description: 'Loads the demo dataset: two markets, six products, shipping and a manual payment setting (Wire transfer), enough to place a test order at once.' },
32
32
  ],
33
33
  effect: 'Creates a sandbox, stores its token on this machine (never printed) and links this folder to it with .c11r.json. With --seed it answers once the seed is applied, in about 20 seconds.',
34
34
  },
@@ -124,8 +124,7 @@ async function destroy(run) {
124
124
  // here. The sandbox is gone either way, so this machine forgets it too.
125
125
  if (!(e instanceof CliError && e.code === 'unauthorized') || run.globals.dryRun || !run.org.org)
126
126
  throw e;
127
- const out = outWritten(run.org.org);
128
- markOutWritten(run.org.org, false);
127
+ const out = forgetOutWritten(run.org.org);
129
128
  forgetOrg(run.org.org);
130
129
  const link = removeLink(run.org.org);
131
130
  const secrets = removeOrgSecrets(run.org.org);
@@ -136,8 +135,7 @@ async function destroy(run) {
136
135
  const org = r.data?.org ?? run.org.org;
137
136
  if (!org)
138
137
  return r;
139
- const out = outWritten(org);
140
- markOutWritten(org, false);
138
+ const out = forgetOutWritten(org);
141
139
  forgetOrg(org);
142
140
  const link = removeLink(org);
143
141
  const secrets = removeOrgSecrets(org);
package/dist/config.js CHANGED
@@ -110,20 +110,37 @@ export function setDefaultOrg(org) {
110
110
  delete c.default_org;
111
111
  writeCredentials(c);
112
112
  }
113
- export function outWritten(org) {
114
- return Boolean(readCredentials().out_written?.[org]);
113
+ /** A record's secret went to an --out file: remembered so its delete can say that file stays. */
114
+ export function markOutWritten(org, record) {
115
+ const keys = record.keys.filter(Boolean);
116
+ if (!keys.length)
117
+ return;
118
+ const c = readCredentials();
119
+ const all = { ...(c.out_written ?? {}) };
120
+ const list = (all[org] ?? []).filter((r) => !(r.noun === record.noun && r.keys.some((k) => keys.includes(k))));
121
+ all[org] = [...list, { noun: record.noun, keys }];
122
+ c.out_written = all;
123
+ writeCredentials(c);
115
124
  }
116
- export function markOutWritten(org, written) {
125
+ /**
126
+ * After a delete: whether that record had a secret written with --out, which is then forgotten.
127
+ * With no record named, the organization goes (sandbox destroy).
128
+ */
129
+ export function forgetOutWritten(org, record) {
117
130
  const c = readCredentials();
131
+ const list = c.out_written?.[org] ?? [];
132
+ const hit = (r) => !record || (r.noun === record.noun && r.keys.some((k) => record.keys.includes(k)));
133
+ if (!list.some(hit))
134
+ return false;
135
+ const rest = list.filter((r) => !hit(r));
118
136
  const all = { ...(c.out_written ?? {}) };
119
- if (written === Boolean(all[org]))
120
- return;
121
- if (written)
122
- all[org] = true;
137
+ if (rest.length)
138
+ all[org] = rest;
123
139
  else
124
140
  delete all[org];
125
141
  c.out_written = all;
126
142
  writeCredentials(c);
143
+ return true;
127
144
  }
128
145
  export function storedBooking(id) {
129
146
  const b = readCredentials().bookings?.[id];
package/dist/engine.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // destructive verb, then the request. A plain noun needs no code here: its schema says
5
5
  // everything. Every answer is the API's envelope, { data, state, warnings, next }.
6
6
  import { parseCommand, parseFields, toFlag } from './argv.js';
7
- import { outWritten, raiseProblem, resolveOrg } from './config.js';
7
+ import { forgetOutWritten, raiseProblem, resolveOrg } from './config.js';
8
8
  import { confirm, terminal } from './confirm.js';
9
9
  import { CliError, closest, fail, fixWith } from './errors.js';
10
10
  import { indexHelp, nounHelp, verbHelp } from './help.js';
@@ -118,9 +118,9 @@ export async function execute(argv, opts = {}) {
118
118
  return filesGone(secretOf(r, run, secretPlan), run);
119
119
  }
120
120
  /**
121
- * A delete of a record whose verbs answer secrets (credentials, webhooks): the files c11r wrote
122
- * for it go with it, listed in removed_files. out_files_not_tracked only when it may apply:
123
- * nothing found here, or a secret of this organization written with --out on this machine.
121
+ * A delete on a noun that answers secrets: the files c11r wrote for that record in the secrets
122
+ * folder go with it, listed in removed_files. out_files_not_tracked only when that record's secret
123
+ * was written with --out on this machine: that file stays where it was written.
124
124
  */
125
125
  function filesGone(r, run) {
126
126
  if (run.verb.name !== 'delete' || run.globals.dryRun || !run.org.org)
@@ -128,8 +128,9 @@ function filesGone(r, run) {
128
128
  if (!Object.values(run.schema.verbs).some((v) => v.secret))
129
129
  return r;
130
130
  const data = (r.data && typeof r.data === 'object' ? r.data : {});
131
- const removed = removeRecordFiles(run.org.org, run.schema.noun, [run.parsed.id, data.id, data.name, data.code]);
132
- const warn = removed.length === 0 || outWritten(run.org.org);
131
+ const keys = [run.parsed.id, data.id, data.name, data.code].filter((k) => typeof k === 'string' && k !== '');
132
+ const removed = removeRecordFiles(run.org.org, run.schema.noun, keys);
133
+ const warn = forgetOutWritten(run.org.org, { noun: run.schema.noun, keys });
133
134
  return { ...r, data: { ...data, removed_files: removed }, warnings: [...r.warnings, ...(warn ? [OUT_NOT_TRACKED] : [])] };
134
135
  }
135
136
  /** A secret in the answer goes to its file; a dry run says where it would go. */
package/dist/secrets.js CHANGED
@@ -97,9 +97,9 @@ export function writeSecret(r, plan, ctx) {
97
97
  const created = ctx.verb === 'create';
98
98
  fail('secret_not_written', `The API answered the ${secret.kind ?? 'secret'} of credential ${id} once, and c11r could not write it to ${file}: ${e.message}. It was not printed, and the API cannot show it again. ${created ? `c11r credentials token ${id} gives a working token now; for a new secret, create another credential and delete ${id}.` : `c11r credentials token ${id} gives another token.`}`, { path: 'out', next_step: { action: 'fix_request', command: `c11r credentials token ${id}` }, details: { credential_id: credentialId ?? null, file } });
99
99
  }
100
- // An --out file is not removed with its credential: remember that one exists, for the warning.
100
+ // An --out file is not removed with its record: remember which record has one, for the warning.
101
101
  if (plan.fixed && ctx.org)
102
- markOutWritten(ctx.org, true);
102
+ markOutWritten(ctx.org, { noun: ctx.noun, keys: [record.id, record.name, record.code, ctx.id].filter((k) => typeof k === 'string' && k !== '') });
103
103
  // The API says the secret is shown once: c11r kept it in the file, and printed none of it.
104
104
  const warnings = r.warnings.filter((w) => w.code !== 'secret_shown_once' && w.code !== 'token_shown_once');
105
105
  if (!ignoredByGit(file))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "1.0.0-dev.2",
3
+ "version": "1.0.0-dev.4",
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": [