dsh-agent-plugins-market 0.4.3

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -0
  3. package/README.zh.md +171 -0
  4. package/client/client.js +954 -0
  5. package/client/client.js.map +1 -0
  6. package/cordis.patch.yml +4 -0
  7. package/lib/commands-mounts.js +169 -0
  8. package/lib/context.js +80 -0
  9. package/lib/discovery/manifests.js +214 -0
  10. package/lib/discovery/surfaces.js +232 -0
  11. package/lib/discovery.js +234 -0
  12. package/lib/git.js +52 -0
  13. package/lib/hooks-mounts.js +95 -0
  14. package/lib/index.js +73 -0
  15. package/lib/manager.js +490 -0
  16. package/lib/mcp-config.js +100 -0
  17. package/lib/mcp-mounts.js +88 -0
  18. package/lib/paths.js +85 -0
  19. package/lib/routes.js +208 -0
  20. package/lib/skills-parse.js +114 -0
  21. package/lib/skills-provider.js +170 -0
  22. package/lib/state.js +64 -0
  23. package/lib/types/commands-mounts.d.ts +29 -0
  24. package/lib/types/context.d.ts +13 -0
  25. package/lib/types/discovery/manifests.d.ts +55 -0
  26. package/lib/types/discovery/surfaces.d.ts +15 -0
  27. package/lib/types/discovery.d.ts +16 -0
  28. package/lib/types/git.d.ts +8 -0
  29. package/lib/types/hooks-mounts.d.ts +19 -0
  30. package/lib/types/index.d.ts +27 -0
  31. package/lib/types/manager.d.ts +87 -0
  32. package/lib/types/mcp-config.d.ts +28 -0
  33. package/lib/types/mcp-mounts.d.ts +31 -0
  34. package/lib/types/paths.d.ts +29 -0
  35. package/lib/types/routes.d.ts +11 -0
  36. package/lib/types/skills-parse.d.ts +22 -0
  37. package/lib/types/skills-provider.d.ts +16 -0
  38. package/lib/types/state.d.ts +6 -0
  39. package/lib/types/types.d.ts +165 -0
  40. package/lib/types/validate.d.ts +34 -0
  41. package/lib/types.js +10 -0
  42. package/lib/validate.js +199 -0
  43. package/package.json +114 -0
  44. package/schemas/1.0.0/mcp.schema.json +120 -0
  45. package/schemas/1.0.0/plugin.schema.json +65 -0
  46. package/schemas/README.md +14 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Manifest layer: detects and parses the suite-manifest dialects a checkout
