yadflow 4.1.0 → 4.2.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/CHANGELOG.md +19 -0
- package/README.md +5 -2
- package/bin/commands.mjs +1 -0
- package/bin/yad.mjs +63 -5
- package/cli/detect.mjs +1 -1
- package/cli/doctor.mjs +24 -0
- package/cli/errors.mjs +1 -0
- package/cli/manifest.mjs +3 -0
- package/cli/toolbox.mjs +814 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
# [4.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.1.0...v4.2.0) (2026-10-01)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **toolbox:** a turned-off plugin is disabled, and ranges read only full versions (E84 review 1) ([56a9864](https://github.com/abdelrahmannasr/yadflow/commit/56a9864bf91b764b736c6da12b2d30cc1d1fd861))
|
|
7
|
+
* **toolbox:** add and remove report a shipped-tool edit through one function (E86 review 4) ([161ece4](https://github.com/abdelrahmannasr/yadflow/commit/161ece48cffd879fe0106747ae10698f7b5e67cf))
|
|
8
|
+
* **toolbox:** clearer remove and dead-copy wording (E86 review 5 notes) ([33ba0b7](https://github.com/abdelrahmannasr/yadflow/commit/33ba0b7bae11462d9b81a739df302fe20bbd2a7c))
|
|
9
|
+
* **toolbox:** npx before a turned-off plugin, Claude Code switches only, Windows PATH rules (E84 review 2) ([f052979](https://github.com/abdelrahmannasr/yadflow/commit/f052979a3018790c1d7c94fe5b761ce32bf15e55))
|
|
10
|
+
* **toolbox:** offer an undo only when the tool's use changed (E86 review 3) ([32e5105](https://github.com/abdelrahmannasr/yadflow/commit/32e5105bb83f856b9b37b8bc0e96e7bf15e396e1))
|
|
11
|
+
* **toolbox:** one remove clears everything under an id; add agrees with the list (E86 review 2) ([da75777](https://github.com/abdelrahmannasr/yadflow/commit/da75777496e4c705e52423c77c553f35e5c99002))
|
|
12
|
+
* **toolbox:** truthful edit messages, remove clears dead lines, --dir is the Product (E86 review 1) ([2159543](https://github.com/abdelrahmannasr/yadflow/commit/21595432d611e190c93d97fc707f27c432ba936f))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **toolbox:** yad toolbox add, remove and check (E86) ([715cd4c](https://github.com/abdelrahmannasr/yadflow/commit/715cd4c6cc63ffdcfdc33c97a9d2df8078965b64))
|
|
18
|
+
* **toolbox:** yad toolbox list and the shipped toolbox of external tools (E84) ([3f5374b](https://github.com/abdelrahmannasr/yadflow/commit/3f5374b830c83782b884f48a7e760943bc2349d4))
|
|
19
|
+
|
|
1
20
|
# [4.1.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.0.1...v4.1.0) (2026-10-01)
|
|
2
21
|
|
|
3
22
|
|
package/README.md
CHANGED
|
@@ -155,7 +155,8 @@ unless CI runs it), or `yad gate advance` with no platform, is the human decisio
|
|
|
155
155
|
- `yad skip` and `yad defer` pass over optional steps, such as the UI design.
|
|
156
156
|
|
|
157
157
|
**Already using Yadflow?** Follow [staying up to date](docs/CLI.md#staying-up-to-date). Coming from
|
|
158
|
-
3.x, read [upgrading to 4.0](docs/migrations/upgrading-to-4.md) first
|
|
158
|
+
3.x, read [upgrading to 4.0](docs/migrations/upgrading-to-4.md) first, then follow the
|
|
159
|
+
[step-by-step upgrade guide](docs/migrations/upgrade-guide-3x-to-4x.md).
|
|
159
160
|
|
|
160
161
|
## How the project record evolves
|
|
161
162
|
|
|
@@ -256,6 +257,7 @@ The record is worth trusting because people approve what goes into it.
|
|
|
256
257
|
- linked changes and `yad thread`
|
|
257
258
|
- `yad history`, `yad next` and `yad usage`
|
|
258
259
|
- `yad detect`, which lists the skills, agents, MCP servers and plugins installed for each AI agent
|
|
260
|
+
- `yad toolbox list`, which shows the external tools yadflow can use, which ones you have, and what happens without each; `yad toolbox add` / `remove` record the tools your team uses, and `yad toolbox check` lists the ones not ready on your machine
|
|
259
261
|
- draft saving, claims and owners
|
|
260
262
|
- the CI checks
|
|
261
263
|
- the Review Companion
|
|
@@ -280,7 +282,8 @@ The record is worth trusting because people approve what goes into it.
|
|
|
280
282
|
- [all 38 skills](docs/SKILLS.md)
|
|
281
283
|
- [the by-hand walkthrough](docs/WALKTHROUGH.md)
|
|
282
284
|
- **Upgrade:** [staying up to date](docs/CLI.md#staying-up-to-date) ·
|
|
283
|
-
[upgrading to 4.0](docs/migrations/upgrading-to-4.md)
|
|
285
|
+
[upgrading to 4.0](docs/migrations/upgrading-to-4.md) ·
|
|
286
|
+
[step-by-step upgrade guide](docs/migrations/upgrade-guide-3x-to-4x.md)
|
|
284
287
|
- **Project:**
|
|
285
288
|
- [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
286
289
|
- [SECURITY.md](SECURITY.md)
|
package/bin/commands.mjs
CHANGED
|
@@ -36,6 +36,7 @@ export { runRiskMap } from '../cli/riskmap-command.mjs';
|
|
|
36
36
|
export { runCodeowners } from '../cli/codeowners-command.mjs';
|
|
37
37
|
export { runDocs } from '../cli/docs.mjs';
|
|
38
38
|
export { runDetect } from '../cli/detect.mjs';
|
|
39
|
+
export { runToolboxAdd, runToolboxCheck, runToolboxList, runToolboxRemove } from '../cli/toolbox.mjs';
|
|
39
40
|
export { runDoctor } from '../cli/doctor.mjs';
|
|
40
41
|
export { runMigrate, warnIfProjectAhead } from '../cli/migrate.mjs';
|
|
41
42
|
export { runNext } from '../cli/next.mjs';
|
package/bin/yad.mjs
CHANGED
|
@@ -116,6 +116,18 @@ ${c.bold('Where am I / what next')}
|
|
|
116
116
|
Bind a step to a skill of your own. Several skills run in
|
|
117
117
|
the order given, one after another — each costs tokens
|
|
118
118
|
yad skill unbind <step> Drop the binding; the step goes back to the engine's default
|
|
119
|
+
yad toolbox list [--json] [--dir <folder>]
|
|
120
|
+
The external tools yadflow can use — core (offered at setup),
|
|
121
|
+
recommended pools, the design/testing/learning connectors and
|
|
122
|
+
this project's own — whether each is found here, whether this
|
|
123
|
+
project uses it, and what yadflow does without it. Installs nothing
|
|
124
|
+
yad toolbox check [--json] The tools this project uses that are not ready here, each with
|
|
125
|
+
its install command and fallback. Never fails: none is required
|
|
126
|
+
yad toolbox add <id> Use a tool in this Product (.sdlc/toolbox.json); prints how to
|
|
127
|
+
install it, installs nothing. Your own tool: add <id> --custom
|
|
128
|
+
--role "<text>" --fallback "<text>" --detect <kind>:<name>[,…]
|
|
129
|
+
[--install "<type>: <command>"] [--source <https URL>]
|
|
130
|
+
yad toolbox remove <id> Stop using a tool in this Product, or drop one of your own
|
|
119
131
|
yad detect [--json] [--dir <folder>] Which skills, agents, MCP servers and plugins are installed
|
|
120
132
|
for the agents that work here — in this folder and in your
|
|
121
133
|
home folder, and which agents read each place. Read-only;
|
|
@@ -326,7 +338,7 @@ ${c.bold('Environment')}
|
|
|
326
338
|
YAD_NO_REPORT=1 Never offer to file a bug report after a failure
|
|
327
339
|
YAD_PLATFORM_LOGIN=0 Name a record's author by git user.name; never ask gh/glab who is logged in`;
|
|
328
340
|
|
|
329
|
-
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--profile', '--parent', '--inherits', '--to', '--retro-ship', '--merge-commit', '--path', '--ide-targets', '--theme', '--thread', '--by', '--count', '--engagement', '--keep']);
|
|
341
|
+
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--profile', '--parent', '--inherits', '--to', '--retro-ship', '--merge-commit', '--path', '--ide-targets', '--theme', '--thread', '--by', '--count', '--engagement', '--keep', '--role', '--fallback', '--detect', '--install', '--source']);
|
|
330
342
|
|
|
331
343
|
function parseArgs(argv) {
|
|
332
344
|
const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
|
|
@@ -373,6 +385,7 @@ function parseArgs(argv) {
|
|
|
373
385
|
// The roadmap and the release check spell the default `--preview`. Accepting it means a script can
|
|
374
386
|
// say what it means rather than relying on the absence of a flag.
|
|
375
387
|
else if (a === '--preview') o.apply = false;
|
|
388
|
+
else if (a === '--custom') o.custom = true;
|
|
376
389
|
else if (a === '--json') o.json = true;
|
|
377
390
|
else if (a === '-h' || a === '--help') o.help = true;
|
|
378
391
|
else if (a === '-v' || a === '--version') o.version = true;
|
|
@@ -422,6 +435,7 @@ const ACTIONS = {
|
|
|
422
435
|
docs: { known: ['list', 'build', 'deploy', 'sync'], default: 'list' },
|
|
423
436
|
reconcile: { known: ['check', 'refresh', 'wire'] },
|
|
424
437
|
hook: { known: ['ledger-guard'] },
|
|
438
|
+
toolbox: { known: ['list', 'check', 'add', 'remove'], default: 'list' },
|
|
425
439
|
};
|
|
426
440
|
// E80 — how each command finds its folder. A PRODUCT command works on the Product; a REPO command works
|
|
427
441
|
// on the code repo it runs in and reads the Product beside it. The rest take their folder as given:
|
|
@@ -557,12 +571,12 @@ async function main() {
|
|
|
557
571
|
// A project written by a newer yadflow is warned about before any command reads it (docs/migrations/
|
|
558
572
|
// shape-8.md). Not on `hook` — its stderr is the channel a block reason reaches a model on — and not
|
|
559
573
|
// where the command reports the same thing itself (doctor, migrate) or runs before a project exists.
|
|
560
|
-
if (!['hook', 'doctor', 'migrate', 'setup', 'report', 'detect', 'new', 'init', 'join'].includes(cmd)) commands.warnIfProjectAhead(o.dir || process.cwd());
|
|
574
|
+
if (!['hook', 'doctor', 'migrate', 'setup', 'report', 'detect', 'toolbox', 'new', 'init', 'join'].includes(cmd)) commands.warnIfProjectAhead(o.dir || process.cwd());
|
|
561
575
|
// E122: the Product's settings (or an epic's PR ledger) under two names that say different things.
|
|
562
576
|
// Every command refuses rather than pick one — whichever it picked, somebody's edit would be silently
|
|
563
577
|
// ignored. Not `doctor` (it reports the drift), `migrate` (it ends it) or `report` (it files a bug
|
|
564
578
|
// about a broken flow, and reads nothing it could get wrong). `hook` returned above: it guards.
|
|
565
|
-
if (!['doctor', 'migrate', 'report', 'detect'].includes(cmd)) {
|
|
579
|
+
if (!['doctor', 'migrate', 'report', 'detect', 'toolbox'].includes(cmd)) {
|
|
566
580
|
// Both the folder the command runs on and, for a code-repo command, the Product it reads.
|
|
567
581
|
for (const root of new Set([o.dir || process.cwd(), o.product].filter(Boolean))) {
|
|
568
582
|
const drift = commands.productDriftError(root);
|
|
@@ -623,6 +637,49 @@ async function main() {
|
|
|
623
637
|
case 'migrate':
|
|
624
638
|
result = await commands.runMigrate(o.dir, { apply: o.apply, json: o.json, keep: o.keep ?? null });
|
|
625
639
|
break;
|
|
640
|
+
case 'toolbox': {
|
|
641
|
+
// E84 + E86. Finding a tool reads the folder yad runs in (a code repo's own skills count); the
|
|
642
|
+
// team's choices are the Product's `.sdlc/toolbox.json`, found the way every Product command finds
|
|
643
|
+
// it. So `toolbox` is not in PRODUCT_CMDS: that would move the finding to the Product's folder.
|
|
644
|
+
const [, action = 'list', id, extra] = o._;
|
|
645
|
+
const usage = 'usage: yad toolbox list | check | add <id> [--custom …] | remove <id> [--json] [--dir <folder>]';
|
|
646
|
+
if (!['list', 'check', 'add', 'remove'].includes(action)) { refuse(`unknown toolbox action: ${action} (list, check, add, remove)`, usage); break; }
|
|
647
|
+
const found = commands.findProduct(dirGiven ? o.dir : shellCwd());
|
|
648
|
+
const rel = (p) => path.relative(process.cwd(), p) || '.';
|
|
649
|
+
if (action === 'list' || action === 'check') {
|
|
650
|
+
if (id) { refuse(`yad toolbox ${action} takes no more words (got: ${id})`, usage); break; }
|
|
651
|
+
// A reader may be pointed at a code repo with --dir: it finds tools there and reads the Product
|
|
652
|
+
// that repo belongs to. Why no Product was found is said, as every Product command says it —
|
|
653
|
+
// otherwise the team's choices would be missing from the answer without a word.
|
|
654
|
+
if (found?.problem) warn(`${found.problem} — showing the defaults`);
|
|
655
|
+
else if (found?.elsewhere) warn(`this folder is not in a repo the Product registers — the Product is ${rel(found.elsewhere)}: cd there, or pass --dir ${rel(found.elsewhere)}`);
|
|
656
|
+
const productRoot = found?.root ?? null;
|
|
657
|
+
const run = action === 'list' ? commands.runToolboxList : commands.runToolboxCheck;
|
|
658
|
+
result = run(o.dir || process.cwd(), { json: o.json, productRoot });
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
if (extra) { refuse(`yad toolbox ${action} takes one tool id (got also: ${extra})`, usage); break; }
|
|
662
|
+
// A writer: the Product's file, never a code repo's, and the same refusals every writer has. With
|
|
663
|
+
// --dir it is the Product itself, as for every other Product command (`yad skill bind --dir`) —
|
|
664
|
+
// never a folder a walk up from it happens to reach.
|
|
665
|
+
const productRoot = dirGiven ? (found?.via === 'here' ? found.root : null) : (found?.root ?? null);
|
|
666
|
+
if (!productRoot) {
|
|
667
|
+
const near = found?.root ?? found?.elsewhere;
|
|
668
|
+
const where = dirGiven ? `with --dir, name the Product folder itself (the one holding .sdlc/product.json)${near ? `: --dir ${rel(near)}` : ''}`
|
|
669
|
+
: found?.elsewhere ? `the Product is ${rel(found.elsewhere)}: cd there, or pass --dir ${rel(found.elsewhere)}`
|
|
670
|
+
: `${found?.problem ? `${found.problem}. ` : ''}run it from the Product, from inside a repo it registers, or pass --dir <the Product>; \`yad new\` / \`yad init\` start one`;
|
|
671
|
+
refuse(`no Product here (${o.dir}) — yad toolbox ${action} writes the Product's .sdlc/toolbox.json`, where);
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
if (found.via !== 'here') process.stderr.write(c.dim(`Product: ${rel(productRoot)}\n`));
|
|
675
|
+
commands.warnIfProjectAhead(productRoot);
|
|
676
|
+
const drift = commands.productDriftError(productRoot);
|
|
677
|
+
if (drift) throw drift;
|
|
678
|
+
result = action === 'add'
|
|
679
|
+
? commands.runToolboxAdd(productRoot, { id, custom: o.custom, role: o.role, fallback: o.fallback, detect: o.detect, install: o.install, source: o.source, json: o.json })
|
|
680
|
+
: commands.runToolboxRemove(productRoot, { id, custom: o.custom, role: o.role, fallback: o.fallback, detect: o.detect, install: o.install, source: o.source, json: o.json });
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
626
683
|
case 'detect': {
|
|
627
684
|
// E50. Reads the folder and the home folder; writes nothing.
|
|
628
685
|
const [, extra] = o._;
|
|
@@ -1001,8 +1058,9 @@ main()
|
|
|
1001
1058
|
// block reason travels on — an update banner there would land in front of a model.
|
|
1002
1059
|
// Resolved the way main() resolves it, NOT from argv[2]: that is the first raw argument, so
|
|
1003
1060
|
// `yad --dir <path> hook ledger-guard` puts `--dir` there and the banner slips through.
|
|
1004
|
-
// Nor `detect` (E50):
|
|
1005
|
-
|
|
1061
|
+
// Nor `detect` (E50) or `toolbox` (E84): they promise no network and no file written, and the
|
|
1062
|
+
// check does both.
|
|
1063
|
+
if (!['hook', 'detect', 'toolbox'].includes(parseArgs(process.argv.slice(2))._[0])) {
|
|
1006
1064
|
const { maybeNotifyUpdate } = await import('./commands.mjs');
|
|
1007
1065
|
await maybeNotifyUpdate();
|
|
1008
1066
|
}
|
package/cli/detect.mjs
CHANGED
|
@@ -163,7 +163,7 @@ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
|
163
163
|
// `--json` escapes only U+0000–001F and hands the rest to a program unchanged.
|
|
164
164
|
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
165
165
|
const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
|
|
166
|
-
const clean = (v) => String(v).replace(UNSAFE, '?');
|
|
166
|
+
export const clean = (v) => String(v).replace(UNSAFE, '?');
|
|
167
167
|
|
|
168
168
|
// ---- the three file formats -------------------------------------------------------------------------
|
|
169
169
|
|
package/cli/doctor.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { soloTeamHint, TEAM_CMD } from './people.mjs';
|
|
|
25
25
|
import { indexFreshness, INDEX_FILE } from './product-index.mjs';
|
|
26
26
|
import { productGit, resolveDefaultBranch } from './productcommit.mjs';
|
|
27
27
|
import { readOwners } from './owners.mjs';
|
|
28
|
+
import { loadChoices, readToolboxFile } from './toolbox.mjs';
|
|
28
29
|
|
|
29
30
|
// A registered path doctor may run git in (E81): a checkout the judgement accepts — never a refused
|
|
30
31
|
// entry (git run in a folder the Product shaped as a repo reads its `config`, which can run a command),
|
|
@@ -446,6 +447,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
446
447
|
// inside the `project` block. The renderer prints a header every time the section CHANGES, so a
|
|
447
448
|
// `project` check added after the `shape` section prints the word "project" a second time.
|
|
448
449
|
skillBindingChecks(checks, root);
|
|
450
|
+
toolboxFileChecks(checks, root);
|
|
449
451
|
|
|
450
452
|
// repos.json: parse + every entry is a live git repo; staleness vs syncedHead
|
|
451
453
|
let registry = { repos: [] };
|
|
@@ -2107,6 +2109,28 @@ export function skillBindingChecks(checks, root) {
|
|
|
2107
2109
|
}
|
|
2108
2110
|
}
|
|
2109
2111
|
|
|
2112
|
+
// `.sdlc/toolbox.json`: which toolbox tools this Product uses or skips, and its own tools (E86). Absent is
|
|
2113
|
+
// the normal case. THE FILE ONLY: whether a tool is installed is `yad toolbox check`'s answer, and a
|
|
2114
|
+
// missing tool is never a doctor line (decided with E86 — no tool is required). A broken file fails, as
|
|
2115
|
+
// skills.json does; a line that does nothing warns, because `yad toolbox list` quietly shows the default.
|
|
2116
|
+
export function toolboxFileChecks(checks, root) {
|
|
2117
|
+
const rel = PROJECT_FILES.toolboxConfig;
|
|
2118
|
+
if (!exists(path.join(root, rel))) return;
|
|
2119
|
+
const { error } = readToolboxFile(root);
|
|
2120
|
+
if (error) {
|
|
2121
|
+
check(checks, 'toolbox', 'project', 'fail', `${rel} ${error}`, 'fix the JSON or restore it from git — `yad toolbox list` shows the defaults until then');
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
const { problems, unreachable, shipped, custom } = loadChoices(root);
|
|
2125
|
+
if (problems.length) {
|
|
2126
|
+
// An entry with no id (or an empty one) is out of `remove`'s reach; only hand-editing fixes it.
|
|
2127
|
+
check(checks, 'toolbox', 'project', 'warn', `${rel}: ${problems.length} line(s) do nothing — ${problems.map((p) => p.replace(`${rel}: `, '')).join('; ')} [YAD-CFG-007]`,
|
|
2128
|
+
`fix them by hand, or \`yad toolbox remove <id>\` — it clears whatever the file holds under that id — and add the tool again${unreachable ? ' (an entry with no id, or an empty one: fix it by hand)' : ''}`);
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
check(checks, 'toolbox', 'project', 'ok', `toolbox: ${shipped.size} shipped tool choice(s), ${custom.length} tool(s) of this project's own`);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2110
2134
|
// A step this release does not recognise. Every step the engine can run has a row in the step
|
|
2111
2135
|
// catalogue (E4), and that row is what gives it both a phase and a skill. So an id no phase claims is
|
|
2112
2136
|
// an id with no row at all: one `yad next` cannot guide, `yad gate` has no artifact rule for, and no
|
package/cli/errors.mjs
CHANGED
|
@@ -30,6 +30,7 @@ export const CODES = {
|
|
|
30
30
|
'YAD-CFG-004': 'learning.json names an unknown learning tool (expected one of config.yaml learning.tools, or none)',
|
|
31
31
|
'YAD-CFG-005': 'the Product settings (product.json, or hub.json on a Product that has only that name) set a platform but are missing git_url (required to scope auth + open PRs)',
|
|
32
32
|
'YAD-CFG-006': 'skills.json binds a step to something that is not a skill name (expected a string, or a non-empty list of strings)',
|
|
33
|
+
'YAD-CFG-007': 'toolbox.json has a line that does nothing — a tool this yadflow does not ship, a choice other than use or skip, or a custom tool that is incomplete, malformed, listed twice or uses a shipped tool\'s id',
|
|
33
34
|
'YAD-CLI-001': 'a --json run needed an answer that only a prompt could give',
|
|
34
35
|
};
|
|
35
36
|
|
package/cli/manifest.mjs
CHANGED
|
@@ -275,6 +275,9 @@ export const PROJECT_FILES = {
|
|
|
275
275
|
// the normal case and means "every step uses the skill the catalogue names" — like design.json,
|
|
276
276
|
// which is absent on a markdown-only project.
|
|
277
277
|
skillsConfig: '.sdlc/skills.json',
|
|
278
|
+
// Which toolbox tools this project uses or skips, and its own tools (E86). Absent is the normal case:
|
|
279
|
+
// the core tools and the connected design, testing and learning tools are the ones in use.
|
|
280
|
+
toolboxConfig: '.sdlc/toolbox.json',
|
|
278
281
|
// The kill switch and the Shape steps a team has set to `advance: auto` (E34). Absent is the normal
|
|
279
282
|
// case: the switch is off and every Shape step is `human`. A project file, not the module config the
|
|
280
283
|
// switch lived in before (`_bmad/sdlc/config.yaml` then, `.sdlc/config.yaml` since E3) — that one is
|
package/cli/toolbox.mjs
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
// The toolbox — every external tool yadflow can use, what each one is for, how it installs, and what
|
|
2
|
+
// yadflow does without it (E84).
|
|
3
|
+
//
|
|
4
|
+
// yad toolbox list [--json] [--dir <folder>]
|
|
5
|
+
// yad toolbox check [--json] [--dir <folder>]
|
|
6
|
+
// yad toolbox add <id> | add <id> --custom --role … --fallback … --detect … [--install …] [--source …]
|
|
7
|
+
// yad toolbox remove <id>
|
|
8
|
+
//
|
|
9
|
+
// THE RULE IT EXISTS FOR: no external tool is ever mandatory, and every one declares a fallback. yadflow
|
|
10
|
+
// degrades; it never breaks. This file is the one place that says, per tool, what "degrades" means.
|
|
11
|
+
//
|
|
12
|
+
// NOT `manifest.mjs`. That is the INSTALL manifest — the files yadflow copies into a project. This list
|
|
13
|
+
// is about tools yadflow does NOT ship and never installs on its own.
|
|
14
|
+
//
|
|
15
|
+
// SHIPPED, NOT A PROJECT FILE (decided with E84). The list moves with each release, like the step
|
|
16
|
+
// catalogue. What a team CHOOSES from it is a project file, `.sdlc/toolbox.json` in the Product (E86):
|
|
17
|
+
// which shipped tools it uses or skips, and its own tools. See "the team's choices" below.
|
|
18
|
+
//
|
|
19
|
+
// THREE TIERS:
|
|
20
|
+
// core offered at setup (E85) — repomix, spec-kit, impeccable
|
|
21
|
+
// recommended listed, never pushed — pools of skills and agents a team may choose (Part 7)
|
|
22
|
+
// connector the tools a Product connects for design, testing and learning (`yad-connect-*`); the
|
|
23
|
+
// same adapters `skills/sdlc/config.yaml` names, with the same fallbacks
|
|
24
|
+
//
|
|
25
|
+
// FINDING A TOOL RUNS NOTHING. It reads `yad detect`'s answer (E50) — skills, plugins, MCP servers — and
|
|
26
|
+
// looks for a program on PATH by reading folders, never by starting `which`. A tool that `npx` fetches
|
|
27
|
+
// on demand (repomix) is "available" whenever `npx` is there.
|
|
28
|
+
//
|
|
29
|
+
// AN OLD OR NEW VERSION WARNS, IT DOES NOT DISQUALIFY (decided with the row). A tool outside its known
|
|
30
|
+
// range still counts as installed: a slightly older tool usually works, and quietly switching to the
|
|
31
|
+
// weaker fallback would be worse. A range is only set where a version is actually visible (a plugin's or
|
|
32
|
+
// a skill's own record) and a known-good bound exists; `versions: null` means "no bound known".
|
|
33
|
+
//
|
|
34
|
+
// EVERY INSTALL COMMAND WAS CHECKED AGAINST THE TOOL'S OWN REPOSITORY OR DOCS, on the `checked` date —
|
|
35
|
+
// never guessed from a package name (npm `ecc` is an unrelated crypto library, not ECC). A connector set
|
|
36
|
+
// up inside a desktop app has no command: its `install` is empty and `manual` links the vendor's steps.
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { c, emitJSON, exists, hand, info, log, ok, readJSON, readJSONStrict, refuse, warn, writeJSON } from './lib.mjs';
|
|
40
|
+
import { clean, detectInstalled } from './detect.mjs';
|
|
41
|
+
import { PROJECT_FILES, SCHEMA_VERSION } from './manifest.mjs';
|
|
42
|
+
|
|
43
|
+
export const INSTALL_TYPES = Object.freeze(['plugin', 'npm', 'python', 'script']);
|
|
44
|
+
export const TIERS = Object.freeze(['core', 'recommended', 'connector']);
|
|
45
|
+
const TIER_TITLES = { core: 'Core — offered at setup', recommended: 'Recommended — listed, never pushed', connector: 'Connectors — design, testing and learning tools a Product connects' };
|
|
46
|
+
|
|
47
|
+
const CHECKED = '2026-10-01';
|
|
48
|
+
|
|
49
|
+
// The list itself, checked against each tool's own repository and docs on CHECKED. `install` keeps only
|
|
50
|
+
// the routes the roadmap's four types describe (plugin, npm, python, script); a tool's other routes
|
|
51
|
+
// (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.
|
|
55
|
+
export const TOOLBOX = Object.freeze([
|
|
56
|
+
// ---- core: offered at setup --------------------------------------------------------------------
|
|
57
|
+
{
|
|
58
|
+
id: 'repomix', name: 'Repomix', tier: 'core', role: 'packs each code repo into one file the Shape steps read',
|
|
59
|
+
licence: 'MIT', source: 'https://github.com/yamadashy/repomix', checked: CHECKED,
|
|
60
|
+
install: [
|
|
61
|
+
{ type: 'npm', command: 'npx repomix@latest' },
|
|
62
|
+
{ type: 'npm', command: 'npm install -g repomix' },
|
|
63
|
+
{ type: 'plugin', command: '/plugin marketplace add yamadashy/repomix, then /plugin install repomix-mcp@repomix' },
|
|
64
|
+
],
|
|
65
|
+
manual: null,
|
|
66
|
+
// yad runs it through `npx repomix@latest` (setup, `yad repo refresh`), so `npx` alone makes it usable.
|
|
67
|
+
detect: { npx: true, bins: ['repomix'], plugins: ['repomix-mcp', 'repomix-commands', 'repomix-explorer'], mcp: ['repomix'], skills: ['repomix-explorer'] },
|
|
68
|
+
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'],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'spec-kit', name: 'Spec Kit', tier: 'core', role: 'runs the spec ceremony (specify → plan → tasks) in a code repo',
|
|
75
|
+
licence: 'MIT', source: 'https://github.com/github/spec-kit', checked: CHECKED,
|
|
76
|
+
install: [
|
|
77
|
+
{ type: 'python', command: 'uv tool install specify-cli, then specify init <project> --integration claude' },
|
|
78
|
+
{ type: 'python', command: 'pipx install specify-cli' },
|
|
79
|
+
],
|
|
80
|
+
manual: null,
|
|
81
|
+
// Claude Code gets `speckit-<command>` skills since Spec Kit 0.4.5; older installs have flat
|
|
82
|
+
// `speckit.<command>.md` commands, which `yad detect` does not read — the `specify` program still shows.
|
|
83
|
+
detect: { skillPrefixes: ['speckit-', 'speckit.'], bins: ['specify'] },
|
|
84
|
+
versions: null,
|
|
85
|
+
fallback: "yad-spec writes the same spec files by hand, in Spec Kit's layout",
|
|
86
|
+
records: 'speckit: not-installed', note: 'A program named specify on PATH also counts; another tool by that name would too.',
|
|
87
|
+
usedBy: ['yad-spec'],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'impeccable', name: 'Impeccable', tier: 'core', role: 'drives the UI design step (document, extract, craft)',
|
|
91
|
+
licence: 'Apache-2.0', source: 'https://github.com/pbakaus/impeccable', checked: CHECKED,
|
|
92
|
+
install: [
|
|
93
|
+
{ type: 'npm', command: 'npx impeccable install' },
|
|
94
|
+
{ type: 'plugin', command: '/plugin marketplace add pbakaus/impeccable, then /plugin install impeccable@impeccable' },
|
|
95
|
+
],
|
|
96
|
+
manual: null,
|
|
97
|
+
detect: { skills: ['impeccable'], plugins: ['impeccable'] },
|
|
98
|
+
versions: null,
|
|
99
|
+
fallback: 'Markdown-only UI design: yad-ui writes ui-design.md and DESIGN.md directly',
|
|
100
|
+
records: 'impeccable: not-installed', note: null,
|
|
101
|
+
usedBy: ['yad-ui'],
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// ---- recommended: listed, never pushed ---------------------------------------------------------
|
|
105
|
+
{
|
|
106
|
+
id: 'bmad-method', name: 'BMAD-METHOD', tier: 'recommended', role: 'a pool of planning and building skills',
|
|
107
|
+
// The LICENSE file is MIT text with attribution added (GitHub reads it as NOASSERTION); "BMad" is a trademark.
|
|
108
|
+
licence: 'MIT', source: 'https://github.com/bmad-code-org/BMAD-METHOD', checked: CHECKED,
|
|
109
|
+
install: [
|
|
110
|
+
{ type: 'npm', command: 'npx bmad-method install' },
|
|
111
|
+
{ type: 'plugin', command: '/plugin marketplace add bmad-code-org/bmad-plugins, then install bmad-method from /plugin' },
|
|
112
|
+
],
|
|
113
|
+
manual: null,
|
|
114
|
+
// The README names a `bmad-core-tools` plugin; the marketplace lists `bmad-toolbox`. Both are kept.
|
|
115
|
+
detect: { skills: ['bmad'], skillPrefixes: ['bmad-', 'bmod-'], plugins: ['bmad-method', 'bmad-toolbox', 'bmad-core-tools'] },
|
|
116
|
+
versions: null,
|
|
117
|
+
fallback: "nothing changes: yadflow's own skills run every step",
|
|
118
|
+
records: null, note: 'Left the engine in E3; returns only as an optional pool you bind with `yad skill bind`.',
|
|
119
|
+
usedBy: ['yad skill bind'],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'ecc', name: 'ECC (Everything Claude Code)', tier: 'recommended', role: 'a large pool of skills, agents and hooks',
|
|
123
|
+
licence: 'MIT', source: 'https://github.com/affaan-m/ECC', checked: CHECKED,
|
|
124
|
+
install: [
|
|
125
|
+
{ type: 'npm', command: 'npx ecc-universal install --guided' },
|
|
126
|
+
{ type: 'plugin', command: '/plugin marketplace add https://github.com/affaan-m/ECC, then /plugin install ecc@ecc' },
|
|
127
|
+
{ type: 'script', command: 'git clone https://github.com/affaan-m/ECC.git && cd ECC && ./install.sh --profile minimal --target claude' },
|
|
128
|
+
],
|
|
129
|
+
manual: null,
|
|
130
|
+
// Its skills have no common prefix, so they cannot identify it; the plugin and the program can.
|
|
131
|
+
detect: { plugins: ['ecc'], bins: ['ecc'] },
|
|
132
|
+
versions: null,
|
|
133
|
+
fallback: "nothing changes: yadflow's own skills run every step",
|
|
134
|
+
records: null, note: 'The npm package is ecc-universal. npm `ecc` is an unrelated crypto library.',
|
|
135
|
+
usedBy: ['yad skill bind'],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'mattpocock-skills', name: 'mattpocock/skills', tier: 'recommended', role: 'a small pool of engineering skills',
|
|
139
|
+
licence: 'MIT', source: 'https://github.com/mattpocock/skills', checked: CHECKED,
|
|
140
|
+
install: [{ type: 'plugin', command: 'claude plugins install mattpocock-skills' }],
|
|
141
|
+
manual: null,
|
|
142
|
+
// Generic names (`tdd`, `code-review`) collide with other packs; this one is its own.
|
|
143
|
+
detect: { plugins: ['mattpocock-skills'], skills: ['setup-matt-pocock-skills'] },
|
|
144
|
+
versions: null,
|
|
145
|
+
fallback: "nothing changes: yadflow's own skills run every step",
|
|
146
|
+
records: null, note: 'About 8 contributors: fine as an option, risky as a default.',
|
|
147
|
+
usedBy: ['yad skill bind'],
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
// ---- connectors: what a Product connects (skills/sdlc/config.yaml names the same adapters) -------
|
|
151
|
+
{
|
|
152
|
+
id: 'figma', name: 'Figma', tier: 'connector', role: 'design tool: yad-ui generates and links screens in it',
|
|
153
|
+
licence: 'proprietary', source: 'https://developers.figma.com/docs/figma-mcp-server/', checked: CHECKED,
|
|
154
|
+
install: [{ type: 'plugin', command: 'claude plugin install figma@claude-plugins-official' }],
|
|
155
|
+
manual: 'https://developers.figma.com/docs/figma-mcp-server/',
|
|
156
|
+
detect: { plugins: ['figma'], mcp: ['figma', 'figma-desktop', 'html-to-design'] },
|
|
157
|
+
versions: null,
|
|
158
|
+
fallback: 'markdown-only: yad-ui writes ui-design.md and DESIGN.md only',
|
|
159
|
+
records: null, note: 'Sign-in is your own (OAuth); yadflow stores no token. The desktop server is named figma-desktop.',
|
|
160
|
+
usedBy: ['yad-connect-design', 'yad-ui'],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'pencil', name: 'Pencil (pen.dev)', tier: 'connector', role: 'design tool: yad-ui writes .pen web and mobile screens',
|
|
164
|
+
licence: 'proprietary', source: 'https://docs.pencil.dev/getting-started/installation', checked: CHECKED,
|
|
165
|
+
install: [],
|
|
166
|
+
manual: 'https://docs.pencil.dev/getting-started/installation',
|
|
167
|
+
detect: { mcp: ['pencil'] },
|
|
168
|
+
versions: null,
|
|
169
|
+
fallback: 'markdown-only: yad-ui writes ui-design.md and DESIGN.md only',
|
|
170
|
+
records: null, note: 'Set up inside the desktop app: open a .pen file, then Settings → MCP → enable your agent.',
|
|
171
|
+
usedBy: ['yad-connect-design', 'yad-ui'],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 'playwright', name: 'Playwright MCP', tier: 'connector', role: 'testing tool: generates and runs browser and API tests',
|
|
175
|
+
licence: 'Apache-2.0', source: 'https://github.com/microsoft/playwright-mcp', checked: CHECKED,
|
|
176
|
+
install: [{ type: 'npm', command: 'claude mcp add playwright npx @playwright/mcp@latest' }],
|
|
177
|
+
manual: null,
|
|
178
|
+
detect: { mcp: ['playwright'], plugins: ['playwright'] },
|
|
179
|
+
versions: null,
|
|
180
|
+
fallback: 'artifacts-only: yad-test-cases writes test-cases.md only',
|
|
181
|
+
records: null,
|
|
182
|
+
note: 'Found by its server name only (yad never reads a server\'s settings), and an unofficial package is often registered under the same name.',
|
|
183
|
+
usedBy: ['yad-connect-testing', 'yad-test-cases'],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'cypress', name: 'Cypress', tier: 'connector', role: 'testing tool: Cypress specs',
|
|
187
|
+
licence: 'proprietary', source: 'https://docs.cypress.io/cloud/integrations/cloud-mcp', checked: CHECKED,
|
|
188
|
+
install: [],
|
|
189
|
+
manual: 'https://docs.cypress.io/cloud/integrations/cloud-mcp',
|
|
190
|
+
detect: { mcp: ['cypress', 'cypress-cloud'] },
|
|
191
|
+
versions: null,
|
|
192
|
+
fallback: 'artifacts-only: yad-test-cases writes test-cases.md only',
|
|
193
|
+
records: null,
|
|
194
|
+
note: 'No official MCP server runs local tests. The official Cypress Cloud MCP only reads Cloud results; community servers exist but none is vetted.',
|
|
195
|
+
usedBy: ['yad-connect-testing', 'yad-test-cases'],
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'pytest', name: 'pytest', tier: 'connector', role: 'testing tool: service-layer tests',
|
|
199
|
+
licence: 'MIT', source: 'https://github.com/pytest-dev/pytest', checked: CHECKED,
|
|
200
|
+
install: [],
|
|
201
|
+
manual: 'https://docs.pytest.org/',
|
|
202
|
+
detect: { mcp: ['pytest'] },
|
|
203
|
+
versions: null,
|
|
204
|
+
fallback: 'artifacts-only: yad-test-cases writes test-cases.md only',
|
|
205
|
+
records: null,
|
|
206
|
+
note: 'pytest has no official MCP server; the small community ones are not vetted. Connect one you trust under the name pytest.',
|
|
207
|
+
usedBy: ['yad-connect-testing', 'yad-test-cases'],
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: 'maestro', name: 'Maestro', tier: 'connector', role: 'testing tool: mobile UI flows',
|
|
211
|
+
licence: 'Apache-2.0', source: 'https://github.com/mobile-dev-inc/Maestro', checked: CHECKED,
|
|
212
|
+
install: [{ type: 'script', command: 'curl -fsSL "https://get.maestro.mobile.dev" | bash, then claude mcp add maestro -- maestro mcp' }],
|
|
213
|
+
manual: 'https://docs.maestro.dev/get-started/maestro-mcp',
|
|
214
|
+
detect: { bins: ['maestro'], mcp: ['maestro'] },
|
|
215
|
+
versions: null,
|
|
216
|
+
fallback: 'artifacts-only: yad-test-cases writes test-cases.md only',
|
|
217
|
+
records: null, note: 'Needs Java 17 or later. The MCP server is built into the maestro program.',
|
|
218
|
+
usedBy: ['yad-connect-testing', 'yad-test-cases'],
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: 'deeptutor', name: 'DeepTutor', tier: 'connector', role: 'learning tool: tutors a team member on what is being built',
|
|
222
|
+
licence: 'Apache-2.0', source: 'https://github.com/HKUDS/DeepTutor', checked: CHECKED,
|
|
223
|
+
install: [{ type: 'python', command: 'pip install -U deeptutor, then deeptutor init' }],
|
|
224
|
+
manual: null,
|
|
225
|
+
detect: { bins: ['deeptutor'] },
|
|
226
|
+
versions: null,
|
|
227
|
+
fallback: 'harness-native tutoring: yad-learn has your agent read the project files directly',
|
|
228
|
+
records: null, note: 'A program, not an MCP server. Needs Python 3.11 or later.',
|
|
229
|
+
usedBy: ['yad-connect-learning', 'yad-learn'],
|
|
230
|
+
},
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
// ---- checking an entry ------------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
236
|
+
const isStringList = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string' && x.length > 0);
|
|
237
|
+
|
|
238
|
+
// Every way an entry can be malformed, as sentences. [] means it is well formed. Run by the tests over the
|
|
239
|
+
// shipped list, so a bad entry fails CI rather than a user's `yad toolbox list`.
|
|
240
|
+
export function toolProblems(t) {
|
|
241
|
+
const out = [];
|
|
242
|
+
const need = (ok, what) => { if (!ok) out.push(what); };
|
|
243
|
+
need(typeof t?.id === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(t.id), 'id is lower-case words joined by -');
|
|
244
|
+
need(typeof t?.name === 'string' && t.name.length > 0, 'name is set');
|
|
245
|
+
need(TIERS.includes(t?.tier), `tier is one of ${TIERS.join(', ')}`);
|
|
246
|
+
need(typeof t?.role === 'string' && t.role.length > 0, 'role is set');
|
|
247
|
+
need(typeof t?.licence === 'string' && t.licence.length > 0, 'licence is set (an SPDX id, or "proprietary")');
|
|
248
|
+
need(typeof t?.source === 'string' && /^https:\/\//.test(t.source), 'source is an https URL');
|
|
249
|
+
need(typeof t?.checked === 'string' && DATE.test(t.checked), 'checked is a YYYY-MM-DD date');
|
|
250
|
+
need(Array.isArray(t?.install), 'install is a list');
|
|
251
|
+
for (const r of Array.isArray(t?.install) ? t.install : []) {
|
|
252
|
+
need(INSTALL_TYPES.includes(r?.type), `an install route's type is one of ${INSTALL_TYPES.join(', ')}`);
|
|
253
|
+
need(typeof r?.command === 'string' && r.command.length > 0, 'an install route has its command');
|
|
254
|
+
}
|
|
255
|
+
need((Array.isArray(t?.install) && t.install.length > 0) || (typeof t?.manual === 'string' && /^https:\/\//.test(t.manual)),
|
|
256
|
+
'a tool with no install command links its manual steps (manual: https URL)');
|
|
257
|
+
const d = t?.detect;
|
|
258
|
+
need(d && typeof d === 'object', 'detect is set');
|
|
259
|
+
if (d && typeof d === 'object') {
|
|
260
|
+
for (const k of ['skills', 'skillPrefixes', 'plugins', 'mcp', 'bins']) need(d[k] === undefined || isStringList(d[k]), `detect.${k} is a list of names`);
|
|
261
|
+
need(d.npx === undefined || typeof d.npx === 'boolean', 'detect.npx is true or false');
|
|
262
|
+
need(['skills', 'skillPrefixes', 'plugins', 'mcp', 'bins'].some((k) => d[k]?.length) || d.npx === true, 'detect names at least one thing to look for');
|
|
263
|
+
}
|
|
264
|
+
need(t?.versions === null || (typeof t?.versions === 'string' && parseRange(t.versions) !== null), 'versions is null or a range this reader understands');
|
|
265
|
+
need(typeof t?.fallback === 'string' && t.fallback.length > 0, 'fallback says what yadflow does without it');
|
|
266
|
+
need(t?.records === null || (typeof t?.records === 'string' && t.records.length > 0), 'records is null or the line a skill writes');
|
|
267
|
+
need(t?.note === null || (typeof t?.note === 'string' && t.note.length > 0), 'note is null or a sentence');
|
|
268
|
+
need(t?.manual === null || (typeof t?.manual === 'string' && /^https:\/\//.test(t.manual)), 'manual is null or an https URL');
|
|
269
|
+
need(isStringList(t?.usedBy), 'usedBy lists the skills or commands that use it');
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- versions ---------------------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
// `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.
|
|
276
|
+
// Anything else (a git sha, a date) is not a version this can compare: null.
|
|
277
|
+
export function parseVersion(v) {
|
|
278
|
+
const m = String(v ?? '').trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
279
|
+
if (!m) return null;
|
|
280
|
+
// `full`: all three numbers were written. A range needs that (see parseRange); a found version does not.
|
|
281
|
+
return { parts: [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)], pre: m[4] ? m[4].slice(1) : null, full: m[2] !== undefined && m[3] !== undefined };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The semantic-versioning order: numbers first; then a pre-release is below its release, and two
|
|
285
|
+
// pre-releases compare label by label — a number below a word, numbers as numbers, a shorter list first.
|
|
286
|
+
function compare(a, b) {
|
|
287
|
+
for (let i = 0; i < 3; i++) if (a.parts[i] !== b.parts[i]) return a.parts[i] < b.parts[i] ? -1 : 1;
|
|
288
|
+
if (!a.pre || !b.pre) return a.pre === b.pre ? 0 : a.pre ? -1 : 1;
|
|
289
|
+
const x = a.pre.split('.');
|
|
290
|
+
const y = b.pre.split('.');
|
|
291
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
292
|
+
if (x[i] === undefined) return -1;
|
|
293
|
+
if (y[i] === undefined) return 1;
|
|
294
|
+
const nx = /^\d+$/.test(x[i]);
|
|
295
|
+
const ny = /^\d+$/.test(y[i]);
|
|
296
|
+
if (nx && ny && Number(x[i]) !== Number(y[i])) return Number(x[i]) < Number(y[i]) ? -1 : 1;
|
|
297
|
+
if (nx !== ny) return nx ? -1 : 1;
|
|
298
|
+
if (!nx && x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
299
|
+
}
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// A range is space-separated comparators, all of which must hold: `>=3.0.0 <4.0.0`, `^3.2.0`, `~1.4.0`,
|
|
304
|
+
// `=2.0.0`. A small reader on purpose — the list sets a handful of simple bounds, and a dependency for
|
|
305
|
+
// that is not worth its weight. Returns the comparator list, or null when it cannot read the range.
|
|
306
|
+
//
|
|
307
|
+
// EVERY VERSION IN A RANGE HAS ALL THREE NUMBERS. A partial one (`~1`, `^0`, `>1.2`) means something
|
|
308
|
+
// different for each operator, and reading it as `.0` got four of them wrong; refusing it makes
|
|
309
|
+
// `toolProblems` reject such a range instead of letting it give a silent wrong answer.
|
|
310
|
+
//
|
|
311
|
+
// ONE RULE FOR PRE-RELEASES: an upper bound `<X.Y.Z` also keeps out X.Y.Z's own pre-releases
|
|
312
|
+
// (`4.0.0-beta` is outside `<4.0.0`), whether it was written or comes from `^` / `~`.
|
|
313
|
+
export function parseRange(range) {
|
|
314
|
+
const out = [];
|
|
315
|
+
// `<X.Y.Z-0` — below every pre-release of X.Y.Z, because `0` is the lowest label there is.
|
|
316
|
+
const below = (parts) => ['<', { parts, pre: '0', full: true }];
|
|
317
|
+
for (const tok of String(range).trim().split(/\s+/)) {
|
|
318
|
+
const m = tok.match(/^(>=|<=|>|<|=|\^|~)?(.+)$/);
|
|
319
|
+
const v = m && parseVersion(m[2]);
|
|
320
|
+
if (!v || !v.full) return null;
|
|
321
|
+
const [x, y, z] = v.parts;
|
|
322
|
+
const op = m[1] || '=';
|
|
323
|
+
if (op === '^') out.push(['>=', v], below(x > 0 ? [x + 1, 0, 0] : y > 0 ? [0, y + 1, 0] : [0, 0, z + 1]));
|
|
324
|
+
else if (op === '~') out.push(['>=', v], below([x, y + 1, 0]));
|
|
325
|
+
else if (op === '<' && !v.pre) out.push(below(v.parts));
|
|
326
|
+
else out.push([op, v]);
|
|
327
|
+
}
|
|
328
|
+
return out.length ? out : null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// true or false when both sides can be read; null when either cannot (a sha, a missing version).
|
|
332
|
+
export function versionInRange(version, range) {
|
|
333
|
+
const v = parseVersion(version);
|
|
334
|
+
const r = range === null ? null : parseRange(range);
|
|
335
|
+
if (!v || !r) return null;
|
|
336
|
+
return r.every(([op, b]) => {
|
|
337
|
+
const k = compare(v, b);
|
|
338
|
+
return op === '>=' ? k >= 0 : op === '>' ? k > 0 : op === '<=' ? k <= 0 : op === '<' ? k < 0 : k === 0;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---- finding a tool ---------------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
// Is a program on PATH — by reading the PATH folders, never by starting one. On Windows a program is
|
|
345
|
+
// `name` plus one of PATHEXT's endings (`.EXE`, `.CMD`, …); elsewhere it must be an executable file.
|
|
346
|
+
export function onPath(bin, { env = process.env, platform = process.platform } = {}) {
|
|
347
|
+
// Only absolute folders: an empty or relative entry means "the current folder", which is not where a
|
|
348
|
+
// tool is installed, and would make the answer depend on where `yad` happened to run.
|
|
349
|
+
// On Windows that means a drive and a root (C:\x) or a share (\\server\share): \x is on whatever drive
|
|
350
|
+
// is current, and D:x in that drive's current folder. Windows accepts an entry in quotes; so does this —
|
|
351
|
+
// but not a `;` inside the quotes, which the plain split cuts in two (rare; that folder is then missed).
|
|
352
|
+
const isAbs = platform === 'win32' ? (d) => /^[A-Za-z]:[\\/]/.test(d) || /^[\\/]{2}[^\\/]/.test(d) : path.posix.isAbsolute;
|
|
353
|
+
const unquote = (d) => (platform === 'win32' ? d.replace(/^"(.*)"$/, '$1') : d);
|
|
354
|
+
const dirs = String(env.PATH || env.Path || '').split(platform === 'win32' ? ';' : ':').map(unquote).filter((d) => d && isAbs(d));
|
|
355
|
+
const exts = platform === 'win32' ? ['', ...String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)] : [''];
|
|
356
|
+
for (const dir of dirs) {
|
|
357
|
+
for (const ext of exts) {
|
|
358
|
+
const full = path.join(dir, bin + ext);
|
|
359
|
+
try {
|
|
360
|
+
if (!fs.statSync(full).isFile()) continue;
|
|
361
|
+
if (platform !== 'win32') fs.accessSync(full, fs.constants.X_OK);
|
|
362
|
+
return true;
|
|
363
|
+
} catch { /* not here */ }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const lower = (s) => String(s).toLowerCase();
|
|
370
|
+
// A plugin id is `name@marketplace`; the toolbox names the plugin by `name` alone, or by the full id.
|
|
371
|
+
const pluginMatches = (id, names) => names.some((n) => lower(n) === lower(id) || lower(n) === lower(String(id).split('@')[0]));
|
|
372
|
+
|
|
373
|
+
// What the toolbox knows about one tool here: `installed` (something it installs was found), `available`
|
|
374
|
+
// (fetched on demand — `npx` is there), `disabled` (found only through a plugin that is turned off), or
|
|
375
|
+
// `missing`. `found` lists where, as `yad detect` shows places;
|
|
376
|
+
// `version` is the first one a found item records; `inRange` is true, false, or null (nothing to compare).
|
|
377
|
+
export function toolStatus(tool, items, { has = onPath } = {}) {
|
|
378
|
+
const d = tool.detect;
|
|
379
|
+
// A Claude Code plugin your settings turn off is not loaded, and neither is anything it brings (its
|
|
380
|
+
// skills, agents and MCP servers carry its id in `plugin`). `enabled: null` means no setting says, which
|
|
381
|
+
// is on. So a tool found ONLY through a plugin that is off is `disabled`, not installed.
|
|
382
|
+
// Claude Code's plugins only: a Codex plugin of the same name has its own switch, which is not read.
|
|
383
|
+
const claude = (it) => Array.isArray(it.agents) && it.agents.includes('Claude Code');
|
|
384
|
+
const off = new Set(items.filter((it) => it.kind === 'plugin' && it.enabled === false && claude(it)).map((it) => it.name));
|
|
385
|
+
const isOff = (it) => claude(it) && off.has(it.kind === 'plugin' ? it.name : it.plugin);
|
|
386
|
+
const matches = items.filter((it) => {
|
|
387
|
+
if (it.kind === 'skill') return (d.skills ?? []).some((n) => lower(n) === lower(it.name)) || (d.skillPrefixes ?? []).some((p) => lower(it.name).startsWith(lower(p)));
|
|
388
|
+
if (it.kind === 'plugin') return pluginMatches(it.name, d.plugins ?? []);
|
|
389
|
+
if (it.kind === 'mcp') return (d.mcp ?? []).some((n) => lower(n) === lower(it.name));
|
|
390
|
+
return false;
|
|
391
|
+
});
|
|
392
|
+
const hits = matches.filter((it) => !isOff(it));
|
|
393
|
+
const bins = (d.bins ?? []).filter((b) => has(b));
|
|
394
|
+
const found = [...new Set([...hits.map((h) => h.where), ...bins.map((b) => `${b} on PATH`)])];
|
|
395
|
+
const version = hits.map((h) => h.version).find((v) => typeof v === 'string' && v) ?? null;
|
|
396
|
+
const inRange = tool.versions === null ? null : versionInRange(version, tool.versions);
|
|
397
|
+
if (found.length) return { state: 'installed', found, version, inRange };
|
|
398
|
+
// `npx` before `disabled`: yadflow runs such a tool through npx, so a plugin that is off does not stop it.
|
|
399
|
+
if (d.npx && has('npx')) return { state: 'available', found: ['npx on PATH'], version: null, inRange: null };
|
|
400
|
+
if (matches.length) return { state: 'disabled', found: [...new Set(matches.map((h) => h.where))], version: null, inRange: null };
|
|
401
|
+
return { state: 'missing', found: [], version: null, inRange: null };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---- the team's choices (E86) -----------------------------------------------------------------------
|
|
405
|
+
//
|
|
406
|
+
// `.sdlc/toolbox.json` in the Product, committed, so the whole team shares one answer:
|
|
407
|
+
//
|
|
408
|
+
// { "schemaVersion": 10,
|
|
409
|
+
// "shipped": { "<id>": "use" | "skip" }, only where this project differs from the default
|
|
410
|
+
// "custom": [ { "id", "role", "fallback", "detect", "install"?, "source"?, "name"? } ] }
|
|
411
|
+
//
|
|
412
|
+
// IN USE BY DEFAULT: the core tools (setup offers them), and a connector the Product has connected — the
|
|
413
|
+
// `tool` its design.json, testing.json or learning.json names. Everything else is listed, not in use.
|
|
414
|
+
// `add` and `remove` write only a difference from that default, so the file never repeats one, and a
|
|
415
|
+
// later release that changes a default is not overruled by a line nobody chose.
|
|
416
|
+
//
|
|
417
|
+
// RECORDS ONLY (decided with the row). Nothing here installs a tool or starts a program: `add` prints the
|
|
418
|
+
// install command, and the person runs it. `yad toolbox check` then says whether it is found.
|
|
419
|
+
|
|
420
|
+
export const TOOL_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
421
|
+
const CHOICES = ['use', 'skip'];
|
|
422
|
+
// `--detect skill:x,prefix:y-,plugin:p,mcp:m,bin:b` — the same five things a shipped entry looks for.
|
|
423
|
+
const DETECT_KINDS = Object.freeze({ skill: 'skills', prefix: 'skillPrefixes', plugin: 'plugins', mcp: 'mcp', bin: 'bins' });
|
|
424
|
+
const CONNECTOR_FILES = ['designConfig', 'testingConfig', 'learningConfig'];
|
|
425
|
+
const FILE = PROJECT_FILES.toolboxConfig;
|
|
426
|
+
const ADD_USAGE = 'usage: yad toolbox add <id> or yad toolbox add <id> --custom --role "<what it does>" --fallback "<what happens without it>" --detect <kind>:<name>[,…] [--install "<type>: <command>"] [--source <https URL>]';
|
|
427
|
+
|
|
428
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
429
|
+
const shippedTool = (id) => TOOLBOX.find((t) => t.id === id) ?? null;
|
|
430
|
+
|
|
431
|
+
// The file as it is on disk, for a read-modify-write: `{ doc }` or `{ error }`. STRICT, like
|
|
432
|
+
// `yad skill bind`: a file that does not parse is refused, never rebuilt from nothing — that would delete
|
|
433
|
+
// every choice in it, silently.
|
|
434
|
+
export function readToolboxFile(root) {
|
|
435
|
+
const file = path.join(root, FILE);
|
|
436
|
+
if (!exists(file)) return { doc: {} };
|
|
437
|
+
let raw;
|
|
438
|
+
try { raw = readJSONStrict(file, null); } catch { return { error: 'does not parse [YAD-STATE-001]' }; }
|
|
439
|
+
if (!isObject(raw)) return { error: 'has the wrong shape [YAD-STATE-002]' };
|
|
440
|
+
if (raw.shipped !== undefined && !isObject(raw.shipped)) return { error: '`shipped` must be a JSON object [YAD-STATE-002]' };
|
|
441
|
+
if (raw.custom !== undefined && !Array.isArray(raw.custom)) return { error: '`custom` must be a JSON list [YAD-STATE-002]' };
|
|
442
|
+
return { doc: raw };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Every way a team's own entry can be malformed, as sentences; [] means it is usable. Lighter than
|
|
446
|
+
// `toolProblems`: a licence, a checked date and `usedBy` are the shipped list's promises, not a team's.
|
|
447
|
+
export function customProblems(t) {
|
|
448
|
+
if (!isObject(t)) return ['an entry is a JSON object'];
|
|
449
|
+
const out = [];
|
|
450
|
+
const need = (ok, what) => { if (!ok) out.push(what); };
|
|
451
|
+
need(typeof t.id === 'string' && TOOL_ID.test(t.id), 'id is lower-case words joined by -');
|
|
452
|
+
need(!(typeof t.id === 'string' && shippedTool(t.id)), 'id is not the id of a shipped tool');
|
|
453
|
+
need(t.name === undefined || (typeof t.name === 'string' && t.name.length > 0), 'name, when set, is text');
|
|
454
|
+
need(typeof t.role === 'string' && t.role.trim().length > 0, 'role says what it does');
|
|
455
|
+
need(typeof t.fallback === 'string' && t.fallback.trim().length > 0, 'fallback says what happens without it');
|
|
456
|
+
const d = t.detect;
|
|
457
|
+
const kinds = Object.values(DETECT_KINDS);
|
|
458
|
+
need(isObject(d) && Object.keys(d).every((k) => kinds.includes(k) && isStringList(d[k])) && kinds.some((k) => d[k]?.length),
|
|
459
|
+
`detect names at least one thing to look for (${kinds.join(', ')}), each a list of names`);
|
|
460
|
+
// A program is a plain name looked up on PATH. A path (`../x`) would make every teammate's `check`
|
|
461
|
+
// look at a file the shared file chose, so it is refused.
|
|
462
|
+
need(!isObject(d) || !Array.isArray(d.bins) || d.bins.every((b) => typeof b !== 'string' || (!/[\\/]/.test(b) && b !== '.' && b !== '..')),
|
|
463
|
+
'detect.bins names programs, not paths (no / or \\)');
|
|
464
|
+
need(t.install === undefined || (Array.isArray(t.install) && t.install.every((r) => INSTALL_TYPES.includes(r?.type) && typeof r?.command === 'string' && r.command.length > 0)),
|
|
465
|
+
`install, when set, is a list of { type: ${INSTALL_TYPES.join(' | ')}, command }`);
|
|
466
|
+
need(t.source === undefined || t.source === null || (typeof t.source === 'string' && /^https:\/\//.test(t.source)), 'source, when set, is an https URL');
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// A team's entry in the shipped entries' shape, so one printer and one finder serve both.
|
|
471
|
+
const customRow = (t) => ({
|
|
472
|
+
id: t.id, name: t.name || t.id, tier: 'project', role: t.role,
|
|
473
|
+
licence: null, source: t.source ?? null, checked: null,
|
|
474
|
+
install: t.install ?? [], manual: null,
|
|
475
|
+
detect: Object.fromEntries(Object.values(DETECT_KINDS).filter((k) => t.detect[k]?.length).map((k) => [k, t.detect[k]])),
|
|
476
|
+
versions: null, fallback: t.fallback, records: null, note: null, usedBy: [],
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// The connector each of design.json, testing.json and learning.json names, when it is one the toolbox
|
|
480
|
+
// knows: id → the file. `none` and an adapter the toolbox does not list name nothing here.
|
|
481
|
+
export function connectedTools(root) {
|
|
482
|
+
const out = new Map();
|
|
483
|
+
for (const key of CONNECTOR_FILES) {
|
|
484
|
+
const doc = readJSON(path.join(root, PROJECT_FILES[key]), null);
|
|
485
|
+
const id = isObject(doc) && typeof doc.tool === 'string' ? doc.tool : null;
|
|
486
|
+
if (id && shippedTool(id)?.tier === 'connector' && !out.has(id)) out.set(id, PROJECT_FILES[key]);
|
|
487
|
+
}
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// What the Product says, read leniently: a broken file or line is a `problem` and the defaults stand,
|
|
492
|
+
// because a list must still answer. With no Product, there is nothing to read: the defaults.
|
|
493
|
+
export function loadChoices(productRoot) {
|
|
494
|
+
const problems = [];
|
|
495
|
+
let doc = {};
|
|
496
|
+
if (productRoot) {
|
|
497
|
+
const r = readToolboxFile(productRoot);
|
|
498
|
+
if (r.error) problems.push(`${FILE} ${r.error} — showing the defaults`);
|
|
499
|
+
else doc = r.doc;
|
|
500
|
+
}
|
|
501
|
+
const shipped = new Map();
|
|
502
|
+
// A line no `yad toolbox remove <id>` can reach — no id, or an empty one — is fixed by hand only.
|
|
503
|
+
let unreachable = false;
|
|
504
|
+
for (const [id, v] of Object.entries(doc.shipped ?? {})) {
|
|
505
|
+
if (!id) unreachable = true;
|
|
506
|
+
// A tool from a newer release, or a typo: the line stays in the file and does nothing here.
|
|
507
|
+
if (!shippedTool(id)) problems.push(`${FILE}: \`${clean(id)}\` is not a tool this yadflow ships — ignored`);
|
|
508
|
+
else if (!CHOICES.includes(v)) problems.push(`${FILE}: \`${id}\` should be "use" or "skip" — ignored`);
|
|
509
|
+
else shipped.set(id, v);
|
|
510
|
+
}
|
|
511
|
+
const custom = [];
|
|
512
|
+
for (const [i, t] of (doc.custom ?? []).entries()) {
|
|
513
|
+
const hasId = isObject(t) && typeof t.id === 'string' && t.id.length > 0;
|
|
514
|
+
const label = hasId ? `\`${clean(t.id)}\`` : `number ${i + 1}`;
|
|
515
|
+
const p = customProblems(t);
|
|
516
|
+
if (p.length && !hasId) unreachable = true;
|
|
517
|
+
if (p.length) problems.push(`${FILE}: custom tool ${label} is ignored — ${p.join('; ')}`);
|
|
518
|
+
else if (custom.some((c2) => c2.id === t.id)) problems.push(`${FILE}: custom tool ${label} is listed twice — the first one is used`);
|
|
519
|
+
else custom.push(customRow(t));
|
|
520
|
+
}
|
|
521
|
+
return { problems, unreachable, shipped, custom, connected: productRoot ? connectedTools(productRoot) : new Map() };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Is a tool in use here, and why. `because`: core | connected | added | removed | custom | null.
|
|
525
|
+
const defaultUsed = (tool, connected) => tool.tier === 'core' || connected.has(tool.id);
|
|
526
|
+
export function toolUse(tool, { shipped, connected }) {
|
|
527
|
+
if (tool.tier === 'project') return { used: true, because: 'custom' };
|
|
528
|
+
const choice = shipped.get(tool.id);
|
|
529
|
+
if (choice === 'use') return { used: true, because: 'added' };
|
|
530
|
+
if (choice === 'skip') return { used: false, because: 'removed' };
|
|
531
|
+
if (tool.tier === 'core') return { used: true, because: 'core' };
|
|
532
|
+
if (connected.has(tool.id)) return { used: true, because: 'connected', connectedIn: connected.get(tool.id) };
|
|
533
|
+
return { used: false, because: null };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Every row — shipped, then the team's own — with what is found here and whether it is in use. Finding
|
|
537
|
+
// reads `detectRoot` (the folder yad runs in: a code repo's own skills count); the choices come from the
|
|
538
|
+
// Product, which may be another folder.
|
|
539
|
+
export function toolboxRows(detectRoot, productRoot, { tools = TOOLBOX, items = null, has = onPath } = {}) {
|
|
540
|
+
const found = items ?? detectInstalled(detectRoot).items;
|
|
541
|
+
const choices = loadChoices(productRoot);
|
|
542
|
+
const rows = [...tools, ...choices.custom].map((t) => ({ ...t, status: toolStatus(t, found, { has }), ...toolUse(t, choices) }));
|
|
543
|
+
return { rows, problems: choices.problems };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---- the command ------------------------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
const TITLES = { ...TIER_TITLES, project: 'Added by this project' };
|
|
549
|
+
const BECAUSE = { core: 'used here', connected: 'used here — connected', added: 'used here — added', custom: 'used here', removed: 'not used here — removed' };
|
|
550
|
+
|
|
551
|
+
// The lines `yad toolbox list` prints, from rows that already carry their status. Separate so a test can
|
|
552
|
+
// read them. Everything from a file is cleaned like every name `yad detect` prints: a found version, and
|
|
553
|
+
// every word of a team's own entry (the Product's file is shared, so anyone can write in it).
|
|
554
|
+
export function toolboxLines(rows) {
|
|
555
|
+
const lines = [c.bold('The toolbox — external tools yadflow can use. None is required; each has a fallback.')];
|
|
556
|
+
for (const tier of [...TIERS, 'project']) {
|
|
557
|
+
const mine = rows.filter((r) => r.tier === tier);
|
|
558
|
+
if (!mine.length) continue;
|
|
559
|
+
lines.push('', c.bold(TITLES[tier]));
|
|
560
|
+
for (const r of mine) {
|
|
561
|
+
const s = r.status;
|
|
562
|
+
const version = s.version ? clean(s.version) : null;
|
|
563
|
+
const mark = s.state === 'missing' || s.state === 'disabled' ? c.dim('–') : c.green('✓');
|
|
564
|
+
const state = s.state === 'installed' ? `installed${version ? ` ${version}` : ''}`
|
|
565
|
+
: s.state === 'available' ? 'available (npx fetches it when needed)'
|
|
566
|
+
: s.state === 'disabled' ? 'installed, but its plugin is turned off in your Claude Code settings' : 'not found';
|
|
567
|
+
const tag = r.because ? c.cyan(` [${BECAUSE[r.because]}]`) : '';
|
|
568
|
+
lines.push(` ${mark} ${c.bold(clean(r.name))} ${c.dim(`— ${clean(r.role)}`)}: ${state}${tag}`);
|
|
569
|
+
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
|
+
if (s.state === 'missing' || s.state === 'disabled') lines.push(` ${c.dim(`without it: ${clean(r.fallback)}`)}`);
|
|
571
|
+
if (r.note) lines.push(` ${c.dim(r.note)}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return lines;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function runToolboxList(root, { json = false, productRoot = null, tools = TOOLBOX, items = null, has = onPath } = {}) {
|
|
578
|
+
const { rows, problems } = toolboxRows(root, productRoot, { tools, items, has });
|
|
579
|
+
if (json) return emitJSON({ ok: true, ...(productRoot ? where(productRoot) : { product: null, file: null }), tools: rows, problems });
|
|
580
|
+
for (const line of toolboxLines(rows)) log(line);
|
|
581
|
+
for (const p of problems) warn(p);
|
|
582
|
+
info(productRoot
|
|
583
|
+
? '[used here] = in use in this Product; change it with `yad toolbox add <id>` / `yad toolbox remove <id>`'
|
|
584
|
+
: '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');
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// How to get a tool, as lines: each install command, else the vendor's steps, else its source.
|
|
590
|
+
const howToGet = (r) => (r.install.length ? r.install.map((i) => `${i.type}: ${clean(i.command)}`)
|
|
591
|
+
: r.manual ? [`set it up by hand: ${r.manual}`] : r.source ? [`see ${clean(r.source)}`] : []);
|
|
592
|
+
|
|
593
|
+
// The tools this project uses that are not ready here: `missing`, `disabled` (its plugin is off), or
|
|
594
|
+
// `out-of-range` (found, but outside its known-good versions — still used). The function E85 calls.
|
|
595
|
+
export function toolboxCheck(root, productRoot, opts = {}) {
|
|
596
|
+
const { rows, problems } = toolboxRows(root, productRoot, opts);
|
|
597
|
+
const used = rows.filter((r) => r.used);
|
|
598
|
+
const findings = used.flatMap((r) => {
|
|
599
|
+
const s = r.status;
|
|
600
|
+
const problem = s.state === 'missing' ? 'missing' : s.state === 'disabled' ? 'disabled' : s.inRange === false ? 'out-of-range' : null;
|
|
601
|
+
if (!problem) return [];
|
|
602
|
+
return [{ id: r.id, name: r.name, tier: r.tier, problem, version: s.version, versions: r.versions, install: r.install, manual: r.manual, source: r.source, fallback: r.fallback }];
|
|
603
|
+
});
|
|
604
|
+
return { used, findings, problems };
|
|
605
|
+
}
|
|
606
|
+
|
|
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.`));
|
|
617
|
+
for (const f of findings) {
|
|
618
|
+
const r = used.find((u) => u.id === f.id);
|
|
619
|
+
const what = f.problem === 'missing' ? 'not found'
|
|
620
|
+
: f.problem === 'disabled' ? 'installed, but its plugin is turned off in your Claude Code settings'
|
|
621
|
+
: `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)}`)}`);
|
|
626
|
+
}
|
|
627
|
+
info(productRoot ? 'stop using a tool here with `yad toolbox remove <id>`' : 'no Product here, so this checks the core tools only');
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// `--detect skill:x,plugin:p` → { skills: ['x'], plugins: ['p'] }, or { error }.
|
|
632
|
+
export function parseDetect(text) {
|
|
633
|
+
const out = {};
|
|
634
|
+
for (const part of String(text ?? '').split(',').map((p) => p.trim()).filter(Boolean)) {
|
|
635
|
+
const m = part.match(/^([a-z]+):(.+)$/);
|
|
636
|
+
const key = m && Object.hasOwn(DETECT_KINDS, m[1]) ? DETECT_KINDS[m[1]] : null;
|
|
637
|
+
if (!key) return { error: `\`${clean(part)}\` is not <kind>:<name> — the kinds are ${Object.keys(DETECT_KINDS).join(', ')}` };
|
|
638
|
+
(out[key] ??= []).push(m[2].trim());
|
|
639
|
+
}
|
|
640
|
+
return Object.keys(out).length ? { detect: out } : { error: '--detect names nothing to look for' };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// `--install "npm: npx thing"` → { type, command }, or { error }.
|
|
644
|
+
export function parseInstall(text) {
|
|
645
|
+
const m = String(text ?? '').match(/^\s*([a-z]+)\s*:\s*(.+?)\s*$/);
|
|
646
|
+
if (!m || !INSTALL_TYPES.includes(m[1])) return { error: `--install is "<type>: <command>", the type one of ${INSTALL_TYPES.join(', ')}` };
|
|
647
|
+
return { route: { type: m[1], command: m[2] } };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const CUSTOM_FLAGS = ['role', 'fallback', 'detect', 'install', 'source'];
|
|
651
|
+
const brokenFile = (error, json) => refuse(`${FILE} ${error}`, 'fix the file (or restore it from git) first — writing over it would delete every choice it holds', { json });
|
|
652
|
+
|
|
653
|
+
// The document plus one edit, ready to write. `schemaVersion` is set outright, as `yad skill bind` does:
|
|
654
|
+
// this command is the file's engine writer, so its write IS the file's migration. Keys this release does
|
|
655
|
+
// not know are kept; an empty `shipped` or `custom` is left out.
|
|
656
|
+
function save(productRoot, doc, shipped, custom) {
|
|
657
|
+
const next = { ...doc, schemaVersion: SCHEMA_VERSION, shipped, custom };
|
|
658
|
+
if (!Object.keys(shipped).length) delete next.shipped;
|
|
659
|
+
if (!custom.length) delete next.custom;
|
|
660
|
+
writeJSON(path.join(productRoot, FILE), next);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// The one edit both verbs make to a shipped tool: write `want` only where it differs from the default.
|
|
664
|
+
function setShipped(doc, tool, want, connected) {
|
|
665
|
+
const shipped = { ...(isObject(doc.shipped) ? doc.shipped : {}) };
|
|
666
|
+
if (defaultUsed(tool, connected) === (want === 'use')) delete shipped[tool.id];
|
|
667
|
+
else shipped[tool.id] = want;
|
|
668
|
+
return shipped;
|
|
669
|
+
}
|
|
670
|
+
const sameJSON = (a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
|
|
671
|
+
|
|
672
|
+
// Where the file is, for an answer: the Product as a path from here, since run from a code repo nothing
|
|
673
|
+
// else on stdout names it. Both sides as the disk has them: `process.cwd()` is already resolved, and a
|
|
674
|
+
// Product reached through a link (macOS's /var is /private/var) would otherwise climb to the top and back.
|
|
675
|
+
const onDisk = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
676
|
+
const where = (productRoot) => ({ product: path.relative(onDisk(process.cwd()), onDisk(productRoot)) || '.', file: FILE });
|
|
677
|
+
|
|
678
|
+
// The edit to a shipped tool, said truthfully. Whether the FILE changed and whether the tool's USE changed
|
|
679
|
+
// are two questions: removing a line that did nothing (`"figma": "bogus"`, or one that repeats the
|
|
680
|
+
// default) changes the file and not the use.
|
|
681
|
+
function sayEdit(tool, was, now, changed) {
|
|
682
|
+
const state = now.used ? 'used here' : 'not used here';
|
|
683
|
+
if (!changed) info(`${tool.name} is already ${state} — nothing to change`);
|
|
684
|
+
else if (was.used === now.used) info(`${tool.name} is already ${state} — removed a line in ${FILE} that did nothing`);
|
|
685
|
+
// The fallback is printed as `list` prints it, after "without it:" — on its own, ECC's ("nothing
|
|
686
|
+
// changes: …") read as if the remove had done nothing.
|
|
687
|
+
else ok(now.used ? `${tool.name} is used here now` : `${tool.name} is not used here now. Without it: ${clean(tool.fallback)}`);
|
|
688
|
+
}
|
|
689
|
+
// The whole report of an edit to a SHIPPED tool, for both verbs — one function, so `add` and `remove`
|
|
690
|
+
// cannot say it differently again. Two facts, each said once: the shipped part (`sayEdit`), and the
|
|
691
|
+
// dead custom copies under the tool's id that the same write removed.
|
|
692
|
+
function sayShippedEdit(tool, was, now, shippedChanged, copies) {
|
|
693
|
+
if (shippedChanged || !copies) sayEdit(tool, was, now, shippedChanged);
|
|
694
|
+
if (copies) {
|
|
695
|
+
// When only copies went, the tool's use did not change: `customProblems` rejects every custom entry
|
|
696
|
+
// under a shipped tool's id, so no copy ever counted toward using it. If that rule ever changes,
|
|
697
|
+
// this branch must call `sayEdit` too.
|
|
698
|
+
const what = `${copies} custom entr${copies > 1 ? 'ies' : 'y'} under the id ${tool.id}, which ${copies > 1 ? 'were' : 'was'} ignored (the id is a shipped tool's)`;
|
|
699
|
+
info(shippedChanged ? `also removed ${what}` : `removed ${what} — ${tool.name} is still ${now.used ? 'used' : 'not used'} here`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const usedFrom = (shipped, connected) => ({ shipped: new Map(Object.entries(shipped).filter(([, v]) => CHOICES.includes(v))), connected });
|
|
703
|
+
const sameId = (id) => (t) => isObject(t) && t.id === id;
|
|
704
|
+
|
|
705
|
+
export function runToolboxAdd(productRoot, { id, custom = false, json = false, ...flags } = {}) {
|
|
706
|
+
if (!id) return refuse('yad toolbox add needs a tool id', ADD_USAGE, { json });
|
|
707
|
+
if (!TOOL_ID.test(id)) return refuse(`\`${clean(id)}\` is not a tool id`, 'an id is lower-case letters, digits and dashes — `yad toolbox list --json` shows each one', { json });
|
|
708
|
+
const given = CUSTOM_FLAGS.filter((k) => flags[k] !== undefined);
|
|
709
|
+
if (!custom && given.length) return refuse(`--${given[0]} describes a tool of your own, and goes with --custom`, ADD_USAGE, { json });
|
|
710
|
+
const { doc, error } = readToolboxFile(productRoot);
|
|
711
|
+
if (error) return brokenFile(error, json);
|
|
712
|
+
const list = Array.isArray(doc.custom) ? doc.custom : [];
|
|
713
|
+
const tool = shippedTool(id);
|
|
714
|
+
const mine = list.find(sameId(id));
|
|
715
|
+
// In use exactly when `list` says so: `loadChoices` keeps the first USABLE copy, so a broken copy
|
|
716
|
+
// before a good one does not make the tool unused, and a broken copy alone does not make it used.
|
|
717
|
+
const usable = loadChoices(productRoot).custom.some((r) => r.id === id);
|
|
718
|
+
const ignored = () => refuse(`${id} is in ${FILE} but ignored: ${customProblems(mine).join('; ')}`, `fix it by hand, or \`yad toolbox remove ${id}\` and add it again with --custom`, { json });
|
|
719
|
+
|
|
720
|
+
if (custom) {
|
|
721
|
+
if (tool) return refuse(`${id} is a shipped tool`, `\`yad toolbox add ${id}\` marks it as used here`, { json });
|
|
722
|
+
if (usable) return refuse(`${id} is already one of this project's tools`, `to change it, \`yad toolbox remove ${id}\` and add it again`, { json });
|
|
723
|
+
if (mine) return ignored();
|
|
724
|
+
const d = parseDetect(flags.detect);
|
|
725
|
+
if (flags.detect === undefined || d.error) return refuse(d.error && flags.detect !== undefined ? d.error : 'a tool of your own needs --detect, so yadflow can tell whether it is here', ADD_USAGE, { json });
|
|
726
|
+
const entry = { id, role: flags.role, fallback: flags.fallback, detect: d.detect };
|
|
727
|
+
if (flags.install !== undefined) {
|
|
728
|
+
const r = parseInstall(flags.install);
|
|
729
|
+
if (r.error) return refuse(r.error, ADD_USAGE, { json });
|
|
730
|
+
entry.install = [r.route];
|
|
731
|
+
}
|
|
732
|
+
if (flags.source !== undefined) entry.source = flags.source;
|
|
733
|
+
const problems = customProblems(entry);
|
|
734
|
+
if (problems.length) return refuse(`cannot add ${id}: ${problems.join('; ')}`, ADD_USAGE, { json });
|
|
735
|
+
save(productRoot, doc, isObject(doc.shipped) ? doc.shipped : {}, [...list, entry]);
|
|
736
|
+
ok(`${id} added — a tool of this project's own, in use here`);
|
|
737
|
+
hand(`written to ${FILE} — commit it so the team shares it (undo with \`yad toolbox remove ${id}\`); \`yad toolbox check\` says whether it is found`);
|
|
738
|
+
return { id, used: true, because: 'custom', changed: true, ...where(productRoot) };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (!tool) {
|
|
742
|
+
if (usable) {
|
|
743
|
+
info(`${id} is already one of this project's tools — nothing to change`);
|
|
744
|
+
return { id, used: true, because: 'custom', changed: false, ...where(productRoot) };
|
|
745
|
+
}
|
|
746
|
+
// An entry `list` and `check` ignore is not in use, whatever the file says.
|
|
747
|
+
if (mine) return ignored();
|
|
748
|
+
return refuse(`${id} is not in the toolbox`, '`yad toolbox list --json` shows each id; add a tool of your own with `yad toolbox add <id> --custom …`', { json });
|
|
749
|
+
}
|
|
750
|
+
const connected = connectedTools(productRoot);
|
|
751
|
+
const was = toolUse(tool, loadChoices(productRoot));
|
|
752
|
+
const shipped = setShipped(doc, tool, 'use', connected);
|
|
753
|
+
// A custom entry under a shipped id is ignored; it goes in the same write, as `remove` does it.
|
|
754
|
+
const left = list.filter((t) => !sameId(id)(t));
|
|
755
|
+
const copies = list.length - left.length;
|
|
756
|
+
const shippedChanged = !sameJSON(shipped, doc.shipped);
|
|
757
|
+
const changed = shippedChanged || copies > 0;
|
|
758
|
+
if (changed) save(productRoot, doc, shipped, left);
|
|
759
|
+
const now = toolUse(tool, usedFrom(shipped, connected));
|
|
760
|
+
sayShippedEdit(tool, was, now, shippedChanged, copies);
|
|
761
|
+
// Records only: the person installs it.
|
|
762
|
+
const how = howToGet(tool);
|
|
763
|
+
if (how.length) hand(`to install it: ${how.join(' or ')}`);
|
|
764
|
+
// An undo is offered only when the tool's use changed: after clearing a line that did nothing, the
|
|
765
|
+
// "opposite" command would switch the tool off, not put anything back.
|
|
766
|
+
if (changed) hand(`written to ${FILE} — commit it so the team shares it${was.used !== now.used ? ` (undo with \`yad toolbox remove ${id}\`)` : ''}; \`yad toolbox check\` says whether it is found`);
|
|
767
|
+
return { id, ...now, changed, ...where(productRoot) };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Removes EVERYTHING the file holds under `id`, in one write: the shipped tool's use, every custom entry
|
|
771
|
+
// with that id (a dead one sharing a shipped tool's id included), and a shipped line for a tool this
|
|
772
|
+
// release does not ship — so it also clears each line `yad doctor` names as doing nothing.
|
|
773
|
+
export function runToolboxRemove(productRoot, { id, json = false, ...flags } = {}) {
|
|
774
|
+
if (!id) return refuse('yad toolbox remove needs a tool id', 'usage: yad toolbox remove <id>', { json });
|
|
775
|
+
const given = CUSTOM_FLAGS.filter((k) => flags[k] !== undefined);
|
|
776
|
+
if (flags.custom || given.length) return refuse(`yad toolbox remove takes only the tool id (got ${flags.custom ? '--custom' : `--${given[0]}`})`, 'usage: yad toolbox remove <id>', { json });
|
|
777
|
+
const { doc, error } = readToolboxFile(productRoot);
|
|
778
|
+
if (error) return brokenFile(error, json);
|
|
779
|
+
const list = Array.isArray(doc.custom) ? doc.custom : [];
|
|
780
|
+
const shipped0 = isObject(doc.shipped) ? doc.shipped : {};
|
|
781
|
+
const tool = shippedTool(id);
|
|
782
|
+
const left = list.filter((t) => !sameId(id)(t));
|
|
783
|
+
const copies = list.length - left.length;
|
|
784
|
+
const deadLine = !tool && Object.hasOwn(shipped0, id);
|
|
785
|
+
if (!tool && !copies && !deadLine) {
|
|
786
|
+
if (!TOOL_ID.test(id)) return refuse(`\`${clean(id)}\` is not a tool id`, 'an id is lower-case letters, digits and dashes — `yad toolbox list --json` shows each one', { json });
|
|
787
|
+
return refuse(`${id} is not in the toolbox`, '`yad toolbox list --json` shows each id', { json });
|
|
788
|
+
}
|
|
789
|
+
if (tool) {
|
|
790
|
+
const connected = connectedTools(productRoot);
|
|
791
|
+
const was = toolUse(tool, loadChoices(productRoot));
|
|
792
|
+
const shipped = setShipped(doc, tool, 'skip', connected);
|
|
793
|
+
const shippedChanged = !sameJSON(shipped, doc.shipped);
|
|
794
|
+
const changed = shippedChanged || copies > 0;
|
|
795
|
+
if (changed) save(productRoot, doc, shipped, left);
|
|
796
|
+
const now = toolUse(tool, usedFrom(shipped, connected));
|
|
797
|
+
sayShippedEdit(tool, was, now, shippedChanged, copies);
|
|
798
|
+
// Removing it from the toolbox does not disconnect it: the connect skill owns that file.
|
|
799
|
+
if (connected.has(id)) info(`${connected.get(id)} still connects it — \`${tool.usedBy[0]}\` changes the connection`);
|
|
800
|
+
if (changed) hand(`written to ${FILE} — commit it so the team shares it${was.used !== now.used ? ` (undo with \`yad toolbox add ${id}\`)` : ''}`);
|
|
801
|
+
return { id, ...now, changed, ...where(productRoot) };
|
|
802
|
+
}
|
|
803
|
+
const usableBefore = loadChoices(productRoot).custom.some((r) => r.id === id);
|
|
804
|
+
const shipped = { ...shipped0 };
|
|
805
|
+
if (deadLine) delete shipped[id];
|
|
806
|
+
save(productRoot, doc, shipped, left);
|
|
807
|
+
if (copies) {
|
|
808
|
+
const many = copies > 1 ? ` (listed ${copies} times; every copy is gone)` : '';
|
|
809
|
+
ok(usableBefore ? `${clean(id)} removed — it was one of this project's own tools${many}` : `removed the entry for ${clean(id)}, which was ignored${many}`);
|
|
810
|
+
}
|
|
811
|
+
if (deadLine) ok(`removed the line for \`${clean(id)}\` under shipped — a tool this yadflow does not ship`);
|
|
812
|
+
hand(`written to ${FILE} — commit it so the team shares it`);
|
|
813
|
+
return { id, used: false, because: null, changed: true, ...where(productRoot) };
|
|
814
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Shared project memory and workflows for teams building with AI: a versioned record of plans, decisions, contracts and reviews across repos, human-approved review gates, and a zero-dependency `yad` CLI plus 38 skills for your AI assistant.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|