yadflow 4.1.0 → 4.3.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 +34 -0
- package/README.md +5 -2
- package/bin/commands.mjs +2 -1
- package/bin/yad.mjs +71 -8
- package/cli/detect.mjs +1 -1
- package/cli/doctor.mjs +24 -0
- package/cli/errors.mjs +1 -0
- package/cli/find-product.mjs +24 -0
- package/cli/manifest.mjs +3 -0
- package/cli/reconcile.mjs +19 -3
- package/cli/setup.mjs +12 -3
- package/cli/toolbox.mjs +845 -0
- package/cli/workspace.mjs +9 -1
- package/package.json +1 -1
package/cli/toolbox.mjs
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
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
|
+
// The lines a check prints, from `toolboxCheck`'s answer, without the closing hint. One printer for
|
|
608
|
+
// `yad toolbox check` and for the toolbox step of setup, update, check and join (E85), so they cannot
|
|
609
|
+
// say it differently.
|
|
610
|
+
export function toolboxCheckLines({ used, findings }) {
|
|
611
|
+
if (!used.length) return [` ${c.dim('•')} this project uses no tool from the toolbox — \`yad toolbox list\` shows them all`];
|
|
612
|
+
if (!findings.length) return [` ${c.green('✓')} all ${used.length} tool(s) this project uses are here: ${used.map((r) => clean(r.name)).join(', ')}`];
|
|
613
|
+
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.`)];
|
|
614
|
+
for (const f of findings) {
|
|
615
|
+
const r = used.find((u) => u.id === f.id);
|
|
616
|
+
const what = f.problem === 'missing' ? 'not found'
|
|
617
|
+
: f.problem === 'disabled' ? 'installed, but its plugin is turned off in your Claude Code settings'
|
|
618
|
+
: `version ${clean(f.version)} is outside the known-good range ${f.versions} — still used`;
|
|
619
|
+
lines.push(` ${f.problem === 'out-of-range' ? c.yellow('!') : c.dim('–')} ${c.bold(clean(f.name))}: ${what}`);
|
|
620
|
+
if (f.problem === 'disabled') lines.push(` ${c.dim('turn it on with /plugin in Claude Code')}`);
|
|
621
|
+
else for (const line of howToGet(r)) lines.push(` ${c.dim(line)}`);
|
|
622
|
+
if (f.problem !== 'out-of-range') lines.push(` ${c.dim(`without it: ${clean(f.fallback)}`)}`);
|
|
623
|
+
}
|
|
624
|
+
return lines;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// NEVER FAILS (decided with the row): no tool is required, so a missing one is news, not an error. Exit 0.
|
|
628
|
+
export function runToolboxCheck(root, { json = false, productRoot = null, ...opts } = {}) {
|
|
629
|
+
const res = toolboxCheck(root, productRoot, opts);
|
|
630
|
+
const { used, findings, problems } = res;
|
|
631
|
+
if (json) return emitJSON({ ok: true, ...(productRoot ? where(productRoot) : { product: null, file: null }), used: used.map((r) => r.id), findings, problems });
|
|
632
|
+
for (const p of problems) warn(p);
|
|
633
|
+
for (const line of toolboxCheckLines(res)) log(line);
|
|
634
|
+
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');
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// The toolbox step of `yad setup`, `yad check`, `yad update` and `yad join` (E85). It OFFERS: it prints
|
|
639
|
+
// what the project uses that is not here and how to get it. It installs nothing, runs no program and
|
|
640
|
+
// writes no file (decided with the row, as `yad toolbox add` is) — and it never fails the command it is
|
|
641
|
+
// part of. Returns the part of the --json answer it adds: `{ used, findings, problems }`.
|
|
642
|
+
// `canRemove: false` leaves out the `remove` advice — for `yad join`, whose reader just cloned the team's
|
|
643
|
+
// choices: one person's machine lacking a tool is no reason to change what the whole team uses.
|
|
644
|
+
export function offerToolbox(root, productRoot, { canRemove = true, ...opts } = {}) {
|
|
645
|
+
let res;
|
|
646
|
+
// A step inside another command: if finding tools throws (a home folder that cannot be read), the
|
|
647
|
+
// command it is part of still finishes, and says why the step is missing.
|
|
648
|
+
try { res = toolboxCheck(root, productRoot, opts); } catch (e) {
|
|
649
|
+
warn(`toolbox: could not check the tools here (${clean(e?.message ?? String(e))}) — run \`yad toolbox check\` to see why`);
|
|
650
|
+
return { used: [], findings: [], problems: [], error: 'could not check' };
|
|
651
|
+
}
|
|
652
|
+
for (const p of res.problems) warn(p);
|
|
653
|
+
for (const line of toolboxCheckLines(res)) log(line);
|
|
654
|
+
if (res.findings.length) {
|
|
655
|
+
info(canRemove
|
|
656
|
+
? 'nothing is installed for you: follow the steps you choose, or, for the whole team, stop using a tool with `yad toolbox remove <id>`'
|
|
657
|
+
: 'nothing is installed for you: follow the steps you choose');
|
|
658
|
+
}
|
|
659
|
+
return { used: res.used.map((r) => r.id), findings: res.findings, problems: res.problems };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// `--detect skill:x,plugin:p` → { skills: ['x'], plugins: ['p'] }, or { error }.
|
|
663
|
+
export function parseDetect(text) {
|
|
664
|
+
const out = {};
|
|
665
|
+
for (const part of String(text ?? '').split(',').map((p) => p.trim()).filter(Boolean)) {
|
|
666
|
+
const m = part.match(/^([a-z]+):(.+)$/);
|
|
667
|
+
const key = m && Object.hasOwn(DETECT_KINDS, m[1]) ? DETECT_KINDS[m[1]] : null;
|
|
668
|
+
if (!key) return { error: `\`${clean(part)}\` is not <kind>:<name> — the kinds are ${Object.keys(DETECT_KINDS).join(', ')}` };
|
|
669
|
+
(out[key] ??= []).push(m[2].trim());
|
|
670
|
+
}
|
|
671
|
+
return Object.keys(out).length ? { detect: out } : { error: '--detect names nothing to look for' };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// `--install "npm: npx thing"` → { type, command }, or { error }.
|
|
675
|
+
export function parseInstall(text) {
|
|
676
|
+
const m = String(text ?? '').match(/^\s*([a-z]+)\s*:\s*(.+?)\s*$/);
|
|
677
|
+
if (!m || !INSTALL_TYPES.includes(m[1])) return { error: `--install is "<type>: <command>", the type one of ${INSTALL_TYPES.join(', ')}` };
|
|
678
|
+
return { route: { type: m[1], command: m[2] } };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const CUSTOM_FLAGS = ['role', 'fallback', 'detect', 'install', 'source'];
|
|
682
|
+
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 });
|
|
683
|
+
|
|
684
|
+
// The document plus one edit, ready to write. `schemaVersion` is set outright, as `yad skill bind` does:
|
|
685
|
+
// this command is the file's engine writer, so its write IS the file's migration. Keys this release does
|
|
686
|
+
// not know are kept; an empty `shipped` or `custom` is left out.
|
|
687
|
+
function save(productRoot, doc, shipped, custom) {
|
|
688
|
+
const next = { ...doc, schemaVersion: SCHEMA_VERSION, shipped, custom };
|
|
689
|
+
if (!Object.keys(shipped).length) delete next.shipped;
|
|
690
|
+
if (!custom.length) delete next.custom;
|
|
691
|
+
writeJSON(path.join(productRoot, FILE), next);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// The one edit both verbs make to a shipped tool: write `want` only where it differs from the default.
|
|
695
|
+
function setShipped(doc, tool, want, connected) {
|
|
696
|
+
const shipped = { ...(isObject(doc.shipped) ? doc.shipped : {}) };
|
|
697
|
+
if (defaultUsed(tool, connected) === (want === 'use')) delete shipped[tool.id];
|
|
698
|
+
else shipped[tool.id] = want;
|
|
699
|
+
return shipped;
|
|
700
|
+
}
|
|
701
|
+
const sameJSON = (a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
|
|
702
|
+
|
|
703
|
+
// Where the file is, for an answer: the Product as a path from here, since run from a code repo nothing
|
|
704
|
+
// else on stdout names it. Both sides as the disk has them: `process.cwd()` is already resolved, and a
|
|
705
|
+
// Product reached through a link (macOS's /var is /private/var) would otherwise climb to the top and back.
|
|
706
|
+
const onDisk = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
707
|
+
const where = (productRoot) => ({ product: path.relative(onDisk(process.cwd()), onDisk(productRoot)) || '.', file: FILE });
|
|
708
|
+
|
|
709
|
+
// The edit to a shipped tool, said truthfully. Whether the FILE changed and whether the tool's USE changed
|
|
710
|
+
// are two questions: removing a line that did nothing (`"figma": "bogus"`, or one that repeats the
|
|
711
|
+
// default) changes the file and not the use.
|
|
712
|
+
function sayEdit(tool, was, now, changed) {
|
|
713
|
+
const state = now.used ? 'used here' : 'not used here';
|
|
714
|
+
if (!changed) info(`${tool.name} is already ${state} — nothing to change`);
|
|
715
|
+
else if (was.used === now.used) info(`${tool.name} is already ${state} — removed a line in ${FILE} that did nothing`);
|
|
716
|
+
// The fallback is printed as `list` prints it, after "without it:" — on its own, ECC's ("nothing
|
|
717
|
+
// changes: …") read as if the remove had done nothing.
|
|
718
|
+
else ok(now.used ? `${tool.name} is used here now` : `${tool.name} is not used here now. Without it: ${clean(tool.fallback)}`);
|
|
719
|
+
}
|
|
720
|
+
// The whole report of an edit to a SHIPPED tool, for both verbs — one function, so `add` and `remove`
|
|
721
|
+
// cannot say it differently again. Two facts, each said once: the shipped part (`sayEdit`), and the
|
|
722
|
+
// dead custom copies under the tool's id that the same write removed.
|
|
723
|
+
function sayShippedEdit(tool, was, now, shippedChanged, copies) {
|
|
724
|
+
if (shippedChanged || !copies) sayEdit(tool, was, now, shippedChanged);
|
|
725
|
+
if (copies) {
|
|
726
|
+
// When only copies went, the tool's use did not change: `customProblems` rejects every custom entry
|
|
727
|
+
// under a shipped tool's id, so no copy ever counted toward using it. If that rule ever changes,
|
|
728
|
+
// this branch must call `sayEdit` too.
|
|
729
|
+
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)`;
|
|
730
|
+
info(shippedChanged ? `also removed ${what}` : `removed ${what} — ${tool.name} is still ${now.used ? 'used' : 'not used'} here`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const usedFrom = (shipped, connected) => ({ shipped: new Map(Object.entries(shipped).filter(([, v]) => CHOICES.includes(v))), connected });
|
|
734
|
+
const sameId = (id) => (t) => isObject(t) && t.id === id;
|
|
735
|
+
|
|
736
|
+
export function runToolboxAdd(productRoot, { id, custom = false, json = false, ...flags } = {}) {
|
|
737
|
+
if (!id) return refuse('yad toolbox add needs a tool id', ADD_USAGE, { json });
|
|
738
|
+
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 });
|
|
739
|
+
const given = CUSTOM_FLAGS.filter((k) => flags[k] !== undefined);
|
|
740
|
+
if (!custom && given.length) return refuse(`--${given[0]} describes a tool of your own, and goes with --custom`, ADD_USAGE, { json });
|
|
741
|
+
const { doc, error } = readToolboxFile(productRoot);
|
|
742
|
+
if (error) return brokenFile(error, json);
|
|
743
|
+
const list = Array.isArray(doc.custom) ? doc.custom : [];
|
|
744
|
+
const tool = shippedTool(id);
|
|
745
|
+
const mine = list.find(sameId(id));
|
|
746
|
+
// In use exactly when `list` says so: `loadChoices` keeps the first USABLE copy, so a broken copy
|
|
747
|
+
// before a good one does not make the tool unused, and a broken copy alone does not make it used.
|
|
748
|
+
const usable = loadChoices(productRoot).custom.some((r) => r.id === id);
|
|
749
|
+
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 });
|
|
750
|
+
|
|
751
|
+
if (custom) {
|
|
752
|
+
if (tool) return refuse(`${id} is a shipped tool`, `\`yad toolbox add ${id}\` marks it as used here`, { json });
|
|
753
|
+
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 });
|
|
754
|
+
if (mine) return ignored();
|
|
755
|
+
const d = parseDetect(flags.detect);
|
|
756
|
+
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 });
|
|
757
|
+
const entry = { id, role: flags.role, fallback: flags.fallback, detect: d.detect };
|
|
758
|
+
if (flags.install !== undefined) {
|
|
759
|
+
const r = parseInstall(flags.install);
|
|
760
|
+
if (r.error) return refuse(r.error, ADD_USAGE, { json });
|
|
761
|
+
entry.install = [r.route];
|
|
762
|
+
}
|
|
763
|
+
if (flags.source !== undefined) entry.source = flags.source;
|
|
764
|
+
const problems = customProblems(entry);
|
|
765
|
+
if (problems.length) return refuse(`cannot add ${id}: ${problems.join('; ')}`, ADD_USAGE, { json });
|
|
766
|
+
save(productRoot, doc, isObject(doc.shipped) ? doc.shipped : {}, [...list, entry]);
|
|
767
|
+
ok(`${id} added — a tool of this project's own, in use here`);
|
|
768
|
+
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`);
|
|
769
|
+
return { id, used: true, because: 'custom', changed: true, ...where(productRoot) };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (!tool) {
|
|
773
|
+
if (usable) {
|
|
774
|
+
info(`${id} is already one of this project's tools — nothing to change`);
|
|
775
|
+
return { id, used: true, because: 'custom', changed: false, ...where(productRoot) };
|
|
776
|
+
}
|
|
777
|
+
// An entry `list` and `check` ignore is not in use, whatever the file says.
|
|
778
|
+
if (mine) return ignored();
|
|
779
|
+
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 });
|
|
780
|
+
}
|
|
781
|
+
const connected = connectedTools(productRoot);
|
|
782
|
+
const was = toolUse(tool, loadChoices(productRoot));
|
|
783
|
+
const shipped = setShipped(doc, tool, 'use', connected);
|
|
784
|
+
// A custom entry under a shipped id is ignored; it goes in the same write, as `remove` does it.
|
|
785
|
+
const left = list.filter((t) => !sameId(id)(t));
|
|
786
|
+
const copies = list.length - left.length;
|
|
787
|
+
const shippedChanged = !sameJSON(shipped, doc.shipped);
|
|
788
|
+
const changed = shippedChanged || copies > 0;
|
|
789
|
+
if (changed) save(productRoot, doc, shipped, left);
|
|
790
|
+
const now = toolUse(tool, usedFrom(shipped, connected));
|
|
791
|
+
sayShippedEdit(tool, was, now, shippedChanged, copies);
|
|
792
|
+
// Records only: the person installs it.
|
|
793
|
+
const how = howToGet(tool);
|
|
794
|
+
if (how.length) hand(`to install it: ${how.join(' or ')}`);
|
|
795
|
+
// An undo is offered only when the tool's use changed: after clearing a line that did nothing, the
|
|
796
|
+
// "opposite" command would switch the tool off, not put anything back.
|
|
797
|
+
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`);
|
|
798
|
+
return { id, ...now, changed, ...where(productRoot) };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Removes EVERYTHING the file holds under `id`, in one write: the shipped tool's use, every custom entry
|
|
802
|
+
// with that id (a dead one sharing a shipped tool's id included), and a shipped line for a tool this
|
|
803
|
+
// release does not ship — so it also clears each line `yad doctor` names as doing nothing.
|
|
804
|
+
export function runToolboxRemove(productRoot, { id, json = false, ...flags } = {}) {
|
|
805
|
+
if (!id) return refuse('yad toolbox remove needs a tool id', 'usage: yad toolbox remove <id>', { json });
|
|
806
|
+
const given = CUSTOM_FLAGS.filter((k) => flags[k] !== undefined);
|
|
807
|
+
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 });
|
|
808
|
+
const { doc, error } = readToolboxFile(productRoot);
|
|
809
|
+
if (error) return brokenFile(error, json);
|
|
810
|
+
const list = Array.isArray(doc.custom) ? doc.custom : [];
|
|
811
|
+
const shipped0 = isObject(doc.shipped) ? doc.shipped : {};
|
|
812
|
+
const tool = shippedTool(id);
|
|
813
|
+
const left = list.filter((t) => !sameId(id)(t));
|
|
814
|
+
const copies = list.length - left.length;
|
|
815
|
+
const deadLine = !tool && Object.hasOwn(shipped0, id);
|
|
816
|
+
if (!tool && !copies && !deadLine) {
|
|
817
|
+
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 });
|
|
818
|
+
return refuse(`${id} is not in the toolbox`, '`yad toolbox list --json` shows each id', { json });
|
|
819
|
+
}
|
|
820
|
+
if (tool) {
|
|
821
|
+
const connected = connectedTools(productRoot);
|
|
822
|
+
const was = toolUse(tool, loadChoices(productRoot));
|
|
823
|
+
const shipped = setShipped(doc, tool, 'skip', connected);
|
|
824
|
+
const shippedChanged = !sameJSON(shipped, doc.shipped);
|
|
825
|
+
const changed = shippedChanged || copies > 0;
|
|
826
|
+
if (changed) save(productRoot, doc, shipped, left);
|
|
827
|
+
const now = toolUse(tool, usedFrom(shipped, connected));
|
|
828
|
+
sayShippedEdit(tool, was, now, shippedChanged, copies);
|
|
829
|
+
// Removing it from the toolbox does not disconnect it: the connect skill owns that file.
|
|
830
|
+
if (connected.has(id)) info(`${connected.get(id)} still connects it — \`${tool.usedBy[0]}\` changes the connection`);
|
|
831
|
+
if (changed) hand(`written to ${FILE} — commit it so the team shares it${was.used !== now.used ? ` (undo with \`yad toolbox add ${id}\`)` : ''}`);
|
|
832
|
+
return { id, ...now, changed, ...where(productRoot) };
|
|
833
|
+
}
|
|
834
|
+
const usableBefore = loadChoices(productRoot).custom.some((r) => r.id === id);
|
|
835
|
+
const shipped = { ...shipped0 };
|
|
836
|
+
if (deadLine) delete shipped[id];
|
|
837
|
+
save(productRoot, doc, shipped, left);
|
|
838
|
+
if (copies) {
|
|
839
|
+
const many = copies > 1 ? ` (listed ${copies} times; every copy is gone)` : '';
|
|
840
|
+
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}`);
|
|
841
|
+
}
|
|
842
|
+
if (deadLine) ok(`removed the line for \`${clean(id)}\` under shipped — a tool this yadflow does not ship`);
|
|
843
|
+
hand(`written to ${FILE} — commit it so the team shares it`);
|
|
844
|
+
return { id, used: false, because: null, changed: true, ...where(productRoot) };
|
|
845
|
+
}
|