yadflow 4.0.1 → 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/cli/detect.mjs ADDED
@@ -0,0 +1,642 @@
1
+ // `yad detect` — which skills, agents, MCP servers and plugins are installed for the agents that could
2
+ // run a step here (E50).
3
+ //
4
+ // yad detect [--json] [--dir <folder>]
5
+ //
6
+ // Worked out fresh on every run and never saved: nothing reads it yet except a person, and a saved copy
7
+ // would be a new file shape that goes stale the moment someone installs a skill. E51 (bind per profile)
8
+ // and E85 (the toolbox check) are the readers this is built for.
9
+ //
10
+ // TWO PLACES ARE LOOKED IN: the folder the command runs on (what a teammate who clones it also gets), and
11
+ // the user's home folder (where most people install skills). Every item says which one, and which agents
12
+ // read the place it came from.
13
+ //
14
+ // IT ONLY READS. No file is written, no directory is made, no network is used, no program is run. The
15
+ // E11 lesson is the reason this is stated: a detector that took a folder's existence as consent once
16
+ // enrolled projects into installs nobody asked for. Here a found item is only ever REPORTED.
17
+ //
18
+ // MCP SERVERS ARE NAMED, NEVER DESCRIBED. Their entries carry a command, arguments, a URL, headers and
19
+ // environment variables — the last three often hold a token. Only the key a server is listed under is
20
+ // read out; nothing else from the entry is ever copied into an item, a problem or an error.
21
+ //
22
+ // WHERE EACH AGENT LOOKS, checked against each agent's own documentation on 2026-09-30, NOT from memory
23
+ // (the project folders repeat the E11 table in manifest.mjs, checked 2026-09-16). Re-check before adding
24
+ // one: a guessed place reports skills that no agent ever loads.
25
+ //
26
+ // Claude Code skills .claude/skills, ~/.claude/skills, plugin skills/; agents .claude/agents,
27
+ // ~/.claude/agents, plugin agents/; MCP .mcp.json, ~/.claude.json (user + this project),
28
+ // plugin .mcp.json / plugin.json; plugins ~/.claude/plugins/installed_plugins.json
29
+ // Codex CLI skills .agents/skills, ~/.agents/skills; agents .codex/agents/*.toml,
30
+ // ~/.codex/agents/*.toml; MCP [mcp_servers.<name>] in .codex/config.toml,
31
+ // ~/.codex/config.toml; plugins [plugins."<name>"] in the same files
32
+ // Cursor skills .agents .cursor .claude .codex /skills, both in the project and in ~;
33
+ // agents .cursor/agents, ~/.cursor/agents; MCP .cursor/mcp.json, ~/.cursor/mcp.json
34
+ // Gemini CLI skills .gemini/skills and .agents/skills, both places; agents .gemini/agents,
35
+ // ~/.gemini/agents; MCP mcpServers in .gemini/settings.json, ~/.gemini/settings.json
36
+ // Copilot, Zencoder, opencode the project skill folders yad installs into (manifest.mjs IDE_AGENTS)
37
+ import crypto from 'node:crypto';
38
+ import fs from 'node:fs';
39
+ import os from 'node:os';
40
+ import path from 'node:path';
41
+ import { c, emitJSON, info, log, samePath } from './lib.mjs';
42
+ import { IDE_AGENTS, IDE_OPENCODE_DIR } from './manifest.mjs';
43
+
44
+ // A SKILL.md or agent file bigger than this is not hashed or parsed — no real one comes near it, and a
45
+ // huge file in a skills folder is not something to read into memory on every run.
46
+ const MAX_FILE_BYTES = 1024 * 1024;
47
+ // Config files are allowed more: Claude Code's `~/.claude.json` keeps per-project history and grows.
48
+ const MAX_CONFIG_BYTES = 64 * 1024 * 1024;
49
+
50
+ // Skill folders: `<place>/<skill>/SKILL.md`. The project list is the installer's own table, so the two
51
+ // cannot drift; Cursor's `.codex/skills` compatibility folder is added (Codex itself does not read it).
52
+ const SKILL_PLACES = [
53
+ ...['.claude', '.agents', '.cursor', '.gemini', '.zencoder'].map((d) => ({ scope: 'project', dir: `${d}/skills`, agents: IDE_AGENTS[d] })),
54
+ { scope: 'project', dir: '.codex/skills', agents: ['Cursor'] },
55
+ { scope: 'user', dir: '.claude/skills', agents: ['Claude Code', 'Cursor'] },
56
+ { scope: 'user', dir: '.agents/skills', agents: ['Codex CLI', 'Gemini CLI', 'Cursor'] },
57
+ { scope: 'user', dir: '.cursor/skills', agents: ['Cursor'] },
58
+ { scope: 'user', dir: '.gemini/skills', agents: ['Gemini CLI'] },
59
+ { scope: 'user', dir: '.codex/skills', agents: ['Cursor'] },
60
+ ];
61
+
62
+ // Agent (subagent) definitions: one file each. Codex writes TOML; the others Markdown with frontmatter.
63
+ const AGENT_PLACES = [
64
+ { scope: 'project', dir: '.claude/agents', ext: '.md', agents: ['Claude Code'] },
65
+ { scope: 'user', dir: '.claude/agents', ext: '.md', agents: ['Claude Code'] },
66
+ { scope: 'project', dir: '.cursor/agents', ext: '.md', agents: ['Cursor'] },
67
+ { scope: 'user', dir: '.cursor/agents', ext: '.md', agents: ['Cursor'] },
68
+ { scope: 'project', dir: '.gemini/agents', ext: '.md', agents: ['Gemini CLI'] },
69
+ { scope: 'user', dir: '.gemini/agents', ext: '.md', agents: ['Gemini CLI'] },
70
+ { scope: 'project', dir: '.codex/agents', ext: '.toml', agents: ['Codex CLI'] },
71
+ { scope: 'user', dir: '.codex/agents', ext: '.toml', agents: ['Codex CLI'] },
72
+ ];
73
+
74
+ // JSON files that list MCP servers under a `mcpServers` object.
75
+ const MCP_JSON_PLACES = [
76
+ { scope: 'project', file: '.mcp.json', agents: ['Claude Code'] },
77
+ { scope: 'project', file: '.cursor/mcp.json', agents: ['Cursor'] },
78
+ { scope: 'user', file: '.cursor/mcp.json', agents: ['Cursor'] },
79
+ { scope: 'project', file: '.gemini/settings.json', agents: ['Gemini CLI'], comments: true },
80
+ { scope: 'user', file: '.gemini/settings.json', agents: ['Gemini CLI'], comments: true },
81
+ ];
82
+
83
+ // Codex's config: MCP servers and plugins are TOML tables.
84
+ const CODEX_CONFIG_PLACES = [
85
+ { scope: 'project', file: '.codex/config.toml' },
86
+ { scope: 'user', file: '.codex/config.toml' },
87
+ ];
88
+
89
+ // The two reasons a JSON file is a problem. "Could not be read" covers a file that does not parse and one
90
+ // too big to read — the same answer for the same cause, whichever file it is.
91
+ const NOT_JSON = 'could not be read as a JSON object';
92
+ const NOT_SERVERS = '`mcpServers` is not an object';
93
+
94
+ const CLAUDE_USER_CONFIG = '.claude.json';
95
+ const CLAUDE_PLUGINS = '.claude/plugins/installed_plugins.json';
96
+ const CLAUDE_USER_SETTINGS = '.claude/settings.json';
97
+
98
+ // ---- reading, without ever failing ------------------------------------------------------------------
99
+
100
+ // `stat`, following a link (skill managers install skills as links into `~/.agents/skills`), or null.
101
+ const statOf = (p) => { try { return fs.statSync(p); } catch { return null; } };
102
+ const isDir = (p) => !!statOf(p)?.isDirectory();
103
+
104
+ // The entries of a folder, sorted so two runs print the same thing; [] when there is no folder.
105
+ function entriesOf(dir) {
106
+ try { return fs.readdirSync(dir).sort(); } catch { return []; }
107
+ }
108
+
109
+ // A file's bytes, or null when it is missing, not a file, too big or unreadable.
110
+ function bytesOf(file, max = MAX_FILE_BYTES) {
111
+ const st = statOf(file);
112
+ if (!st?.isFile() || st.size > max) return null;
113
+ try { return fs.readFileSync(file); } catch { return null; }
114
+ }
115
+
116
+ // Parsed JSON, `undefined` when the file is not there, and `null` when it is there but does not parse
117
+ // (or is too big to read) — the caller reports that as a problem rather than as "no servers".
118
+ function jsonOf(file, { comments = false } = {}) {
119
+ if (!statOf(file)) return undefined;
120
+ const bytes = bytesOf(file, MAX_CONFIG_BYTES);
121
+ if (!bytes) return null;
122
+ const text = bytes.toString('utf8').replace(/^\uFEFF/, '');
123
+ try { return JSON.parse(comments ? stripJsonComments(text) : text); } catch { return null; }
124
+ }
125
+
126
+ // `//` and `/* */` comments removed, strings left alone — for a file its agent reads that way (Gemini CLI
127
+ // strips comments from settings.json before parsing it). Not for the others: a comment there stops the
128
+ // agent from loading the file, so reading past it would report servers the agent never sees.
129
+ export function stripJsonComments(text) {
130
+ let out = '';
131
+ for (let i = 0; i < text.length; i++) {
132
+ const ch = text[i];
133
+ if (ch === '"') {
134
+ let j = i + 1;
135
+ while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
136
+ out += text.slice(i, j + 1);
137
+ i = j;
138
+ } else if (ch === '/' && text[i + 1] === '/') {
139
+ while (i < text.length && text[i] !== '\n') i++;
140
+ out += '\n';
141
+ } else if (ch === '/' && text[i + 1] === '*') {
142
+ const end = text.indexOf('*/', i + 2);
143
+ i = end < 0 ? text.length : end + 1;
144
+ out += ' ';
145
+ } else out += ch;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // The same content hashes the same on every machine: CRLF counts as LF, as `contentSha` in lib.mjs does
151
+ // (E113). Git on Windows checks a committed SKILL.md out with CRLF, and E55 will compare these hashes
152
+ // across machines. A file with no CR hashes exactly as its bytes do.
153
+ const sha256 = (bytes) => {
154
+ const text = bytes.includes(13) ? Buffer.from(bytes.toString('latin1').replace(/\r\n/g, '\n'), 'latin1') : bytes;
155
+ return `sha256:${crypto.createHash('sha256').update(text).digest('hex')}`;
156
+ };
157
+ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
158
+
159
+ // A name or version comes from a file anyone could have written: no control character reaches the
160
+ // terminal, where one could move the cursor or recolour what follows, and no character that reorders or
161
+ // breaks a line (the bidirectional marks, overrides and isolates, the line and paragraph separators), and
162
+ // no zero-width character (a name made only of them would print as nothing). This is for the TERMINAL:
163
+ // `--json` escapes only U+0000–001F and hands the rest to a program unchanged.
164
+ // eslint-disable-next-line no-control-regex -- matching control characters is the point
165
+ const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
166
+ export const clean = (v) => String(v).replace(UNSAFE, '?');
167
+
168
+ // ---- the three file formats -------------------------------------------------------------------------
169
+
170
+ // The two frontmatter keys this needs: `name`, and `version` — at the top, or nested one level under
171
+ // `metadata:` (the Agent Skills spec puts it there). A small reader on purpose: a YAML library is a
172
+ // dependency for two keys, and a value this cannot read is simply reported as missing.
173
+ export function skillMeta(text) {
174
+ const m = String(text).replace(/^\uFEFF/, '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
175
+ if (!m) return {};
176
+ const out = {};
177
+ let inMetadata = false;
178
+ let childIndent = null; // the indent of `metadata:`'s own keys; a deeper `version:` is not its version
179
+ for (const line of m[1].split(/\r?\n/)) {
180
+ const top = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
181
+ if (top) {
182
+ inMetadata = top[1] === 'metadata' && scalar(top[2]) === '';
183
+ childIndent = null;
184
+ if ((top[1] === 'name' || top[1] === 'version') && printable(scalar(top[2]))) out[top[1]] = scalar(top[2]);
185
+ continue;
186
+ }
187
+ const nested = inMetadata && line.match(/^(\s+)([A-Za-z_][\w-]*):\s*(.*)$/);
188
+ if (!nested) continue;
189
+ childIndent ??= nested[1].length;
190
+ if (nested[1].length === childIndent && nested[2] === 'version' && out.version === undefined && printable(scalar(nested[3]))) out.version = scalar(nested[3]);
191
+ }
192
+ return out;
193
+ }
194
+
195
+ // A value made only of control and invisible characters is no value: the folder name is used instead.
196
+ const printable = (v) => /\S/.test(String(v).replace(UNSAFE, ''));
197
+
198
+ // A one-line YAML value: a quoted string as written, or a plain one with a ` # comment` cut off. A value
199
+ // with a trailing comment is otherwise read AS the value (the frontmatter trap this project has hit).
200
+ function scalar(v) {
201
+ const t = v.trim();
202
+ // The closing quote is the FIRST one after the opening, escapes aside, so `"a" # was "b"` is `a`. A
203
+ // double-quoted value decodes its escapes (JSON's are a subset of YAML's); a single-quoted one has only
204
+ // one, `''` for a quote.
205
+ // YAML escapes JSON lacks (`\x41`, `\e`, `\0`) make the parse fail, and the value is then shown as
206
+ // written — a display matter only.
207
+ const dq = t.match(/^"((?:[^"\\]|\\.)*)"\s*(#.*)?$/);
208
+ if (dq) { try { return JSON.parse(`"${dq[1]}"`); } catch { return dq[1]; } }
209
+ const sq = t.match(/^'((?:[^']|'')*)'\s*(#.*)?$/);
210
+ if (sq) return sq[1].replace(/''/g, "'");
211
+ return t.replace(/(^|\s)#.*$/, '').trim();
212
+ }
213
+
214
+ // ---- TOML: keys only --------------------------------------------------------------------------------
215
+ //
216
+ // Codex keeps MCP servers and plugins as TOML tables, and its agents as TOML files. This reads KEYS, and
217
+ // the one string value `name`, and skips every other value WHOLE — a multi-line array, a `"""` string,
218
+ // an inline table — so text inside a value can never be taken for a key. That matters because the values
219
+ // under `[mcp_servers.<name>]` are commands, arguments and environment variables, and they hold tokens:
220
+ // a line-by-line reader once listed `"sk-live-…"`, an element of a multi-line `args` array, as a server.
221
+ // A statement this cannot follow is dropped up to the end of its line; nothing from it is kept.
222
+
223
+ // The file as tokens: strings, bare words, punctuation and line breaks. Comments are dropped. A
224
+ // multi-line string is only ever a value, so its text is never kept (`v: null`).
225
+ function tomlTokens(text) {
226
+ const src = String(text).replace(/^\uFEFF/, '');
227
+ const out = [];
228
+ let i = 0;
229
+ // A basic string's escapes, as TOML defines them (JSON has no `\U` or `\e`). An escape TOML does not
230
+ // define is kept as written.
231
+ const ESC = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', e: '\x1b', '"': '"', '\\': '\\' };
232
+ const decode = (raw) => raw.replace(/\\(u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|.)/g, (all, e) => {
233
+ if (e.length > 1) { const cp = parseInt(e.slice(1), 16); return cp <= 0x10ffff ? String.fromCodePoint(cp) : all; }
234
+ return Object.hasOwn(ESC, e) ? ESC[e] : all;
235
+ });
236
+ while (i < src.length) {
237
+ const ch = src[i];
238
+ if (ch === '\n') { out.push({ t: 'nl' }); i++; continue; }
239
+ if (ch === ' ' || ch === '\t' || ch === '\r') { i++; continue; }
240
+ if (ch === '#') { while (i < src.length && src[i] !== '\n') i++; continue; }
241
+ const triple = src.slice(i, i + 3);
242
+ if (triple === '"""' || triple === "'''") {
243
+ let j = i + 3;
244
+ for (;;) {
245
+ const at = src.indexOf(triple, j);
246
+ if (at < 0) { j = src.length; break; }
247
+ // In a basic string `\"""` is an escaped quote, unless the backslash is itself escaped.
248
+ let slashes = 0;
249
+ while (triple === '"""' && src[at - 1 - slashes] === '\\') slashes++;
250
+ if (slashes % 2 === 1) { j = at + 1; continue; }
251
+ // Up to two quotes may sit right before the closing three: the string ends at the last three.
252
+ j = at + 3;
253
+ while (src[j] === triple[0] && j - at < 5) j++;
254
+ break;
255
+ }
256
+ out.push({ t: 'str', v: null });
257
+ i = j;
258
+ continue;
259
+ }
260
+ if (ch === '"' || ch === "'") {
261
+ let j = i + 1;
262
+ while (j < src.length && src[j] !== ch && src[j] !== '\n') j += ch === '"' && src[j] === '\\' ? 2 : 1;
263
+ const raw = src.slice(i + 1, Math.min(j, src.length));
264
+ out.push({ t: 'str', v: ch === '"' ? decode(raw) : raw });
265
+ i = src[j] === ch ? j + 1 : j;
266
+ continue;
267
+ }
268
+ if ('[]{}=.,'.includes(ch)) { out.push({ t: ch }); i++; continue; }
269
+ let j = i;
270
+ while (j < src.length && !' \t\r\n#[]{}=.,"\''.includes(src[j])) j++;
271
+ out.push({ t: 'word', v: src.slice(i, j) });
272
+ i = j;
273
+ }
274
+ return out;
275
+ }
276
+
277
+ // Every statement of the file: a table header (`header: true`), or a `key = value` with its FULL path
278
+ // (the header's path, then the key's own). For a value, only two things are kept: a single-line string
279
+ // (`str`, for the one caller that wants `name`) and the first-level keys of an inline table.
280
+ function tomlEntries(text) {
281
+ const tk = tomlTokens(text);
282
+ const entries = [];
283
+ let i = 0;
284
+ let table = [];
285
+ const isKey = (x) => !!x && (x.t === 'word' || (x.t === 'str' && x.v !== null));
286
+ const toLineEnd = () => { while (i < tk.length && tk[i].t !== 'nl') i++; };
287
+ const keyPath = () => {
288
+ const parts = [];
289
+ while (isKey(tk[i])) {
290
+ parts.push(tk[i].v);
291
+ i++;
292
+ if (tk[i]?.t === '.') i++; else break;
293
+ }
294
+ return parts;
295
+ };
296
+ // Steps over one value, and returns the first-level keys when it is an inline table.
297
+ const value = (depth = 0) => {
298
+ const x = tk[i];
299
+ if (!x || depth > 64) { toLineEnd(); return null; }
300
+ if (x.t === '{') {
301
+ i++;
302
+ const keys = [];
303
+ while (i < tk.length && tk[i].t !== '}') {
304
+ if (tk[i].t === ',' || tk[i].t === 'nl') { i++; continue; }
305
+ const k = keyPath();
306
+ if (!k.length || tk[i]?.t !== '=') { i++; continue; }
307
+ i++;
308
+ keys.push(k[0]);
309
+ value(depth + 1);
310
+ }
311
+ i++;
312
+ return keys;
313
+ }
314
+ if (x.t === '[') {
315
+ i++;
316
+ while (i < tk.length && tk[i].t !== ']') {
317
+ if (tk[i].t === ',' || tk[i].t === 'nl') { i++; continue; }
318
+ const before = i;
319
+ value(depth + 1);
320
+ if (i === before) i++;
321
+ }
322
+ i++;
323
+ return null;
324
+ }
325
+ if (x.t === 'str') { i++; return null; }
326
+ // A bare scalar — a number, a date, `true` — may hold dots: step over words and dots together.
327
+ while (i < tk.length && (tk[i].t === 'word' || tk[i].t === '.')) i++;
328
+ return null;
329
+ };
330
+ while (i < tk.length) {
331
+ if (tk[i].t === 'nl') { i++; continue; }
332
+ if (tk[i].t === '[') {
333
+ i += tk[i + 1]?.t === '[' ? 2 : 1;
334
+ table = keyPath();
335
+ toLineEnd();
336
+ entries.push({ path: table, header: true });
337
+ continue;
338
+ }
339
+ const k = keyPath();
340
+ if (!k.length || tk[i]?.t !== '=') { toLineEnd(); continue; }
341
+ i++;
342
+ const str = tk[i]?.t === 'str' ? tk[i].v : null;
343
+ const inlineKeys = value();
344
+ entries.push({ path: [...table, ...k], str, inlineKeys });
345
+ toLineEnd();
346
+ }
347
+ return entries;
348
+ }
349
+
350
+ // A Codex agent file's top-level `name = "…"` (before any table header).
351
+ export function tomlAgentName(text) {
352
+ for (const e of tomlEntries(text)) {
353
+ if (e.header) return null;
354
+ if (e.path.length === 1 && e.path[0] === 'name') return typeof e.str === 'string' && e.str ? e.str : null;
355
+ }
356
+ return null;
357
+ }
358
+
359
+ // The names under a TOML table (`mcp_servers`, `plugins`), from any of the ways TOML can write them:
360
+ // [mcp_servers.docs] [mcp_servers.docs.env] [mcp_servers] + docs = { … } / docs.command = …
361
+ // mcp_servers.docs.command = … mcp_servers = { docs = { … } }
362
+ export function tomlTableNames(text, table) {
363
+ const names = new Set();
364
+ for (const e of tomlEntries(text)) {
365
+ if (e.path[0] !== table) continue;
366
+ if (e.path.length >= 2) names.add(e.path[1]);
367
+ else if (Array.isArray(e.inlineKeys)) for (const k of e.inlineKeys) names.add(k);
368
+ }
369
+ return [...names].sort();
370
+ }
371
+
372
+ // ---- what is found ----------------------------------------------------------------------------------
373
+
374
+ // How a place is shown: relative to the folder for the project, `~/…` for the home folder. Never the
375
+ // home folder's absolute path — it names the machine's user, and this output is pasted into issues.
376
+ const shownPath = (scope, rel) => (scope === 'user' ? `~/${rel}` : rel);
377
+
378
+ function skillsIn(base, place, { scope = place.scope, agents = place.agents, plugin = null, version = null } = {}) {
379
+ const items = [];
380
+ const dir = path.join(base, place.dir);
381
+ for (const entry of entriesOf(dir)) {
382
+ const folder = path.join(dir, entry);
383
+ if (!isDir(folder)) continue;
384
+ const bytes = bytesOf(path.join(folder, 'SKILL.md'));
385
+ if (!bytes) continue;
386
+ const meta = skillMeta(bytes.toString('utf8'));
387
+ items.push({
388
+ kind: 'skill', name: meta.name || entry, scope, where: plugin ? `${plugin}: ${place.dir}/${entry}` : shownPath(scope, `${place.dir}/${entry}`),
389
+ agents: [...agents], version: meta.version ?? version, hash: sha256(bytes), ...(plugin ? { plugin } : {}),
390
+ });
391
+ }
392
+ return items;
393
+ }
394
+
395
+ // opencode's flat copy: `.opencode/commands/<skill>.md`.
396
+ function opencodeSkills(root) {
397
+ const items = [];
398
+ const dir = path.join(root, IDE_OPENCODE_DIR);
399
+ for (const entry of entriesOf(dir)) {
400
+ if (!entry.endsWith('.md')) continue;
401
+ const bytes = bytesOf(path.join(dir, entry));
402
+ if (!bytes) continue;
403
+ const meta = skillMeta(bytes.toString('utf8'));
404
+ items.push({ kind: 'skill', name: meta.name || entry.slice(0, -3), scope: 'project', where: `${IDE_OPENCODE_DIR}/${entry}`,
405
+ agents: [...IDE_AGENTS['.opencode']], version: meta.version ?? null, hash: sha256(bytes) });
406
+ }
407
+ return items;
408
+ }
409
+
410
+ function agentsIn(base, place, { scope = place.scope, agents = place.agents, plugin = null, version = null } = {}) {
411
+ const items = [];
412
+ const dir = path.join(base, place.dir);
413
+ for (const entry of entriesOf(dir)) {
414
+ if (!entry.endsWith(place.ext)) continue;
415
+ const bytes = bytesOf(path.join(dir, entry));
416
+ if (!bytes) continue;
417
+ const text = bytes.toString('utf8');
418
+ const name = place.ext === '.toml' ? tomlAgentName(text) : skillMeta(text).name;
419
+ const meta = place.ext === '.md' ? skillMeta(text) : {};
420
+ items.push({
421
+ kind: 'agent', name: name || entry.slice(0, -place.ext.length), scope, where: plugin ? `${plugin}: ${place.dir}/${entry}` : shownPath(scope, `${place.dir}/${entry}`),
422
+ agents: [...agents], version: meta.version ?? version, hash: sha256(bytes), ...(plugin ? { plugin } : {}),
423
+ });
424
+ }
425
+ return items;
426
+ }
427
+
428
+ const mcpItems = (servers, { scope, where, agents, plugin = null }) => Object.keys(servers).sort()
429
+ .map((name) => ({ kind: 'mcp', name, scope, where, agents: [...agents], ...(plugin ? { plugin } : {}) }));
430
+
431
+ // The folder the project scope reads from, and the home folder the user scope reads from.
432
+ function baseOf(scope, root, home) { return scope === 'user' ? home : root; }
433
+
434
+ // Everything installed, in a fixed order. `root` is the folder the command runs on; `home` the user's
435
+ // home folder (a parameter so a test can point it at a fixture). `problems` lists files that are there
436
+ // but could not be read — by path and reason only, never by content.
437
+ export function detectInstalled(root, { home = os.homedir() } = {}) {
438
+ const items = [];
439
+ const problems = [];
440
+ // One line per file and reason: run in the home folder, a file is both the folder's and the user's.
441
+ const problem = (scope, rel, why) => {
442
+ const where = shownPath(scope, rel);
443
+ if (!problems.some((p) => p.where === where && p.problem === why)) problems.push({ where, problem: why });
444
+ };
445
+ // The home folder may BE the project (someone runs it in ~): the user places then repeat the project
446
+ // places, and every item would be listed twice. The project scope wins and those user places are
447
+ // skipped. `~/.claude.json` and the plugin list have no project twin, so they are read either way.
448
+ const sameFolder = !!home && (path.resolve(root) === path.resolve(home) || samePath(root, home));
449
+ const scopes = (list) => list.filter((p) => p.scope === 'project' || (home && !sameFolder));
450
+
451
+ for (const place of SKILL_PLACES.filter((p) => p.scope === 'project')) items.push(...skillsIn(root, place));
452
+ items.push(...opencodeSkills(root));
453
+ for (const place of scopes(SKILL_PLACES.filter((p) => p.scope === 'user'))) items.push(...skillsIn(home, place));
454
+ for (const place of scopes(AGENT_PLACES)) items.push(...agentsIn(baseOf(place.scope, root, home), place));
455
+
456
+ for (const place of scopes(MCP_JSON_PLACES)) {
457
+ const doc = jsonOf(path.join(baseOf(place.scope, root, home), place.file), { comments: !!place.comments });
458
+ if (doc === undefined) continue;
459
+ if (!isPlainObject(doc)) { problem(place.scope, place.file, NOT_JSON); continue; }
460
+ if (doc.mcpServers === undefined) continue;
461
+ if (!isPlainObject(doc.mcpServers)) { problem(place.scope, place.file, NOT_SERVERS); continue; }
462
+ items.push(...mcpItems(doc.mcpServers, { scope: place.scope, where: shownPath(place.scope, place.file), agents: place.agents }));
463
+ }
464
+
465
+ // Claude Code's own file in the home folder: user-scope servers at the top, and this project's
466
+ // local-scope servers under `projects[<absolute path>]`.
467
+ if (home) {
468
+ const doc = jsonOf(path.join(home, CLAUDE_USER_CONFIG));
469
+ if (doc === null || (doc !== undefined && !isPlainObject(doc))) problem('user', CLAUDE_USER_CONFIG, NOT_JSON);
470
+ else if (doc) {
471
+ if (isPlainObject(doc.mcpServers)) items.push(...mcpItems(doc.mcpServers, { scope: 'user', where: shownPath('user', CLAUDE_USER_CONFIG), agents: ['Claude Code'] }));
472
+ else if (doc.mcpServers !== undefined) problem('user', CLAUDE_USER_CONFIG, NOT_SERVERS);
473
+ // Keyed by the folder's absolute path as Claude Code wrote it: the exact spelling first, then any key
474
+ // that is the same folder by another spelling (a link, or letter case on Windows and macOS).
475
+ const projects = isPlainObject(doc.projects) ? doc.projects : {};
476
+ // Only an entry that is an object counts: a stray value under the exact spelling must not hide the
477
+ // real entry under another one.
478
+ const usable = (k) => isPlainObject(projects[k]);
479
+ const exact = path.resolve(root);
480
+ const key = Object.hasOwn(projects, exact) && usable(exact) ? exact : Object.keys(projects).find((k) => usable(k) && samePath(k, root));
481
+ const mine = key === undefined ? null : projects[key];
482
+ // `user`, not `project`: these live in YOUR home folder and a teammate who clones gets none of them.
483
+ if (isPlainObject(mine?.mcpServers)) items.push(...mcpItems(mine.mcpServers, { scope: 'user', where: `${shownPath('user', CLAUDE_USER_CONFIG)} (this folder)`, agents: ['Claude Code'] }));
484
+ else if (isPlainObject(mine) && mine.mcpServers !== undefined) problem('user', `${CLAUDE_USER_CONFIG} (this folder)`, NOT_SERVERS);
485
+ }
486
+ }
487
+
488
+ for (const place of scopes(CODEX_CONFIG_PLACES)) {
489
+ const file = path.join(baseOf(place.scope, root, home), place.file);
490
+ const bytes = bytesOf(file, MAX_CONFIG_BYTES);
491
+ if (!bytes) { if (statOf(file)) problem(place.scope, place.file, 'could not be read'); continue; }
492
+ const text = bytes.toString('utf8');
493
+ const where = shownPath(place.scope, place.file);
494
+ for (const name of tomlTableNames(text, 'mcp_servers')) items.push({ kind: 'mcp', name, scope: place.scope, where, agents: ['Codex CLI'] });
495
+ for (const name of tomlTableNames(text, 'plugins')) items.push({ kind: 'plugin', name, scope: place.scope, where, agents: ['Codex CLI'], version: null });
496
+ }
497
+
498
+ if (home) items.push(...claudePlugins(root, home, problem));
499
+
500
+ // Every item carries the same keys, so a reader never tests whether one exists: `version` and `hash`
501
+ // are null where the place records none (an MCP server, a Codex plugin), `plugin` is null unless a
502
+ // Claude Code plugin brought the item. Only a Claude Code plugin row adds `commit` and `enabled`.
503
+ return { items: items.map((it) => ({ version: null, hash: null, plugin: null, ...it })), problems };
504
+ }
505
+
506
+ // Claude Code plugins, and the skills, agents and MCP servers each one brings. A plugin installed for
507
+ // ANOTHER project (a `projectPath` that is not this folder) is not available here and is left out.
508
+ function claudePlugins(root, home, problem) {
509
+ const items = [];
510
+ const doc = jsonOf(path.join(home, CLAUDE_PLUGINS));
511
+ if (doc === undefined) return items;
512
+ if (!isPlainObject(doc) || !isPlainObject(doc.plugins)) { problem('user', CLAUDE_PLUGINS, 'does not parse as the installed-plugins list'); return items; }
513
+ // On or off, as Claude Code decides it: `.claude/settings.local.json` over `.claude/settings.json` in the
514
+ // folder, over `~/.claude/settings.json`. null when none of the three says. A file that is there and
515
+ // cannot be read is a problem: the answer then falls through to the next file, and that must be visible.
516
+ // Run in the home folder, the folder's `.claude/settings.json` IS the home one: read it once.
517
+ const inHome = path.resolve(root) === path.resolve(home) || samePath(root, home);
518
+ const enabledIn = [['project', '.claude/settings.local.json'], ['project', '.claude/settings.json'], ...(inHome ? [] : [['user', CLAUDE_USER_SETTINGS]])]
519
+ .map(([scope, rel]) => {
520
+ const d = jsonOf(path.join(scope === 'user' ? home : root, rel));
521
+ if (d === null || (d !== undefined && !isPlainObject(d))) problem(scope, rel, NOT_JSON);
522
+ return isPlainObject(d?.enabledPlugins) ? d.enabledPlugins : null;
523
+ });
524
+ const enabledOf = (id) => {
525
+ for (const m of enabledIn) if (m && Object.hasOwn(m, id) && typeof m[id] === 'boolean') return m[id];
526
+ return null;
527
+ };
528
+ for (const id of Object.keys(doc.plugins).sort()) {
529
+ const installs = Array.isArray(doc.plugins[id]) ? doc.plugins[id] : [];
530
+ for (const inst of installs) {
531
+ if (!isPlainObject(inst)) continue;
532
+ if (typeof inst.projectPath === 'string' && !samePath(inst.projectPath, root)) continue;
533
+ // Claude Code's `project` scope is recorded in the folder's own `.claude/settings.json`, so a teammate
534
+ // gets it; `user` and `local` are this person's alone.
535
+ const scope = inst.scope === 'project' ? 'project' : 'user';
536
+ const version = typeof inst.version === 'string' ? inst.version : null;
537
+ items.push({
538
+ kind: 'plugin', name: id, scope, where: shownPath('user', CLAUDE_PLUGINS), agents: ['Claude Code'], version,
539
+ commit: typeof inst.gitCommitSha === 'string' ? inst.gitCommitSha : null,
540
+ // null when the settings file does not say: an absent entry is not proof it is off.
541
+ enabled: enabledOf(id),
542
+ });
543
+ const at = typeof inst.installPath === 'string' ? inst.installPath : null;
544
+ if (!at || !isDir(at)) continue;
545
+ const from = { scope, agents: ['Claude Code'], plugin: id, version };
546
+ items.push(...skillsIn(at, { dir: 'skills' }, from));
547
+ items.push(...agentsIn(at, { dir: 'agents', ext: '.md' }, from));
548
+ items.push(...pluginMcp(at, id, scope, problem));
549
+ }
550
+ }
551
+ return items;
552
+ }
553
+
554
+ // A plugin's MCP servers: `.mcp.json` at its root, or `mcpServers` in `.claude-plugin/plugin.json` —
555
+ // an object, or the path of a JSON file inside the plugin.
556
+ function pluginMcp(at, id, scope, problem) {
557
+ const found = [];
558
+ const add = (servers, rel) => found.push(...mcpItems(servers, { scope, where: `${id}: ${rel}`, agents: ['Claude Code'], plugin: id }));
559
+ const bad = (rel, why = NOT_JSON) => problem('user', `.claude/plugins (${id}: ${rel})`, why);
560
+ // `optional`: the default `.mcp.json` a plugin need not have. A file the plugin NAMES must be there.
561
+ const fromFile = (rel, { optional = false } = {}) => {
562
+ const full = path.resolve(at, rel);
563
+ // The plugin's own files only. `path.relative` across two Windows drives is an absolute path with no
564
+ // `..` in it, so that is refused too; a name that merely starts with `..` (`..x.json`) is not.
565
+ const r = path.relative(at, full);
566
+ // Named by the manifest, never by the path it gave: that path may be absolute and name the machine.
567
+ if (!r) { bad('.claude-plugin/plugin.json', 'names the plugin folder, not an MCP file'); return; }
568
+ if (r === '..' || r.startsWith(`..${path.sep}`) || path.isAbsolute(r)) { bad('.claude-plugin/plugin.json', 'names an MCP file outside the plugin'); return; }
569
+ const doc = jsonOf(full);
570
+ if (doc === undefined) { if (!optional) bad('.claude-plugin/plugin.json', 'names an MCP file that is not there'); return; }
571
+ if (!isPlainObject(doc)) { bad(r.split(path.sep).join('/')); return; }
572
+ // `.mcp.json` wraps the servers in `mcpServers`; a file plugin.json points at may list them bare. A
573
+ // `mcpServers` that is not an object is a problem, as it is in `.cursor/mcp.json` — reading the whole
574
+ // file as the list instead would name its other keys (`$schema`, `mcpServers`) as servers.
575
+ const shown = r.split(path.sep).join('/');
576
+ if (!Object.hasOwn(doc, 'mcpServers')) add(doc, shown);
577
+ else if (isPlainObject(doc.mcpServers)) add(doc.mcpServers, shown);
578
+ else bad(shown, NOT_SERVERS);
579
+ };
580
+ const manifest = jsonOf(path.join(at, '.claude-plugin', 'plugin.json'));
581
+ if (manifest === null || (manifest !== undefined && !isPlainObject(manifest))) bad('.claude-plugin/plugin.json');
582
+ const declared = isPlainObject(manifest) ? manifest.mcpServers : undefined;
583
+ // `mcpServers` may be the servers, the path of a file holding them, or a list of either. Anything else
584
+ // is a problem, as a `mcpServers` that is not an object is everywhere else.
585
+ const one = (d) => {
586
+ if (isPlainObject(d)) add(d, '.claude-plugin/plugin.json');
587
+ else if (typeof d === 'string') fromFile(d);
588
+ else bad('.claude-plugin/plugin.json', NOT_SERVERS);
589
+ };
590
+ if (declared === undefined) fromFile('.mcp.json', { optional: true });
591
+ else if (Array.isArray(declared)) declared.forEach(one);
592
+ else one(declared);
593
+ return found;
594
+ }
595
+
596
+ // ---- the command ------------------------------------------------------------------------------------
597
+
598
+ // How many names one place shows before "… and N more". `--json` always lists every item.
599
+ const NAMES_SHOWN = 8;
600
+
601
+ const KIND_TITLES = [['skill', 'Skills'], ['agent', 'Agents'], ['mcp', 'MCP servers'], ['plugin', 'Plugins']];
602
+
603
+ export function runDetect(root, { json = false, home = os.homedir() } = {}) {
604
+ const { items, problems } = detectInstalled(root, { home });
605
+ const counts = Object.fromEntries(KIND_TITLES.map(([k]) => [k, items.filter((i) => i.kind === k).length]));
606
+ if (json) return emitJSON({ ok: true, counts, items, problems });
607
+
608
+ log(c.bold('Installed for the agents that work in this folder'));
609
+ info('project = inside this folder (a teammate who clones it gets it too) · user = your home folder only');
610
+ for (const [kind, title] of KIND_TITLES) {
611
+ const mine = items.filter((i) => i.kind === kind);
612
+ log(`\n${c.bold(title)} (${mine.length})`);
613
+ if (!mine.length) { info('none found'); continue; }
614
+ // Grouped by the place they came from: a home folder holds dozens of skills, and one line per place
615
+ // with its agents keeps the answer readable. `--json` has every item on its own.
616
+ const groups = new Map();
617
+ for (const it of mine) {
618
+ const place = it.plugin ? `plugin ${it.plugin}` : kind === 'skill' || kind === 'agent' ? it.where.replace(/\/[^/]+$/, '') : it.where;
619
+ const agents = it.agents.join(', ');
620
+ const key = JSON.stringify([place, it.scope, agents]);
621
+ if (!groups.has(key)) groups.set(key, { place, scope: it.scope, agents, list: [] });
622
+ groups.get(key).list.push(it);
623
+ }
624
+ for (const { place, scope, agents, list } of groups.values()) {
625
+ log(` ${c.cyan(clean(place))} ${c.dim(`[${scope}] — ${list.length} — read by ${agents}`)}`);
626
+ const label = (it) => (it.version ? `${clean(it.name)} ${c.dim(clean(it.version))}` : clean(it.name)) + (it.enabled === false ? c.dim(' (disabled)') : '');
627
+ const shown = list.slice(0, NAMES_SHOWN).map(label).join(', ');
628
+ log(` ${shown}${list.length > NAMES_SHOWN ? c.dim(` … and ${list.length - NAMES_SHOWN} more`) : ''}`);
629
+ }
630
+ }
631
+ const dupes = duplicateNames(items.filter((i) => i.kind === 'skill'));
632
+ if (dupes.length) info(`${dupes.length} skill name(s) are installed in more than one place (${dupes.slice(0, 5).map(clean).join(', ')}${dupes.length > 5 ? ', …' : ''}) — which copy an agent loads is that agent's rule`);
633
+ if (items.length) info('`yad detect --json` lists every item, with its version and content hash');
634
+ for (const p of problems) log(` ${c.yellow('!')} ${clean(p.where)}: ${p.problem}`);
635
+ return undefined;
636
+ }
637
+
638
+ function duplicateNames(skills) {
639
+ const seen = new Map();
640
+ for (const s of skills) seen.set(s.name, (seen.get(s.name) ?? 0) + 1);
641
+ return [...seen].filter(([, n]) => n > 1).map(([name]) => name).sort();
642
+ }