dsh-agent-plugins-market 0.4.5 → 0.4.6

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/lib/discovery.js CHANGED
@@ -23,15 +23,10 @@ const DOT_DIRS = new Set(['.git', '.github', '.claude', '.cursor', '.kimi', '.pl
23
23
  /** Discover every suite under one cloned source checkout. */
24
24
  export async function discoverSuitesInSource(checkoutDir, sourceId, dimension) {
25
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;
26
+ const suites = await Promise.all(roots.map(async (root) => root.dir === undefined
27
+ ? remoteSuite(sourceId, dimension, root)
28
+ : readSuite(root.dir, sourceId, dimension, root.hint)));
29
+ return suites.filter((suite) => suite !== undefined);
35
30
  }
36
31
  /**
37
32
  * Resolve the suite roots of one checkout. A marketplace manifest is the
@@ -205,30 +200,37 @@ export function isOutside(root, candidate) {
205
200
  return isAbsolute(candidate) ? !candidate.startsWith(root) : false;
206
201
  }
207
202
  /**
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.
203
+ * Discover suites for one dimension. User catalogs only read configured
204
+ * sources: unmanaged user checkouts have no persisted identity and otherwise
205
+ * leak stale repositories into the catalog totals. Project catalogs retain
206
+ * unmanaged checkout ids because project state may authorize them through its
207
+ * installed map without duplicating source configuration. All selected
208
+ * checkouts are discovered concurrently so one large repository does not
209
+ * serialize every other repository's scan.
212
210
  */
213
211
  export async function discoverSourceList(sources, dimension, dimensionRoot) {
214
212
  const checkoutRoot = sourcesDir(dimensionRoot);
215
- const bySource = new Map();
216
213
  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));
214
+ const checkouts = sources.map(source => ({
215
+ sourceId: source.id,
216
+ checkout: source.local === true ? expandHome(source.url) : join(checkoutRoot, source.id),
217
+ }));
218
+ if (dimension === 'project') {
219
+ try {
220
+ for (const entry of await readdir(checkoutRoot, { withFileTypes: true })) {
221
+ if (!entry.isDirectory() || entry.name.startsWith('.') || listed.has(entry.name))
222
+ continue;
223
+ checkouts.push({ sourceId: entry.name, checkout: join(checkoutRoot, entry.name) });
224
+ }
225
+ }
226
+ catch {
227
+ // A missing project checkout root has no unmanaged project sources.
222
228
  }
223
229
  }
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);
230
+ const discovered = await Promise.all(checkouts.map(async ({ sourceId, checkout }) => {
229
231
  if (!await isDirectory(checkout))
230
- continue;
231
- bySource.set(source.id, await discoverSuitesInSource(checkout, source.id, dimension));
232
- }
233
- return [...bySource.values()].flat();
232
+ return [];
233
+ return discoverSuitesInSource(checkout, sourceId, dimension);
234
+ }));
235
+ return discovered.flat();
234
236
  }
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@ import { HooksMountRegistry } from './hooks-mounts.js';
3
3
  import { mountSuiteContext } from './context.js';
4
4
  import { SuiteManager } from './manager.js';
5
5
  import { McpMountRegistry } from './mcp-mounts.js';
6
+ import { inspectToolRegistry } from './mcp-status.js';
6
7
  import { resolveDataRoot, resolveUserRoot } from './paths.js';
7
8
  import { mountSuiteRoutes } from './routes.js';
8
9
  import { SuiteSkillProvider } from './skills-provider.js';
@@ -53,6 +54,9 @@ export function apply(ctx, config = {}) {
53
54
  reconcileMounts();
54
55
  };
55
56
  const manager = new SuiteManager({ userRoot, dataRoot, onChanged });
