@commercelayer/agent-cli 1.0.0-dev.5 → 1.0.0-dev.6
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 +2 -1
- package/dist/argv.js +4 -0
- package/dist/engine.js +26 -1
- package/dist/help.js +2 -1
- package/dist/http.js +10 -0
- package/dist/schemas.js +82 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
|
|
|
14
14
|
- A plain noun has no code in the CLI. Its fields, filters and verbs come from the API's schema, and `c11r schema <noun>` returns that schema.
|
|
15
15
|
- Flags are the schema's fields, in kebab or snake case: `--price-list` and `--price_list` are the same.
|
|
16
16
|
- On `list`, `--<field> <value>` filters by equality and `--<field>.<op> <value>` by another operator: `--placed-at.gte 2026-11-01`, `--status.in placed,approved`. `--waiting` lists what needs a decision now.
|
|
17
|
+
- A ref flag given `null`, or an empty value, takes the relationship off: `c11r customers update ada@example.com --customer-group null` sends `customer_group: null`.
|
|
17
18
|
- A dotted flag sets part of an object: `--customer.email ada@example.com`. An `in` filter takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it (`--tax-calculator.in "UK VAT, manual"`), and a list that has one is sent as a JSON array. Any other array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes `key=value` pairs, one object per flag: `--items sku=COFFEE,quantity=2`. `--data` takes the fields as one JSON object, or `@` and a file name.
|
|
18
19
|
- Amounts are in major units: `--amount 12.50`.
|
|
19
20
|
- A secret field the schema marks `secret_input` (provider credentials) has no flag of its own: `--credentials-file <file>` reads it, as JSON or `KEY=value` lines, from a plain file only its owner can read (mode 600). Without the file, a terminal asks for it without echo; with neither, the command stops with `secret_required`. It is refused as a flag value or in `--data`, and never printed.
|
|
@@ -124,6 +125,6 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
|
|
|
124
125
|
| `C11R_PROVISIONING_TOKEN` | A ready login token, used as is: for CI. Never a client secret. |
|
|
125
126
|
| `C11R_AUTH_URL` | Commerce Layer's token endpoint, `https://auth.commercelayer.io/oauth/token` by default: for tests. |
|
|
126
127
|
| `C11R_TEST` | `1` marks a sandbox as a test organization, like a name starting `zztest`. |
|
|
127
|
-
| `C11R_SCHEMA_CACHE` | `0` turns off the 10 minute schema cache in `~/.cache/c11r`. |
|
|
128
|
+
| `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. |
|
|
128
129
|
| `C11R_RETRY_CAP_S` | The longest the CLI waits before one retry, 30 by default. |
|
|
129
130
|
| `C11R_TIMEOUT_MS` | How long a request may take, 30000 by default (sandbox create waits longer). |
|
package/dist/argv.js
CHANGED
|
@@ -132,7 +132,11 @@ export function coerce(key, spec, raw) {
|
|
|
132
132
|
}
|
|
133
133
|
switch (spec.type) {
|
|
134
134
|
case 'string':
|
|
135
|
+
return raw;
|
|
135
136
|
case 'ref':
|
|
137
|
+
// In a body, null or "" takes the relationship off (customers update --customer-group null).
|
|
138
|
+
if (spec.to === 'body' && (raw === 'null' || raw.trim() === ''))
|
|
139
|
+
return null;
|
|
136
140
|
return raw;
|
|
137
141
|
case 'integer':
|
|
138
142
|
if (!/^-?\d+$/.test(raw.trim()))
|
package/dist/engine.js
CHANGED
|
@@ -14,9 +14,34 @@ import { hasLogin, loginToken } from './login.js';
|
|
|
14
14
|
import { resourceRequest } from './resources.js';
|
|
15
15
|
import { describePlan, OUT_NOT_TRACKED, planSecret, removeRecordFiles, writeSecret } from './secrets.js';
|
|
16
16
|
import { verbOf } from './schema.js';
|
|
17
|
-
import { apiNouns, apiSchema } from './schemas.js';
|
|
17
|
+
import { apiNouns, apiSchema, refreshSchemas, refusedBySchema } from './schemas.js';
|
|
18
18
|
import { VERSION } from './version.js';
|
|
19
|
+
/**
|
|
20
|
+
* One command. When it was read against a cached schema the API has since changed (a deploy), and
|
|
21
|
+
* the refusal is one that explains (refusedBySchema), it runs once more on fresh schemas. The API
|
|
22
|
+
* refused, so nothing happened; the second run asks again what the fresh schema asks, and never
|
|
23
|
+
* adds confirm=true on its own.
|
|
24
|
+
*/
|
|
19
25
|
export async function execute(argv, opts = {}) {
|
|
26
|
+
try {
|
|
27
|
+
return await executeOnce(argv, opts);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
if (!(e instanceof CliError) || !refusedBySchema(e.code) || !refusedBeforeEffect(e))
|
|
31
|
+
throw e;
|
|
32
|
+
refreshSchemas();
|
|
33
|
+
process.stderr.write(JSON.stringify({ retrying: { code: e.code, why: 'the schema changed on the API: read again' } }) + '\n');
|
|
34
|
+
return executeOnce(argv, opts);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** A refusal the API answered (4xx), or a flag or verb the cached schema did not know: nothing was done. */
|
|
38
|
+
function refusedBeforeEffect(e) {
|
|
39
|
+
const status = e.body.details?.status;
|
|
40
|
+
if (typeof status === 'number')
|
|
41
|
+
return status >= 400 && status < 500;
|
|
42
|
+
return e.code === 'unknown_field' || e.code === 'unknown_verb';
|
|
43
|
+
}
|
|
44
|
+
async function executeOnce(argv, opts) {
|
|
20
45
|
const cmd = parseCommand(argv);
|
|
21
46
|
const builtins = opts.builtins ?? {};
|
|
22
47
|
if (cmd.globals.version && !cmd.noun)
|
package/dist/help.js
CHANGED
|
@@ -29,7 +29,7 @@ export function indexHelp(builtins, api) {
|
|
|
29
29
|
description: 'c11r is the Commerce Layer CLI for coding agents. Every answer, errors included, is one JSON object on stdout.',
|
|
30
30
|
nouns: [...builtins, ...(api ?? [])].sort((a, b) => (a.noun < b.noun ? -1 : 1)),
|
|
31
31
|
standard_verbs: 'list, get, create, update, delete. Nouns with a state machine add their own verbs.',
|
|
32
|
-
flags: 'Fields in kebab or snake case (--price-list, --price_list). On list, --<field> <value> filters by equality and --<field>.<op> <value> by another operator (--created-at.gte 2026-11-01). in takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it, and a list that has one is sent as a JSON array. A dotted flag sets part of an object (--customer.email). Any other array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes key=value pairs (--items sku=COFFEE,quantity=2). --data takes the fields as one JSON object, or @ and a file name.',
|
|
32
|
+
flags: 'Fields in kebab or snake case (--price-list, --price_list). On list, --<field> <value> filters by equality and --<field>.<op> <value> by another operator (--created-at.gte 2026-11-01). in takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it, and a list that has one is sent as a JSON array. A ref flag given null, or an empty value, takes the relationship off (--customer-group null). A dotted flag sets part of an object (--customer.email). Any other array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes key=value pairs (--items sku=COFFEE,quantity=2). --data takes the fields as one JSON object, or @ and a file name.',
|
|
33
33
|
global_flags: GLOBAL_FLAG_HELP,
|
|
34
34
|
exit_codes: EXIT_CODE_HELP,
|
|
35
35
|
more: 'c11r <noun> --help describes a noun and its verbs; c11r <noun> <verb> --help a verb and its flags; c11r schema <noun> returns the full schema.',
|
|
@@ -67,6 +67,7 @@ function flagHelp(key, f) {
|
|
|
67
67
|
...(f.required || (f.input?.kind === 'secret' && f.input.required) ? { required: true } : {}),
|
|
68
68
|
...(f.input ? { reads: f.input.kind === 'secret' ? `${f.input.target}, a secret: never a flag value, never printed` : `${f.input.target}, a JSON document` } : {}),
|
|
69
69
|
...(f.values ? { values: f.values } : {}),
|
|
70
|
+
...(f.type === 'ref' && f.to === 'body' ? { clear: 'null, or an empty value, takes it off' } : {}),
|
|
70
71
|
...(f.filter ? { filter: f.filter.op } : {}),
|
|
71
72
|
...(f.description ? { description: f.description } : {}),
|
|
72
73
|
};
|
package/dist/http.js
CHANGED
|
@@ -33,6 +33,13 @@ export function urlOf(req, ctx) {
|
|
|
33
33
|
const qs = q.toString();
|
|
34
34
|
return `${apiUrl()}${req.path}${qs ? `?${qs}` : ''}`;
|
|
35
35
|
}
|
|
36
|
+
/** The header with the hash of the schemas the API serves: it changes with a deploy that changes them. */
|
|
37
|
+
export const SCHEMA_HEADER = 'c11r-schema';
|
|
38
|
+
let schemaWatcher = null;
|
|
39
|
+
/** Called with the header's value on every answer that has it (the schema cache listens). */
|
|
40
|
+
export function watchSchemaHeader(fn) {
|
|
41
|
+
schemaWatcher = fn;
|
|
42
|
+
}
|
|
36
43
|
/** Sends the request: the envelope, or the API's error as a CliError. */
|
|
37
44
|
export async function request(req, ctx) {
|
|
38
45
|
for (let attempt = 1;; attempt++) {
|
|
@@ -76,6 +83,9 @@ async function once(req, ctx) {
|
|
|
76
83
|
next_step: req.method === 'GET' ? { action: 'retry_later' } : { action: 'none' },
|
|
77
84
|
});
|
|
78
85
|
}
|
|
86
|
+
const version = res.headers.get(SCHEMA_HEADER);
|
|
87
|
+
if (version && schemaWatcher)
|
|
88
|
+
schemaWatcher(version);
|
|
79
89
|
const text = await res.text();
|
|
80
90
|
let data;
|
|
81
91
|
try {
|
package/dist/schemas.js
CHANGED
|
@@ -2,28 +2,91 @@
|
|
|
2
2
|
// one (contract section 5), cached on disk for a few minutes so a command costs one request, not
|
|
3
3
|
// two. Schemas are the same at every state, so no token is sent. C11R_SCHEMA_CACHE=0 turns the
|
|
4
4
|
// cache off.
|
|
5
|
+
//
|
|
6
|
+
// A deploy can change a schema inside those minutes. Every answer carries C11R-Schema, the hash of
|
|
7
|
+
// the schemas served: an entry records the one it was loaded with, and an answer with another
|
|
8
|
+
// drops the cache. The engine then runs the command once more on fresh schemas when the API
|
|
9
|
+
// refused it (refusedBySchema). Without the header, a refusal that a stale schema explains
|
|
10
|
+
// (confirmation_required, unknown_field, unknown_verb) does the same.
|
|
5
11
|
import crypto from 'node:crypto';
|
|
6
12
|
import fs from 'node:fs';
|
|
7
13
|
import os from 'node:os';
|
|
8
14
|
import path from 'node:path';
|
|
9
15
|
import { CliError, closest, fail, fixWith } from './errors.js';
|
|
10
|
-
import { apiUrl, request } from './http.js';
|
|
16
|
+
import { apiUrl, request, watchSchemaHeader } from './http.js';
|
|
11
17
|
import { parseSchema } from './schema.js';
|
|
12
18
|
import { VERSION } from './version.js';
|
|
13
19
|
const TTL_MS = 10 * 60_000;
|
|
14
|
-
function
|
|
20
|
+
function cacheDir() {
|
|
15
21
|
if (process.env.C11R_SCHEMA_CACHE === '0')
|
|
16
22
|
return null;
|
|
17
23
|
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
18
24
|
const host = crypto.createHash('sha256').update(apiUrl()).digest('hex').slice(0, 12);
|
|
19
|
-
return path.join(base, 'c11r', `schema-${VERSION}-${host}
|
|
25
|
+
return path.join(base, 'c11r', `schema-${VERSION}-${host}`);
|
|
26
|
+
}
|
|
27
|
+
/** This process: the header last seen, the versions of the cache entries used, and whether to skip the cache. */
|
|
28
|
+
const seen = { header: null, used: new Set(), fromCache: false, stale: false, fresh: false, written: null };
|
|
29
|
+
/** The hash the cache on disk was written under, in its own file: any answer can check it, whatever the command read. */
|
|
30
|
+
const HASH_FILE = '_hash';
|
|
31
|
+
watchSchemaHeader((version) => {
|
|
32
|
+
seen.header = version;
|
|
33
|
+
// Schemas this command read from the cache, under another hash: the refusal may be theirs.
|
|
34
|
+
if (seen.fromCache && [...seen.used].some((v) => v !== version))
|
|
35
|
+
seen.stale = true;
|
|
36
|
+
// The cache on disk, under another hash (or none recorded): gone, whatever this command read.
|
|
37
|
+
const dir = cacheDir();
|
|
38
|
+
if (!dir || seen.written === version)
|
|
39
|
+
return;
|
|
40
|
+
let names;
|
|
41
|
+
try {
|
|
42
|
+
names = fs.readdirSync(dir);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const hash = names.includes(HASH_FILE) ? fs.readFileSync(path.join(dir, HASH_FILE), 'utf8').trim() : null;
|
|
48
|
+
if (hash !== version && names.some((n) => n.endsWith('.json')))
|
|
49
|
+
dropSchemaCache();
|
|
50
|
+
});
|
|
51
|
+
/** Every cached schema of this API, gone: the next command loads them again. */
|
|
52
|
+
export function dropSchemaCache() {
|
|
53
|
+
const dir = cacheDir();
|
|
54
|
+
if (dir)
|
|
55
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether a refusal may come from a schema older than the API's: one was served from the cache,
|
|
59
|
+
* and the API's header says the schemas changed, or, with no header, the refusal is one a stale
|
|
60
|
+
* schema explains. The caller then refreshes (refreshSchemas) and runs the command once more.
|
|
61
|
+
*/
|
|
62
|
+
export function refusedBySchema(code) {
|
|
63
|
+
if (!seen.fromCache || seen.fresh)
|
|
64
|
+
return false;
|
|
65
|
+
if (seen.stale)
|
|
66
|
+
return true;
|
|
67
|
+
return seen.header === null && STALE_CODES.has(code);
|
|
68
|
+
}
|
|
69
|
+
const STALE_CODES = new Set(['confirmation_required', 'unknown_field', 'unknown_verb']);
|
|
70
|
+
/** From now on, in this process, schemas come from the API, and the cache is rewritten. */
|
|
71
|
+
export function refreshSchemas() {
|
|
72
|
+
dropSchemaCache();
|
|
73
|
+
seen.fresh = true;
|
|
74
|
+
seen.fromCache = false;
|
|
75
|
+
seen.used.clear();
|
|
20
76
|
}
|
|
21
77
|
async function cached(key, load) {
|
|
22
|
-
const
|
|
23
|
-
|
|
78
|
+
const dir = cacheDir();
|
|
79
|
+
const file = dir ? path.join(dir, `${key}.json`) : null;
|
|
80
|
+
if (file && !seen.fresh) {
|
|
24
81
|
try {
|
|
25
|
-
if (Date.now() - fs.statSync(file).mtimeMs < TTL_MS)
|
|
26
|
-
|
|
82
|
+
if (Date.now() - fs.statSync(file).mtimeMs < TTL_MS) {
|
|
83
|
+
const entry = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
84
|
+
if (entry && typeof entry === 'object' && 'value' in entry) {
|
|
85
|
+
seen.fromCache = true;
|
|
86
|
+
seen.used.add(entry.version ?? null);
|
|
87
|
+
return entry.value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
27
90
|
}
|
|
28
91
|
catch {
|
|
29
92
|
// Not cached, or unreadable: load it.
|
|
@@ -33,7 +96,18 @@ async function cached(key, load) {
|
|
|
33
96
|
if (file) {
|
|
34
97
|
try {
|
|
35
98
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
-
|
|
99
|
+
// Written under another hash than the files there: those go first, so the cache is one hash.
|
|
100
|
+
if (seen.header && seen.written !== seen.header) {
|
|
101
|
+
const hashFile = path.join(path.dirname(file), HASH_FILE);
|
|
102
|
+
const hash = fs.existsSync(hashFile) ? fs.readFileSync(hashFile, 'utf8').trim() : null;
|
|
103
|
+
if (hash !== seen.header) {
|
|
104
|
+
dropSchemaCache();
|
|
105
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
106
|
+
fs.writeFileSync(hashFile, seen.header);
|
|
107
|
+
}
|
|
108
|
+
seen.written = seen.header;
|
|
109
|
+
}
|
|
110
|
+
fs.writeFileSync(file, JSON.stringify({ version: seen.header, value }));
|
|
37
111
|
}
|
|
38
112
|
catch {
|
|
39
113
|
// A read-only home is fine: no cache.
|
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.6",
|
|
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": [
|