@dzhechkov/harness-core 0.3.22 → 0.3.24

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.
@@ -0,0 +1,489 @@
1
+ /**
2
+ * `dz mcp-scan` — "npm audit for agent tools".
3
+ *
4
+ * A deterministic, static (no-execution) security scan of a project's agent
5
+ * permission surface. It reads Claude-Code-style `.claude/settings*.json`
6
+ * permission grants and MCP server declarations (`.mcp.json`, `.vscode/mcp.json`)
7
+ * and emits a three-tier verdict (`clean` / `medium` / `high`) with findings.
8
+ *
9
+ * The rule set is adapted from the MetaHarness `threat-model` skill
10
+ * (ruvnet/agent-harness-generator) and mapped onto the Claude Code permission
11
+ * grammar. See `docs/research/metaharness-analysis.md` §1.
12
+ *
13
+ * Semantics (verified against the MetaHarness rules + Claude Code merge model):
14
+ * - settings.json and settings.local.json are evaluated as a MERGED surface
15
+ * (union of allow, union of deny); deny wins over allow.
16
+ * - findings are reported per CAPABILITY (one shell / network / write finding
17
+ * with a count + examples), not per individual grant.
18
+ * - secrets-reachability requires MCP to be active (per MetaHarness).
19
+ * - `low`-severity findings are informational and do NOT change the verdict.
20
+ *
21
+ * Verdict (highest non-low severity wins):
22
+ * - `high` (exit 2): shell granted · default-deny off · secrets reachable ·
23
+ * hardcoded secret in MCP env · all-MCP-servers enabled ·
24
+ * MCP server runs an interpreter / package-runner
25
+ * - `medium` (exit 1): network granted · file-write granted · remote MCP server
26
+ * - `clean` (exit 0): no high/medium findings (low/info may still be present)
27
+ *
28
+ * @packageDocumentation
29
+ */
30
+
31
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
32
+ import { basename, join } from 'node:path';
33
+
34
+ import {
35
+ SHELL_TOOLS,
36
+ INTERPRETER_RE, PACKAGE_RUNNERS, INLINE_CODE_ARGS,
37
+ SHELL_NET_RE, SHELL_WRITE_RE, SECRET_FILE_RE, MAX_FILE_BYTES,
38
+ parseGrant, isWildcard, toolKind,
39
+ type CapabilityClass,
40
+ } from './capability-vocab.js';
41
+
42
+ /** Re-exported for back-compat (was historically exported from this module). */
43
+ export { parseGrant } from './capability-vocab.js';
44
+
45
+ /** Severity of a single finding. `low` is informational (verdict-neutral). */
46
+ export type McpSeverity = 'high' | 'medium' | 'low';
47
+
48
+ /** The capability class a finding concerns. */
49
+ export type McpCapability = CapabilityClass;
50
+
51
+ /** A single static-scan finding. */
52
+ export interface McpFinding {
53
+ /** Stable rule id, e.g. `MS-SHELL-GRANT`. */
54
+ readonly id: string;
55
+ readonly severity: McpSeverity;
56
+ readonly capability: McpCapability;
57
+ /** Relative path / source the finding came from. */
58
+ readonly source: string;
59
+ /** Human-readable explanation. */
60
+ readonly detail: string;
61
+ /** The grant / value (or aggregated count + examples) that triggered the rule. */
62
+ readonly evidence: string;
63
+ }
64
+
65
+ /** The overall verdict. `clean` when there are zero high/medium findings. */
66
+ export type McpVerdict = 'clean' | 'medium' | 'high';
67
+
68
+ /** Result of {@link scanMcp}. */
69
+ export interface McpScanReport {
70
+ readonly verdict: McpVerdict;
71
+ /** Process exit code: clean=0, medium=1, high=2. */
72
+ readonly exitCode: 0 | 1 | 2;
73
+ readonly findings: readonly McpFinding[];
74
+ /** Relative paths actually read during the scan. */
75
+ readonly scanned: readonly string[];
76
+ /** Derived capability flags (for badges / `--json`). */
77
+ readonly capabilities: {
78
+ readonly shell: boolean;
79
+ readonly network: boolean;
80
+ readonly fileWrite: boolean;
81
+ readonly secretsReachable: boolean;
82
+ /** Scoped to the `.claude/settings*.json` surface. */
83
+ readonly defaultDeny: boolean;
84
+ };
85
+ }
86
+
87
+ /** Compile a Claude arg glob (`*` wildcard) to a RegExp. */
88
+ function globToRe(arg: string): RegExp {
89
+ const esc = arg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
90
+ return new RegExp(`^${esc}$`);
91
+ }
92
+
93
+ /** True when a deny rule covers a grant (deny wins). Case-insensitive on tool. */
94
+ function denyCovers(grant: string, denyList: string[]): boolean {
95
+ const g = parseGrant(grant);
96
+ return denyList.some((d) => {
97
+ const dd = parseGrant(d);
98
+ if (dd.tool === '*') return true;
99
+ if (dd.tool.toLowerCase() !== g.tool.toLowerCase()) return false;
100
+ if (dd.arg === null) return true; // bare-tool deny covers every arg
101
+ if (g.arg === null) return false;
102
+ try {
103
+ return globToRe(dd.arg).test(g.arg);
104
+ } catch {
105
+ return dd.arg === g.arg;
106
+ }
107
+ });
108
+ }
109
+
110
+ /** True when the deny list actually protects secret FILE locations. */
111
+ function denyProtectsSecrets(denyList: string[]): boolean {
112
+ return denyList.some((d) => {
113
+ const { tool, arg } = parseGrant(d);
114
+ const t = tool.toLowerCase();
115
+ if (t !== 'read' && t !== 'glob' && t !== '*' && t !== 'bash') return false;
116
+ return arg !== null && SECRET_FILE_RE.test(arg);
117
+ });
118
+ }
119
+
120
+ /** Heuristic: does an MCP env value look like a hardcoded secret (not a `${VAR}` ref)? */
121
+ function looksLikeHardcodedSecret(key: string, value: unknown): boolean {
122
+ if (typeof value !== 'string') return false;
123
+ if (!/key|token|secret|password|api|credential|auth/i.test(key)) return false;
124
+ const v = value.trim();
125
+ if (v === '') return false;
126
+ if (/^\$\{?\w+\}?$/.test(v)) return false; // pure ${VAR} / $VAR reference
127
+ if (/^(true|false|production|development|test|none|null|\d+)$/i.test(v)) return false;
128
+ return v.length >= 8;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // accumulator
133
+ // ---------------------------------------------------------------------------
134
+
135
+ interface Accumulator {
136
+ findings: McpFinding[];
137
+ scanned: string[];
138
+ shellGrants: string[];
139
+ networkGrants: string[];
140
+ writeGrants: string[];
141
+ shellArgNetwork: string[];
142
+ shellArgWrite: string[];
143
+ mcpToolGrants: string[];
144
+ unknownTools: string[];
145
+ denyRules: string[];
146
+ hasReadGrant: boolean;
147
+ hasSettings: boolean;
148
+ hasDeny: boolean;
149
+ wildcardAll: boolean;
150
+ mcpActive: boolean;
151
+ enableAllMcp: boolean;
152
+ enableAllMcpSource: string;
153
+ serverShell: boolean;
154
+ serverNetwork: boolean;
155
+ serverSecret: boolean;
156
+ }
157
+
158
+ function readJson(path: string): unknown | null {
159
+ try {
160
+ const st = lstatSync(path);
161
+ if (st.isSymbolicLink() || !st.isFile() || st.size > MAX_FILE_BYTES) return null;
162
+ return JSON.parse(readFileSync(path, 'utf-8'));
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ function asStringArray(value: unknown): string[] {
169
+ return Array.isArray(value) ? value.filter((x): x is string => typeof x === 'string') : [];
170
+ }
171
+
172
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
173
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // scanners
178
+ // ---------------------------------------------------------------------------
179
+
180
+ function scanSettings(root: string, rel: string, acc: Accumulator): void {
181
+ const abs = join(root, rel);
182
+ if (!existsSync(abs)) return;
183
+ const data = readJson(abs);
184
+ if (!isPlainObject(data)) return;
185
+ acc.scanned.push(rel);
186
+ acc.hasSettings = true;
187
+
188
+ const perms = isPlainObject(data['permissions']) ? data['permissions'] : {};
189
+ const allow = asStringArray(perms['allow']);
190
+ const deny = asStringArray(perms['deny']);
191
+ acc.denyRules.push(...deny);
192
+ if (deny.length > 0) acc.hasDeny = true;
193
+
194
+ for (const grant of allow) {
195
+ const { tool, arg } = parseGrant(grant);
196
+ if (grant.trim() === '*' || (SHELL_TOOLS.has(tool.toLowerCase()) && arg === null)) {
197
+ acc.wildcardAll = true;
198
+ }
199
+ const kind = toolKind(tool);
200
+ switch (kind) {
201
+ case 'shell':
202
+ acc.shellGrants.push(grant);
203
+ if (arg !== null && SHELL_NET_RE.test(arg)) acc.shellArgNetwork.push(grant);
204
+ if (arg !== null && SHELL_WRITE_RE.test(arg)) acc.shellArgWrite.push(grant);
205
+ break;
206
+ case 'network':
207
+ acc.networkGrants.push(grant);
208
+ break;
209
+ case 'file-write':
210
+ acc.writeGrants.push(grant);
211
+ break;
212
+ case 'read':
213
+ acc.hasReadGrant = true;
214
+ break;
215
+ case 'mcp':
216
+ acc.mcpToolGrants.push(grant);
217
+ acc.mcpActive = true;
218
+ break;
219
+ case 'safe':
220
+ break;
221
+ default:
222
+ acc.unknownTools.push(grant);
223
+ }
224
+ }
225
+
226
+ if (data['enableAllProjectMcpServers'] === true) {
227
+ acc.enableAllMcp = true;
228
+ acc.enableAllMcpSource = rel;
229
+ acc.mcpActive = true;
230
+ }
231
+ if (Array.isArray(data['enabledMcpjsonServers']) && data['enabledMcpjsonServers'].length > 0) {
232
+ acc.mcpActive = true;
233
+ }
234
+ }
235
+
236
+ function scanMcpServers(root: string, rel: string, acc: Accumulator): void {
237
+ const abs = join(root, rel);
238
+ if (!existsSync(abs)) return;
239
+ const data = readJson(abs);
240
+ if (!isPlainObject(data)) return;
241
+ acc.scanned.push(rel);
242
+
243
+ const merged: Record<string, unknown> = {};
244
+ if (isPlainObject(data['mcpServers'])) Object.assign(merged, data['mcpServers']);
245
+ if (isPlainObject(data['servers'])) Object.assign(merged, data['servers']);
246
+ if (Object.keys(merged).length > 0) acc.mcpActive = true;
247
+
248
+ for (const [name, raw] of Object.entries(merged)) {
249
+ if (!isPlainObject(raw)) continue;
250
+ const type = typeof raw['type'] === 'string' ? (raw['type'] as string) : '';
251
+ const url = typeof raw['url'] === 'string' ? (raw['url'] as string) : '';
252
+ const command = typeof raw['command'] === 'string' ? (raw['command'] as string) : '';
253
+ const args = asStringArray(raw['args']);
254
+ const env = isPlainObject(raw['env']) ? raw['env'] : {};
255
+
256
+ if (type === 'sse' || type === 'http' || url !== '') {
257
+ acc.serverNetwork = true;
258
+ acc.findings.push({
259
+ id: 'MS-MCP-REMOTE',
260
+ severity: 'medium',
261
+ capability: 'network',
262
+ source: rel,
263
+ detail: `MCP server "${name}" is remote (${type || 'url'}) — sends data to an external endpoint.`,
264
+ evidence: url || type,
265
+ });
266
+ }
267
+
268
+ const cmdBase = basename(command).toLowerCase();
269
+ const isInterpreter = INTERPRETER_RE.test(cmdBase);
270
+ const isRunner = PACKAGE_RUNNERS.has(cmdBase);
271
+ const hasInlineCode = args.some((a) => INLINE_CODE_ARGS.has(a));
272
+ if (command !== '' && (isInterpreter || isRunner || hasInlineCode)) {
273
+ acc.serverShell = true;
274
+ const why = isRunner
275
+ ? `package runner "${cmdBase}" fetches and executes remote code`
276
+ : hasInlineCode
277
+ ? `inline-code argument`
278
+ : `interpreter "${cmdBase}" can run arbitrary code`;
279
+ acc.findings.push({
280
+ id: 'MS-MCP-SHELL-CMD',
281
+ severity: 'high',
282
+ capability: 'shell',
283
+ source: rel,
284
+ detail: `MCP server "${name}" launches via ${why}.`,
285
+ evidence: [command, ...args].join(' ').slice(0, 120),
286
+ });
287
+ }
288
+
289
+ for (const [k, v] of Object.entries(env)) {
290
+ if (looksLikeHardcodedSecret(k, v)) {
291
+ acc.serverSecret = true;
292
+ acc.findings.push({
293
+ id: 'MS-SECRET-HARDCODED',
294
+ severity: 'high',
295
+ capability: 'secrets',
296
+ source: rel,
297
+ detail: `MCP server "${name}" env "${k}" appears to contain a hardcoded secret — use \${ENV_VAR} indirection instead.`,
298
+ evidence: `${k}=${String(v).slice(0, 4)}…`,
299
+ });
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // entry point
307
+ // ---------------------------------------------------------------------------
308
+
309
+ function aggregate(
310
+ grants: string[],
311
+ deny: string[],
312
+ id: string,
313
+ severity: McpSeverity,
314
+ capability: McpCapability,
315
+ label: string,
316
+ ): McpFinding | null {
317
+ const survivors = grants.filter((g) => !denyCovers(g, deny));
318
+ if (survivors.length === 0) return null;
319
+ const examples = survivors.slice(0, 3).join(', ');
320
+ return {
321
+ id,
322
+ severity,
323
+ capability,
324
+ source: '.claude/settings*.json',
325
+ detail: `${label} via ${survivors.length} allow rule(s) not covered by a deny rule.`,
326
+ evidence: survivors.length > 3 ? `e.g. ${examples}, … (+${survivors.length - 3} more)` : examples,
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Statically scan a project/pack root for an unsafe agent permission surface.
332
+ * Deterministic and read-only — never executes anything it finds.
333
+ */
334
+ export function scanMcp(rootDir: string): McpScanReport {
335
+ const acc: Accumulator = {
336
+ findings: [],
337
+ scanned: [],
338
+ shellGrants: [],
339
+ networkGrants: [],
340
+ writeGrants: [],
341
+ shellArgNetwork: [],
342
+ shellArgWrite: [],
343
+ mcpToolGrants: [],
344
+ unknownTools: [],
345
+ denyRules: [],
346
+ hasReadGrant: false,
347
+ hasSettings: false,
348
+ hasDeny: false,
349
+ wildcardAll: false,
350
+ mcpActive: false,
351
+ enableAllMcp: false,
352
+ enableAllMcpSource: '.claude/settings*.json',
353
+ serverShell: false,
354
+ serverNetwork: false,
355
+ serverSecret: false,
356
+ };
357
+
358
+ // order is irrelevant to the result: all signals are merged before evaluation.
359
+ scanSettings(rootDir, join('.claude', 'settings.json'), acc);
360
+ scanSettings(rootDir, join('.claude', 'settings.local.json'), acc);
361
+ scanMcpServers(rootDir, '.mcp.json', acc);
362
+ scanMcpServers(rootDir, join('.vscode', 'mcp.json'), acc);
363
+
364
+ const deny = acc.denyRules;
365
+
366
+ // --- aggregated capability findings (merged surface, deny-suppressed) ---
367
+ const shellSurvivors = acc.shellGrants.filter((g) => !denyCovers(g, deny));
368
+ if (shellSurvivors.length > 0) {
369
+ const anyWildcard = shellSurvivors.some((g) => isWildcard(parseGrant(g).arg));
370
+ const examples = shellSurvivors.slice(0, 3).join(', ');
371
+ acc.findings.push({
372
+ id: anyWildcard ? 'MS-SHELL-WILDCARD' : 'MS-SHELL-GRANT',
373
+ severity: 'high',
374
+ capability: 'shell',
375
+ source: '.claude/settings*.json',
376
+ detail: anyWildcard
377
+ ? `Wildcard shell grant — arbitrary command execution. ${shellSurvivors.length} shell allow rule(s).`
378
+ : `Shell execution granted via ${shellSurvivors.length} allow rule(s).`,
379
+ evidence: shellSurvivors.length > 3 ? `e.g. ${examples}, … (+${shellSurvivors.length - 3} more)` : examples,
380
+ });
381
+ }
382
+
383
+ const netFinding = aggregate(
384
+ [...acc.networkGrants, ...acc.shellArgNetwork],
385
+ deny,
386
+ 'MS-NETWORK-GRANT',
387
+ 'medium',
388
+ 'network',
389
+ 'Outbound network access granted',
390
+ );
391
+ if (netFinding) acc.findings.push(netFinding);
392
+
393
+ const writeFinding = aggregate(
394
+ [...acc.writeGrants, ...acc.shellArgWrite],
395
+ deny,
396
+ 'MS-FILEWRITE-GRANT',
397
+ 'medium',
398
+ 'file-write',
399
+ 'Filesystem write access granted',
400
+ );
401
+ if (writeFinding) acc.findings.push(writeFinding);
402
+
403
+ // --- enable-all-mcp ---
404
+ if (acc.enableAllMcp) {
405
+ acc.findings.push({
406
+ id: 'MS-MCP-ALL-ENABLED',
407
+ severity: 'high',
408
+ capability: 'mcp',
409
+ source: acc.enableAllMcpSource,
410
+ detail: `enableAllProjectMcpServers: true — every project MCP server is trusted unconditionally (no per-server gate).`,
411
+ evidence: 'enableAllProjectMcpServers: true',
412
+ });
413
+ }
414
+
415
+ // --- secrets reachability (MetaHarness: requires MCP active) ---
416
+ const secretsReachable = acc.mcpActive && acc.hasReadGrant && !denyProtectsSecrets(deny);
417
+ if (secretsReachable) {
418
+ acc.findings.push({
419
+ id: 'MS-SECRETS-REACHABLE',
420
+ severity: 'high',
421
+ capability: 'secrets',
422
+ source: '.claude/settings*.json',
423
+ detail: `MCP is active and a Read grant exists without a deny rule protecting secret files (.env, SSH keys, cloud creds) — secrets are reachable by tools.`,
424
+ evidence: 'allow contains Read(...) ∧ MCP active ∧ deny lacks a secret-file guard',
425
+ });
426
+ }
427
+
428
+ // --- default-deny posture (scoped to the settings surface) ---
429
+ const defaultDeny = acc.hasSettings ? acc.hasDeny && !acc.wildcardAll : true;
430
+ if (acc.hasSettings && !defaultDeny) {
431
+ acc.findings.push({
432
+ id: 'MS-DEFAULT-DENY-OFF',
433
+ severity: 'high',
434
+ capability: 'policy',
435
+ source: '.claude/settings*.json',
436
+ detail: acc.wildcardAll
437
+ ? `An allow rule grants a bare wildcard — everything is permitted (no default-deny).`
438
+ : `No deny rules declared — the permission surface has no guardrail (no default-deny baseline).`,
439
+ evidence: acc.wildcardAll ? 'allow contains "*" / "Bash(*)"' : 'permissions.deny is empty',
440
+ });
441
+ }
442
+
443
+ // --- informational (low) findings: verdict-neutral coverage signal ---
444
+ if (acc.mcpToolGrants.length > 0) {
445
+ acc.findings.push({
446
+ id: 'MS-MCP-TOOLS-ALLOWED',
447
+ severity: 'low',
448
+ capability: 'mcp',
449
+ source: '.claude/settings*.json',
450
+ detail: `${acc.mcpToolGrants.length} MCP tool(s) explicitly allowed (per-tool gate — the safe pattern).`,
451
+ evidence: acc.mcpToolGrants.slice(0, 3).join(', '),
452
+ });
453
+ }
454
+ if (acc.unknownTools.length > 0) {
455
+ acc.findings.push({
456
+ id: 'MS-UNKNOWN-TOOL',
457
+ severity: 'low',
458
+ capability: 'policy',
459
+ source: '.claude/settings*.json',
460
+ detail: `${acc.unknownTools.length} grant(s) for tools not in the recognised capability sets — coverage gap, review manually.`,
461
+ evidence: acc.unknownTools.slice(0, 3).join(', '),
462
+ });
463
+ }
464
+
465
+ // --- verdict (low is verdict-neutral) ---
466
+ const hasHigh = acc.findings.some((f) => f.severity === 'high');
467
+ const hasMedium = acc.findings.some((f) => f.severity === 'medium');
468
+ const verdict: McpVerdict = hasHigh ? 'high' : hasMedium ? 'medium' : 'clean';
469
+ const exitCode: 0 | 1 | 2 = verdict === 'high' ? 2 : verdict === 'medium' ? 1 : 0;
470
+
471
+ const order = { high: 0, medium: 1, low: 2 } as const;
472
+ const findings = [...acc.findings].sort(
473
+ (a, b) => order[a.severity] - order[b.severity] || a.id.localeCompare(b.id),
474
+ );
475
+
476
+ return {
477
+ verdict,
478
+ exitCode,
479
+ findings,
480
+ scanned: acc.scanned,
481
+ capabilities: {
482
+ shell: shellSurvivors.length > 0 || acc.serverShell,
483
+ network: Boolean(netFinding) || acc.serverNetwork,
484
+ fileWrite: Boolean(writeFinding),
485
+ secretsReachable: secretsReachable || acc.serverSecret,
486
+ defaultDeny,
487
+ },
488
+ };
489
+ }
package/src/operations.ts CHANGED
@@ -10,6 +10,10 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
 
12
12
  import { claudeAdapter } from '@dzhechkov/adapter-claude';
13
+ import { CODEX_SKILLS_ROOT } from '@dzhechkov/adapter-codex';
14
+ import { HERMES_SKILLS_ROOT } from '@dzhechkov/adapter-hermes';
15
+ import { OPENCODE_SKILLS_ROOT } from '@dzhechkov/adapter-opencode';
16
+ import { OPENCLAUDE_SKILLS_ROOT } from '@dzhechkov/adapter-openclaude';
13
17
  import type { CanonicalSkill, EmitResult, SkillAsset } from '@dzhechkov/core';
14
18
  import { computeRiskScore } from './risk-scoring.js';
15
19
 
@@ -63,12 +67,19 @@ export interface InitReport {
63
67
  // Platform enrichment — optional extras beyond SKILL.md
64
68
  // ---------------------------------------------------------------------------
65
69
 
70
+ /**
71
+ * Where each target's skill tree is rooted. These MUST agree with the
72
+ * `skillsRoot` each adapter actually emits to — so we import the adapters'
73
+ * exported `*_SKILLS_ROOT` constants rather than duplicating string literals.
74
+ * (`adapter-claude` does not yet re-export its constant from its package entry,
75
+ * so `.claude/skills` is mirrored here with a guard test in operations.test.ts.)
76
+ */
66
77
  const SKILLS_ROOTS: Record<TargetName, string> = {
67
78
  'claude-code': '.claude/skills',
68
- codex: '.agents/skills',
69
- opencode: '.opencode/skills',
70
- hermes: 'skills',
71
- openclaude: '.openclaude/skills',
79
+ codex: CODEX_SKILLS_ROOT,
80
+ opencode: OPENCODE_SKILLS_ROOT,
81
+ hermes: HERMES_SKILLS_ROOT,
82
+ openclaude: OPENCLAUDE_SKILLS_ROOT,
72
83
  };
73
84
 
74
85
  /**
@@ -157,9 +168,9 @@ export async function runInit(options: InitOptions): Promise<InitReport> {
157
168
  : selection.filter((id) => !discovered.includes(id));
158
169
  for (const id of ids) {
159
170
  const skill = loadSkillFromDir(options.skillsDir, id);
160
- const emit = await adapter.compile(skill, { targetRoot: options.projectRoot });
171
+ let emit = await adapter.compile(skill, { targetRoot: options.projectRoot });
161
172
  if (options.enrich === true) {
162
- enrichEmitForTarget(emit, options.target, id, skill);
173
+ emit = enrichEmitForTarget(emit, options.target, id, skill);
163
174
  }
164
175
  const applied = applyEmitResult(emit, {
165
176
  targetRoot: options.projectRoot,
package/src/plugin.ts CHANGED
@@ -23,6 +23,8 @@ export interface PluginManifest {
23
23
  readonly keywords: readonly string[];
24
24
  readonly skillPacks: readonly { name: string; skills: number; description: string }[];
25
25
  readonly totalSkills: number;
26
+ /** Explicit skill directory paths (relative to plugin source) for discovery. */
27
+ readonly skills: readonly string[];
26
28
  }
27
29
 
28
30
  /** Generate .claude-plugin/ directory from registry. */
@@ -34,11 +36,20 @@ export function generatePlugin(
34
36
  const pluginDir = join(projectRoot, '.claude-plugin');
35
37
  mkdirSync(pluginDir, { recursive: true });
36
38
 
37
- // Build skill pack info from registry
39
+ // Build skill pack info from registry. Track the skill ids per pack so we can
40
+ // emit explicit `skills` path arrays — Claude Code only discovers a plugin's
41
+ // skills when they live under a `skills/` dir OR are listed by path in the
42
+ // manifest. Our pack layout keeps SKILL.md dirs at the package root
43
+ // (`skills-<pack>/<id>/SKILL.md`), so without explicit paths every installed
44
+ // plugin reported "Skills (0)".
38
45
  const packMap = new Map<string, number>();
46
+ const packSkillIds = new Map<string, string[]>();
39
47
  for (const entry of registry.entries) {
40
48
  const count = packMap.get(entry.pack) ?? 0;
41
49
  packMap.set(entry.pack, count + 1);
50
+ const ids = packSkillIds.get(entry.pack) ?? [];
51
+ ids.push(entry.id);
52
+ packSkillIds.set(entry.pack, ids);
42
53
  }
43
54
 
44
55
  const packDescriptions: Record<string, string> = {
@@ -55,6 +66,13 @@ export function generatePlugin(
55
66
  description: packDescriptions[name] ?? `${count} skills`,
56
67
  }));
57
68
 
69
+ // Explicit skill paths for the full-suite plugin (source `./`): every skill
70
+ // dir across every pack, relative to the repo root. This is what makes
71
+ // `claude plugin details` report the real skill count instead of 0.
72
+ const allSkillPaths = [...packSkillIds.entries()]
73
+ .flatMap(([pack, ids]) => ids.map((id) => `packages/@dzhechkov/${pack}/${id}`))
74
+ .sort();
75
+
58
76
  const pluginJson = {
59
77
  name: 'dz-harness-hub',
60
78
  displayName: 'DZ Harness Hub',
@@ -65,6 +83,7 @@ export function generatePlugin(
65
83
  keywords: ['agent-skills', 'agentskills.io', 'cross-platform', 'claude-plugin', ...registry.categories],
66
84
  skillPacks,
67
85
  totalSkills: registry.totalSkills,
86
+ skills: allSkillPaths,
68
87
  };
69
88
 
70
89
  const marketplaceJson = {
@@ -87,6 +106,9 @@ export function generatePlugin(
87
106
  source: `packages/@dzhechkov/skills-${pack.name}`,
88
107
  description: `${pack.skills} ${pack.name} skills — ${pack.description}`,
89
108
  keywords: [pack.name],
109
+ // Skill dirs sit at the pack source root, so they are listed relative
110
+ // to `source` here (`./<id>`) — required for Claude Code to discover them.
111
+ skills: (packSkillIds.get(`skills-${pack.name}`) ?? []).slice().sort().map((id) => `./${id}`),
90
112
  })),
91
113
  ],
92
114
  };