dsh-agent-plugins-market 0.4.6 → 0.5.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.
Files changed (62) hide show
  1. package/README.md +16 -10
  2. package/README.zh.md +16 -10
  3. package/client/client.js +540 -417
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +447 -0
  6. package/lib/application/details.js +136 -0
  7. package/lib/application/queries.js +1 -0
  8. package/lib/{discovery → catalog}/manifests.js +20 -12
  9. package/lib/{paths.js → catalog/paths.js} +5 -1
  10. package/lib/{skills-parse.js → catalog/skills-parse.js} +7 -4
  11. package/lib/catalog/source-catalog.js +38 -0
  12. package/lib/{discovery.js → catalog/suite-scanner.js} +28 -86
  13. package/lib/{discovery → catalog}/surfaces.js +9 -11
  14. package/lib/{validate.js → catalog/validate.js} +3 -3
  15. package/lib/context.js +11 -13
  16. package/lib/contracts/market.js +27 -0
  17. package/lib/contracts/mcp-status.js +2 -0
  18. package/lib/index.js +32 -49
  19. package/lib/{state.js → model/state.js} +2 -2
  20. package/lib/routes.js +21 -21
  21. package/lib/{commands-mounts.js → runtime/commands-mounts.js} +10 -11
  22. package/lib/{hooks-mounts.js → runtime/hooks-mounts.js} +1 -1
  23. package/lib/{mcp-config.js → runtime/mcp-config.js} +7 -4
  24. package/lib/{mcp-status.js → runtime/mcp-status.js} +8 -41
  25. package/lib/runtime/reconciler.js +46 -0
  26. package/lib/{skills-provider.js → runtime/skills-provider.js} +26 -23
  27. package/lib/runtime/tool-registry-observer.js +35 -0
  28. package/lib/types/application/catalog.d.ts +113 -0
  29. package/lib/types/application/details.d.ts +12 -0
  30. package/lib/types/application/queries.d.ts +33 -0
  31. package/lib/types/{discovery → catalog}/manifests.d.ts +1 -1
  32. package/lib/types/catalog/source-catalog.d.ts +3 -0
  33. package/lib/types/catalog/suite-scanner.d.ts +9 -0
  34. package/lib/types/{discovery → catalog}/surfaces.d.ts +1 -1
  35. package/lib/types/{validate.d.ts → catalog/validate.d.ts} +1 -1
  36. package/lib/types/context.d.ts +2 -2
  37. package/lib/types/contracts/market.d.ts +153 -0
  38. package/lib/types/contracts/mcp-status.d.ts +38 -0
  39. package/lib/types/index.d.ts +1 -1
  40. package/lib/types/{types.d.ts → model/types.d.ts} +2 -42
  41. package/lib/types/routes.d.ts +2 -2
  42. package/lib/types/{commands-mounts.d.ts → runtime/commands-mounts.d.ts} +1 -1
  43. package/lib/types/{hooks-mounts.d.ts → runtime/hooks-mounts.d.ts} +1 -1
  44. package/lib/types/{mcp-config.d.ts → runtime/mcp-config.d.ts} +1 -1
  45. package/lib/types/{mcp-mounts.d.ts → runtime/mcp-mounts.d.ts} +1 -1
  46. package/lib/types/runtime/mcp-status.d.ts +13 -0
  47. package/lib/types/runtime/reconciler.d.ts +33 -0
  48. package/lib/types/{skills-provider.d.ts → runtime/skills-provider.d.ts} +3 -3
  49. package/lib/types/runtime/tool-registry-observer.d.ts +14 -0
  50. package/package.json +34 -19
  51. package/schemas/README.md +2 -5
  52. package/lib/manager.js +0 -581
  53. package/lib/types/discovery.d.ts +0 -19
  54. package/lib/types/manager.d.ts +0 -124
  55. package/lib/types/mcp-status.d.ts +0 -51
  56. /package/lib/{git.js → catalog/git.js} +0 -0
  57. /package/lib/{types.js → model/types.js} +0 -0
  58. /package/lib/{mcp-mounts.js → runtime/mcp-mounts.js} +0 -0
  59. /package/lib/types/{git.d.ts → catalog/git.d.ts} +0 -0
  60. /package/lib/types/{paths.d.ts → catalog/paths.d.ts} +0 -0
  61. /package/lib/types/{skills-parse.d.ts → catalog/skills-parse.d.ts} +0 -0
  62. /package/lib/types/{state.d.ts → model/state.d.ts} +0 -0
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Catalog application module: the host-authoritative owner of source state,
3
+ * discovery, install mutations, and coherent suite snapshots.
4
+ *
5
+ * The module deliberately keeps the existing filesystem and Git operations
6
+ * behind one interface while callers migrate from SuiteManager. User and
7
+ * project snapshots use their own persisted state files and share the same
8
+ * source-selection and suite-scanning rules.
9
+ */
10
+ import { mkdir } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { repoName } from '../catalog/manifests.js';
13
+ import { discoverSourceList } from '../catalog/source-catalog.js';
14
+ import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
15
+ import { gitClone, gitHead, gitPull, gitRemove } from '../catalog/git.js';
16
+ import { buildMcpStatus } from '../runtime/mcp-status.js';
17
+ import { buildSuiteDetail, readSkillContent } from './details.js';
18
+ import { deriveSourceId, expandHome, isDirectory, resolveProjectRoot, sanitizeId, sourceCheckoutDir, sourcesDir, STATE_FILE_NAME } from '../catalog/paths.js';
19
+ import { loadState, saveState, EMPTY_STATE } from '../model/state.js';
20
+ export class Catalog {
21
+ options;
22
+ state = EMPTY_STATE;
23
+ mutationQueue = Promise.resolve();
24
+ statePath;
25
+ headCache = new Map();
26
+ listeners = new Set();
27
+ revision = 0;
28
+ userSnapshot;
29
+ userSnapshotPromise;
30
+ /** Latest MCP mount diagnostics (suiteId -> reasons), fed by host reconcile. */
31
+ mcpDiagnostics = [];
32
+ toolSnapshotProvider = () => [];
33
+ constructor(options) {
34
+ this.options = options;
35
+ this.statePath = join(options.userRoot, STATE_FILE_NAME);
36
+ }
37
+ /** Install the host tool snapshot provider used by the MCP status surface. */
38
+ setMcpToolSnapshotProvider(provider) {
39
+ this.toolSnapshotProvider = provider;
40
+ }
41
+ /** Subscribe to completed catalog mutations; returns an unsubscribe function. */
42
+ subscribe(listener) {
43
+ this.listeners.add(listener);
44
+ return () => this.listeners.delete(listener);
45
+ }
46
+ /** Build the flat MCP service inventory for the status surface. */
47
+ async mcpStatus() {
48
+ const snapshot = await this.readUserCatalog();
49
+ return buildMcpStatus(snapshot.suites, this.mcpDiagnostics, this.toolSnapshotProvider());
50
+ }
51
+ /** Load persisted user state once at plugin activation. */
52
+ async load() {
53
+ this.state = await loadState(this.statePath);
54
+ this.invalidateSnapshot(false);
55
+ }
56
+ get sources() {
57
+ return this.state.sources;
58
+ }
59
+ /** The user-dimension suite root this catalog operates. */
60
+ get userRoot() {
61
+ return this.options.userRoot;
62
+ }
63
+ /** Read one coherent user-dimension snapshot, reusing in-flight discovery. */
64
+ async readUserCatalog() {
65
+ if (this.userSnapshot !== undefined)
66
+ return this.userSnapshot;
67
+ this.userSnapshotPromise ??= this.buildSnapshot(this.state, 'user', this.options.userRoot)
68
+ .then(snapshot => {
69
+ this.userSnapshot = snapshot;
70
+ return snapshot;
71
+ })
72
+ .finally(() => {
73
+ this.userSnapshotPromise = undefined;
74
+ });
75
+ return this.userSnapshotPromise;
76
+ }
77
+ /** Read one coherent project-dimension snapshot for a workspace cwd. */
78
+ async readProjectCatalog(cwd) {
79
+ const projectRoot = await resolveProjectRoot(cwd);
80
+ const state = await loadState(join(projectRoot, STATE_FILE_NAME));
81
+ return this.buildSnapshot(state, 'project', projectRoot);
82
+ }
83
+ /** One suite's full detail for the market detail modal. */
84
+ async suiteDetail(sourceId, suiteId) {
85
+ const snapshot = await this.readUserCatalog();
86
+ const suite = snapshot.suites.find(entry => entry.sourceId === sourceId && entry.id === suiteId);
87
+ if (suite === undefined)
88
+ throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
89
+ return buildSuiteDetail(suite, this.state.installed[installKey(sourceId, suiteId)], this.mcpDiagnostics);
90
+ }
91
+ /** One skill's full SKILL.md text for the market detail modal. */
92
+ async skillContent(sourceId, suiteId, skillName) {
93
+ const snapshot = await this.readUserCatalog();
94
+ const suite = snapshot.suites.find(entry => entry.sourceId === sourceId && entry.id === suiteId);
95
+ if (suite === undefined)
96
+ throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
97
+ return readSkillContent(suite, skillName);
98
+ }
99
+ /** The full market overview from one user snapshot. */
100
+ async overview() {
101
+ const snapshot = await this.readUserCatalog();
102
+ const sourceRows = [];
103
+ for (const source of snapshot.sources) {
104
+ const inFlight = this.currentSourceState?.sourceId === source.id ? this.currentSourceState : undefined;
105
+ const checkout = this.sourceCheckoutPath(source);
106
+ let cloned = false;
107
+ let lockCommit;
108
+ let error;
109
+ if (source.local === true) {
110
+ cloned = await isDirectory(checkout);
111
+ if (!cloned)
112
+ error = `local source directory ${checkout} is missing`;
113
+ }
114
+ else if (inFlight !== undefined) {
115
+ // A mutation owns this source right now: do not race git against its checkout.
116
+ cloned = inFlight.cloned || (await isDirectory(checkout));
117
+ lockCommit = inFlight.head;
118
+ }
119
+ else {
120
+ const cachedHead = this.headCache.get(source.id);
121
+ if (cachedHead !== undefined && (await isDirectory(checkout))) {
122
+ cloned = true;
123
+ lockCommit = cachedHead;
124
+ }
125
+ else {
126
+ try {
127
+ lockCommit = await gitHead(checkout);
128
+ cloned = true;
129
+ this.headCache.set(source.id, lockCommit);
130
+ }
131
+ catch {
132
+ // Not cloned yet or a broken checkout; refresh reports the actionable error.
133
+ }
134
+ }
135
+ }
136
+ const sourceSuites = snapshot.suites.filter(suite => suite.sourceId === source.id);
137
+ sourceRows.push({
138
+ id: source.id,
139
+ url: source.url,
140
+ ...(source.branch === undefined ? {} : { branch: source.branch }),
141
+ ...(source.local === true ? { local: true } : {}),
142
+ cloned,
143
+ ...(lockCommit === undefined ? {} : { lockCommit }),
144
+ ...(error === undefined ? {} : { error }),
145
+ suiteIds: sourceSuites.map(suite => suite.id)
146
+ });
147
+ }
148
+ const installed = new Set(Object.keys(this.state.installed));
149
+ const cards = snapshot.suites.map(suite => ({
150
+ sourceId: suite.sourceId,
151
+ suiteId: suite.id,
152
+ name: suite.manifest.name,
153
+ version: suite.manifest.version,
154
+ description: suite.manifest.description,
155
+ keywords: suite.manifest.keywords ?? [],
156
+ surfaces: suite.surfaces,
157
+ enabled: suite.enabled,
158
+ installed: installed.has(installKey(suite.sourceId, suite.id)),
159
+ ...(suite.remote === undefined ? {} : { remoteUrl: suite.remote.url }),
160
+ dimension: suite.dimension,
161
+ layout: suite.manifest.layout,
162
+ errors: suite.errors,
163
+ mcpErrors: this.mcpDiagnostics.filter(diagnostic => diagnostic.suiteId === suite.id).map(diagnostic => `${diagnostic.serverKey}: ${diagnostic.reason}`)
164
+ }));
165
+ return {
166
+ sources: sourceRows,
167
+ suites: cards,
168
+ totals: {
169
+ all: cards.length,
170
+ installed: cards.filter(card => card.installed).length,
171
+ enabled: cards.filter(card => card.enabled).length
172
+ },
173
+ roots: { user: this.options.userRoot, data: this.options.dataRoot }
174
+ };
175
+ }
176
+ /** Enabled user-dimension suites from one user snapshot. */
177
+ async enabledUserSuites() {
178
+ return (await this.readUserCatalog()).enabledSuites;
179
+ }
180
+ /** Enabled user- and project-dimension suites for a workspace cwd. */
181
+ async enabledSuitesForCwd(cwd) {
182
+ const [user, project] = await Promise.all([this.readUserCatalog(), this.readProjectCatalog(cwd)]);
183
+ return { user: user.enabledSuites, project: project.enabledSuites };
184
+ }
185
+ /** All suites of one dimension. */
186
+ async suitesForDimension(dimension, dimensionRoot) {
187
+ const state = dimension === 'user' && dimensionRoot === this.options.userRoot ? this.state : await loadState(join(dimensionRoot, STATE_FILE_NAME));
188
+ return (await this.buildSnapshot(state, dimension, dimensionRoot)).suites;
189
+ }
190
+ /** Add a source and clone it immediately. */
191
+ async addSource(input) {
192
+ return this.enqueue(async () => {
193
+ const baseId = this.uniqueSourceId(deriveSourceId(input.url));
194
+ const source = {
195
+ id: baseId,
196
+ url: input.url,
197
+ ...(input.branch === undefined ? {} : { branch: input.branch }),
198
+ ...(input.local === true ? { local: true } : {})
199
+ };
200
+ const checkout = this.sourceCheckoutPath(source);
201
+ if (input.local === true) {
202
+ if (!(await isDirectory(checkout)))
203
+ throw new Error(`local source directory ${checkout} is missing`);
204
+ }
205
+ else {
206
+ this.beginSourceState(baseId, 'cloning', false);
207
+ try {
208
+ await this.ensureClone(source);
209
+ }
210
+ catch (error) {
211
+ this.endSourceState();
212
+ throw error;
213
+ }
214
+ this.updateSourceStep('reading');
215
+ }
216
+ source.id = this.uniqueSourceId(sanitizeId(await repoName(checkout)));
217
+ const head = await tryHead(checkout);
218
+ if (head !== undefined)
219
+ this.headCache.set(source.id, head);
220
+ this.endSourceState();
221
+ this.state = { ...this.state, sources: [...this.state.sources, source] };
222
+ await saveState(this.statePath, this.state);
223
+ this.notifyChanged();
224
+ return source;
225
+ });
226
+ }
227
+ /** Progress snapshot of the source mutation currently in flight. */
228
+ currentSourceState;
229
+ /** Begin reporting progress for a source mutation. */
230
+ beginSourceState(sourceId, step, cloned) {
231
+ this.currentSourceState = { sourceId, step, cloned };
232
+ }
233
+ /** Advance the in-flight source mutation step. */
234
+ updateSourceStep(step) {
235
+ if (this.currentSourceState !== undefined)
236
+ this.currentSourceState = { ...this.currentSourceState, step };
237
+ }
238
+ /** Stop reporting source mutation progress. */
239
+ endSourceState() {
240
+ this.currentSourceState = undefined;
241
+ }
242
+ /** Progress snapshot for the progress route. */
243
+ sourceProgress() {
244
+ const state = this.currentSourceState;
245
+ return state === undefined ? { active: false, sourceId: '', step: '' } : { active: true, sourceId: state.sourceId, step: state.step };
246
+ }
247
+ uniqueSourceId(derived) {
248
+ if (!this.state.sources.some(source => source.id === derived))
249
+ return derived;
250
+ for (let suffix = 2;; suffix++) {
251
+ const candidate = `${derived}-${suffix}`;
252
+ if (!this.state.sources.some(source => source.id === candidate))
253
+ return candidate;
254
+ }
255
+ }
256
+ /** Update one source's URL, branch, or local flag. */
257
+ async updateSource(sourceId, patch) {
258
+ return this.enqueue(async () => {
259
+ const index = this.state.sources.findIndex(source => source.id === sourceId);
260
+ if (index === -1)
261
+ throw new Error(`unknown source "${sourceId}"`);
262
+ const current = this.state.sources[index];
263
+ const next = {
264
+ id: sourceId,
265
+ url: patch.url ?? current.url,
266
+ ...(patch.branch !== undefined ? { branch: patch.branch } : current.branch === undefined ? {} : { branch: current.branch }),
267
+ ...(patch.local !== undefined ? { local: patch.local } : current.local === undefined ? {} : { local: current.local })
268
+ };
269
+ if (current.local !== true && patch.url !== undefined && patch.url !== current.url) {
270
+ this.headCache.delete(sourceId);
271
+ await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
272
+ }
273
+ this.state = { ...this.state, sources: this.state.sources.map((source, i) => (i === index ? next : source)) };
274
+ await saveState(this.statePath, this.state);
275
+ this.notifyChanged();
276
+ });
277
+ }
278
+ /** Remove a source, its install entries, and its managed checkout. */
279
+ async removeSource(sourceId) {
280
+ return this.enqueue(async () => {
281
+ const source = this.state.sources.find(entry => entry.id === sourceId);
282
+ this.state = {
283
+ ...this.state,
284
+ sources: this.state.sources.filter(entry => entry.id !== sourceId),
285
+ installed: Object.fromEntries(Object.entries(this.state.installed).filter(([key]) => !key.startsWith(`${sourceId}/`)))
286
+ };
287
+ await saveState(this.statePath, this.state);
288
+ this.headCache.delete(sourceId);
289
+ if (source === undefined || source.local !== true)
290
+ await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
291
+ this.notifyChanged();
292
+ });
293
+ }
294
+ /** Refresh one source checkout, or every source when sourceId is omitted. */
295
+ async refreshSource(sourceId) {
296
+ return this.enqueue(async () => {
297
+ const targets = sourceId === undefined ? this.state.sources : this.state.sources.filter(source => source.id === sourceId);
298
+ for (const source of targets) {
299
+ if (source.local === true) {
300
+ if (!(await isDirectory(expandHome(source.url))))
301
+ throw new Error(`local source directory ${expandHome(source.url)} is missing`);
302
+ continue;
303
+ }
304
+ const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
305
+ this.headCache.delete(source.id);
306
+ try {
307
+ await gitHead(checkout);
308
+ }
309
+ catch {
310
+ await this.ensureClone(source);
311
+ try {
312
+ this.headCache.set(source.id, await gitHead(checkout));
313
+ }
314
+ catch {
315
+ // A failed HEAD read means the next overview re-probes.
316
+ }
317
+ continue;
318
+ }
319
+ await gitPull(checkout);
320
+ try {
321
+ this.headCache.set(source.id, await gitHead(checkout));
322
+ }
323
+ catch {
324
+ // A failed HEAD read means the next overview re-probes.
325
+ }
326
+ }
327
+ this.notifyChanged();
328
+ });
329
+ }
330
+ /** Install a suite from a source and enable it. */
331
+ async install(sourceId, suiteId) {
332
+ return this.enqueue(async () => {
333
+ const source = this.state.sources.find(entry => entry.id === sourceId);
334
+ if (source === undefined)
335
+ throw new Error(`unknown source "${sourceId}"`);
336
+ const checkout = this.sourceCheckoutPath(source);
337
+ if (!(await isDirectory(checkout)))
338
+ await this.ensureClone(source);
339
+ const suites = await discoverSuitesInSource(checkout, sourceId, 'user');
340
+ const suite = suites.find(entry => entry.id === suiteId);
341
+ if (suite === undefined)
342
+ throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
343
+ if (suite.remote !== undefined)
344
+ throw new Error(`suite "${suiteId}" is a remote reference (${suite.remote.url}); add its repository as a source before installing`);
345
+ await this.setInstalled(sourceId, suiteId, { enabled: true, installedAt: new Date().toISOString(), lockCommit: await tryHead(checkout) });
346
+ this.notifyChanged();
347
+ });
348
+ }
349
+ /** Uninstall a suite while retaining the source checkout. */
350
+ async uninstall(sourceId, suiteId) {
351
+ return this.enqueue(async () => {
352
+ const key = installKey(sourceId, suiteId);
353
+ if (this.state.installed[key] === undefined)
354
+ throw new Error(`suite "${suiteId}" is not installed`);
355
+ const rest = Object.fromEntries(Object.entries(this.state.installed).filter(([entryKey]) => entryKey !== key));
356
+ this.state = { ...this.state, installed: rest };
357
+ await saveState(this.statePath, this.state);
358
+ this.notifyChanged();
359
+ });
360
+ }
361
+ /** Enable or disable an installed suite. */
362
+ async setEnabled(sourceId, suiteId, enabled) {
363
+ return this.enqueue(async () => {
364
+ const key = installKey(sourceId, suiteId);
365
+ const entry = this.state.installed[key];
366
+ if (entry === undefined)
367
+ throw new Error(`suite "${suiteId}" is not installed`);
368
+ await this.setInstalled(sourceId, suiteId, { ...entry, enabled });
369
+ this.notifyChanged();
370
+ });
371
+ }
372
+ /** Append config-seeded sources missing from user state and persist them. */
373
+ async mergeSources(sources) {
374
+ const existing = new Set(this.state.sources.map(source => source.id));
375
+ const additions = sources.filter(source => !existing.has(source.id));
376
+ if (additions.length === 0)
377
+ return;
378
+ this.state = { ...this.state, sources: [...this.state.sources, ...additions] };
379
+ await saveState(this.statePath, this.state);
380
+ this.invalidateSnapshot(true);
381
+ }
382
+ async setInstalled(sourceId, suiteId, entry) {
383
+ this.state = { ...this.state, installed: { ...this.state.installed, [installKey(sourceId, suiteId)]: entry } };
384
+ await saveState(this.statePath, this.state);
385
+ }
386
+ async ensureClone(source) {
387
+ if (source.local === true) {
388
+ if (!(await isDirectory(expandHome(source.url))))
389
+ throw new Error(`local source directory ${expandHome(source.url)} is missing`);
390
+ return;
391
+ }
392
+ const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
393
+ await mkdir(sourcesDir(this.options.userRoot), { recursive: true });
394
+ await gitClone(source.url, source.branch, checkout);
395
+ }
396
+ /** The filesystem location of one source. */
397
+ sourceCheckoutPath(source) {
398
+ return source.local === true ? expandHome(source.url) : sourceCheckoutDir(this.options.userRoot, source.id);
399
+ }
400
+ async buildSnapshot(state, dimension, dimensionRoot) {
401
+ const discovered = await discoverSourceList(state.sources, dimension, dimensionRoot);
402
+ const suites = discovered.map(suite => {
403
+ const installed = state.installed[installKey(suite.sourceId, suite.id)];
404
+ return {
405
+ ...suite,
406
+ enabled: installed?.enabled === true,
407
+ ...(installed?.lockCommit === undefined ? {} : { lockCommit: installed.lockCommit }),
408
+ ...(installed?.installedAt === undefined ? {} : { installedAt: installed.installedAt })
409
+ };
410
+ });
411
+ return {
412
+ revision: this.revision,
413
+ sources: [...state.sources],
414
+ suites,
415
+ enabledSuites: suites.filter(suite => suite.enabled)
416
+ };
417
+ }
418
+ invalidateSnapshot(increment) {
419
+ if (increment)
420
+ this.revision++;
421
+ this.userSnapshot = undefined;
422
+ this.userSnapshotPromise = undefined;
423
+ }
424
+ notifyChanged() {
425
+ this.invalidateSnapshot(true);
426
+ this.options.onChanged();
427
+ for (const listener of this.listeners)
428
+ listener();
429
+ }
430
+ enqueue(operation) {
431
+ const result = this.mutationQueue.then(operation, operation);
432
+ this.mutationQueue = result.catch(() => { });
433
+ return result;
434
+ }
435
+ }
436
+ function installKey(sourceId, suiteId) {
437
+ return `${sourceId}/${suiteId}`;
438
+ }
439
+ /** Read a checkout's HEAD when it is a Git repository. */
440
+ async function tryHead(dir) {
441
+ try {
442
+ return await gitHead(dir);
443
+ }
444
+ catch {
445
+ return undefined;
446
+ }
447
+ }
@@ -0,0 +1,136 @@
1
+ /** Suite detail and preview projections owned by the application layer. */
2
+ import { readFile } from 'node:fs/promises';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { discoverLspEntries, listMdFiles } from '../catalog/surfaces.js';
5
+ /** Build the detail response for one normalized suite. */
6
+ export async function buildSuiteDetail(suite, installed, diagnostics) {
7
+ const remoteUrl = suite.remote?.url;
8
+ return {
9
+ sourceId: suite.sourceId,
10
+ suiteId: suite.id,
11
+ name: suite.manifest.name,
12
+ version: suite.manifest.version ?? null,
13
+ description: suite.manifest.description ?? null,
14
+ author: suite.manifest.author ?? null,
15
+ keywords: suite.manifest.keywords ?? [],
16
+ layout: suite.manifest.layout,
17
+ dimension: suite.dimension,
18
+ root: remoteUrl ?? suite.root,
19
+ remoteUrl: remoteUrl ?? null,
20
+ installed: installed !== undefined,
21
+ enabled: installed?.enabled === true,
22
+ skills: suite.skills.map(skill => ({
23
+ name: skill.name,
24
+ description: skill.description,
25
+ ...(skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }),
26
+ path: skill.file
27
+ })),
28
+ mcpServers: suite.mcp === undefined ? [] : Object.entries(suite.mcp.servers).map(([key, server]) => ({ key, ...server })),
29
+ hooks: remoteUrl === undefined ? await hooksPreviews(suite.root) : { count: 0, entries: [] },
30
+ commands: remoteUrl === undefined ? await markdownPreviews(`${suite.root}/commands`) : [],
31
+ agents: remoteUrl === undefined ? await markdownPreviews(`${suite.root}/agents`) : [],
32
+ lsp: remoteUrl === undefined ? await lspPreviews(suite.root) : [],
33
+ errors: suite.errors,
34
+ mcpErrors: diagnostics.filter(diagnostic => diagnostic.suiteId === suite.id).map(diagnostic => `${diagnostic.serverKey}: ${diagnostic.reason}`)
35
+ };
36
+ }
37
+ /** Read one skill body from a normalized suite. */
38
+ export async function readSkillContent(suite, skillName) {
39
+ const skill = suite.skills.find(entry => entry.name === skillName);
40
+ if (skill === undefined)
41
+ throw new Error(`skill "${skillName}" not found in suite "${suite.id}"`);
42
+ let content;
43
+ try {
44
+ content = await readFile(skill.file, 'utf8');
45
+ }
46
+ catch (error) {
47
+ throw new Error(`skill file unreadable: ${error instanceof Error ? error.message : String(error)}`);
48
+ }
49
+ return { name: skill.name, description: skill.description, content, path: skill.file };
50
+ }
51
+ async function readPreview(path, capBytes = 64 * 1024) {
52
+ const text = await readFile(path, 'utf8');
53
+ return text.length > capBytes ? `${text.slice(0, capBytes)}\n… (truncated)` : text;
54
+ }
55
+ async function markdownPreviews(dir) {
56
+ const names = await listMdFiles(dir);
57
+ const previews = [];
58
+ for (const name of names) {
59
+ const file = `${dir}/${name}`;
60
+ try {
61
+ const content = await readPreview(file);
62
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
63
+ let description;
64
+ if (match !== null) {
65
+ const yaml = parseYaml(match[1]);
66
+ if (typeof yaml === 'object' && yaml !== null) {
67
+ const desc = yaml['description'];
68
+ if (typeof desc === 'string')
69
+ description = desc;
70
+ }
71
+ }
72
+ previews.push({ name: name.slice(0, -3), ...(description === undefined ? {} : { description }), content });
73
+ }
74
+ catch {
75
+ // Unreadable preview files are omitted from the detail response.
76
+ }
77
+ }
78
+ return previews;
79
+ }
80
+ async function hooksPreviews(root) {
81
+ for (const relative of ['hooks/hooks.json', 'hooks.json']) {
82
+ let text;
83
+ try {
84
+ text = await readFile(`${root}/${relative}`, 'utf8');
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ try {
90
+ const parsed = JSON.parse(text);
91
+ if (typeof parsed !== 'object' || parsed === null)
92
+ continue;
93
+ const hooks = parsed['hooks'];
94
+ if (typeof hooks !== 'object' || hooks === null)
95
+ continue;
96
+ const entries = [];
97
+ for (const [event, groups] of Object.entries(hooks)) {
98
+ if (!Array.isArray(groups))
99
+ continue;
100
+ for (const group of groups) {
101
+ if (typeof group !== 'object' || group === null)
102
+ continue;
103
+ const record = group;
104
+ const matcher = typeof record['matcher'] === 'string' ? record['matcher'] : undefined;
105
+ const hooksList = record['hooks'];
106
+ if (!Array.isArray(hooksList))
107
+ continue;
108
+ for (const hook of hooksList) {
109
+ if (typeof hook !== 'object' || hook === null)
110
+ continue;
111
+ const hookRecord = hook;
112
+ if (typeof hookRecord['command'] === 'string')
113
+ entries.push({ event, ...(matcher === undefined ? {} : { matcher }), command: hookRecord['command'] });
114
+ }
115
+ }
116
+ }
117
+ return { count: entries.length, entries };
118
+ }
119
+ catch {
120
+ // Unparsable hook files yield zero entries.
121
+ }
122
+ }
123
+ return { count: 0, entries: [] };
124
+ }
125
+ async function lspPreviews(root) {
126
+ const previews = [];
127
+ for (const entry of await discoverLspEntries(root)) {
128
+ try {
129
+ previews.push({ name: entry.name, content: await readPreview(entry.path) });
130
+ }
131
+ catch {
132
+ // Unreadable LSP files are omitted from the detail response.
133
+ }
134
+ }
135
+ return previews;
136
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -17,8 +17,8 @@
17
17
  */
18
18
  import { readFile, stat } from 'node:fs/promises';
19
19
  import { join, resolve } from 'node:path';
20
- import { sanitizeId } from '../paths.js';
21
- import { isRecognizedSchema, validatePluginManifest } from '../validate.js';
20
+ import { sanitizeId } from './paths.js';
21
+ import { isRecognizedSchema, validatePluginManifest } from './validate.js';
22
22
  /** Dialect precedence: v1 wins over universal over claude over cursor over kimi over codex. */
23
23
  const KIND_PRECEDENCE = ['agent-plugin-v1', 'universal', 'claude-code', 'cursor', 'kimi', 'codex'];
24
24
  const MANIFEST_PATHS = {
@@ -27,7 +27,7 @@ const MANIFEST_PATHS = {
27
27
  'claude-code': join('.claude-plugin', 'plugin.json'),
28
28
  cursor: join('.cursor-plugin', 'plugin.json'),
29
29
  kimi: join('.kimi-plugin', 'plugin.json'),
30
- codex: join('.codex-plugin', 'plugin.json'),
30
+ codex: join('.codex-plugin', 'plugin.json')
31
31
  };
32
32
  /** The highest-precedence manifest file a directory carries, if any. */
33
33
  export async function detectManifest(dir) {
@@ -73,24 +73,32 @@ export async function readManifest(root, errors, hint) {
73
73
  return undefined;
74
74
  }
75
75
  const record = raw;
76
- const problems = candidate.kind === 'agent-plugin-v1'
77
- ? await validatePluginManifest(raw)
78
- : [];
76
+ const problems = candidate.kind === 'agent-plugin-v1' ? await validatePluginManifest(raw) : [];
79
77
  errors.push(...problems.map(problem => `${candidate.path}: ${problem}`));
80
78
  const name = pickString(record.name) ?? hint?.name ?? syntheticManifestName(root);
81
79
  const version = pickString(record.version) ?? hint?.version;
82
80
  const description = pickString(record.description) ?? hint?.description;
83
81
  const author = record.author;
84
82
  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',
83
+ layout: candidate.kind === 'agent-plugin-v1'
84
+ ? 'agent-plugin-v1'
85
+ : candidate.kind === 'universal'
86
+ ? 'universal'
87
+ : candidate.kind === 'claude-code'
88
+ ? 'claude-code'
89
+ : candidate.kind === 'cursor'
90
+ ? 'cursor'
91
+ : candidate.kind === 'kimi'
92
+ ? 'kimi'
93
+ : 'codex',
86
94
  path: candidate.path,
87
95
  id: sanitizeId(name),
88
96
  name,
89
- ...version === undefined ? {} : { version },
90
- ...description === undefined ? {} : { description },
97
+ ...(version === undefined ? {} : { version }),
98
+ ...(description === undefined ? {} : { description }),
91
99
  ...(author?.name !== undefined ? { author: author.name } : pickString(record.homepage) !== undefined ? { author: pickString(record.homepage) } : {}),
92
100
  keywords: Array.isArray(record.keywords) ? record.keywords.filter((entry) => typeof entry === 'string') : [],
93
- ...isRecognizedSchema(record.$schema) ? { schemaVersion: record.$schema } : {},
101
+ ...(isRecognizedSchema(record.$schema) ? { schemaVersion: record.$schema } : {})
94
102
  };
95
103
  }
96
104
  function pickString(value) {
@@ -117,8 +125,8 @@ export async function readMarketplace(checkoutDir) {
117
125
  if (!Array.isArray(plugins))
118
126
  continue;
119
127
  return {
120
- ...typeof record['name'] === 'string' ? { name: record['name'] } : {},
121
- entries: plugins,
128
+ ...(typeof record['name'] === 'string' ? { name: record['name'] } : {}),
129
+ entries: plugins
122
130
  };
123
131
  }
124
132
  catch {
@@ -72,7 +72,11 @@ export async function isDirectory(path) {
72
72
  }
73
73
  /** Sanitize a plugin or server id into `[a-z0-9-]` (lowercased). */
74
74
  export function sanitizeId(raw) {
75
- const cleaned = raw.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '').replace(/-{2,}/g, '-');
75
+ const cleaned = raw
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9_-]+/g, '-')
78
+ .replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '')
79
+ .replace(/-{2,}/g, '-');
76
80
  return cleaned === '' ? 'unnamed' : cleaned;
77
81
  }
78
82
  /** Derive a source id from a repository URL or local path: last path segment, `.git` stripped. */