@commercelayer/agent-cli 1.0.0-dev.10 → 1.0.0-dev.12
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 +6 -3
- package/dist/builtins/extensions.js +63 -6
- package/dist/confirm.js +4 -3
- package/dist/engine.js +27 -1
- package/dist/help.js +5 -2
- package/dist/inputs.js +21 -1
- package/dist/schema.js +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,8 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
|
|
|
18
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.
|
|
19
19
|
- Amounts are in major units: `--amount 12.50`.
|
|
20
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.
|
|
21
|
-
- A
|
|
21
|
+
- A field with documented values the API does not enforce (`known`: webhook topics) takes any value. One outside them is sent anyway, with the warning `unknown_value` and `did_you_mean`. Help lists up to 20 of them, and past that says how many and that `c11r schema <noun>` has them all.
|
|
22
|
+
- A document field the schema marks `file_input` (price rules, rule filters) also reads from a JSON file: `--file <file>`, or `--<field>-file` when a verb has more than one. Where the verb takes `--format csv` (`imports create`), the file goes as CSV text with `--format csv`, or when its name ends in `.csv` and no format is given; otherwise it is JSON.
|
|
22
23
|
- A verb whose schema lists `id_alternative` takes the id or those flags in its place: `c11r prices get pr_1`, or `c11r prices get --sku BEANS-1KG --price-list eur`. Both at once, or half the flags, stop before sending.
|
|
23
24
|
|
|
24
25
|
## Built-in nouns
|
|
@@ -64,7 +65,7 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
|
|
|
64
65
|
| `--live` | Live data, on a claimed organization: `?mode=live` on every request. Without it, test mode. |
|
|
65
66
|
| `--help` | Describes the noun or verb it follows, from its schema. |
|
|
66
67
|
|
|
67
|
-
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.
|
|
68
|
+
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.
|
|
68
69
|
|
|
69
70
|
## Which organization
|
|
70
71
|
|
|
@@ -76,12 +77,14 @@ Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sand
|
|
|
76
77
|
|
|
77
78
|
## The store's setup in files
|
|
78
79
|
|
|
79
|
-
`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
|
|
80
|
+
`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.
|
|
80
81
|
|
|
81
82
|
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.
|
|
82
83
|
|
|
83
84
|
After a Configure write that went through (create, update, delete or a domain verb), in a folder whose `store.yml` names the same organization, the answer warns `config_drift` and `.c11r/drift` notes the write; `config plan` repeats the warning, and `config export` clears it.
|
|
84
85
|
|
|
86
|
+
`c11r scaffold drop-in --out <file>` writes the drop-in.js snippet as one HTML file: the part for the page's `<head>`, then the part for its `<body>`, each under a comment saying where it goes. It never replaces a different file without `--yes`. Without `--out`, the snippet stays in the JSON answer.
|
|
87
|
+
|
|
85
88
|
`c11r exports get <id> --out <file>` downloads a completed export while its link lasts (5 minutes), unzipped unless the name ends in `.gz`; `--wait` reads the job again until it completes.
|
|
86
89
|
|
|
87
90
|
## Output, errors and exit codes
|
|
@@ -169,14 +169,18 @@ export const config = {
|
|
|
169
169
|
};
|
|
170
170
|
}
|
|
171
171
|
if (name === 'plan' || name === 'apply') {
|
|
172
|
-
const
|
|
173
|
-
let r = await run.send(
|
|
172
|
+
const values = run.parsed.values;
|
|
173
|
+
let r = await run.send(resourceRequest('config', run.verb, undefined, values));
|
|
174
174
|
const started = Date.now();
|
|
175
|
-
while (name === 'apply' &&
|
|
175
|
+
while (name === 'apply' && values.wait && r.data?.status === 'running' && Date.now() - started < WAIT_MAX_MS) {
|
|
176
176
|
const ms = waitMs(r.data);
|
|
177
177
|
process.stderr.write(JSON.stringify({ running: { nouns: states(r.data), next_in_ms: ms } }) + '\n');
|
|
178
178
|
await sleep(ms);
|
|
179
|
-
|
|
179
|
+
// The same run carries on (45.9): its token goes back with the same files, so done counts
|
|
180
|
+
// everything the run wrote.
|
|
181
|
+
if (typeof r.data?.run === 'string')
|
|
182
|
+
values.run = r.data.run;
|
|
183
|
+
r = await run.send(resourceRequest('config', run.verb, undefined, values));
|
|
180
184
|
}
|
|
181
185
|
const warnings = [...r.warnings];
|
|
182
186
|
const here = storeFileOrg();
|
|
@@ -192,12 +196,25 @@ export const config = {
|
|
|
192
196
|
...r,
|
|
193
197
|
data: running ? { ...data, retry_after: waitMs(data) / 1000, states: states(data) } : data,
|
|
194
198
|
warnings,
|
|
195
|
-
next: running
|
|
199
|
+
next: running ? [{ command: carryOn(run.line, run.verb.flags.run ? data.run : undefined), why: 'carry on with the same files, and wait until every noun is done or failed' }, ...r.next] : r.next,
|
|
196
200
|
};
|
|
197
201
|
}
|
|
198
202
|
return undefined;
|
|
199
203
|
},
|
|
200
204
|
};
|
|
205
|
+
/**
|
|
206
|
+
* The apply that carries this run on: the same line with its --run token and --wait. Without --yes:
|
|
207
|
+
* a yes covers the run the human saw, so carrying on a prune is confirmed again.
|
|
208
|
+
*/
|
|
209
|
+
function carryOn(line, token) {
|
|
210
|
+
let out = line
|
|
211
|
+
.replace(/\s--run(=|\s+)("[^"]*"|'[^']*'|\S+)/g, '')
|
|
212
|
+
.replace(/\s--wait\b/g, '')
|
|
213
|
+
.replace(/\s(--yes|-y)(?=\s|$)/g, '');
|
|
214
|
+
if (typeof token === 'string' && token)
|
|
215
|
+
out += ` --run ${/^[\w.~:+\/=-]+$/.test(token) ? token : `'${token.replace(/'/g, `'\\''`)}'`}`;
|
|
216
|
+
return `${out} --wait`;
|
|
217
|
+
}
|
|
201
218
|
/** Each noun's state while apply runs: done, running (with its import), waiting, failed or unchanged. */
|
|
202
219
|
function states(data) {
|
|
203
220
|
const out = {};
|
|
@@ -255,4 +272,44 @@ export const exports = {
|
|
|
255
272
|
return { ...r, data: { ...rest, file, bytes: bytes.length, unzipped: zipped && !file.endsWith('.gz') } };
|
|
256
273
|
},
|
|
257
274
|
};
|
|
258
|
-
|
|
275
|
+
// scaffold (46): drop-in --out writes the snippet as one HTML file, its head part and its body part
|
|
276
|
+
// each under a comment saying where it goes. It never replaces a different file without --yes.
|
|
277
|
+
export const scaffold = {
|
|
278
|
+
flags: {
|
|
279
|
+
'drop-in': {
|
|
280
|
+
out: { type: 'string', to: 'local', description: 'Write the snippet to this HTML file: the part for the page\'s <head>, then the part for its <body>, each under a comment saying where it goes. A different file there is replaced only with --yes.' },
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
async run(run) {
|
|
284
|
+
const out = run.parsed.values.out;
|
|
285
|
+
if (run.verb.name !== 'drop-in' || !out)
|
|
286
|
+
return undefined;
|
|
287
|
+
const file = path.resolve(out);
|
|
288
|
+
// Before anything is sent: a different file there stays without --yes.
|
|
289
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
290
|
+
const r = await run.send(resourceRequest('scaffold', run.verb, undefined, run.parsed.values));
|
|
291
|
+
if (run.globals.dryRun)
|
|
292
|
+
return r;
|
|
293
|
+
const data = r.data ?? {};
|
|
294
|
+
const snippet = data.snippet;
|
|
295
|
+
if (!snippet || typeof snippet.head !== 'string' || typeof snippet.body !== 'string')
|
|
296
|
+
fail('invalid_response', 'scaffold drop-in answered without snippet.head and snippet.body.', { next_step: { action: 'none' } });
|
|
297
|
+
const html = [
|
|
298
|
+
`<!-- c11r scaffold drop-in${run.org.org ? ` (${run.org.org})` : ''}: this part goes in the page's <head>. -->`,
|
|
299
|
+
snippet.head.trimEnd(),
|
|
300
|
+
'',
|
|
301
|
+
`<!-- This part goes in the page's <body>, where the products show. -->`,
|
|
302
|
+
snippet.body.trimEnd(),
|
|
303
|
+
'',
|
|
304
|
+
].join('\n');
|
|
305
|
+
if (before !== null && before !== html && !run.globals.yes) {
|
|
306
|
+
fail('conflict', `${file} is there already, and differs from this snippet. c11r replaces it only with --yes.`, { path: 'out', details: { files: [file] }, next_step: { action: 'fix_request', command: null } }, 'rejected');
|
|
307
|
+
}
|
|
308
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
309
|
+
if (before !== html)
|
|
310
|
+
fs.writeFileSync(file, html);
|
|
311
|
+
const { snippet: _snippet, ...rest } = data;
|
|
312
|
+
return { ...r, data: { ...rest, file, replaced: before !== null && before !== html } };
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
export const extensions = { organizations, 'sales-calls': salesCalls, config, exports, scaffold };
|
package/dist/confirm.js
CHANGED
|
@@ -45,9 +45,10 @@ export async function confirm(action, command, opts) {
|
|
|
45
45
|
return;
|
|
46
46
|
const p = opts.prompter ?? terminal;
|
|
47
47
|
if (!p.interactive) {
|
|
48
|
-
// The agent asks the human; on a yes it repeats the command with --yes.
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
// The agent asks the human; on a yes it repeats the command with --yes. next never carries the
|
|
49
|
+
// --yes itself: only the human's answer adds it.
|
|
50
|
+
fail('confirmation_required', `${action} needs confirmation, and there is no terminal to ask in. Ask the human first; on their yes, run it again with --yes.`, {
|
|
51
|
+
next_step: { action: 'fix_request', command },
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
54
|
const answer = (await p.ask(`${action}. Go ahead? [y/N] `)).trim().toLowerCase();
|
package/dist/engine.js
CHANGED
|
@@ -74,6 +74,7 @@ async function executeOnce(argv, opts) {
|
|
|
74
74
|
await readInputs(schema.noun, verb, parsed, null);
|
|
75
75
|
check(schema, verb, parsed);
|
|
76
76
|
await readInputs(schema.noun, verb, parsed, opts.prompter ?? terminal);
|
|
77
|
+
const unknown = unknownValues(verb, parsed.values);
|
|
77
78
|
const line = ['c11r', ...argv.filter((a) => a !== '--yes' && a !== '-y')].map(quote).join(' ');
|
|
78
79
|
const org = resolveOrg(cmd.globals.org);
|
|
79
80
|
const login = hasLogin();
|
|
@@ -143,7 +144,32 @@ async function executeOnce(argv, opts) {
|
|
|
143
144
|
};
|
|
144
145
|
const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
|
|
145
146
|
const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
|
|
146
|
-
|
|
147
|
+
const out = drifted(filesGone(secretOf(r, run, secretPlan), run), run);
|
|
148
|
+
return unknown.length ? { ...out, warnings: [...unknown, ...out.warnings] } : out;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A value outside a flag's documented values (known: webhook topics): not enforced, so it is sent,
|
|
152
|
+
* with a warning and the closest documented ones.
|
|
153
|
+
*/
|
|
154
|
+
function unknownValues(verb, values) {
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const [key, f] of Object.entries(verb.flags)) {
|
|
157
|
+
const v = values[key];
|
|
158
|
+
if (!f.known || v === undefined || v === null)
|
|
159
|
+
continue;
|
|
160
|
+
for (const x of (Array.isArray(v) ? v : [v]).filter((x) => typeof x === 'string' && x !== '')) {
|
|
161
|
+
if (f.known.includes(x))
|
|
162
|
+
continue;
|
|
163
|
+
const near = closest(x, f.known);
|
|
164
|
+
out.push({
|
|
165
|
+
code: 'unknown_value',
|
|
166
|
+
message: `${x} is not one of the documented values of ${toFlag(key)}${near.length ? ` (closest: ${near.join(', ')})` : ''}; it was sent anyway.`,
|
|
167
|
+
path: key,
|
|
168
|
+
...(near.length ? { did_you_mean: near } : {}),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
147
173
|
}
|
|
148
174
|
/**
|
|
149
175
|
* A Configure write that went through, in a folder whose store.yml names this organization: the
|
package/dist/help.js
CHANGED
|
@@ -58,16 +58,19 @@ export function verbHelp(s, v) {
|
|
|
58
58
|
...(v.confirm ? { confirmation: 'Asks for confirmation unless --yes; with no terminal and no --yes it stops with confirmation_required.' } : {}),
|
|
59
59
|
...(v.loopable ? { loopable: 'A loop policy may allow this verb.' } : {}),
|
|
60
60
|
...(v.live ? {} : { live: 'Not in live mode.' }),
|
|
61
|
-
flags: Object.entries(v.flags).map(([key, f]) => flagHelp(key, f)),
|
|
61
|
+
flags: Object.entries(v.flags).map(([key, f]) => flagHelp(key, f, s.noun)),
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
/** Up to this many documented values are listed in help; past it, help says where they are. */
|
|
65
|
+
const KNOWN_IN_FULL = 20;
|
|
66
|
+
function flagHelp(key, f, noun) {
|
|
65
67
|
return {
|
|
66
68
|
flag: toFlag(key),
|
|
67
69
|
type: f.type === 'array' ? `array of ${f.items ?? 'string'}` : f.type === 'ref' ? `${f.noun ?? 'resource'} id` : f.type === 'money' ? 'amount in major units' : f.type,
|
|
68
70
|
...(f.required || (f.input?.kind === 'secret' && f.input.required) ? { required: true } : {}),
|
|
69
71
|
...(f.input ? { reads: f.input.kind === 'secret' ? `${f.input.target}, a secret: never a flag value, never printed` : `${f.input.target}, a JSON document` } : {}),
|
|
70
72
|
...(f.values ? { values: f.values } : {}),
|
|
73
|
+
...(f.known ? { known: f.known.length <= KNOWN_IN_FULL ? f.known : `one of ${f.known.length} documented values: c11r schema ${noun}` } : {}),
|
|
71
74
|
...(clearable(f) ? { clear: 'null, or an empty value, takes it off' } : {}),
|
|
72
75
|
...(f.filter ? { filter: f.filter.op } : {}),
|
|
73
76
|
...(f.description ? { description: f.description } : {}),
|
package/dist/inputs.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// KEY=value lines, from a plain file only its owner can read; or, in a terminal, typed without
|
|
5
5
|
// echo. Never from a flag value or --data, never printed: an error names the file, not the value.
|
|
6
6
|
// A document field (file_input: price rules, rule filters) is also read from a JSON file, --file.
|
|
7
|
+
// Where the verb takes --format csv (imports), the file is sent as CSV text with --format csv, or
|
|
8
|
+
// when it ends in .csv and no format is given; JSON otherwise.
|
|
7
9
|
import fs from 'node:fs';
|
|
8
10
|
import { toFlag } from './argv.js';
|
|
9
11
|
import { fail } from './errors.js';
|
|
@@ -29,7 +31,12 @@ export async function readInputs(noun, verb, parsed, prompter) {
|
|
|
29
31
|
continue;
|
|
30
32
|
if (parsed.values[input.target] !== undefined)
|
|
31
33
|
fail('invalid_value', `${cmd} takes ${input.target} from ${toFlag(input.target)} or from ${toFlag(key)}, not both.`, { path: key });
|
|
32
|
-
|
|
34
|
+
if (isCsv(verb, parsed, file)) {
|
|
35
|
+
parsed.values.format ??= 'csv';
|
|
36
|
+
parsed.values[input.target] = csvOf(file, key);
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
parsed.values[input.target] = documentOf(file, key, spec);
|
|
33
40
|
continue;
|
|
34
41
|
}
|
|
35
42
|
if (file !== undefined) {
|
|
@@ -51,6 +58,19 @@ export async function readInputs(noun, verb, parsed, prompter) {
|
|
|
51
58
|
});
|
|
52
59
|
}
|
|
53
60
|
}
|
|
61
|
+
const isCsv = (verb, parsed, file) => Boolean(verb.flags.format?.values?.includes('csv')) && (parsed.values.format === 'csv' || (parsed.values.format === undefined && /\.csv$/i.test(file)));
|
|
62
|
+
function csvOf(file, key) {
|
|
63
|
+
let text;
|
|
64
|
+
try {
|
|
65
|
+
text = fs.readFileSync(file, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
fail('invalid_value', `${toFlag(key)} ${file}: ${e.code === 'ENOENT' ? 'no such file' : e.message}.`, { path: key });
|
|
69
|
+
}
|
|
70
|
+
if (!text.trim())
|
|
71
|
+
fail('invalid_value', `${file} is empty: a CSV file starts with its header row.`, { path: key });
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
54
74
|
function documentOf(file, key, spec) {
|
|
55
75
|
let text;
|
|
56
76
|
try {
|
package/dist/schema.js
CHANGED
|
@@ -103,7 +103,9 @@ function fileInputs(flags, fields, body) {
|
|
|
103
103
|
type: 'string',
|
|
104
104
|
to: 'local',
|
|
105
105
|
input: { kind: 'document', target: k, required: Boolean(flags[k].required), target_type: flags[k].type },
|
|
106
|
-
description:
|
|
106
|
+
description: flags.format?.values?.includes('csv')
|
|
107
|
+
? `A file with ${k}, in place of ${toFlag(k)}: JSON, or CSV with --format csv or a name ending in .csv.`
|
|
108
|
+
: `A JSON file with ${k}, in place of ${toFlag(k)}.`,
|
|
107
109
|
};
|
|
108
110
|
}
|
|
109
111
|
}
|
|
@@ -116,7 +118,7 @@ function listFlags(flags, fields, doc) {
|
|
|
116
118
|
if (op === 'in')
|
|
117
119
|
flags[key] = { ...base, type: 'array', items: f.type === 'array' ? f.items : f.type, filter: { field: f.name, op } };
|
|
118
120
|
else if (op === 'null')
|
|
119
|
-
flags[key] = { ...base, type: 'boolean', values: undefined, filter: { field: f.name, op } };
|
|
121
|
+
flags[key] = { ...base, type: 'boolean', values: undefined, known: undefined, filter: { field: f.name, op } };
|
|
120
122
|
else
|
|
121
123
|
flags[key] = { ...base, filter: { field: f.name, op } };
|
|
122
124
|
}
|
|
@@ -145,6 +147,7 @@ function flag(f, to, required) {
|
|
|
145
147
|
type: f.type,
|
|
146
148
|
to,
|
|
147
149
|
...(f.values ? { values: f.values } : {}),
|
|
150
|
+
...(Array.isArray(f.known) && f.known.length ? { known: f.known } : {}),
|
|
148
151
|
...(f.items ? { items: f.items } : {}),
|
|
149
152
|
...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, { type: p.type, ...(p.values ? { values: p.values } : {}) }])) } : {}),
|
|
150
153
|
...(f.noun ? { noun: f.noun } : {}),
|
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.12",
|
|
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": [
|