@commercelayer/agent-cli 1.0.0-dev.2 → 1.0.0-dev.21
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 +83 -11
- package/dist/argv.js +23 -5
- package/dist/builtins/auth.js +21 -3
- package/dist/builtins/extensions.js +329 -5
- package/dist/builtins/index.js +4 -2
- package/dist/builtins/loops.js +68 -31
- package/dist/builtins/sandbox.js +45 -15
- package/dist/builtins/shop.js +504 -0
- package/dist/builtins/skills.js +355 -0
- package/dist/config.js +25 -8
- package/dist/confirm.js +17 -6
- package/dist/engine.js +192 -28
- package/dist/errors.js +6 -0
- package/dist/help.js +36 -6
- package/dist/http.js +10 -0
- package/dist/inputs.js +21 -1
- package/dist/project.js +185 -0
- package/dist/schema.js +15 -4
- package/dist/schemas.js +82 -8
- package/dist/secrets.js +25 -7
- package/dist/storefront.js +280 -0
- package/dist/ucp.js +207 -0
- package/package.json +1 -1
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// c11r skills sync: local copies of the skills, from the list the site publishes (skills.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/skills.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
|
+
/** Up to this many skills added, updated or removed are listed; past it, only the count unless --verbose. */
|
|
29
|
+
const LISTED = 10;
|
|
30
|
+
export const skills = {
|
|
31
|
+
schema: parseSchema({
|
|
32
|
+
noun: 'skills',
|
|
33
|
+
group: 'setup',
|
|
34
|
+
kind: 'task',
|
|
35
|
+
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.',
|
|
36
|
+
verbs: [
|
|
37
|
+
{
|
|
38
|
+
verb: 'sync',
|
|
39
|
+
method: 'POST',
|
|
40
|
+
path: '/skills/sync',
|
|
41
|
+
state: 'none',
|
|
42
|
+
live: false,
|
|
43
|
+
body: [
|
|
44
|
+
{ name: 'except', type: 'array', items: 'string', description: 'A skill not to keep a copy of, repeated. Remembered: later syncs leave it out too.' },
|
|
45
|
+
{ name: 'include', type: 'array', items: 'string', description: 'A skill left out with --except to keep a copy of again, repeated.' },
|
|
46
|
+
{ name: 'verbose', type: 'boolean', description: `Lists every skill added, updated and removed. Without it, a list longer than ${LISTED} is only its count.` },
|
|
47
|
+
],
|
|
48
|
+
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 and removed as a count with the skills when there are few, every edited copy, and how many are unchanged. With --dry-run it reads only the index and writes nothing.',
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
}),
|
|
52
|
+
defaultVerb: 'sync',
|
|
53
|
+
async run(run) {
|
|
54
|
+
return sync(run);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
// ---------- the site
|
|
58
|
+
function indexUrl() {
|
|
59
|
+
const given = process.env.C11R_SKILLS_URL;
|
|
60
|
+
if (!given)
|
|
61
|
+
return INDEX_URL;
|
|
62
|
+
let u;
|
|
63
|
+
try {
|
|
64
|
+
u = new URL(given);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
fail('invalid_config', `C11R_SKILLS_URL ${given} is not a URL.`);
|
|
68
|
+
}
|
|
69
|
+
if (!['localhost', '127.0.0.1'].includes(u.hostname))
|
|
70
|
+
fail('invalid_config', `C11R_SKILLS_URL is for a test site on localhost only, not ${u.host}.`);
|
|
71
|
+
return u.toString();
|
|
72
|
+
}
|
|
73
|
+
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');
|
|
74
|
+
async function get(url, etag) {
|
|
75
|
+
let res;
|
|
76
|
+
try {
|
|
77
|
+
res = await fetch(url, { headers: { 'user-agent': USER_AGENT, accept: 'text/markdown', ...(etag ? { 'if-none-match': etag } : {}) }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
throw unavailable(`${new URL(url).host} could not be reached: ${e.message}. The local copies are unchanged and still usable.`);
|
|
81
|
+
}
|
|
82
|
+
if (res.status !== 200 && res.status !== 304)
|
|
83
|
+
throw unavailable(`${url} answered ${res.status}. The local copies are unchanged and still usable.`);
|
|
84
|
+
return { status: res.status, etag: res.headers.get('etag'), text: res.status === 200 ? await res.text() : '' };
|
|
85
|
+
}
|
|
86
|
+
/** The index lines: - [name](url) VERSION, state ..., plan ...[, requires ...]: description. */
|
|
87
|
+
function parseIndex(text, from) {
|
|
88
|
+
const origin = new URL(from).origin;
|
|
89
|
+
const entries = [];
|
|
90
|
+
const skipped = [];
|
|
91
|
+
for (const line of text.split('\n')) {
|
|
92
|
+
const m = line.match(/^- \[([^\]]+)\]\((\S+)\) (\d+\.\d+\.\d+\S*?),[^:]*: (.*)$/);
|
|
93
|
+
if (!m)
|
|
94
|
+
continue;
|
|
95
|
+
const [, name, link, version, description] = m;
|
|
96
|
+
let u = null;
|
|
97
|
+
try {
|
|
98
|
+
u = new URL(link, from);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
u = null;
|
|
102
|
+
}
|
|
103
|
+
if (!NAME.test(name) || !u || u.origin !== origin || !u.pathname.startsWith('/skills/')) {
|
|
104
|
+
skipped.push(name);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!entries.some((e) => e.name === name))
|
|
108
|
+
entries.push({ name, version, url: u.toString(), description: description.trim() });
|
|
109
|
+
}
|
|
110
|
+
return { entries, version: frontmatter(text).version ?? null, skipped };
|
|
111
|
+
}
|
|
112
|
+
function frontmatter(text) {
|
|
113
|
+
const m = text.match(/^---\n([\s\S]*?)\n---/);
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const line of m?.[1].split('\n') ?? []) {
|
|
116
|
+
const kv = line.match(/^([a-z_]+):\s*(.*)$/);
|
|
117
|
+
if (kv)
|
|
118
|
+
out[kv[1]] = kv[2].replace(/^"(.*)"$/, '$1').trim();
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
// ---------- this machine
|
|
123
|
+
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
124
|
+
const readOr = (file) => {
|
|
125
|
+
try {
|
|
126
|
+
return fs.readFileSync(file, 'utf8');
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
function readManifest(file) {
|
|
133
|
+
const raw = readOr(file);
|
|
134
|
+
if (raw === null)
|
|
135
|
+
return null;
|
|
136
|
+
try {
|
|
137
|
+
const m = JSON.parse(raw);
|
|
138
|
+
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 ?? [] };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
fail('invalid_config', `${file} is not JSON: c11r skills sync writes it, and it was changed by hand.`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function writeAtomic(file, text) {
|
|
145
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
146
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
147
|
+
fs.writeFileSync(tmp, text);
|
|
148
|
+
fs.renameSync(tmp, file);
|
|
149
|
+
}
|
|
150
|
+
const firstSentence = (d) => {
|
|
151
|
+
const i = d.indexOf('. ');
|
|
152
|
+
return i === -1 ? d : d.slice(0, i + 1);
|
|
153
|
+
};
|
|
154
|
+
function agentsBlock(entries, rel) {
|
|
155
|
+
const link = (n) => `${rel}/${n}/SKILL.md`;
|
|
156
|
+
const lines = [
|
|
157
|
+
START,
|
|
158
|
+
'## Commerce Layer skills',
|
|
159
|
+
'',
|
|
160
|
+
`Local copies of the Commerce Layer skills, from ${INDEX_URL}, kept up to date by \`c11r skills sync\`. When a skill's task comes, load its copy.`,
|
|
161
|
+
'',
|
|
162
|
+
...entries.map((e) => `- [${e.name}](${link(e.name)}): ${firstSentence(e.description)}`),
|
|
163
|
+
END,
|
|
164
|
+
];
|
|
165
|
+
return lines.join('\n');
|
|
166
|
+
}
|
|
167
|
+
/** AGENTS.md with the block put in or replaced; nothing outside the markers changes. */
|
|
168
|
+
function withBlock(current, block) {
|
|
169
|
+
if (current === null)
|
|
170
|
+
return `${block}\n`;
|
|
171
|
+
const a = current.indexOf(START);
|
|
172
|
+
const b = current.indexOf(END);
|
|
173
|
+
if (a !== -1 && b > a)
|
|
174
|
+
return current.slice(0, a) + block + current.slice(b + END.length);
|
|
175
|
+
return `${current.replace(/\n*$/, '')}\n\n${block}\n`;
|
|
176
|
+
}
|
|
177
|
+
// ---------- sync
|
|
178
|
+
async function sync(run) {
|
|
179
|
+
const v = run.parsed.values;
|
|
180
|
+
const dryRun = run.globals.dryRun;
|
|
181
|
+
// The project root: where the copies already are, the folder's link or store files, a git
|
|
182
|
+
// repository's root, else here.
|
|
183
|
+
const root = findUp([path.join(SKILLS_DIR, MANIFEST)]) ?? findUp([LINK_FILE]) ?? findUp(['store.yml', 'loops.yml']) ?? findUp(['.git']) ?? process.cwd();
|
|
184
|
+
const dir = path.join(root, SKILLS_DIR);
|
|
185
|
+
const manifestFile = path.join(dir, MANIFEST);
|
|
186
|
+
const from = indexUrl();
|
|
187
|
+
const before = readManifest(manifestFile);
|
|
188
|
+
const manifest = before ?? { index: { url: from, version: null, etag: null, entries: [] }, skills: {}, except: [] };
|
|
189
|
+
const warnings = [];
|
|
190
|
+
const except = v.except ?? [];
|
|
191
|
+
const include = v.include ?? [];
|
|
192
|
+
const verbose = v.verbose === true;
|
|
193
|
+
for (const n of [...except, ...include])
|
|
194
|
+
if (!NAME.test(n))
|
|
195
|
+
fail('invalid_value', `${n} is not a skill name: lowercase letters, digits and dashes, as in manage-orders.`, { path: except.includes(n) ? 'except' : 'include' });
|
|
196
|
+
const excluded = new Set([...manifest.except.filter((n) => !include.includes(n)), ...except]);
|
|
197
|
+
// The index: a 304 means the one sync last read, whose entries the manifest keeps.
|
|
198
|
+
const sameUrl = manifest.index.url === from;
|
|
199
|
+
const idx = await get(from, sameUrl && manifest.index.entries.length ? manifest.index.etag : null);
|
|
200
|
+
let entries;
|
|
201
|
+
let indexVersion;
|
|
202
|
+
if (idx.status === 304) {
|
|
203
|
+
entries = manifest.index.entries;
|
|
204
|
+
indexVersion = manifest.index.version;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const parsed = parseIndex(idx.text, from);
|
|
208
|
+
if (!parsed.entries.length)
|
|
209
|
+
throw unavailable(`${from} lists no skills: it may be mid-deploy. The local copies are unchanged.`);
|
|
210
|
+
entries = parsed.entries;
|
|
211
|
+
indexVersion = parsed.version;
|
|
212
|
+
for (const n of parsed.skipped)
|
|
213
|
+
warnings.push({ code: 'skill_skipped', message: `${n} links outside ${new URL(from).origin}/skills/ in the index: not copied.` });
|
|
214
|
+
}
|
|
215
|
+
for (const n of include)
|
|
216
|
+
if (!entries.some((e) => e.name === n))
|
|
217
|
+
warnings.push({ code: 'unknown_skill', message: `${n} is not in the index.` });
|
|
218
|
+
for (const n of except)
|
|
219
|
+
if (!entries.some((e) => e.name === n) && !manifest.skills[n])
|
|
220
|
+
warnings.push({ code: 'unknown_skill', message: `${n} is not in the index.` });
|
|
221
|
+
const wanted = entries.filter((e) => !excluded.has(e.name));
|
|
222
|
+
const fileOf = (n) => path.join(dir, n, 'SKILL.md');
|
|
223
|
+
const editedNow = (n) => {
|
|
224
|
+
const kept = manifest.skills[n];
|
|
225
|
+
const text = readOr(fileOf(n));
|
|
226
|
+
return Boolean(kept && text !== null && sha(text) !== kept.sha256);
|
|
227
|
+
};
|
|
228
|
+
const added = [];
|
|
229
|
+
const updated = [];
|
|
230
|
+
const removed = [];
|
|
231
|
+
const edited = [];
|
|
232
|
+
let unchanged = 0;
|
|
233
|
+
const toFetch = [];
|
|
234
|
+
for (const e of wanted) {
|
|
235
|
+
const kept = manifest.skills[e.name];
|
|
236
|
+
const present = readOr(fileOf(e.name)) !== null;
|
|
237
|
+
if (kept && present && kept.version === e.version) {
|
|
238
|
+
// Same version: still checked, since an edit there is not what the index says either.
|
|
239
|
+
if (editedNow(e.name))
|
|
240
|
+
edited.push({ name: e.name, local: kept.version, latest: e.version });
|
|
241
|
+
else
|
|
242
|
+
unchanged++;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (kept && present && editedNow(e.name)) {
|
|
246
|
+
edited.push({ name: e.name, local: kept.version, latest: e.version });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// A file there that sync never wrote is the person's: kept, as an edit.
|
|
250
|
+
if (!kept && present) {
|
|
251
|
+
edited.push({ name: e.name, local: frontmatter(readOr(fileOf(e.name))).version ?? 'unknown', latest: e.version });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
toFetch.push(e);
|
|
255
|
+
}
|
|
256
|
+
const gone = Object.values(manifest.skills).filter((k) => !wanted.some((e) => e.name === k.name));
|
|
257
|
+
const toRemove = [];
|
|
258
|
+
for (const k of gone) {
|
|
259
|
+
if (readOr(fileOf(k.name)) === null)
|
|
260
|
+
continue;
|
|
261
|
+
if (editedNow(k.name))
|
|
262
|
+
edited.push({ name: k.name, local: k.version, latest: null });
|
|
263
|
+
else
|
|
264
|
+
toRemove.push(k);
|
|
265
|
+
}
|
|
266
|
+
// Edits are replaced or removed only on the human's yes.
|
|
267
|
+
const replaceEdits = edited.length > 0 && !dryRun && run.globals.yes;
|
|
268
|
+
if (dryRun) {
|
|
269
|
+
for (const e of toFetch)
|
|
270
|
+
(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 }));
|
|
271
|
+
for (const k of toRemove)
|
|
272
|
+
removed.push({ name: k.name, version: k.version });
|
|
273
|
+
const agentsNow = readOr(path.join(root, AGENTS_FILE));
|
|
274
|
+
const agentsNext = withBlock(agentsNow, agentsBlock(wanted, SKILLS_DIR.split(path.sep).join('/')));
|
|
275
|
+
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, verbose);
|
|
276
|
+
}
|
|
277
|
+
// Fetch first, write after: a cut-off sync writes nothing.
|
|
278
|
+
if (replaceEdits)
|
|
279
|
+
for (const e of edited)
|
|
280
|
+
if (e.latest)
|
|
281
|
+
toFetch.push(wanted.find((w) => w.name === e.name));
|
|
282
|
+
const bodies = new Map();
|
|
283
|
+
for (const e of toFetch) {
|
|
284
|
+
const r = await get(e.url);
|
|
285
|
+
const fm = frontmatter(r.text);
|
|
286
|
+
if (fm.name !== e.name || fm.version !== e.version) {
|
|
287
|
+
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(' ', ' ') });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
bodies.set(e.name, r.text);
|
|
291
|
+
}
|
|
292
|
+
for (const e of toFetch) {
|
|
293
|
+
const text = bodies.get(e.name);
|
|
294
|
+
if (text === undefined)
|
|
295
|
+
continue;
|
|
296
|
+
const kept = manifest.skills[e.name];
|
|
297
|
+
const had = readOr(fileOf(e.name)) !== null;
|
|
298
|
+
writeAtomic(fileOf(e.name), text);
|
|
299
|
+
if (kept && had)
|
|
300
|
+
updated.push({ name: e.name, from: kept.version, to: e.version });
|
|
301
|
+
else
|
|
302
|
+
added.push({ name: e.name, version: e.version });
|
|
303
|
+
manifest.skills[e.name] = { ...e, sha256: sha(text) };
|
|
304
|
+
}
|
|
305
|
+
const removeOne = (name, version) => {
|
|
306
|
+
fs.rmSync(fileOf(name), { force: true });
|
|
307
|
+
try {
|
|
308
|
+
fs.rmdirSync(path.join(dir, name));
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// The folder holds something of the person's: it stays.
|
|
312
|
+
}
|
|
313
|
+
removed.push({ name, version });
|
|
314
|
+
delete manifest.skills[name];
|
|
315
|
+
};
|
|
316
|
+
for (const k of toRemove)
|
|
317
|
+
removeOne(k.name, k.version);
|
|
318
|
+
for (const k of gone)
|
|
319
|
+
if (readOr(fileOf(k.name)) === null && manifest.skills[k.name])
|
|
320
|
+
delete manifest.skills[k.name];
|
|
321
|
+
if (replaceEdits)
|
|
322
|
+
for (const e of edited)
|
|
323
|
+
if (!e.latest)
|
|
324
|
+
removeOne(e.name, e.local);
|
|
325
|
+
// Unchanged entries take the index's description, which AGENTS.md lists.
|
|
326
|
+
for (const e of wanted)
|
|
327
|
+
if (manifest.skills[e.name] && manifest.skills[e.name].version === e.version)
|
|
328
|
+
manifest.skills[e.name] = { ...manifest.skills[e.name], url: e.url, description: e.description };
|
|
329
|
+
manifest.index = { url: from, version: indexVersion, etag: idx.status === 304 ? manifest.index.etag : idx.etag, entries };
|
|
330
|
+
manifest.except = [...excluded].sort();
|
|
331
|
+
writeAtomic(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
|
|
332
|
+
const listed = wanted.filter((e) => manifest.skills[e.name] || readOr(fileOf(e.name)) !== null);
|
|
333
|
+
const agentsFile = path.join(root, AGENTS_FILE);
|
|
334
|
+
const agentsNow = readOr(agentsFile);
|
|
335
|
+
const agentsNext = withBlock(agentsNow, agentsBlock(listed, SKILLS_DIR.split(path.sep).join('/')));
|
|
336
|
+
const agents_md = agentsNow === null ? 'created' : agentsNow === agentsNext ? 'unchanged' : 'updated';
|
|
337
|
+
if (agents_md !== 'unchanged')
|
|
338
|
+
writeAtomic(agentsFile, agentsNext);
|
|
339
|
+
const stillEdited = replaceEdits ? [] : edited;
|
|
340
|
+
return answer({ dir, index: { url: from, version: indexVersion }, added, updated, removed, edited: stillEdited, unchanged, agents_md }, warnings, stillEdited, replaceEdits, verbose);
|
|
341
|
+
}
|
|
342
|
+
function answer(data, warnings, edited, replaced, verbose) {
|
|
343
|
+
for (const k of ['added', 'updated', 'removed']) {
|
|
344
|
+
const list = data[k];
|
|
345
|
+
data[k] = verbose || list.length <= LISTED ? { count: list.length, skills: list } : { count: list.length };
|
|
346
|
+
}
|
|
347
|
+
if (!replaced) {
|
|
348
|
+
for (const e of edited) {
|
|
349
|
+
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.` });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (data.agents_md === 'created')
|
|
353
|
+
warnings.push({ code: 'agents_md_created', message: 'AGENTS.md was created, holding only the list of skills: tell the human in one line.' });
|
|
354
|
+
return envelope(data, { warnings, next: [] });
|
|
355
|
+
}
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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 (
|
|
120
|
-
|
|
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];
|
|
@@ -144,7 +161,7 @@ export function storeBooking(id, booking) {
|
|
|
144
161
|
/** The last four characters, as the API shows them. */
|
|
145
162
|
export const hintOf = (token) => `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
146
163
|
/** The nearest folder, from the current one up, that holds one of the files. */
|
|
147
|
-
function findUp(names, from = process.cwd()) {
|
|
164
|
+
export function findUp(names, from = process.cwd()) {
|
|
148
165
|
let dir = path.resolve(from);
|
|
149
166
|
for (;;) {
|
|
150
167
|
if (names.some((n) => fs.existsSync(path.join(dir, n))))
|
package/dist/confirm.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Confirmation for destructive verbs. --yes skips it. With a terminal, it asks on stderr and
|
|
2
2
|
// reads one line. With no terminal (an agent, a pipe, a schedule) it never waits: it fails with
|
|
3
|
-
// confirmation_required, and the agent asks the human,
|
|
3
|
+
// confirmation_required, next_step confirm with the command and --yes: the agent asks the human,
|
|
4
|
+
// and runs it only on their yes.
|
|
4
5
|
import readline from 'node:readline';
|
|
5
6
|
import { fail } from './errors.js';
|
|
6
7
|
export const terminal = {
|
|
@@ -40,17 +41,27 @@ export const terminal = {
|
|
|
40
41
|
});
|
|
41
42
|
},
|
|
42
43
|
};
|
|
44
|
+
/** The command the human's yes runs: the same, with --yes. */
|
|
45
|
+
export const withYes = (command) => (/\s(--yes|-y)(\s|$)/.test(command) ? command : `${command} --yes`);
|
|
46
|
+
/**
|
|
47
|
+
* action names what needs a yes ("Destroy sandbox"). sentence, when given, is what the human is
|
|
48
|
+
* shown instead, whole ("Sharing with shop.example.com: Ada Lovelace, ..."), and details go with
|
|
49
|
+
* the error, so the agent can show the human the same.
|
|
50
|
+
*/
|
|
43
51
|
export async function confirm(action, command, opts) {
|
|
44
52
|
if (opts.yes)
|
|
45
53
|
return;
|
|
46
54
|
const p = opts.prompter ?? terminal;
|
|
55
|
+
const said = opts.sentence ? `${opts.sentence.replace(/\.?$/, '.')} This` : action;
|
|
47
56
|
if (!p.interactive) {
|
|
48
|
-
// The agent asks the human
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
// The agent asks the human, and runs the command, which carries --yes, only on their yes: the
|
|
58
|
+
// action confirm is the guard (contract section 3), never fix_request.
|
|
59
|
+
fail('confirmation_required', `${said} needs the human's yes, and there is no terminal to ask in. Ask the human first, and run the command only on their yes.`, {
|
|
60
|
+
next_step: { action: 'confirm', command: withYes(command) },
|
|
61
|
+
...(opts.details ? { details: opts.details } : {}),
|
|
51
62
|
});
|
|
52
63
|
}
|
|
53
|
-
const answer = (await p.ask(`${action}. Go ahead? [y/N] `)).trim().toLowerCase();
|
|
64
|
+
const answer = (await p.ask(opts.sentence ? `${opts.sentence.replace(/\.?$/, '.')} Go ahead? [y/N] ` : `${action}. Go ahead? [y/N] `)).trim().toLowerCase();
|
|
54
65
|
if (answer !== 'y' && answer !== 'yes')
|
|
55
|
-
fail('confirmation_declined', `${action}: not confirmed, nothing was changed.`);
|
|
66
|
+
fail('confirmation_declined', opts.sentence ? `${opts.sentence.replace(/\.?$/, '')}: not confirmed, nothing was sent.` : `${action}: not confirmed, nothing was changed.`);
|
|
56
67
|
}
|