57
+ ctx.inject(['tools'], (toolsCtx) => {
58
+ manager.setMcpToolSnapshotProvider(() => inspectToolRegistry(toolsCtx.tools));
59
+ });
56
60
  void manager.load().then(async () => {
57
61
  await manager.mergeSources(config.sources ?? []);
58
62
  reconcileMounts();
package/lib/manager.js CHANGED
@@ -13,6 +13,7 @@ import { mkdir, readFile } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
14
  import { discoverLspEntries, discoverSourceList, discoverSuitesInSource, listMdFiles, repoName } from './discovery.js';
15
15
  import { gitClone, gitHead, gitPull, gitRemove } from './git.js';
16
+ import { buildMcpStatus } from './mcp-status.js';
16
17
  import { deriveSourceId, expandHome, isDirectory, resolveProjectRoot, sanitizeId, sourceCheckoutDir, sourcesDir, STATE_FILE_NAME } from './paths.js';
17
18
  import { loadState, saveState, EMPTY_STATE } from './state.js';
18
19
  export class SuiteManager {
@@ -24,10 +25,20 @@ export class SuiteManager {
24
25
  headCache = new Map();
25
26
  /** Latest MCP mount diagnostics (suiteId -> reasons), fed by the host reconcile. */
26
27
  mcpDiagnostics = [];
28
+ toolSnapshotProvider = () => [];
27
29
  constructor(options) {
28
30
  this.options = options;
29
31
  this.statePath = join(options.userRoot, STATE_FILE_NAME);
30
32
  }
33
+ /** Install the host tool snapshot provider used by the MCP status surface. */
34
+ setMcpToolSnapshotProvider(provider) {
35
+ this.toolSnapshotProvider = provider;
36
+ }
37
+ /** Build the flat MCP service inventory for the status surface. */
38
+ async mcpStatus() {
39
+ const suites = await this.discoverDimension('user', this.options.userRoot);
40
+ return buildMcpStatus(suites, this.mcpDiagnostics, this.toolSnapshotProvider());
41
+ }
31
42
  /** Load persisted state once at plugin activation. */
32
43
  async load() {
33
44
  this.state = await loadState(this.statePath);
@@ -0,0 +1,147 @@
1
+ import { deriveServerName } from './mcp-config.js';
2
+ /** Build status rows from discovered plugin MCP definitions and observed tool names. */
3
+ export function buildMcpStatus(suites, diagnostics, observed) {
4
+ const entries = [];
5
+ const claimedServers = new Set();
6
+ const knownServerNames = new Set();
7
+ for (const suite of suites) {
8
+ if (suite.mcp === undefined || suite.installedAt === undefined || !suite.enabled)
9
+ continue;
10
+ for (const serverKey of Object.keys(suite.mcp.servers))
11
+ knownServerNames.add(deriveServerName(suite.id, serverKey));
12
+ }
13
+ const diagnosticsByKey = new Map(diagnostics.map(diagnostic => [`${diagnostic.suiteId}\u0000${diagnostic.serverKey}`, diagnostic]));
14
+ const observedByServer = groupObservedTools(observed, knownServerNames);
15
+ for (const suite of suites) {
16
+ // This is an operational inventory, not a configuration audit: suite MCP
17
+ // definitions only appear after their suite is both installed and enabled.
18
+ if (suite.mcp === undefined || suite.installedAt === undefined || !suite.enabled)
19
+ continue;
20
+ for (const [serverKey, server] of Object.entries(suite.mcp.servers)) {
21
+ const serverName = deriveServerName(suite.id, serverKey);
22
+ const tools = observedByServer.get(serverName) ?? [];
23
+ claimedServers.add(serverName);
24
+ const diagnostic = diagnosticsByKey.get(`${suite.id}\u0000${serverKey}`);
25
+ const state = diagnostic !== undefined
26
+ ? 'failed'
27
+ : tools.length > 0
28
+ ? 'connected'
29
+ : 'degraded';
30
+ entries.push({
31
+ id: `plugin:${suite.sourceId}/${suite.id}/${serverKey}`,
32
+ name: serverName,
33
+ kind: 'plugin',
34
+ state,
35
+ source: suite.manifest.name,
36
+ suiteId: suite.id,
37
+ serverKey,
38
+ transport: server.type,
39
+ endpoint: endpointOf(server),
40
+ config: redactConfig(server),
41
+ tools: tools.map(tool => ({ name: tool.name, ...tool.description === undefined ? {} : { description: tool.description } })),
42
+ ...diagnostic === undefined ? {} : { reason: diagnostic.reason },
43
+ });
44
+ }
45
+ }
46
+ for (const [serverName, tools] of observedByServer) {
47
+ if (claimedServers.has(serverName))
48
+ continue;
49
+ entries.push({
50
+ id: `direct:${serverName}`,
51
+ name: serverName,
52
+ kind: 'direct',
53
+ state: 'connected',
54
+ transport: 'observed',
55
+ tools: tools.map(tool => ({ name: tool.name, ...tool.description === undefined ? {} : { description: tool.description } })),
56
+ });
57
+ }
58
+ const totals = {
59
+ all: entries.length,
60
+ connected: entries.filter(entry => entry.state === 'connected').length,
61
+ degraded: entries.filter(entry => entry.state === 'degraded').length,
62
+ failed: entries.filter(entry => entry.state === 'failed').length,
63
+ disabled: entries.filter(entry => entry.state === 'disabled').length,
64
+ };
65
+ return { entries, observedAt: new Date().toISOString(), totals, directObservationOnly: true };
66
+ }
67
+ function groupObservedTools(observed, knownServerNames) {
68
+ const grouped = new Map();
69
+ for (const tool of observed) {
70
+ if (!tool.name.startsWith('mcp__'))
71
+ continue;
72
+ let serverName;
73
+ let rawName;
74
+ // Plugin server names contain `__` themselves (`suite__server`), so use
75
+ // known names first and choose the longest matching namespace.
76
+ for (const candidate of knownServerNames) {
77
+ const prefix = `mcp__${candidate}__`;
78
+ if (tool.name.startsWith(prefix) && (serverName === undefined || candidate.length > serverName.length)) {
79
+ serverName = candidate;
80
+ rawName = tool.name.slice(prefix.length);
81
+ }
82
+ }
83
+ if (serverName === undefined) {
84
+ const parts = tool.name.split('__');
85
+ if (parts.length < 3)
86
+ continue;
87
+ serverName = parts[1];
88
+ rawName = parts.slice(2).join('__');
89
+ }
90
+ if (serverName === undefined || rawName === undefined || rawName === '')
91
+ continue;
92
+ const list = grouped.get(serverName) ?? [];
93
+ list.push({ name: rawName, ...tool.description === undefined ? {} : { description: tool.description } });
94
+ grouped.set(serverName, list);
95
+ }
96
+ return grouped;
97
+ }
98
+ function endpointOf(server) {
99
+ if (server.type === 'stdio')
100
+ return [server.command, ...(server.args ?? [])].join(' ');
101
+ return server.url;
102
+ }
103
+ const SENSITIVE_KEY = /(authorization|token|secret|password|credential|api[-_]?key)/i;
104
+ function redactConfig(value) {
105
+ return redactValue(value);
106
+ }
107
+ function redactValue(value, key = '') {
108
+ if (SENSITIVE_KEY.test(key))
109
+ return '[redacted]';
110
+ if (Array.isArray(value))
111
+ return value.map(item => redactValue(item));
112
+ if (typeof value === 'object' && value !== null) {
113
+ return Object.fromEntries(Object.entries(value).map(([childKey, childValue]) => [childKey, redactValue(childValue, childKey)]));
114
+ }
115
+ return value;
116
+ }
117
+ /** Read model-facing MCP tool names from the dsh-tools runtime when available.
118
+ * The registry intentionally has no public listing API in rc.6; this adapter
119
+ * uses only the runtime's stable `layers.merge(...).tools.entries()` data shape
120
+ * and safely returns an empty observation when another host changes it. */
121
+ export function inspectToolRegistry(runtime) {
122
+ if (typeof runtime !== 'object' || runtime === null)
123
+ return [];
124
+ const layers = runtime.layers;
125
+ if (typeof layers !== 'object' || layers === null)
126
+ return [];
127
+ const merge = layers.merge;
128
+ if (typeof merge !== 'function')
129
+ return [];
130
+ const empty = { entries: () => [] };
131
+ try {
132
+ const visible = merge.call(layers, undefined, layer => layer.tools ?? empty);
133
+ const output = [];
134
+ for (const [name, definition] of visible.entries()) {
135
+ if (!name.startsWith('mcp__'))
136
+ continue;
137
+ const description = typeof definition === 'object' && definition !== null && typeof definition.description === 'string'
138
+ ? definition.description
139
+ : undefined;
140
+ output.push({ name, ...description === undefined ? {} : { description } });
141
+ }
142
+ return output;
143
+ }
144
+ catch {
145
+ return [];
146
+ }
147
+ }
package/lib/routes.js CHANGED
@@ -46,6 +46,9 @@ export function mountSuiteRoutes(hostCtx, manager) {
46
46
  get(`${API_PREFIX}overview`, async (_request, response) => {
47
47
  sendJson(response, 200, await manager.overview());
48
48
  });
49
+ get(`${API_PREFIX}mcp-status`, async (_request, response) => {
50
+ sendJson(response, 200, await manager.mcpStatus());
51
+ });
49
52
  get(`${API_PREFIX}progress`, async (_request, response) => {
50
53
  sendJson(response, 200, manager.sourceProgress());
51
54
  });
@@ -8,9 +8,12 @@ export declare function discoverSuitesInSource(checkoutDir: string, sourceId: st
8
8
  /** Whether a suite root path lies outside the checkout (defense for malformed marketplace sources). */
9
9
  export declare function isOutside(root: string, candidate: string): boolean;
10
10
  /**
11
- * Discover every suite of one dimension's configured sources, plus manual
12
- * checkouts present under the dimension's `.sources/` that no source entry
13
- * names. Local sources read their directory directly; git sources read
14
- * their clone; a missing checkout contributes nothing.
11
+ * Discover suites for one dimension. User catalogs only read configured
12
+ * sources: unmanaged user checkouts have no persisted identity and otherwise
13
+ * leak stale repositories into the catalog totals. Project catalogs retain
14
+ * unmanaged checkout ids because project state may authorize them through its
15
+ * installed map without duplicating source configuration. All selected
16
+ * checkouts are discovered concurrently so one large repository does not
17
+ * serialize every other repository's scan.
15
18
  */
16
19
  export declare function discoverSourceList(sources: SourceRef[], dimension: SuiteDimension, dimensionRoot: string): Promise<Suite[]>;
@@ -1,3 +1,4 @@
1
+ import { type McpStatusPayload, type McpToolSnapshot } from './mcp-status.js';
1
2
  import type { OverviewPayload, SourceRef, Suite, SuiteDimension } from './types.js';
2
3
  export interface ManagerOptions {
3
4
  userRoot: string;
@@ -17,7 +18,12 @@ export declare class SuiteManager {
17
18
  serverKey: string;
18
19
  reason: string;
19
20
  }>;
21
+ private toolSnapshotProvider;
20
22
  constructor(options: ManagerOptions);
23
+ /** Install the host tool snapshot provider used by the MCP status surface. */
24
+ setMcpToolSnapshotProvider(provider: () => readonly McpToolSnapshot[]): void;
25
+ /** Build the flat MCP service inventory for the status surface. */
26
+ mcpStatus(): Promise<McpStatusPayload>;
21
27
  /** Load persisted state once at plugin activation. */
22
28
  load(): Promise<void>;
23
29
  get sources(): SourceRef[];
@@ -0,0 +1,51 @@
1
+ import type { Suite } from './types.js';
2
+ /** A tool observed in the host tool registry. */
3
+ export interface McpToolSnapshot {
4
+ name: string;
5
+ description?: string;
6
+ }
7
+ export type McpStatusKind = 'plugin' | 'direct';
8
+ export type McpStatusState = 'connected' | 'degraded' | 'failed' | 'disabled';
9
+ /** One MCP service row for the status surface. */
10
+ export interface McpStatusEntry {
11
+ id: string;
12
+ name: string;
13
+ kind: McpStatusKind;
14
+ state: McpStatusState;
15
+ source?: string;
16
+ suiteId?: string;
17
+ serverKey?: string;
18
+ transport: string;
19
+ endpoint?: string;
20
+ config?: Record<string, unknown>;
21
+ tools: Array<{
22
+ name: string;
23
+ description?: string;
24
+ }>;
25
+ reason?: string;
26
+ }
27
+ export interface McpStatusPayload {
28
+ entries: McpStatusEntry[];
29
+ observedAt: string;
30
+ totals: {
31
+ all: number;
32
+ connected: number;
33
+ degraded: number;
34
+ failed: number;
35
+ disabled: number;
36
+ };
37
+ directObservationOnly: boolean;
38
+ }
39
+ interface McpDiagnostic {
40
+ suiteId: string;
41
+ serverKey: string;
42
+ reason: string;
43
+ }
44
+ /** Build status rows from discovered plugin MCP definitions and observed tool names. */
45
+ export declare function buildMcpStatus(suites: Suite[], diagnostics: McpDiagnostic[], observed: readonly McpToolSnapshot[]): McpStatusPayload;
46
+ /** Read model-facing MCP tool names from the dsh-tools runtime when available.
47
+ * The registry intentionally has no public listing API in rc.6; this adapter
48
+ * uses only the runtime's stable `layers.merge(...).tools.entries()` data shape
49
+ * and safely returns an empty observation when another host changes it. */
50
+ export declare function inspectToolRegistry(runtime: unknown): McpToolSnapshot[];
51
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agent-plugins-market",
3
3
  "description": "The standard way to install Claude Code / Codex / Cursor / Kimi plugin marketplace suites in DeepSeek Harness (DSH) — zero conversion, zero file copying. Skills, MCP servers, hooks and slash commands are injected into your dsh sessions at runtime.",
4
- "version": "0.4.5",
4
+ "version": "0.4.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",