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

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.
@@ -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
@@ -6,6 +6,8 @@
6
6
  // A project folder links to its organization with .c11r.json, { "org": "<slug>" }: no secret,
7
7
  // safe to commit, found by walking up from the current folder like .git. The project folder is
8
8
  // the one with .c11r.json, else the nearest with store.yml or loops.yml, else the current one.
9
+ // A link found above is the folder's for every command but a create: sandbox create and
10
+ // organizations create link the current folder, and leave the link above as it is.
9
11
  //
10
12
  // The organization: --org, then C11R_ORG, then .c11r.json, then the stored one when exactly one
11
13
  // is stored. With several stored and nothing naming one, the command stops and names them.
@@ -110,20 +112,37 @@ export function setDefaultOrg(org) {
110
112
  delete c.default_org;
111
113
  writeCredentials(c);
112
114
  }
113
- export function outWritten(org) {
114
- return Boolean(readCredentials().out_written?.[org]);
115
+ /** A record's secret went to an --out file: remembered so its delete can say that file stays. */
116
+ export function markOutWritten(org, record) {
117
+ const keys = record.keys.filter(Boolean);
118
+ if (!keys.length)
119
+ return;
120
+ const c = readCredentials();
121
+ const all = { ...(c.out_written ?? {}) };
122
+ const list = (all[org] ?? []).filter((r) => !(r.noun === record.noun && r.keys.some((k) => keys.includes(k))));
123
+ all[org] = [...list, { noun: record.noun, keys }];
124
+ c.out_written = all;
125
+ writeCredentials(c);
115
126
  }
116
- export function markOutWritten(org, written) {
127
+ /**
128
+ * After a delete: whether that record had a secret written with --out, which is then forgotten.
129
+ * With no record named, the organization goes (sandbox destroy).
130
+ */
131
+ export function forgetOutWritten(org, record) {
117
132
  const c = readCredentials();
133
+ const list = c.out_written?.[org] ?? [];
134
+ const hit = (r) => !record || (r.noun === record.noun && r.keys.some((k) => record.keys.includes(k)));
135
+ if (!list.some(hit))
136
+ return false;
137
+ const rest = list.filter((r) => !hit(r));
118
138
  const all = { ...(c.out_written ?? {}) };
119
- if (written === Boolean(all[org]))
120
- return;
121
- if (written)
122
- all[org] = true;
139
+ if (rest.length)
140
+ all[org] = rest;
123
141
  else
124
142
  delete all[org];
125
143
  c.out_written = all;
126
144
  writeCredentials(c);
145
+ return true;
127
146
  }
128
147
  export function storedBooking(id) {
129
148
  const b = readCredentials().bookings?.[id];
@@ -144,7 +163,7 @@ export function storeBooking(id, booking) {
144
163
  /** The last four characters, as the API shows them. */
145
164
  export const hintOf = (token) => `${token.slice(0, 4)}...${token.slice(-4)}`;
146
165
  /** The nearest folder, from the current one up, that holds one of the files. */
147
- function findUp(names, from = process.cwd()) {
166
+ export function findUp(names, from = process.cwd()) {
148
167
  let dir = path.resolve(from);
149
168
  for (;;) {
150
169
  if (names.some((n) => fs.existsSync(path.join(dir, n))))
@@ -174,15 +193,23 @@ export function readLink() {
174
193
  const org = raw?.org;
175
194
  if (typeof org !== 'string' || !org)
176
195
  fail('invalid_config', `${file} has no org.`, { path: `${LINK_FILE}.org` });
177
- return { file, org };
196
+ return { file, org, here: dir === path.resolve(process.cwd()) };
178
197
  }
179
- /** Links the project folder to the organization; returns the file and the org it linked before. */
180
- export function writeLink(org) {
181
- const existing = readLink();
198
+ /**
199
+ * Links the project folder to the organization; returns the file and the org it linked before.
200
+ * With here, the current folder, whatever a folder above links: a create makes a new project.
201
+ */
202
+ export function writeLink(org, opts = {}) {
203
+ const found = readLink();
204
+ const existing = opts.here && !found?.here ? null : found;
182
205
  const file = existing?.file ?? path.join(process.cwd(), LINK_FILE);
183
206
  fs.writeFileSync(file, JSON.stringify({ org }, null, 2) + '\n');
184
207
  return { file, previous: existing && existing.org !== org ? existing.org : null };
185
208
  }
209
+ /** The warning of a create that linked this folder and left the link of a folder above as it is. */
210
+ export function parentLinkKept(above) {
211
+ return { code: 'parent_link_kept', message: `The folder above links ${above.org} (${above.file}); it stays as it is, and this folder now has its own link. Tell the human only that the shop in the folder above stays as it is.` };
212
+ }
186
213
  /** Removes the link, only when it names this organization. */
187
214
  export function removeLink(org) {
188
215
  const link = readLink();
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, then repeats the command with --yes.
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; on a yes it repeats the command with --yes.
49
- fail('confirmation_required', `${action} needs confirmation, and there is no terminal to ask in. Ask the human whether to go ahead.`, {
50
- next_step: { action: 'fix_request', command: `${command} --yes` },
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
  }