@commercelayer/agent-cli 1.0.0-dev.13 → 1.0.0-dev.14
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 +12 -0
- package/dist/builtins/index.js +3 -2
- package/dist/builtins/skills.js +349 -0
- package/dist/config.js +1 -1
- package/dist/errors.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
|
|
|
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
37
|
| `c11r auth logout`, `auth switch <slug> [--global]` | Forgetting the login; 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.
|
|
@@ -141,6 +142,16 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
|
|
|
141
142
|
- 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
143
|
- With `--dry-run`, checkout answers what it would send and order the total it would pay: nothing is sent.
|
|
143
144
|
|
|
145
|
+
## Skills
|
|
146
|
+
|
|
147
|
+
`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.
|
|
148
|
+
|
|
149
|
+
- 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.
|
|
150
|
+
- 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.
|
|
151
|
+
- `--except <skill>` (repeated) leaves a skill out, and later syncs remember it; `--include <skill>` takes it back.
|
|
152
|
+
- 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`).
|
|
153
|
+
- 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.
|
|
154
|
+
|
|
144
155
|
## Environment
|
|
145
156
|
|
|
146
157
|
| Variable | What it does |
|
|
@@ -152,5 +163,6 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
|
|
|
152
163
|
| `C11R_AUTH_URL` | Commerce Layer's token endpoint, `https://auth.commercelayer.io/oauth/token` by default: for tests. |
|
|
153
164
|
| `C11R_TEST` | `1` marks a sandbox as a test organization, like a name starting `zztest`. |
|
|
154
165
|
| `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. |
|
|
166
|
+
| `C11R_SKILLS_URL` | The skills index, for a test site on localhost only: `https://commercelayer.ai/start.md` otherwise. |
|
|
155
167
|
| `C11R_RETRY_CAP_S` | The longest the CLI waits before one retry, 30 by default. |
|
|
156
168
|
| `C11R_TIMEOUT_MS` | How long a request may take, 30000 by default (sandbox create waits longer). |
|
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/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/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.14",
|
|
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": [
|