3
+ * can carry, and resolves a repo-level name for source-id derivation.
4
+ *
5
+ * Dialects (each a JSON document at the suite root):
6
+ * - agent-plugin-v1: root `plugin.json` (agent-plugins.org), schema-validated;
7
+ * - universal: `.plugin/plugin.json`;
8
+ * - claude-code: `.claude-plugin/plugin.json`;
9
+ * - cursor: `.cursor-plugin/plugin.json`;
10
+ * - kimi: `.kimi-plugin/plugin.json`;
11
+ * - codex: `.codex-plugin/plugin.json`.
12
+ *
13
+ * The same repo can declare several dialects (e.g. vercel/vercel-plugin ships
14
+ * all of them); a suite's identity comes from the highest-precedence dialect
15
+ * present, while surfaces (skills/commands/agents/hooks/mcp) are scanned from
16
+ * the directories regardless of which dialect won.
17
+ */
18
+ import { readFile, stat } from 'node:fs/promises';
19
+ import { join, resolve } from 'node:path';
20
+ import { sanitizeId } from '../paths.js';
21
+ import { isRecognizedSchema, validatePluginManifest } from '../validate.js';
22
+ /** Dialect precedence: v1 wins over universal over claude over cursor over kimi over codex. */
23
+ const KIND_PRECEDENCE = ['agent-plugin-v1', 'universal', 'claude-code', 'cursor', 'kimi', 'codex'];
24
+ const MANIFEST_PATHS = {
25
+ 'agent-plugin-v1': 'plugin.json',
26
+ universal: join('.plugin', 'plugin.json'),
27
+ 'claude-code': join('.claude-plugin', 'plugin.json'),
28
+ cursor: join('.cursor-plugin', 'plugin.json'),
29
+ kimi: join('.kimi-plugin', 'plugin.json'),
30
+ codex: join('.codex-plugin', 'plugin.json'),
31
+ };
32
+ /** The highest-precedence manifest file a directory carries, if any. */
33
+ export async function detectManifest(dir) {
34
+ for (const kind of KIND_PRECEDENCE) {
35
+ const path = join(dir, MANIFEST_PATHS[kind]);
36
+ try {
37
+ if ((await stat(path)).isFile())
38
+ return { kind, path };
39
+ }
40
+ catch {
41
+ // try the next dialect
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+ /** Whether a directory carries any known suite manifest. */
47
+ export async function hasSuiteManifest(dir) {
48
+ return (await detectManifest(dir)) !== undefined;
49
+ }
50
+ /** Fallback suite identity for manifest-less skill collections. */
51
+ export function syntheticManifestName(root) {
52
+ return root.split(/[\\/]/).at(-1) ?? 'plugin';
53
+ }
54
+ /**
55
+ * Parse one manifest document into a normalized SuiteManifest. The v1 dialect
56
+ * is schema-validated (fail-closed); the others are structurally read with
57
+ * light tolerance, and `hint` (a marketplace plugin entry) fills in gaps.
58
+ */
59
+ export async function readManifest(root, errors, hint) {
60
+ const candidate = await detectManifest(root);
61
+ if (candidate === undefined)
62
+ return undefined;
63
+ let raw;
64
+ try {
65
+ raw = JSON.parse(await readFile(candidate.path, 'utf8'));
66
+ }
67
+ catch (error) {
68
+ errors.push(`${candidate.path} unparsable: ${error instanceof Error ? error.message : String(error)}`);
69
+ return undefined;
70
+ }
71
+ if (typeof raw !== 'object' || raw === null) {
72
+ errors.push(`${candidate.path}: manifest is not a JSON object`);
73
+ return undefined;
74
+ }
75
+ const record = raw;
76
+ const problems = candidate.kind === 'agent-plugin-v1'
77
+ ? await validatePluginManifest(raw)
78
+ : [];
79
+ errors.push(...problems.map(problem => `${candidate.path}: ${problem}`));
80
+ const name = pickString(record.name) ?? hint?.name ?? syntheticManifestName(root);
81
+ const version = pickString(record.version) ?? hint?.version;
82
+ const description = pickString(record.description) ?? hint?.description;
83
+ const author = record.author;
84
+ return {
85
+ layout: candidate.kind === 'agent-plugin-v1' ? 'agent-plugin-v1' : candidate.kind === 'universal' ? 'universal' : candidate.kind === 'claude-code' ? 'claude-code' : candidate.kind === 'cursor' ? 'cursor' : candidate.kind === 'kimi' ? 'kimi' : 'codex',
86
+ path: candidate.path,
87
+ id: sanitizeId(name),
88
+ name,
89
+ ...version === undefined ? {} : { version },
90
+ ...description === undefined ? {} : { description },
91
+ ...(author?.name !== undefined ? { author: author.name } : pickString(record.homepage) !== undefined ? { author: pickString(record.homepage) } : {}),
92
+ keywords: Array.isArray(record.keywords) ? record.keywords.filter((entry) => typeof entry === 'string') : [],
93
+ ...isRecognizedSchema(record.$schema) ? { schemaVersion: record.$schema } : {},
94
+ };
95
+ }
96
+ function pickString(value) {
97
+ return typeof value === 'string' && value !== '' ? value : undefined;
98
+ }
99
+ /** Marketplace manifest locations per dialect (Claude Code, Codex). */
100
+ const MARKETPLACE_PATHS = ['.claude-plugin/marketplace.json', '.agents/plugins/marketplace.json'];
101
+ /** Read a marketplace manifest from a checkout root, or undefined when absent. */
102
+ export async function readMarketplace(checkoutDir) {
103
+ for (const relative of MARKETPLACE_PATHS) {
104
+ let text;
105
+ try {
106
+ text = await readFile(join(checkoutDir, relative), 'utf8');
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ try {
112
+ const parsed = JSON.parse(text);
113
+ if (typeof parsed !== 'object' || parsed === null)
114
+ continue;
115
+ const record = parsed;
116
+ const plugins = record['plugins'];
117
+ if (!Array.isArray(plugins))
118
+ continue;
119
+ return {
120
+ ...typeof record['name'] === 'string' ? { name: record['name'] } : {},
121
+ entries: plugins,
122
+ };
123
+ }
124
+ catch {
125
+ continue;
126
+ }
127
+ }
128
+ return undefined;
129
+ }
130
+ /** Resolve one marketplace entry to a local checkout-relative directory, or
131
+ * `undefined` for remote-URL entries that are not present in the clone. */
132
+ export function marketplaceEntryDir(checkoutDir, entry) {
133
+ const source = entry.source;
134
+ if (typeof source === 'string')
135
+ return resolve(checkoutDir, source);
136
+ if (source?.path !== undefined)
137
+ return resolve(checkoutDir, source.path);
138
+ return undefined;
139
+ }
140
+ /**
141
+ * Resolve a repo-level name for source-id derivation, in precedence order:
142
+ * marketplace plugin entry name > marketplace name > root manifest name >
143
+ * the checkout basename. The suite repo's own JSON is authoritative; the
144
+ * basename is only the fallback.
145
+ */
146
+ export async function repoName(checkoutDir) {
147
+ const marketplace = await readMarketplace(checkoutDir);
148
+ if (marketplace !== undefined) {
149
+ const entries = marketplace.entries;
150
+ if (entries.length === 1) {
151
+ // A single-suite marketplace: the plugin entry names the repo (vercel → vercel-plugin).
152
+ const entryName = pickString(entries[0].name);
153
+ if (entryName !== undefined)
154
+ return entryName;
155
+ }
156
+ const marketplaceName = pickString(marketplace.name);
157
+ if (marketplaceName !== undefined)
158
+ return marketplaceName;
159
+ if (entries.length === 1) {
160
+ const entryName = pickString(entries[0].name);
161
+ if (entryName !== undefined)
162
+ return entryName;
163
+ }
164
+ }
165
+ const candidate = await detectManifest(checkoutDir);
166
+ if (candidate !== undefined) {
167
+ try {
168
+ const raw = JSON.parse(await readFile(candidate.path, 'utf8'));
169
+ if (typeof raw === 'object' && raw !== null) {
170
+ const name = raw['name'];
171
+ if (typeof name === 'string' && name !== '')
172
+ return name;
173
+ }
174
+ }
175
+ catch {
176
+ // fall through to basename
177
+ }
178
+ }
179
+ return syntheticManifestName(checkoutDir);
180
+ }
181
+ /** The winning manifest's declared `skills` path (string or array), or undefined. */
182
+ export async function declaredSkillsPath(root) {
183
+ const candidate = await detectManifest(root);
184
+ if (candidate === undefined)
185
+ return undefined;
186
+ try {
187
+ const raw = JSON.parse(await readFile(candidate.path, 'utf8'));
188
+ if (typeof raw === 'object' && raw !== null) {
189
+ return raw['skills'];
190
+ }
191
+ }
192
+ catch {
193
+ return undefined;
194
+ }
195
+ return undefined;
196
+ }
197
+ /** The winning manifest's inline `mcpServers`, or undefined. */
198
+ export async function declaredMcpServers(root) {
199
+ const candidate = await detectManifest(root);
200
+ if (candidate === undefined)
201
+ return undefined;
202
+ try {
203
+ const raw = JSON.parse(await readFile(candidate.path, 'utf8'));
204
+ if (typeof raw === 'object' && raw !== null) {
205
+ const servers = raw['mcpServers'];
206
+ if (typeof servers === 'object' && servers !== null)
207
+ return servers;
208
+ }
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ return undefined;
214
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Surface layer: scans a suite root's content directories — skills, mcp,
3
+ * commands, agents, hooks, lsp — into normalized previews and counts.
4
+ *
5
+ * Skills honor any path the winning manifest declares (`skills` may be a
6
+ * string, an array, or absent), scan up to three levels of nesting, and
7
+ * dedupe by frontmatter name. MCP reads both `mcp.json` and the dot-prefixed
8
+ * `.mcp.json`, tolerating unknown transports per server. Every read is
9
+ * fail-closed: broken files produce a diagnostic and are skipped, never a
10
+ * thrown discovery.
11
+ */
12
+ import { readFile, readdir, stat } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { parseSkillFrontmatter } from '../skills-parse.js';
15
+ import { isDirectory } from '../paths.js';
16
+ import { validateMcpJson } from '../validate.js';
17
+ import { declaredMcpServers } from './manifests.js';
18
+ const DOT_DIRS = new Set(['.git', '.github', '.claude', '.cursor', '.kimi', '.plugin', '.sources', 'node_modules']);
19
+ /** Resolve a manifest-declared skills path into absolute directories (string or array form). */
20
+ function declaredSkillDirs(root, declared) {
21
+ const values = Array.isArray(declared) ? declared : [declared];
22
+ const dirs = [];
23
+ for (const value of values) {
24
+ if (typeof value !== 'string' || value === '')
25
+ continue;
26
+ const cleaned = value.replace(/^\.\//, '');
27
+ const path = join(root, cleaned);
28
+ if (isAbsoluteWithin(root, path))
29
+ dirs.push(path);
30
+ }
31
+ return dirs;
32
+ }
33
+ function isAbsoluteWithin(root, candidate) {
34
+ return candidate.startsWith(`${root}${candidate.includes('/') ? '/' : '\\'}`) || candidate.startsWith(root);
35
+ }
36
+ /** Discover SKILL.md files under the suite's skills directory, up to 3 levels deep. */
37
+ export async function discoverSkills(root, errors, declared) {
38
+ const skills = [];
39
+ const rootSkill = join(root, 'SKILL.md');
40
+ const rootName = root.split(/[\\/]/).at(-1) ?? 'plugin';
41
+ const rootParsed = await parseOneSkill(rootSkill, root, rootName, errors);
42
+ if (rootParsed !== undefined)
43
+ skills.push(rootParsed);
44
+ const skillsDirs = declaredSkillDirs(root, declared);
45
+ if (skillsDirs.length === 0) {
46
+ const fallback = join(root, 'skills');
47
+ if (await isDirectory(fallback))
48
+ skillsDirs.push(fallback);
49
+ }
50
+ const seen = new Set();
51
+ const pushUnique = (skill) => {
52
+ if (skill === undefined || seen.has(skill.name))
53
+ return;
54
+ seen.add(skill.name);
55
+ skills.push(skill);
56
+ };
57
+ for (const skillsDir of skillsDirs) {
58
+ // A declared path may be one skill directory (a manifest listing
59
+ // individual skills, e.g. mattpocock) or a container of skills.
60
+ if (await isFile(join(skillsDir, 'SKILL.md'))) {
61
+ const name = skillsDir.split(/[\\/]/).at(-1) ?? '';
62
+ pushUnique(await parseOneSkill(join(skillsDir, 'SKILL.md'), skillsDir, name, errors));
63
+ continue;
64
+ }
65
+ for (const child of await listChildDirs(skillsDir)) {
66
+ const name = child.split(/[\\/]/).at(-1) ?? '';
67
+ pushUnique(await parseOneSkill(join(child, 'SKILL.md'), child, name, errors));
68
+ }
69
+ // Category-nested collections and upstream mirrors (depth 3).
70
+ for (const category of await listChildDirs(skillsDir)) {
71
+ for (const child of await listChildDirs(category)) {
72
+ const name = child.split(/[\\/]/).at(-1) ?? '';
73
+ pushUnique(await parseOneSkill(join(child, 'SKILL.md'), child, name, errors));
74
+ }
75
+ }
76
+ }
77
+ return skills;
78
+ }
79
+ async function parseOneSkill(file, directory, fallbackName, errors) {
80
+ if (!await isFile(file))
81
+ return undefined;
82
+ let text;
83
+ try {
84
+ text = await readFile(file, 'utf8');
85
+ }
86
+ catch (error) {
87
+ errors.push(`skill "${fallbackName}": unreadable SKILL.md (${error instanceof Error ? error.message : String(error)})`);
88
+ return undefined;
89
+ }
90
+ const parsed = parseSkillFrontmatter(text, undefined);
91
+ if (typeof parsed === 'string') {
92
+ errors.push(`skill "${fallbackName}": ${parsed}`);
93
+ return undefined;
94
+ }
95
+ return {
96
+ name: parsed.name,
97
+ directory,
98
+ file,
99
+ description: parsed.description,
100
+ ...parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse },
101
+ invocation: parsed.invocation,
102
+ };
103
+ }
104
+ /** Read the suite's MCP config: `mcp.json` or `.mcp.json`, else the winning manifest's inline `mcpServers`. */
105
+ export async function discoverMcp(root, errors) {
106
+ for (const name of ['mcp.json', '.mcp.json']) {
107
+ const path = join(root, name);
108
+ if (!await isFile(path))
109
+ continue;
110
+ let raw;
111
+ try {
112
+ raw = JSON.parse(await readFile(path, 'utf8'));
113
+ }
114
+ catch (error) {
115
+ errors.push(`${name} unparsable: ${error instanceof Error ? error.message : String(error)}`);
116
+ return undefined;
117
+ }
118
+ const result = await validateMcpJson(root, raw, { strict: name === 'mcp.json' });
119
+ errors.push(...result.errors);
120
+ return result.config;
121
+ }
122
+ const inline = await declaredMcpServers(root);
123
+ if (inline === undefined)
124
+ return undefined;
125
+ const result = await validateMcpJson(root, { mcpServers: inline }, { strict: false });
126
+ errors.push(...result.errors);
127
+ return result.config;
128
+ }
129
+ /** Count surfaces for a suite; mcp counts only validated servers. */
130
+ export async function countSurfaces(root, skills, mcp) {
131
+ let hooks = 0;
132
+ for (const relative of [join('hooks', 'hooks.json'), 'hooks.json']) {
133
+ hooks += await countHookEntries(join(root, relative));
134
+ }
135
+ const commands = (await listMdFiles(join(root, 'commands'))).length;
136
+ const agents = (await listMdFiles(join(root, 'agents'))).length;
137
+ const lsp = (await discoverLspEntries(root)).length;
138
+ return {
139
+ skills: skills.length,
140
+ mcp: mcp === undefined ? 0 : Object.keys(mcp.servers).length,
141
+ hooks,
142
+ commands,
143
+ agents,
144
+ lsp,
145
+ };
146
+ }
147
+ /** LSP definitions: `.claude-plugin/lsp/*.json` plus reverse-domain `lsp/` dirs. */
148
+ export async function discoverLspEntries(root) {
149
+ const entries = [];
150
+ try {
151
+ for (const entry of await readdir(join(root, '.claude-plugin', 'lsp'))) {
152
+ if (entry.endsWith('.json'))
153
+ entries.push({ name: entry.slice(0, -5), path: join(root, '.claude-plugin', 'lsp', entry) });
154
+ }
155
+ }
156
+ catch {
157
+ // no .claude-plugin/lsp directory
158
+ }
159
+ try {
160
+ for (const entry of await readdir(root, { withFileTypes: true })) {
161
+ if (!entry.isDirectory() || !/^[a-z0-9-]+(\.[a-z0-9-]+){2,}$/.test(entry.name))
162
+ continue;
163
+ const lspDir = join(root, entry.name, 'lsp');
164
+ let names;
165
+ try {
166
+ names = await readdir(lspDir);
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ for (const name of names) {
172
+ entries.push({ name, path: join(lspDir, name) });
173
+ }
174
+ }
175
+ }
176
+ catch {
177
+ // unreadable root contributes no LSP entries
178
+ }
179
+ return entries;
180
+ }
181
+ async function countHookEntries(path) {
182
+ let text;
183
+ try {
184
+ text = await readFile(path, 'utf8');
185
+ }
186
+ catch {
187
+ return 0;
188
+ }
189
+ try {
190
+ const parsed = JSON.parse(text);
191
+ if (typeof parsed !== 'object' || parsed === null)
192
+ return 0;
193
+ const hooks = parsed['hooks'];
194
+ if (typeof hooks !== 'object' || hooks === null)
195
+ return 0;
196
+ return Object.values(hooks).reduce((total, entries) => total + (Array.isArray(entries) ? entries.length : 0), 0);
197
+ }
198
+ catch {
199
+ return 0;
200
+ }
201
+ }
202
+ /** File names under a suite's commands/ or agents/ directory. */
203
+ export async function listMdFiles(dir) {
204
+ let entries;
205
+ try {
206
+ entries = await readdir(dir);
207
+ }
208
+ catch {
209
+ return [];
210
+ }
211
+ return entries.filter(name => name.endsWith('.md')).sort();
212
+ }
213
+ async function listChildDirs(dir) {
214
+ let entries;
215
+ try {
216
+ entries = await readdir(dir, { withFileTypes: true });
217
+ }
218
+ catch {
219
+ return [];
220
+ }
221
+ return entries
222
+ .filter(entry => entry.isDirectory() && !DOT_DIRS.has(entry.name) && !entry.name.startsWith('.'))
223
+ .map(entry => join(dir, entry.name));
224
+ }
225
+ async function isFile(path) {
226
+ try {
227
+ return (await stat(path)).isFile();
228
+ }
229
+ catch {
230
+ return false;
231
+ }
232
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Suite discovery facade: maps a repository checkout (or a configured source
3
+ * list) onto normalized suites.
4
+ *
5
+ * Pipeline: the manifest layer (src/discovery/manifests.ts) detects which
6
+ * suite-manifest dialects a root carries and resolves names; the roots step
7
+ * decides where suites live (marketplace entries, a single root, container
8
+ * scans, or manifest-less skill collections); the surface layer
9
+ * (src/discovery/surfaces.ts) scans skills/commands/agents/hooks/lsp/mcp.
10
+ *
11
+ * The public API of this module is the discovery contract importers depend
12
+ * on: discoverSuitesInSource, discoverSourceList, listMdFiles,
13
+ * discoverLspEntries, and the new repoName for source-id derivation.
14
+ */
15
+ import { readdir } from 'node:fs/promises';
16
+ import { isAbsolute, join } from 'node:path';
17
+ import { expandHome, isDirectory, sanitizeId, sourcesDir } from './paths.js';
18
+ import { declaredSkillsPath, detectManifest, marketplaceEntryDir, readManifest, readMarketplace, repoName, syntheticManifestName, } from './discovery/manifests.js';
19
+ import { countSurfaces, discoverLspEntries, discoverMcp, discoverSkills, listMdFiles, } from './discovery/surfaces.js';
20
+ export { repoName, listMdFiles, discoverLspEntries };
21
+ const CONTAINER_DIRS = ['plugins', 'external_plugins', 'skills'];
22
+ const DOT_DIRS = new Set(['.git', '.github', '.claude', '.cursor', '.kimi', '.plugin', '.sources', 'node_modules']);
23
+ /** Discover every suite under one cloned source checkout. */
24
+ export async function discoverSuitesInSource(checkoutDir, sourceId, dimension) {
25
+ const roots = await suiteRoots(checkoutDir);
26
+ const suites = [];
27
+ for (const root of roots) {
28
+ const suite = root.dir === undefined
29
+ ? remoteSuite(sourceId, dimension, root)
30
+ : await readSuite(root.dir, sourceId, dimension, root.hint);
31
+ if (suite !== undefined)
32
+ suites.push(suite);
33
+ }
34
+ return suites;
35
+ }
36
+ /**
37
+ * Resolve the suite roots of one checkout. A marketplace manifest is the
38
+ * authoritative list: local entries (relative paths, Codex `{source:
39
+ * 'local', path}`) surface when they carry a manifest or skills, container
40
+ * paths recurse into nested plugin dirs, and remote-URL entries become
41
+ * metadata-only remote suites. Manifest-bearing container dirs the
42
+ * marketplace did not list (e.g. official example plugins) are supplemented.
43
+ * Without a marketplace: a single-suite root, a recursive manifest/skill
44
+ * scan, or a manifest-less skill collection applies.
45
+ */
46
+ async function suiteRoots(checkoutDir) {
47
+ const marketplace = await readMarketplace(checkoutDir);
48
+ if (marketplace !== undefined && marketplace.entries.length > 0) {
49
+ const roots = [];
50
+ const seen = new Set();
51
+ for (const entry of marketplace.entries) {
52
+ const hint = { name: entry.name, version: entry.version, description: entry.description };
53
+ const dir = marketplaceEntryDir(checkoutDir, entry);
54
+ if (dir === undefined) {
55
+ const remoteUrl = typeof entry.source === 'object' ? entry.source?.url : undefined;
56
+ // Dedupe by entry name, not URL: one remote repo can host several
57
+ // plugins that each appear as their own marketplace entry.
58
+ const entryName = typeof entry.name === 'string' && entry.name !== '' ? entry.name : remoteUrl;
59
+ if (remoteUrl !== undefined && entryName !== undefined && !seen.has(entryName)) {
60
+ roots.push({ hint, remoteUrl });
61
+ seen.add(entryName);
62
+ }
63
+ continue;
64
+ }
65
+ await collectRoot(dir, hint, roots, seen);
66
+ }
67
+ for (const container of CONTAINER_DIRS) {
68
+ const containerDir = join(checkoutDir, container);
69
+ if (!await isDirectory(containerDir))
70
+ continue;
71
+ for (const child of await listChildDirs(containerDir)) {
72
+ if (!seen.has(child) && await hasSuiteManifest(child))
73
+ roots.push({ dir: child });
74
+ }
75
+ }
76
+ return roots;
77
+ }
78
+ if (await hasSuiteManifest(checkoutDir))
79
+ return [{ dir: checkoutDir }];
80
+ const found = [];
81
+ await collectRoot(checkoutDir, undefined, found, new Set());
82
+ if (found.length > 0)
83
+ return found;
84
+ // Manifest-less skill collection (the flat `<root>/<name>/SKILL.md` shape).
85
+ const collection = [];
86
+ for (const child of await listChildDirs(checkoutDir)) {
87
+ if (await hasSkillFiles(child))
88
+ collection.push({ dir: child });
89
+ }
90
+ return collection;
91
+ }
92
+ /** Collect one root, recursing through container dirs (nested `plugins/`
93
+ * bundles) up to four levels so Codex runtime layouts
94
+ * (`plugins/<bundle>/plugins/<name>`) are reached. */
95
+ async function collectRoot(dir, hint, out, seen, depth = 0) {
96
+ if (depth > 4 || seen.has(dir))
97
+ return;
98
+ if (await hasSuiteManifest(dir) || await hasSkillFiles(dir)) {
99
+ out.push({ dir, hint });
100
+ seen.add(dir);
101
+ return;
102
+ }
103
+ for (const child of await listChildDirs(dir)) {
104
+ await collectRoot(child, hint, out, seen, depth + 1);
105
+ }
106
+ }
107
+ /** Whether a directory carries any known suite manifest. */
108
+ async function hasSuiteManifest(dir) {
109
+ return (await detectManifest(dir)) !== undefined;
110
+ }
111
+ /** Whether a directory carries skill files in the flat or bundled shape. */
112
+ async function hasSkillFiles(dir) {
113
+ if (await isFile(join(dir, 'SKILL.md')))
114
+ return true;
115
+ const skillsDir = join(dir, 'skills');
116
+ if (!await isDirectory(skillsDir))
117
+ return false;
118
+ for (const child of await listChildDirs(skillsDir)) {
119
+ if (await isFile(join(child, 'SKILL.md')))
120
+ return true;
121
+ }
122
+ return false;
123
+ }
124
+ async function listChildDirs(dir) {
125
+ let entries;
126
+ try {
127
+ entries = await readdir(dir, { withFileTypes: true });
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ return entries
133
+ .filter(entry => entry.isDirectory() && !DOT_DIRS.has(entry.name) && !entry.name.startsWith('.'))
134
+ .map(entry => join(dir, entry.name));
135
+ }
136
+ /** Read one suite root into the normalized shape, or `undefined` when no manifest parses. */
137
+ async function readSuite(root, sourceId, dimension, hint) {
138
+ const errors = [];
139
+ const manifest = await readManifest(root, errors, hint) ?? await syntheticManifest(root);
140
+ if (manifest === undefined)
141
+ return undefined;
142
+ const declared = await declaredSkillsPath(root);
143
+ const skills = await discoverSkills(root, errors, declared);
144
+ const mcp = await discoverMcp(root, errors);
145
+ const surfaces = await countSurfaces(root, skills, mcp);
146
+ return {
147
+ sourceId,
148
+ id: manifest.id,
149
+ root,
150
+ manifest,
151
+ skills,
152
+ ...mcp === undefined ? {} : { mcp },
153
+ surfaces,
154
+ dimension,
155
+ enabled: false,
156
+ errors,
157
+ };
158
+ }
159
+ /** Metadata-only suite for a marketplace entry whose content lives in a
160
+ * remote repository that is not part of this clone. */
161
+ function remoteSuite(sourceId, dimension, root) {
162
+ const name = root.hint?.name ?? 'remote-plugin';
163
+ return {
164
+ sourceId,
165
+ id: sanitizeId(name),
166
+ root: '',
167
+ manifest: {
168
+ layout: 'remote',
169
+ path: '',
170
+ id: sanitizeId(name),
171
+ name,
172
+ version: root.hint?.version,
173
+ description: root.hint?.description,
174
+ },
175
+ skills: [],
176
+ surfaces: { skills: 0, mcp: 0, hooks: 0, commands: 0, agents: 0, lsp: 0 },
177
+ dimension,
178
+ enabled: false,
179
+ remote: { url: root.remoteUrl ?? '' },
180
+ errors: [],
181
+ };
182
+ }
183
+ /** Manifest-less directories still produce a synthetic suite identity. */
184
+ async function syntheticManifest(root) {
185
+ if (!await hasSkillFiles(root))
186
+ return undefined;
187
+ const name = syntheticManifestName(root);
188
+ return {
189
+ layout: 'skill-collection',
190
+ path: join(root, 'SKILL.md'),
191
+ id: sanitizeId(name),
192
+ name,
193
+ };
194
+ }
195
+ async function isFile(path) {
196
+ try {
197
+ return (await (await import('node:fs/promises')).stat(path)).isFile();
198
+ }
199
+ catch {
200
+ return false;
201
+ }
202
+ }
203
+ /** Whether a suite root path lies outside the checkout (defense for malformed marketplace sources). */
204
+ export function isOutside(root, candidate) {
205
+ return isAbsolute(candidate) ? !candidate.startsWith(root) : false;
206
+ }
207
+ /**
208
+ * Discover every suite of one dimension's configured sources, plus manual
209
+ * checkouts present under the dimension's `.sources/` that no source entry
210
+ * names. Local sources read their directory directly; git sources read
211
+ * their clone; a missing checkout contributes nothing.
212
+ */
213
+ export async function discoverSourceList(sources, dimension, dimensionRoot) {
214
+ const checkoutRoot = sourcesDir(dimensionRoot);
215
+ const bySource = new Map();
216
+ const listed = new Set(sources.map(source => source.id));
217
+ try {
218
+ for (const entry of await readdir(checkoutRoot, { withFileTypes: true })) {
219
+ if (!entry.isDirectory() || entry.name.startsWith('.') || listed.has(entry.name))
220
+ continue;
221
+ bySource.set(entry.name, await discoverSuitesInSource(join(checkoutRoot, entry.name), entry.name, dimension));
222
+ }
223
+ }
224
+ catch {
225
+ // a missing checkout root simply has no manual checkouts
226
+ }
227
+ for (const source of sources) {
228
+ const checkout = source.local === true ? expandHome(source.url) : join(checkoutRoot, source.id);
229
+ if (!await isDirectory(checkout))
230
+ continue;
231
+ bySource.set(source.id, await discoverSuitesInSource(checkout, source.id, dimension));
232
+ }
233
+ return [...bySource.values()].flat();
234
+ }