yadflow 4.2.0 → 4.4.0

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/cli/toolbox.mjs CHANGED
@@ -46,33 +46,75 @@ const TIER_TITLES = { core: 'Core — offered at setup', recommended: 'Recommend
46
46
 
47
47
  const CHECKED = '2026-10-01';
48
48
 
49
+ // ---- vetting (E88) --------------------------------------------------------------------------------
50
+ //
51
+ // EVERY ENTRY IS VETTED BEFORE IT SHIPS, and the vetting is redone before it goes stale. `checked` above
52
+ // is when an entry's INSTALL routes were read; `vetted` is the proof that the tool is what it claims:
53
+ //
54
+ // on the day the vetting was done. Per entry: they all start on VETTED_ON, and re-vetting ONE
55
+ // tool gives that entry its own date literal rather than moving the shared one
56
+ // licenceFrom where the licence was read — the repository's LICENSE file, or, for a closed tool, the
57
+ // vendor's own terms page. `licence` on the entry is the answer; this is the proof
58
+ // source the official home the tool comes from (its owner's repository or docs)
59
+ // release the source's latest release tag on that day, or null where none is published. A tag is
60
+ // provenance, not a version: tags read `v1.18.1`, `skill-v4.5.0`, `cli-2.11.0`
61
+ // packages each registry package an install route names, confirmed to point back to `source`
62
+ // (npm `repository`, or the source's own README naming it), with the version on that day
63
+ // note anything a reader of the record has to know — a missing LICENSE, a rebrand
64
+ //
65
+ // Licence and source only, as decided with the row: no maintenance score, no security scan, and no
66
+ // tested version range — `versions` stays null. A team's own tools (`yad toolbox add --custom`) are the
67
+ // team's to vet; yad vets only the list it ships.
68
+ //
69
+ // TWO GATES. The unit suite holds every record complete (`vettingProblems`); the release check refuses
70
+ // to publish while any record is older than VET_MAX_AGE_DAYS (`staleVettings`, `scripts/vet-check.mjs`).
71
+ // The age is checked only at release, never in the unit suite, so `main` does not turn red by itself.
72
+ const VETTED_ON = '2026-10-02';
73
+ export const VET_MAX_AGE_DAYS = 180;
74
+ const VET_REGISTRIES = Object.freeze(['npm', 'pypi']);
75
+
49
76
  // The list itself, checked against each tool's own repository and docs on CHECKED. `install` keeps only
50
77
  // the routes the roadmap's four types describe (plugin, npm, python, script); a tool's other routes
51
78
  // (Docker, Homebrew, a VS Code extension) are in its own README, which `source` links. `versions` is null
52
- // everywhere for now: no bound has been tested yet — E88 vets each default and is where a known-good
53
- // range is earned. `records` is the exact line a skill writes when the tool is absent, so E87 can point
54
- // the skill at this entry without renaming anything.
79
+ // everywhere: no bound has been tested, and E88 vets licence and source, not a version range. `records`
80
+ // is the exact line a skill writes when the tool is absent. Each skill in
81
+ // `usedBy` quotes `fallback` and `records` word for word in its "When a tool is missing" section (E87):
82
+ // change the words here, then rewrite the sections with `skillFallbackSection`.
55
83
  export const TOOLBOX = Object.freeze([
56
84
  // ---- core: offered at setup --------------------------------------------------------------------
57
85
  {
58
86
  id: 'repomix', name: 'Repomix', tier: 'core', role: 'packs each code repo into one file the Shape steps read',
59
87
  licence: 'MIT', source: 'https://github.com/yamadashy/repomix', checked: CHECKED,
88
+ vetted: {
89
+ on: VETTED_ON, licenceFrom: 'https://github.com/yamadashy/repomix/blob/main/LICENSE',
90
+ source: 'https://github.com/yamadashy/repomix', release: 'v1.18.1',
91
+ packages: [{ registry: 'npm', name: 'repomix', version: '1.18.1' }],
92
+ note: null,
93
+ },
60
94
  install: [
61
- { type: 'npm', command: 'npx repomix@latest' },
62
- { type: 'npm', command: 'npm install -g repomix' },
95
+ { type: 'npm', command: 'npx repomix@1.18.1' },
96
+ { type: 'npm', command: 'npm install -g repomix@1.18.1' },
63
97
  { type: 'plugin', command: '/plugin marketplace add yamadashy/repomix, then /plugin install repomix-mcp@repomix' },
64
98
  ],
65
99
  manual: null,
66
- // yad runs it through `npx repomix@latest` (setup, `yad repo refresh`), so `npx` alone makes it usable.
100
+ // yad runs it through npx, at the vetted version (setup, `yad repo refresh`; REPOMIX_VERSION below),
101
+ // so `npx` alone makes it usable.
67
102
  detect: { npx: true, bins: ['repomix'], plugins: ['repomix-mcp', 'repomix-commands', 'repomix-explorer'], mcp: ['repomix'], skills: ['repomix-explorer'] },
68
103
  versions: null,
69
- fallback: 'no code pack: repos are not packed, and the Shape steps read the code map alone',
70
- records: null, note: null,
71
- usedBy: ['yad setup', 'yad repo refresh', 'yad-connect-repos'],
104
+ fallback: 'no Repomix pack. yad-connect-repos and yad-backfill put the same context together by hand, from the source tree and the recent git log, and the Shape steps read the code map',
105
+ records: 'source: repomix-unavailable',
106
+ note: 'yad setup and yad repo refresh skip the pack only when npx is missing. They do not follow a team skip.',
107
+ usedBy: ['yad setup', 'yad repo refresh', 'yad-connect-repos', 'yad-backfill'],
72
108
  },
73
109
  {
74
110
  id: 'spec-kit', name: 'Spec Kit', tier: 'core', role: 'runs the spec ceremony (specify → plan → tasks) in a code repo',
75
111
  licence: 'MIT', source: 'https://github.com/github/spec-kit', checked: CHECKED,
112
+ vetted: {
113
+ on: VETTED_ON, licenceFrom: 'https://github.com/github/spec-kit/blob/main/LICENSE',
114
+ source: 'https://github.com/github/spec-kit', release: 'v1.0.13',
115
+ packages: [{ registry: 'pypi', name: 'specify-cli', version: '1.0.13' }],
116
+ note: "PyPI's specify-cli lists no project links and no provenance; Spec Kit's own README names `uv tool install specify-cli`, which is the link to the source.",
117
+ },
76
118
  install: [
77
119
  { type: 'python', command: 'uv tool install specify-cli, then specify init <project> --integration claude' },
78
120
  { type: 'python', command: 'pipx install specify-cli' },
@@ -89,6 +131,12 @@ export const TOOLBOX = Object.freeze([
89
131
  {
90
132
  id: 'impeccable', name: 'Impeccable', tier: 'core', role: 'drives the UI design step (document, extract, craft)',
91
133
  licence: 'Apache-2.0', source: 'https://github.com/pbakaus/impeccable', checked: CHECKED,
134
+ vetted: {
135
+ on: VETTED_ON, licenceFrom: 'https://github.com/pbakaus/impeccable/blob/main/LICENSE',
136
+ source: 'https://github.com/pbakaus/impeccable', release: 'skill-v4.5.0',
137
+ packages: [{ registry: 'npm', name: 'impeccable', version: '4.1.0' }],
138
+ note: "The skill and the npm installer are versioned separately: the release tag is the skill, the npm version is the installer.",
139
+ },
92
140
  install: [
93
141
  { type: 'npm', command: 'npx impeccable install' },
94
142
  { type: 'plugin', command: '/plugin marketplace add pbakaus/impeccable, then /plugin install impeccable@impeccable' },
@@ -96,7 +144,7 @@ export const TOOLBOX = Object.freeze([
96
144
  manual: null,
97
145
  detect: { skills: ['impeccable'], plugins: ['impeccable'] },
98
146
  versions: null,
99
- fallback: 'Markdown-only UI design: yad-ui writes ui-design.md and DESIGN.md directly',
147
+ fallback: 'markdown-only UI design: yad-ui writes ui-design.md and DESIGN.md directly',
100
148
  records: 'impeccable: not-installed', note: null,
101
149
  usedBy: ['yad-ui'],
102
150
  },
@@ -106,6 +154,12 @@ export const TOOLBOX = Object.freeze([
106
154
  id: 'bmad-method', name: 'BMAD-METHOD', tier: 'recommended', role: 'a pool of planning and building skills',
107
155
  // The LICENSE file is MIT text with attribution added (GitHub reads it as NOASSERTION); "BMad" is a trademark.
108
156
  licence: 'MIT', source: 'https://github.com/bmad-code-org/BMAD-METHOD', checked: CHECKED,
157
+ vetted: {
158
+ on: VETTED_ON, licenceFrom: 'https://github.com/bmad-code-org/BMAD-METHOD/blob/main/LICENSE',
159
+ source: 'https://github.com/bmad-code-org/BMAD-METHOD', release: 'v6.12.0',
160
+ packages: [{ registry: 'npm', name: 'bmad-method', version: '6.12.0' }],
161
+ note: "GitHub reads the LICENSE as NOASSERTION: it is MIT text with an attribution clause added; npm declares MIT.",
162
+ },
109
163
  install: [
110
164
  { type: 'npm', command: 'npx bmad-method install' },
111
165
  { type: 'plugin', command: '/plugin marketplace add bmad-code-org/bmad-plugins, then install bmad-method from /plugin' },
@@ -121,6 +175,12 @@ export const TOOLBOX = Object.freeze([
121
175
  {
122
176
  id: 'ecc', name: 'ECC (Everything Claude Code)', tier: 'recommended', role: 'a large pool of skills, agents and hooks',
123
177
  licence: 'MIT', source: 'https://github.com/affaan-m/ECC', checked: CHECKED,
178
+ vetted: {
179
+ on: VETTED_ON, licenceFrom: 'https://github.com/affaan-m/ECC/blob/main/LICENSE',
180
+ source: 'https://github.com/affaan-m/ECC', release: 'v2.2.3',
181
+ packages: [{ registry: 'npm', name: 'ecc-universal', version: '2.2.3' }],
182
+ note: null,
183
+ },
124
184
  install: [
125
185
  { type: 'npm', command: 'npx ecc-universal install --guided' },
126
186
  { type: 'plugin', command: '/plugin marketplace add https://github.com/affaan-m/ECC, then /plugin install ecc@ecc' },
@@ -137,6 +197,12 @@ export const TOOLBOX = Object.freeze([
137
197
  {
138
198
  id: 'mattpocock-skills', name: 'mattpocock/skills', tier: 'recommended', role: 'a small pool of engineering skills',
139
199
  licence: 'MIT', source: 'https://github.com/mattpocock/skills', checked: CHECKED,
200
+ vetted: {
201
+ on: VETTED_ON, licenceFrom: 'https://github.com/mattpocock/skills/blob/main/LICENSE',
202
+ source: 'https://github.com/mattpocock/skills', release: 'v1.2.3',
203
+ packages: [],
204
+ note: null,
205
+ },
140
206
  install: [{ type: 'plugin', command: 'claude plugins install mattpocock-skills' }],
141
207
  manual: null,
142
208
  // Generic names (`tdd`, `code-review`) collide with other packs; this one is its own.
@@ -151,6 +217,12 @@ export const TOOLBOX = Object.freeze([
151
217
  {
152
218
  id: 'figma', name: 'Figma', tier: 'connector', role: 'design tool: yad-ui generates and links screens in it',
153
219
  licence: 'proprietary', source: 'https://developers.figma.com/docs/figma-mcp-server/', checked: CHECKED,
220
+ vetted: {
221
+ on: VETTED_ON, licenceFrom: 'https://www.figma.com/legal/tos/',
222
+ source: 'https://github.com/figma/mcp-server-guide', release: null,
223
+ packages: [],
224
+ note: "The plugin comes from Anthropic's official marketplace, which points at figma/mcp-server-guide; that repository has no LICENSE file, so the proof of terms is Figma's own.",
225
+ },
154
226
  install: [{ type: 'plugin', command: 'claude plugin install figma@claude-plugins-official' }],
155
227
  manual: 'https://developers.figma.com/docs/figma-mcp-server/',
156
228
  detect: { plugins: ['figma'], mcp: ['figma', 'figma-desktop', 'html-to-design'] },
@@ -162,6 +234,12 @@ export const TOOLBOX = Object.freeze([
162
234
  {
163
235
  id: 'pencil', name: 'Pencil (pen.dev)', tier: 'connector', role: 'design tool: yad-ui writes .pen web and mobile screens',
164
236
  licence: 'proprietary', source: 'https://docs.pencil.dev/getting-started/installation', checked: CHECKED,
237
+ vetted: {
238
+ on: VETTED_ON, licenceFrom: 'https://www.pen.dev/terms-of-use',
239
+ source: 'https://docs.pencil.dev/getting-started/installation', release: null,
240
+ packages: [],
241
+ note: "Pencil now publishes as pen.dev; its terms live there and the old docs address still answers.",
242
+ },
165
243
  install: [],
166
244
  manual: 'https://docs.pencil.dev/getting-started/installation',
167
245
  detect: { mcp: ['pencil'] },
@@ -173,6 +251,12 @@ export const TOOLBOX = Object.freeze([
173
251
  {
174
252
  id: 'playwright', name: 'Playwright MCP', tier: 'connector', role: 'testing tool: generates and runs browser and API tests',
175
253
  licence: 'Apache-2.0', source: 'https://github.com/microsoft/playwright-mcp', checked: CHECKED,
254
+ vetted: {
255
+ on: VETTED_ON, licenceFrom: 'https://github.com/microsoft/playwright-mcp/blob/main/LICENSE',
256
+ source: 'https://github.com/microsoft/playwright-mcp', release: 'v0.0.83',
257
+ packages: [{ registry: 'npm', name: '@playwright/mcp', version: '0.0.83' }],
258
+ note: null,
259
+ },
176
260
  install: [{ type: 'npm', command: 'claude mcp add playwright npx @playwright/mcp@latest' }],
177
261
  manual: null,
178
262
  detect: { mcp: ['playwright'], plugins: ['playwright'] },
@@ -185,6 +269,12 @@ export const TOOLBOX = Object.freeze([
185
269
  {
186
270
  id: 'cypress', name: 'Cypress', tier: 'connector', role: 'testing tool: Cypress specs',
187
271
  licence: 'proprietary', source: 'https://docs.cypress.io/cloud/integrations/cloud-mcp', checked: CHECKED,
272
+ vetted: {
273
+ on: VETTED_ON, licenceFrom: 'https://www.cypress.io/terms-of-use',
274
+ source: 'https://docs.cypress.io/cloud/integrations/cloud-mcp', release: null,
275
+ packages: [],
276
+ note: null,
277
+ },
188
278
  install: [],
189
279
  manual: 'https://docs.cypress.io/cloud/integrations/cloud-mcp',
190
280
  detect: { mcp: ['cypress', 'cypress-cloud'] },
@@ -197,6 +287,12 @@ export const TOOLBOX = Object.freeze([
197
287
  {
198
288
  id: 'pytest', name: 'pytest', tier: 'connector', role: 'testing tool: service-layer tests',
199
289
  licence: 'MIT', source: 'https://github.com/pytest-dev/pytest', checked: CHECKED,
290
+ vetted: {
291
+ on: VETTED_ON, licenceFrom: 'https://github.com/pytest-dev/pytest/blob/main/LICENSE',
292
+ source: 'https://github.com/pytest-dev/pytest', release: '9.1.1',
293
+ packages: [],
294
+ note: null,
295
+ },
200
296
  install: [],
201
297
  manual: 'https://docs.pytest.org/',
202
298
  detect: { mcp: ['pytest'] },
@@ -209,6 +305,12 @@ export const TOOLBOX = Object.freeze([
209
305
  {
210
306
  id: 'maestro', name: 'Maestro', tier: 'connector', role: 'testing tool: mobile UI flows',
211
307
  licence: 'Apache-2.0', source: 'https://github.com/mobile-dev-inc/Maestro', checked: CHECKED,
308
+ vetted: {
309
+ on: VETTED_ON, licenceFrom: 'https://github.com/mobile-dev-inc/Maestro/blob/main/LICENSE',
310
+ source: 'https://github.com/mobile-dev-inc/Maestro', release: 'cli-2.11.0',
311
+ packages: [],
312
+ note: null,
313
+ },
212
314
  install: [{ type: 'script', command: 'curl -fsSL "https://get.maestro.mobile.dev" | bash, then claude mcp add maestro -- maestro mcp' }],
213
315
  manual: 'https://docs.maestro.dev/get-started/maestro-mcp',
214
316
  detect: { bins: ['maestro'], mcp: ['maestro'] },
@@ -220,6 +322,12 @@ export const TOOLBOX = Object.freeze([
220
322
  {
221
323
  id: 'deeptutor', name: 'DeepTutor', tier: 'connector', role: 'learning tool: tutors a team member on what is being built',
222
324
  licence: 'Apache-2.0', source: 'https://github.com/HKUDS/DeepTutor', checked: CHECKED,
325
+ vetted: {
326
+ on: VETTED_ON, licenceFrom: 'https://github.com/HKUDS/DeepTutor/blob/main/LICENSE',
327
+ source: 'https://github.com/HKUDS/DeepTutor', release: 'v1.6.12',
328
+ packages: [{ registry: 'pypi', name: 'deeptutor', version: '1.6.12' }],
329
+ note: "PyPI's deeptutor lists no project links; DeepTutor's own README names `pip install -U deeptutor`, which is the link to the source.",
330
+ },
223
331
  install: [{ type: 'python', command: 'pip install -U deeptutor, then deeptutor init' }],
224
332
  manual: null,
225
333
  detect: { bins: ['deeptutor'] },
@@ -263,13 +371,89 @@ export function toolProblems(t) {
263
371
  }
264
372
  need(t?.versions === null || (typeof t?.versions === 'string' && parseRange(t.versions) !== null), 'versions is null or a range this reader understands');
265
373
  need(typeof t?.fallback === 'string' && t.fallback.length > 0, 'fallback says what yadflow does without it');
374
+ // A skill's section adds the full stop (E87), so a fallback ending in one would print two.
375
+ need(typeof t?.fallback !== 'string' || !/[.\s]$/.test(t.fallback), 'fallback ends without a full stop or a space');
266
376
  need(t?.records === null || (typeof t?.records === 'string' && t.records.length > 0), 'records is null or the line a skill writes');
267
377
  need(t?.note === null || (typeof t?.note === 'string' && t.note.length > 0), 'note is null or a sentence');
268
378
  need(t?.manual === null || (typeof t?.manual === 'string' && /^https:\/\//.test(t.manual)), 'manual is null or an https URL');
269
379
  need(isStringList(t?.usedBy), 'usedBy lists the skills or commands that use it');
380
+ need(t?.tier !== 'connector' || !isStringList(t?.usedBy) || connectorFile(t) !== null, 'a connector names the yad-connect-* skill that connects it');
381
+ return out;
382
+ }
383
+
384
+ // Every way a SHIPPED entry's vetting record is incomplete, as sentences (E88). [] means vetted. Kept out of
385
+ // `toolProblems` because a team's own tool is well formed without one: yad vets only what it ships.
386
+ export function vettingProblems(t) {
387
+ const out = [];
388
+ const need = (ok, what) => { if (!ok) out.push(what); };
389
+ const v = t?.vetted;
390
+ need(v && typeof v === 'object' && !Array.isArray(v), 'vetted is set — a tool ships only once its licence and source are vetted');
391
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return out;
392
+ need(isCalendarDay(v.on), 'vetted.on is a real YYYY-MM-DD date');
393
+ need(typeof v.licenceFrom === 'string' && HTTPS_URL.test(v.licenceFrom), 'vetted.licenceFrom is the https URL the licence was read from');
394
+ need(typeof v.source === 'string' && HTTPS_URL.test(v.source), 'vetted.source is the https URL of the official home');
395
+ need(v.release === null || (typeof v.release === 'string' && v.release.length > 0), 'vetted.release is null or the release tag on that day');
396
+ need(Array.isArray(v.packages), 'vetted.packages is a list');
397
+ for (const p of Array.isArray(v.packages) ? v.packages : []) {
398
+ need(VET_REGISTRIES.includes(p?.registry), `a vetted package's registry is one of ${VET_REGISTRIES.join(', ')}`);
399
+ need(typeof p?.name === 'string' && p.name.length > 0, 'a vetted package has its name');
400
+ need(parseVersion(p?.version)?.full === true, 'a vetted package has the full version that was vetted (1.2.3)');
401
+ }
402
+ need(v.note === null || (typeof v.note === 'string' && v.note.length > 0), 'vetted.note is null or a sentence');
403
+ // A closed tool has no LICENSE file to read, so its proof must be the vendor's terms, not a repo file.
404
+ need(t.licence !== 'proprietary' || !isRepoLicenceFile(v.licenceFrom), 'a proprietary tool\'s licence proof is its terms page');
270
405
  return out;
271
406
  }
272
407
 
408
+ // An https URL with a host, not just the scheme.
409
+ const HTTPS_URL = /^https:\/\/[^/\s]+\.[^/\s]+/;
410
+ // Is this URL a licence FILE in a code repository (LICENSE, license, LICENSE-MIT, License.md, COPYING)?
411
+ // Decided by WHERE the URL points, not by its letter case (review 4): a repository file is shown under a
412
+ // `/blob/` or `/raw/` path (GitHub, GitLab's `/-/blob/`, Bitbucket, Codeberg, most self-hosted forges) or
413
+ // served from raw.githubusercontent.com. A repository's front page (`github.com/org/license-tool`) is not
414
+ // a file, and a vendor's terms page at `/license` or `/license-agreement` is the right proof for a closed
415
+ // tool. The query and fragment are not the file; a percent-encoded name is read decoded. `new URL`
416
+ // already lowercases the host.
417
+ const RAW_HOSTS = new Set(['raw.githubusercontent.com']);
418
+ export function isRepoLicenceFile(url) {
419
+ let u;
420
+ try { u = new URL(String(url)); } catch { return false; }
421
+ let pathname = u.pathname;
422
+ try { pathname = decodeURIComponent(pathname); } catch { /* a stray % — read it as written */ }
423
+ const inRepo = RAW_HOSTS.has(u.hostname) || /\/(blob|raw|src)\//.test(pathname);
424
+ const file = pathname.split('/').filter(Boolean).at(-1) || '';
425
+ return inRepo && /^(licen[cs]e|copying)([-_.].*)?$/i.test(file);
426
+ }
427
+
428
+ // A YYYY-MM-DD string that names a real day. `Date.parse` rolls an impossible day forward (2026-02-30
429
+ // becomes 2 March) instead of refusing it, so the day has to come back out unchanged.
430
+ export const isCalendarDay = (s) => {
431
+ if (typeof s !== 'string' || !DATE.test(s)) return false;
432
+ const t = Date.parse(`${s}T00:00:00Z`);
433
+ return !Number.isNaN(t) && new Date(t).toISOString().slice(0, 10) === s;
434
+ };
435
+
436
+ // How many whole days old a vetting record is on `today` (a Date). A parameter, never `new Date()` here,
437
+ // so a test can pass any day and the unit suite never ages by itself.
438
+ export const vettingAge = (t, today) =>
439
+ Math.floor((Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) - Date.parse(`${t.vetted.on}T00:00:00Z`)) / 86400000);
440
+
441
+ // The shipped entries whose vetting cannot be trusted on `today`: `[{ id, name, on, age, why }]`, where
442
+ // `why` is 'stale' (older than VET_MAX_AGE_DAYS) or 'future' (a date after today — a typo like 2027 for
443
+ // 2026 would otherwise keep a tool out of the gate for a year). The release check refuses to publish
444
+ // while this is not empty (`scripts/vet-check.mjs`).
445
+ export const staleVettings = (today, list = TOOLBOX) => list
446
+ .filter((t) => vettingProblems(t).length === 0)
447
+ .map((t) => ({ id: t.id, name: t.name, on: t.vetted.on, age: vettingAge(t, today) }))
448
+ .filter((s) => s.age > VET_MAX_AGE_DAYS || s.age < 0)
449
+ .map((s) => ({ ...s, why: s.age < 0 ? 'future' : 'stale' }));
450
+
451
+ // The ONE Repomix version yad runs (E88): the npm package version its vetting confirmed. `yad setup`,
452
+ // `yad repo refresh` and the install line use it; the skills write it out as text, and a test holds every
453
+ // npx Repomix command in cli/ and skills/ to this value. Moving up is a re-vet and a yadflow release.
454
+ export const REPOMIX_VERSION = TOOLBOX.find((t) => t.id === 'repomix').vetted.packages
455
+ .find((p) => p.registry === 'npm' && p.name === 'repomix').version;
456
+
273
457
  // ---- versions ---------------------------------------------------------------------------------------
274
458
 
275
459
  // `1.2.3`, `v1.2`, `3` → [1,2,3] / [1,2,0] / [3,0,0]; a pre-release (`1.2.3-beta`) is below its release.
@@ -471,6 +655,8 @@ export function customProblems(t) {
471
655
  const customRow = (t) => ({
472
656
  id: t.id, name: t.name || t.id, tier: 'project', role: t.role,
473
657
  licence: null, source: t.source ?? null, checked: null,
658
+ // A team's own tool is the team's to vet (E88): yad vets only the list it ships.
659
+ vetted: null,
474
660
  install: t.install ?? [], manual: null,
475
661
  detect: Object.fromEntries(Object.values(DETECT_KINDS).filter((k) => t.detect[k]?.length).map((k) => [k, t.detect[k]])),
476
662
  versions: null, fallback: t.fallback, records: null, note: null, usedBy: [],
@@ -533,6 +719,240 @@ export function toolUse(tool, { shipped, connected }) {
533
719
  return { used: false, because: null };
534
720
  }
535
721
 
722
+ // ---- what a skill does when its tool is missing (E87) ------------------------------------------------
723
+ //
724
+ // Every skill a shipped tool's `usedBy` names carries one section, built here, so its words are the
725
+ // toolbox's words and a test can hold the two together. A skill decides from yad's own answer — one rule
726
+ // for every skill, which already sees a plugin turned off in Claude Code — not from its own guess:
727
+ // core used when `yad toolbox list --json` says `used: true` and the state is `installed` or
728
+ // `available`. A team's `skip` (`yad toolbox remove <id>`) means the fallback, even when the
729
+ // tool is installed.
730
+ // connector used when the Product's design, testing or learning file connects it. The connect skill
731
+ // writes that file and the step skill follows it, as before E87.
732
+ // Recommended tools are bound with `yad skill bind`, so no skill names one.
733
+ export const SKILL_FALLBACK_HEADING = '## When a tool is missing';
734
+ // The section's last line. The section is exactly the lines from its heading to this marker, so a
735
+ // rewrite never has to guess from the Markdown where the section ends (rounds 5 to 9 of the E87 review
736
+ // each found one more way such a guess could cut part of a skill).
737
+ export const SKILL_FALLBACK_END = '<!-- end: When a tool is missing -->';
738
+ const CONNECT_FILES = { 'yad-connect-design': 'designConfig', 'yad-connect-testing': 'testingConfig', 'yad-connect-learning': 'learningConfig' };
739
+
740
+ // The Product file that connects a connector: the one its `yad-connect-*` skill writes.
741
+ export function connectorFile(tool) {
742
+ const skill = tool.usedBy.find((u) => CONNECT_FILES[u]);
743
+ return skill ? PROJECT_FILES[CONNECT_FILES[skill]] : null;
744
+ }
745
+
746
+ // The skills (folders under skills/, not `yad <command>`s) that use a tool.
747
+ export const skillsUsing = (tool) => tool.usedBy.filter((u) => !u.startsWith('yad '));
748
+
749
+ export function skillFallbackSection(skill, tools = TOOLBOX) {
750
+ const mine = tools.filter((t) => t.tier !== 'recommended' && skillsUsing(t).includes(skill));
751
+ if (!mine.length) return null;
752
+ const core = mine.filter((t) => t.tier !== 'connector');
753
+ const connectors = mine.filter((t) => t.tier === 'connector');
754
+ const lines = [
755
+ SKILL_FALLBACK_HEADING,
756
+ '',
757
+ '<!-- Written from yadflow\'s cli/toolbox.mjs (E87). Change the words there: a test holds this section to it. -->',
758
+ '',
759
+ 'No tool below is required. When one is missing, this skill still finishes, using the fallback written',
760
+ 'beside it.',
761
+ '',
762
+ ];
763
+ // Only a skill with a core tool asks yad: a connector is decided by its Product file, and an answer the
764
+ // skill must then ignore would only confuse it.
765
+ if (core.length) {
766
+ lines.push(
767
+ // Name the tools: "the one without a connector" means nothing in a skill that has no connector.
768
+ `For ${core.map((t) => t.name).join(' and ')}${connectors.length ? ` (${core.length === 1 ? 'the one tool here that is not a connector' : 'the tools here that are not connectors'})` : ''}, decide from yad's answer, not from a guess:`,
769
+ 'run `yad toolbox list --json` and read, in its `tools` list, the entry with the tool\'s `id`. Run it',
770
+ 'where the tool will run: for work inside a code repo, add `--dir <that repo>`, so the tools installed',
771
+ 'there are found.',
772
+ '',
773
+ '- **The team\'s choices.** They come from the Product. If the answer\'s `product` is null, yad found no',
774
+ ' Product, so a tool the team chose not to use still shows `used: true`. Tell the person that.',
775
+ '- **When yad cannot see the tool.** yad does not read every way a tool can be set up (an older install',
776
+ ' may be missed). If the entry says `missing` but the check in the steps below finds the tool, use',
777
+ ' it — but never when the entry says `used: false`.',
778
+ '- **When yad cannot answer** (yad is not installed here, or the command fails), use the check in the',
779
+ ' steps below.',
780
+ '',
781
+ );
782
+ }
783
+ lines.push('Tell the person which tools you used and which fallbacks, and why.', '');
784
+ for (const t of core) {
785
+ lines.push(`- **${t.name}** (\`${t.id}\`). Use it when its entry has \`used: true\` and \`status.state\` is \`installed\` or \`available\`. Otherwise — not found, its plugin turned off in Claude Code, or the team chose not to use it (\`yad toolbox remove ${t.id}\`) — use the fallback.`);
786
+ lines.push(` - Fallback — ${t.fallback}.`);
787
+ if (t.records) lines.push(` - Record \`${t.records}\`. It means the tool was not used, whatever the reason.`);
788
+ }
789
+ for (const t of connectors) {
790
+ const file = connectorFile(t);
791
+ const connect = t.usedBy.find((u) => CONNECT_FILES[u]);
792
+ // The connect skill is the one that decides and writes the file; every other skill follows it.
793
+ lines.push(CONNECT_FILES[skill]
794
+ ? `- **${t.name}** (\`${t.id}\`, a connector). This skill connects it, by writing \`${file}\` as the steps below say. The other skills follow that file, not yad's toolbox answer.`
795
+ : `- **${t.name}** (\`${t.id}\`, a connector). Used when \`${file}\` connects it. ${connect} writes that file; follow it, not yad's toolbox answer.`);
796
+ lines.push(` - Fallback — ${t.fallback}.`);
797
+ if (t.records) lines.push(` - Record \`${t.records}\`. It means the tool was not used, whatever the reason.`);
798
+ }
799
+ lines.push('', SKILL_FALLBACK_END);
800
+ return lines.join('\n');
801
+ }
802
+
803
+ // Where the section is in a skill's text: from its heading down to its end marker, both included. A
804
+ // line inside a fenced code block is never either, so an example that quotes them is skipped. A fence
805
+ // closes only with the same character, at least as many times, and nothing after it (CommonMark).
806
+ // A heading may be indented up to three spaces and end in spaces or closing `#`s, as CommonMark allows.
807
+ // `first`, where a new section goes, is the first `##` heading: a skill's `#` title is above it.
808
+ // `[^]*`, not `.*`: `.` stops at a lone \r or a Unicode line break, and `$` then forces a slow retry.
809
+ const HEADING = /^ {0,3}(#{1,2})(?:[ \t]+([^]*))?$/;
810
+ // Only spaces and tabs are trimmed, as CommonMark does — by hand, since /[ \t]+$/ backtracks.
811
+ const trimSpaces = (s) => { let k = s.length; while (k > 0 && (s[k - 1] === ' ' || s[k - 1] === '\t')) k--; return s.slice(0, k); };
812
+ const headingOf = (line) => {
813
+ const h = HEADING.exec(line);
814
+ if (!h) return null;
815
+ // Cut a closing run of `#`s only when a space or tab comes before it (`## C#` keeps its `#`).
816
+ // Walked back by hand: a pattern for the run of `#`s restarts at each one, which is slow on a long run.
817
+ let text = trimSpaces(h[2] ?? '');
818
+ let j = text.length;
819
+ while (j > 0 && text[j - 1] === '#') j--;
820
+ if (j < text.length && (j === 0 || text[j - 1] === ' ' || text[j - 1] === '\t')) text = trimSpaces(text.slice(0, j));
821
+ return { level: h[1].length, text };
822
+ };
823
+
824
+ // Is this line the section's heading, in any spelling the finder accepts (indent, case, trailing spaces
825
+ // or `#`s)? One rule, so the finder and the test that counts copies cannot disagree.
826
+ export function isFallbackHeading(line) {
827
+ const h = headingOf(line.replace(/\r$/, ''));
828
+ return h?.level === 2 && h.text.toLowerCase() === SKILL_FALLBACK_HEADING.slice(3).toLowerCase();
829
+ }
830
+
831
+ const isFallbackEnd = (line) => trimSpaces(line.replace(/\r$/, '')) === SKILL_FALLBACK_END;
832
+
833
+ function sectionBounds(lines) {
834
+ let fence = null;
835
+ const headings = [];
836
+ const ends = [];
837
+ // A YAML frontmatter block at the top (`---` … `---`, after any byte-order mark) is the skill's
838
+ // metadata, never Markdown: a heading-like line there (a `## comment`, a quoted heading in a YAML
839
+ // value) is neither our heading nor where a new section goes, and a fence there is not one. Only our
840
+ // exact end marker is looked for in it: a section that seems to sit inside (a file opening with a
841
+ // `---` divider) then shows as a marker with no heading, and is refused rather than hidden — so a
842
+ // write never runs a section across the block's closing `---`. A copy without a marker inside such a
843
+ // block reads as metadata, as a YAML loader would read it, and is left alone. The block closes at `---`
844
+ // or `...`, trailing spaces allowed, as common frontmatter readers accept.
845
+ let skip = 0;
846
+ // Only when the next line is a YAML key, quoted or not (every SKILL.md opens `name:`): a `---` divider
847
+ // above a title is Markdown, and treating it as frontmatter could hide a section. A YAML comment is not
848
+ // enough: `# T` is a title too.
849
+ if (trimSpaces(lines[0]?.replace(/^\uFEFF/, '') ?? '') === '---' && /^([A-Za-z_][\w-]*[ \t]*:|["'][^"']+["'][ \t]*:)/.test(lines[1] ?? '')) {
850
+ const close = lines.findIndex((l, i) => i > 0 && (trimSpaces(l) === '---' || trimSpaces(l) === '...'));
851
+ if (close > 0) skip = close + 1;
852
+ }
853
+ let fenceAt = -1;
854
+ lines.forEach((l, i) => {
855
+ if (i < skip) {
856
+ if (isFallbackEnd(l)) ends.push(i);
857
+ return;
858
+ }
859
+ const f = /^ {0,3}(`{3,}|~{3,})([^]*)$/.exec(l);
860
+ if (fence) {
861
+ if (f && f[1][0] === fence[0] && f[1].length >= fence.length && f[2].trim() === '') fence = null;
862
+ } else if (f && !(f[1][0] === '`' && f[2].includes('`'))) { fence = f[1]; fenceAt = i; }
863
+ else if (isFallbackEnd(l)) ends.push(i);
864
+ else {
865
+ const h = headingOf(l);
866
+ if (h) headings.push({ i, level: h.level, ours: isFallbackHeading(l) });
867
+ }
868
+ });
869
+ const ours = headings.filter((h) => h.ours).map((h) => h.i);
870
+ const at = ours[0];
871
+ const end = at === undefined ? undefined : ends.find((i) => i > at);
872
+ // Why a section cannot be rewritten safely, and what to do, or null. Without its marker, where the
873
+ // section ends is a guess; behind a fence that never closes, nothing after it can be seen at all.
874
+ const problem = fence !== null ? `a code fence is never closed (it opens on line ${fenceAt + 1}), so yad cannot see past it — close it`
875
+ : ours.length > 1 ? 'the section\'s heading is there more than once — delete one of the copies (one without an end marker, if there is one), from its heading down to the end of its text'
876
+ : ends.length > 1 ? 'the section\'s end marker is there more than once — delete the extra marker line'
877
+ : at === undefined ? (ends.length ? 'the end marker is there without the section\'s heading — delete that marker line' : null)
878
+ : end === undefined ? 'the section has no end marker (a copy made by hand, or older than the marker) — delete it, from its heading down to the end of its text' : null;
879
+ return { at, end, problem, first: headings.find((h) => h.level === 2)?.i };
880
+ }
881
+
882
+ // Why yad will not rewrite this skill's section and what to do about it, or null when it can (or there
883
+ // is none).
884
+ export function skillSectionProblem(text) {
885
+ return sectionBounds(text.replace(/\r\n/g, '\n').split('\n')).problem;
886
+ }
887
+
888
+ // Bring every skill under `skillsDir` in line with the toolbox: the section where a tool names the
889
+ // skill, none where no tool does. `check` changes nothing. Returns the skill folder names it found
890
+ // stale and wrote, and `refused: [{ skill, reason }]` — a section it will not touch, and why.
891
+ export function syncSkillFallbacks(skillsDir, { check = false, tools = TOOLBOX } = {}) {
892
+ const out = { stale: [], wrote: [], refused: [] };
893
+ const skills = fs.readdirSync(skillsDir).filter((s) => fs.existsSync(path.join(skillsDir, s, 'SKILL.md'))).sort();
894
+ for (const skill of skills) {
895
+ const file = path.join(skillsDir, skill, 'SKILL.md');
896
+ const text = fs.readFileSync(file, 'utf8');
897
+ const want = skillFallbackSection(skill, tools); // null: no tool names this skill, so no section
898
+ const reason = skillSectionProblem(text);
899
+ // Compared as text, not bytes, so a CRLF checkout (Windows) is not stale for its line endings alone.
900
+ if (!reason && skillSectionOf(text) === want) continue;
901
+ out.stale.push(skill);
902
+ const next = reason ? null : withSkillSection(text, want);
903
+ // A last check that ignores fences, frontmatter and indent: after a write the file holds exactly one
904
+ // `##` line that is the heading and one marker line (none after a removal), counted at any indent,
905
+ // fenced or not. So a copy the finder skipped (fenced, indented in a list) cannot be doubled. It does
906
+ // not catch a copy in another heading form — in a `>` quote, underlined with `---`, or as `<h2>`.
907
+ const seen = next === null ? [] : next.split(/\r?\n/).map((l) => l.replace(/^[ \t]+/, ''));
908
+ const expect = want === null ? 0 : 1;
909
+ const doubled = next !== null && (seen.filter(isFallbackHeading).length !== expect || seen.filter(isFallbackEnd).length !== expect);
910
+ const why = reason || (doubled ? 'after the write the file would hold another line that looks like the section\'s heading or end marker (in a code block, say) — delete or reword it' : null);
911
+ // One answer for both modes: `check` names what a write would refuse.
912
+ if (why) out.refused.push({ skill, reason: why });
913
+ if (check || why) continue;
914
+ fs.writeFileSync(file, next);
915
+ out.wrote.push(skill);
916
+ }
917
+ return out;
918
+ }
919
+
920
+ // By hand: /\n+$/ restarts at every newline of a long blank run in the middle of the text.
921
+ const dropTrailingNewlines = (s) => { let k = s.length; while (k > 0 && s[k - 1] === '\n') k--; return s.slice(0, k); };
922
+
923
+ // The section as the skill holds it (LF), heading to end marker, or null when it has none. With a
924
+ // problem (no marker, two copies), null too: there is no section yad can read whole.
925
+ export function skillSectionOf(text) {
926
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
927
+ const { at, end, problem } = sectionBounds(lines);
928
+ return at === undefined || problem ? null : lines.slice(at, end + 1).join('\n');
929
+ }
930
+
931
+ // The skill's text with `section` in place of its old one, or, when it has none, just before its first
932
+ // `## ` heading, so the agent reads it early. `null` removes it, with the blank line it was written
933
+ // with. A section with a problem is left as it is. The file keeps its own line ending.
934
+ export function withSkillSection(text, section) {
935
+ // A file keeps the ending most of its lines have (a Windows checkout is all CRLF).
936
+ const crlf = (text.match(/\r\n/g) || []).length;
937
+ const eol = crlf > (text.match(/\n/g) || []).length - crlf ? '\r\n' : '\n';
938
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
939
+ const { at, end, first, problem } = sectionBounds(lines);
940
+ if (problem) return text;
941
+ let out;
942
+ if (at !== undefined) {
943
+ // A removal also takes the blank line yad wrote below it — when a blank line sits above too, or a
944
+ // heading comes next (yad inserts just above one), so the text on either side never closes up.
945
+ let after = end + 1;
946
+ if (section === null && lines[after] === '' && (at === 0 || lines[at - 1] === '' || headingOf(lines[after + 1] ?? '') !== null)) after++;
947
+ out = [...lines.slice(0, at), ...(section === null ? [] : section.split('\n')), ...lines.slice(after)];
948
+ } else if (section === null) out = lines;
949
+ else if (first === undefined) {
950
+ const body = dropTrailingNewlines(lines.join('\n'));
951
+ out = body ? [...body.split('\n'), '', ...section.split('\n'), ''] : [...section.split('\n'), ''];
952
+ } else out = [...lines.slice(0, first), ...section.split('\n'), '', ...lines.slice(first)];
953
+ return out.join('\n').replace(/\n/g, eol);
954
+ }
955
+
536
956
  // Every row — shipped, then the team's own — with what is found here and whether it is in use. Finding
537
957
  // reads `detectRoot` (the folder yad runs in: a code repo's own skills count); the choices come from the
538
958
  // Product, which may be another folder.
@@ -569,6 +989,8 @@ export function toolboxLines(rows) {
569
989
  if (s.inRange === false) lines.push(` ${c.yellow('!')} version ${version} is outside the known-good range ${r.versions} — still used; if it misbehaves, install a version in range`);
570
990
  if (s.state === 'missing' || s.state === 'disabled') lines.push(` ${c.dim(`without it: ${clean(r.fallback)}`)}`);
571
991
  if (r.note) lines.push(` ${c.dim(r.note)}`);
992
+ // E88: what was vetted and when — the licence, and the day its proof was read.
993
+ if (r.vetted) lines.push(` ${c.dim(`licence ${clean(r.licence)}, vetted ${r.vetted.on}`)}`);
572
994
  }
573
995
  }
574
996
  return lines;
@@ -582,7 +1004,7 @@ export function runToolboxList(root, { json = false, productRoot = null, tools =
582
1004
  info(productRoot
583
1005
  ? '[used here] = in use in this Product; change it with `yad toolbox add <id>` / `yad toolbox remove <id>`'
584
1006
  : 'no Product here, so this shows the defaults: the core tools are the ones in use');
585
- info('`yad toolbox list --json` adds each tool\'s licence, source, install commands and where it was found');
1007
+ info('`yad toolbox list --json` adds each tool\'s licence and its vetting record, source, install commands and where it was found');
586
1008
  return undefined;
587
1009
  }
588
1010
 
@@ -604,30 +1026,61 @@ export function toolboxCheck(root, productRoot, opts = {}) {
604
1026
  return { used, findings, problems };
605
1027
  }
606
1028
 
607
- // NEVER FAILS (decided with the row): no tool is required, so a missing one is news, not an error. Exit 0.
608
- export function runToolboxCheck(root, { json = false, productRoot = null, ...opts } = {}) {
609
- const { used, findings, problems } = toolboxCheck(root, productRoot, opts);
610
- if (json) return emitJSON({ ok: true, ...(productRoot ? where(productRoot) : { product: null, file: null }), used: used.map((r) => r.id), findings, problems });
611
- for (const p of problems) warn(p);
612
- if (!findings.length) {
613
- ok(`all ${used.length} tool(s) this project uses are here: ${used.map((r) => clean(r.name)).join(', ')}`);
614
- return undefined;
615
- }
616
- log(c.bold(`${findings.length} of the ${used.length} tool(s) this project uses ${findings.length === 1 ? 'is' : 'are'} not ready here. None is required.`));
1029
+ // The lines a check prints, from `toolboxCheck`'s answer, without the closing hint. One printer for
1030
+ // `yad toolbox check` and for the toolbox step of setup, update, check and join (E85), so they cannot
1031
+ // say it differently.
1032
+ export function toolboxCheckLines({ used, findings }) {
1033
+ if (!used.length) return [` ${c.dim('•')} this project uses no tool from the toolbox — \`yad toolbox list\` shows them all`];
1034
+ if (!findings.length) return [` ${c.green('✓')} all ${used.length} tool(s) this project uses are here: ${used.map((r) => clean(r.name)).join(', ')}`];
1035
+ const lines = [c.bold(`${findings.length} of the ${used.length} tool(s) this project uses ${findings.length === 1 ? 'is' : 'are'} not ready here. None is required.`)];
617
1036
  for (const f of findings) {
618
1037
  const r = used.find((u) => u.id === f.id);
619
1038
  const what = f.problem === 'missing' ? 'not found'
620
1039
  : f.problem === 'disabled' ? 'installed, but its plugin is turned off in your Claude Code settings'
621
1040
  : `version ${clean(f.version)} is outside the known-good range ${f.versions} — still used`;
622
- log(` ${f.problem === 'out-of-range' ? c.yellow('!') : c.dim('–')} ${c.bold(clean(f.name))}: ${what}`);
623
- if (f.problem === 'disabled') log(` ${c.dim('turn it on with /plugin in Claude Code')}`);
624
- else for (const line of howToGet(r)) log(` ${c.dim(line)}`);
625
- if (f.problem !== 'out-of-range') log(` ${c.dim(`without it: ${clean(f.fallback)}`)}`);
1041
+ lines.push(` ${f.problem === 'out-of-range' ? c.yellow('!') : c.dim('–')} ${c.bold(clean(f.name))}: ${what}`);
1042
+ if (f.problem === 'disabled') lines.push(` ${c.dim('turn it on with /plugin in Claude Code')}`);
1043
+ else for (const line of howToGet(r)) lines.push(` ${c.dim(line)}`);
1044
+ if (f.problem !== 'out-of-range') lines.push(` ${c.dim(`without it: ${clean(f.fallback)}`)}`);
626
1045
  }
627
- info(productRoot ? 'stop using a tool here with `yad toolbox remove <id>`' : 'no Product here, so this checks the core tools only');
1046
+ return lines;
1047
+ }
1048
+
1049
+ // NEVER FAILS (decided with the row): no tool is required, so a missing one is news, not an error. Exit 0.
1050
+ export function runToolboxCheck(root, { json = false, productRoot = null, ...opts } = {}) {
1051
+ const res = toolboxCheck(root, productRoot, opts);
1052
+ const { used, findings, problems } = res;
1053
+ if (json) return emitJSON({ ok: true, ...(productRoot ? where(productRoot) : { product: null, file: null }), used: used.map((r) => r.id), findings, problems });
1054
+ for (const p of problems) warn(p);
1055
+ for (const line of toolboxCheckLines(res)) log(line);
1056
+ if (findings.length) info(productRoot ? 'stop using a tool, for the whole team, with `yad toolbox remove <id>`' : 'no Product here, so this checks the core tools only');
628
1057
  return undefined;
629
1058
  }
630
1059
 
1060
+ // The toolbox step of `yad setup`, `yad check`, `yad update` and `yad join` (E85). It OFFERS: it prints
1061
+ // what the project uses that is not here and how to get it. It installs nothing, runs no program and
1062
+ // writes no file (decided with the row, as `yad toolbox add` is) — and it never fails the command it is
1063
+ // part of. Returns the part of the --json answer it adds: `{ used, findings, problems }`.
1064
+ // `canRemove: false` leaves out the `remove` advice — for `yad join`, whose reader just cloned the team's
1065
+ // choices: one person's machine lacking a tool is no reason to change what the whole team uses.
1066
+ export function offerToolbox(root, productRoot, { canRemove = true, ...opts } = {}) {
1067
+ let res;
1068
+ // A step inside another command: if finding tools throws (a home folder that cannot be read), the
1069
+ // command it is part of still finishes, and says why the step is missing.
1070
+ try { res = toolboxCheck(root, productRoot, opts); } catch (e) {
1071
+ warn(`toolbox: could not check the tools here (${clean(e?.message ?? String(e))}) — run \`yad toolbox check\` to see why`);
1072
+ return { used: [], findings: [], problems: [], error: 'could not check' };
1073
+ }
1074
+ for (const p of res.problems) warn(p);
1075
+ for (const line of toolboxCheckLines(res)) log(line);
1076
+ if (res.findings.length) {
1077
+ info(canRemove
1078
+ ? 'nothing is installed for you: follow the steps you choose, or, for the whole team, stop using a tool with `yad toolbox remove <id>`'
1079
+ : 'nothing is installed for you: follow the steps you choose');
1080
+ }
1081
+ return { used: res.used.map((r) => r.id), findings: res.findings, problems: res.problems };
1082
+ }
1083
+
631
1084
  // `--detect skill:x,plugin:p` → { skills: ['x'], plugins: ['p'] }, or { error }.
632
1085
  export function parseDetect(text) {
633
1086
  const out = {};