@hegemonart/get-design-done 1.28.7 → 1.28.8

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,549 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/build-distribution-bundles.cjs — Phase 28.8 (Plan 28-8-X1).
4
+ *
5
+ * Shared-source / multi-channel distribution bundler.
6
+ *
7
+ * Per CONTEXT D-06: skills are shared source / per-channel converters at
8
+ * distribution-build time. This script fans out canonical `skills/` into
9
+ * three channel-specific bundles under `dist/`:
10
+ *
11
+ * - dist/cursor-marketplace/ (via scripts/lib/install/converters/cursor-marketplace.cjs)
12
+ * - dist/codex-plugin/ (via scripts/lib/install/converters/codex-plugin.cjs)
13
+ * - dist/agentskills-io/ (passthrough per D-13 lint-only)
14
+ *
15
+ * Tier-2 channels are discovered by inspecting `runtimes.cjs` entries with
16
+ * `kind: 'cursor-marketplace'` or `kind: 'codex-plugin'`. `agentskills-io`
17
+ * is hardcoded as a passthrough (it's a spec, not a runtime — D-02/D-13).
18
+ *
19
+ * Determinism: two consecutive runs produce byte-identical output.
20
+ * Tier-1 unaffected: only writes under `dist/`.
21
+ *
22
+ * --- ADAPTER NOTE (Plan 28-8-X1 implementation): the Wave-B converters
23
+ * (B1: cursor-marketplace.cjs, C1: codex-plugin.cjs) actually export
24
+ * `{ buildManifest, convert, CURATED_KEYWORDS }` — NOT the
25
+ * `{ convertSkill, buildManifest, MANIFEST_PATH }` shape that the plan's
26
+ * `<interfaces>` block hypothesized. Per the plan's "Adapter divergence
27
+ * handling" clause, this bundler adapts to the actual converter shape
28
+ * rather than modifying the converters (which are already shipped and
29
+ * out of scope per D-05).
30
+ *
31
+ * Actual converter contract used here:
32
+ * - converter.buildManifest({ packageJson, claudePlugin, claudePluginJson,
33
+ * marketplaceJson, readmeFirstPara })
34
+ * → returns a manifest OBJECT (not a string).
35
+ * (cursor-marketplace looks for `claudePluginJson`; codex-plugin
36
+ * looks for `claudePlugin`. We pass BOTH keys to be compatible
37
+ * with either accessor.)
38
+ * - converter.convert({ skillsDir, outDir, manifest })
39
+ * → writes manifest + copies skills/ tree under outDir. Owns its
40
+ * own manifest path (.cursor-plugin/plugin.json or .codex-plugin/
41
+ * plugin.json) — the bundler doesn't need to know.
42
+ *
43
+ * CLI:
44
+ * node scripts/build-distribution-bundles.cjs # all channels
45
+ * node scripts/build-distribution-bundles.cjs --channel cursor-marketplace
46
+ * node scripts/build-distribution-bundles.cjs --help
47
+ *
48
+ * Exit codes: 0 ok / 1 converter error / 2 missing dependency.
49
+ */
50
+
51
+ const fs = require('fs');
52
+ const path = require('path');
53
+
54
+ const EXIT_CODES = Object.freeze({
55
+ OK: 0,
56
+ CONVERTER_ERROR: 1,
57
+ MISSING_DEPENDENCY: 2,
58
+ });
59
+
60
+ // agentskills-io is hardcoded — it is a spec, not a runtime entry
61
+ // (per CONTEXT D-02 / D-13). No converter file, no manifest file.
62
+ const PASSTHROUGH_CHANNEL = Object.freeze({
63
+ id: 'agentskills-io',
64
+ kind: 'passthrough',
65
+ converterPath: null,
66
+ });
67
+
68
+ // Set of runtime `kind` values that the bundler dispatches to Wave-B
69
+ // converters. Hardcoded to two kinds — adding a third Tier-2 channel in
70
+ // a future phase requires (a) adding the runtime entry with a new kind,
71
+ // (b) shipping a converter at scripts/lib/install/converters/<kind>.cjs,
72
+ // (c) extending this set. The channel-ID discovery itself is data-driven.
73
+ const TIER2_KINDS = Object.freeze(new Set(['cursor-marketplace', 'codex-plugin']));
74
+
75
+ // ---------------------------------------------------------------
76
+ // Channel discovery
77
+ // ---------------------------------------------------------------
78
+
79
+ /**
80
+ * Discover Tier-2 channels from the runtimes registry + add the hardcoded
81
+ * passthrough channel. Returns Array<{id, kind, converterPath}>.
82
+ *
83
+ * `runtimesModule` is dependency-injected so tests can supply a fixture.
84
+ * Production callers pass `require('./lib/install/runtimes.cjs')`.
85
+ *
86
+ * Determinism: runtime list sorted lexicographically by id before iteration.
87
+ * The hardcoded PASSTHROUGH_CHANNEL is appended last; callers that want a
88
+ * fully lexicographic ordering should re-sort the returned array.
89
+ */
90
+ function discoverTier2Channels(runtimesModule) {
91
+ const channels = [];
92
+ const runtimes = (runtimesModule && typeof runtimesModule.listRuntimes === 'function')
93
+ ? runtimesModule.listRuntimes()
94
+ : [];
95
+ const sorted = runtimes.slice().sort((a, b) => a.id.localeCompare(b.id));
96
+ for (const rt of sorted) {
97
+ if (!TIER2_KINDS.has(rt.kind)) continue;
98
+ channels.push({
99
+ id: rt.id,
100
+ kind: rt.kind,
101
+ // Converter file lives at scripts/lib/install/converters/<kind>.cjs.
102
+ // T-28.8-X1-01 (Tampering / require()): `kind` originates in the
103
+ // version-controlled runtimes.cjs file — an attacker would already
104
+ // need write access to introduce a malicious value. Acceptable.
105
+ converterPath: path.join(
106
+ __dirname,
107
+ 'lib', 'install', 'converters',
108
+ rt.kind + '.cjs',
109
+ ),
110
+ });
111
+ }
112
+ channels.push(PASSTHROUGH_CHANNEL);
113
+ return channels;
114
+ }
115
+
116
+ // ---------------------------------------------------------------
117
+ // Skill enumeration (canonical source)
118
+ // ---------------------------------------------------------------
119
+
120
+ /**
121
+ * Enumerate child directories of `<sourceRoot>/skills/` that contain a
122
+ * `SKILL.md`. Returns { skillsRoot, skillNames } where skillNames is
123
+ * sorted lexicographically (determinism).
124
+ *
125
+ * Throws Error with code MISSING_SKILLS_ROOT if skills/ is absent.
126
+ */
127
+ function enumerateSkills(sourceRoot) {
128
+ const skillsRoot = path.join(sourceRoot, 'skills');
129
+ if (!fs.existsSync(skillsRoot)) {
130
+ const err = new Error('Canonical skills/ tree not found at ' + skillsRoot);
131
+ err.code = 'MISSING_SKILLS_ROOT';
132
+ throw err;
133
+ }
134
+ const names = fs.readdirSync(skillsRoot)
135
+ .filter((name) => {
136
+ const skillDir = path.join(skillsRoot, name);
137
+ try {
138
+ return fs.statSync(skillDir).isDirectory()
139
+ && fs.existsSync(path.join(skillDir, 'SKILL.md'));
140
+ } catch {
141
+ return false;
142
+ }
143
+ })
144
+ .sort();
145
+ return { skillsRoot, skillNames: names };
146
+ }
147
+
148
+ // ---------------------------------------------------------------
149
+ // Filesystem helpers
150
+ // ---------------------------------------------------------------
151
+
152
+ function ensureCleanDir(dirPath) {
153
+ if (fs.existsSync(dirPath)) {
154
+ fs.rmSync(dirPath, { recursive: true, force: true });
155
+ }
156
+ fs.mkdirSync(dirPath, { recursive: true });
157
+ }
158
+
159
+ /**
160
+ * Deterministic file write: 0o644, no timestamp metadata leaked into content.
161
+ */
162
+ function writeFile(dest, content) {
163
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
164
+ fs.writeFileSync(dest, content, { mode: 0o644 });
165
+ }
166
+
167
+ /**
168
+ * Recursive byte-for-byte copy of `srcDir` into `destDir`. Used by the
169
+ * passthrough channel. Deterministic: lexicographic readdir + 0o644.
170
+ *
171
+ * T-28.8-X1-03 (Tampering / symlinks): only entry.isFile() and
172
+ * entry.isDirectory() are propagated. Symlinks and other types are
173
+ * silently skipped — the canonical skills/ tree is expected to be
174
+ * regular files only.
175
+ */
176
+ function copyDirRecursive(srcDir, destDir) {
177
+ fs.mkdirSync(destDir, { recursive: true });
178
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true })
179
+ .slice()
180
+ .sort((a, b) => a.name.localeCompare(b.name));
181
+ for (const entry of entries) {
182
+ const srcPath = path.join(srcDir, entry.name);
183
+ const destPath = path.join(destDir, entry.name);
184
+ if (entry.isDirectory()) {
185
+ copyDirRecursive(srcPath, destPath);
186
+ } else if (entry.isFile()) {
187
+ const content = fs.readFileSync(srcPath);
188
+ writeFile(destPath, content);
189
+ }
190
+ // Symlinks / other entry types: skip.
191
+ }
192
+ }
193
+
194
+ function countFiles(dir) {
195
+ let count = 0;
196
+ if (!fs.existsSync(dir)) return 0;
197
+ const stack = [dir];
198
+ while (stack.length) {
199
+ const d = stack.pop();
200
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
201
+ const p = path.join(d, entry.name);
202
+ if (entry.isDirectory()) stack.push(p);
203
+ else if (entry.isFile()) count++;
204
+ }
205
+ }
206
+ return count;
207
+ }
208
+
209
+ // ---------------------------------------------------------------
210
+ // Optional ancillary sources (loaded best-effort from repo root)
211
+ // ---------------------------------------------------------------
212
+
213
+ /**
214
+ * Best-effort loader for ancillary inputs the converters may consult:
215
+ * - .claude-plugin/plugin.json → claudePlugin / claudePluginJson
216
+ * - .claude-plugin/marketplace.json → marketplaceJson
217
+ * - README.md (first paragraph) → readmeFirstPara
218
+ *
219
+ * Returns an object with each key present only if the corresponding source
220
+ * exists and parses cleanly. Never throws — converters are tolerant of
221
+ * absent optional sources, and tmpdir test fixtures typically omit them.
222
+ */
223
+ function loadAncillarySources(sourceRoot) {
224
+ const sources = {};
225
+ const pluginJsonPath = path.join(sourceRoot, '.claude-plugin', 'plugin.json');
226
+ if (fs.existsSync(pluginJsonPath)) {
227
+ try {
228
+ const parsed = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8'));
229
+ sources.claudePlugin = parsed;
230
+ sources.claudePluginJson = parsed;
231
+ } catch {
232
+ // Best-effort: malformed plugin.json is the converter's problem,
233
+ // not the bundler's. Continue without it.
234
+ }
235
+ }
236
+ const marketplaceJsonPath = path.join(sourceRoot, '.claude-plugin', 'marketplace.json');
237
+ if (fs.existsSync(marketplaceJsonPath)) {
238
+ try {
239
+ sources.marketplaceJson = JSON.parse(fs.readFileSync(marketplaceJsonPath, 'utf8'));
240
+ } catch {
241
+ // skip
242
+ }
243
+ }
244
+ const readmePath = path.join(sourceRoot, 'README.md');
245
+ if (fs.existsSync(readmePath)) {
246
+ try {
247
+ const raw = fs.readFileSync(readmePath, 'utf8');
248
+ // First non-empty, non-heading paragraph.
249
+ const paragraphs = raw.split(/\n\s*\n/);
250
+ for (const p of paragraphs) {
251
+ const trimmed = p.trim();
252
+ if (!trimmed || trimmed.startsWith('#')) continue;
253
+ sources.readmeFirstPara = trimmed.replace(/\s+/g, ' ');
254
+ break;
255
+ }
256
+ } catch {
257
+ // skip
258
+ }
259
+ }
260
+ return sources;
261
+ }
262
+
263
+ // ---------------------------------------------------------------
264
+ // Channel build dispatch
265
+ // ---------------------------------------------------------------
266
+
267
+ /**
268
+ * Build a single channel into `outRoot/<channelId>/`.
269
+ * Returns { channel, fileCount }.
270
+ *
271
+ * Throws on converter error or missing dependency — caller maps to exit code.
272
+ *
273
+ * Errors set `err.code` to one of:
274
+ * - 'MISSING_CONVERTER' → exit 2
275
+ * - 'CONVERTER_LOAD_FAILED' → exit 2 (require() failed — broken module)
276
+ * - 'CONVERTER_EXEC_FAILED' → exit 1 (converter ran and threw)
277
+ * - 'MANIFEST_BUILD_FAILED' → exit 1 (buildManifest threw)
278
+ * - 'MISSING_SKILLS_ROOT' → exit 2 (no skills/ dir to read)
279
+ */
280
+ function buildChannel(channel, opts) {
281
+ const { sourceRoot, outRoot, packageJson } = opts || {};
282
+ if (!channel || typeof channel !== 'object') {
283
+ throw new Error('buildChannel: channel is required');
284
+ }
285
+ if (typeof sourceRoot !== 'string' || sourceRoot.length === 0) {
286
+ throw new Error('buildChannel: opts.sourceRoot is required');
287
+ }
288
+ if (typeof outRoot !== 'string' || outRoot.length === 0) {
289
+ throw new Error('buildChannel: opts.outRoot is required');
290
+ }
291
+
292
+ const bundleRoot = path.join(outRoot, channel.id);
293
+ ensureCleanDir(bundleRoot);
294
+
295
+ if (channel.kind === 'passthrough') {
296
+ // agentskills-io: passthrough copy of skills/ (D-13).
297
+ const skillsSrc = path.join(sourceRoot, 'skills');
298
+ if (!fs.existsSync(skillsSrc)) {
299
+ const err = new Error('Canonical skills/ tree not found at ' + skillsSrc);
300
+ err.code = 'MISSING_SKILLS_ROOT';
301
+ err.channelId = channel.id;
302
+ throw err;
303
+ }
304
+ copyDirRecursive(skillsSrc, path.join(bundleRoot, 'skills'));
305
+ return { channel: channel.id, fileCount: countFiles(bundleRoot) };
306
+ }
307
+
308
+ // Tier-2 converter-backed channels (cursor-marketplace, codex-plugin).
309
+ if (!fs.existsSync(channel.converterPath)) {
310
+ const err = new Error(
311
+ 'Missing converter for channel "' + channel.id + '": expected at ' + channel.converterPath
312
+ );
313
+ err.code = 'MISSING_CONVERTER';
314
+ err.channelId = channel.id;
315
+ throw err;
316
+ }
317
+
318
+ let converter;
319
+ try {
320
+ converter = require(channel.converterPath);
321
+ } catch (e) {
322
+ const err = new Error(
323
+ 'Failed to load converter for channel "' + channel.id + '": ' + e.message
324
+ );
325
+ err.code = 'CONVERTER_LOAD_FAILED';
326
+ err.channelId = channel.id;
327
+ err.cause = e;
328
+ throw err;
329
+ }
330
+
331
+ if (typeof converter.buildManifest !== 'function' || typeof converter.convert !== 'function') {
332
+ const err = new Error(
333
+ 'Converter for channel "' + channel.id + '" missing required exports: ' +
334
+ 'expected { buildManifest, convert }, got ' + Object.keys(converter).join(', ')
335
+ );
336
+ err.code = 'CONVERTER_LOAD_FAILED';
337
+ err.channelId = channel.id;
338
+ throw err;
339
+ }
340
+
341
+ // Enumerate skills BEFORE invoking convert(). This both validates that
342
+ // skills/ exists and surfaces a deterministic name list for error
343
+ // messages — convert() will re-walk the directory itself per its own
344
+ // semantics, which is fine (idempotent) but we want a stable list for
345
+ // the CONVERTER_EXEC_FAILED error message.
346
+ const { skillsRoot, skillNames } = enumerateSkills(sourceRoot);
347
+
348
+ // Build manifest via the converter.
349
+ // Pass BOTH `claudePlugin` and `claudePluginJson` accessor keys so the
350
+ // adapter works regardless of which key the specific converter consults.
351
+ const ancillary = loadAncillarySources(sourceRoot);
352
+ const sources = Object.assign({}, ancillary, { packageJson });
353
+
354
+ let manifest;
355
+ try {
356
+ manifest = converter.buildManifest(sources);
357
+ } catch (e) {
358
+ const err = new Error(
359
+ 'Converter "' + channel.id + '" failed building manifest: ' + e.message
360
+ );
361
+ err.code = 'MANIFEST_BUILD_FAILED';
362
+ err.channelId = channel.id;
363
+ err.cause = e;
364
+ throw err;
365
+ }
366
+
367
+ // Invoke convert() — converter writes manifest + copies skills/ under outDir.
368
+ try {
369
+ converter.convert({
370
+ skillsDir: skillsRoot,
371
+ outDir: bundleRoot,
372
+ manifest,
373
+ });
374
+ } catch (e) {
375
+ // The converter walked skills/ internally so we don't know which
376
+ // individual skill triggered the throw — surface the full list to
377
+ // aid debugging.
378
+ const skillsHint = skillNames.length > 0
379
+ ? ' (skills: ' + skillNames.join(', ') + ')'
380
+ : '';
381
+ const err = new Error(
382
+ 'Converter "' + channel.id + '" failed during convert()' + skillsHint + ': ' + e.message
383
+ );
384
+ err.code = 'CONVERTER_EXEC_FAILED';
385
+ err.channelId = channel.id;
386
+ err.skillName = skillNames[0] || null;
387
+ err.cause = e;
388
+ throw err;
389
+ }
390
+
391
+ return { channel: channel.id, fileCount: countFiles(bundleRoot) };
392
+ }
393
+
394
+ /**
395
+ * Build all (or one filtered) channel(s) into `outRoot`.
396
+ *
397
+ * Options:
398
+ * sourceRoot — repo root containing skills/ + ancillary sources
399
+ * outRoot — destination root (e.g., repo/dist)
400
+ * runtimesModule — dependency-injected runtimes registry (test seam)
401
+ * packageJson — parsed package.json object passed to converters
402
+ * channelFilter — optional channel id to scope the build to one channel
403
+ *
404
+ * Returns Array<{ channel, fileCount }> in lexicographic channel order.
405
+ */
406
+ function buildAllChannels(opts) {
407
+ const { sourceRoot, outRoot, runtimesModule, packageJson, channelFilter } = opts || {};
408
+ const channels = discoverTier2Channels(runtimesModule);
409
+ const targets = channelFilter
410
+ ? channels.filter((c) => c.id === channelFilter)
411
+ : channels;
412
+ if (channelFilter && targets.length === 0) {
413
+ const err = new Error(
414
+ 'Unknown channel: "' + channelFilter + '". Available: ' +
415
+ channels.map((c) => c.id).join(', ')
416
+ );
417
+ err.code = 'UNKNOWN_CHANNEL';
418
+ throw err;
419
+ }
420
+ // Lexicographic order for deterministic stdout + filesystem traversal.
421
+ targets.sort((a, b) => a.id.localeCompare(b.id));
422
+ const results = [];
423
+ for (const channel of targets) {
424
+ results.push(buildChannel(channel, { sourceRoot, outRoot, packageJson }));
425
+ }
426
+ return results;
427
+ }
428
+
429
+ // ---------------------------------------------------------------
430
+ // CLI entrypoint
431
+ // ---------------------------------------------------------------
432
+
433
+ function parseArgs(argv) {
434
+ const args = { help: false, channel: null };
435
+ for (let i = 0; i < argv.length; i++) {
436
+ const a = argv[i];
437
+ if (a === '--help' || a === '-h') {
438
+ args.help = true;
439
+ } else if (a === '--channel') {
440
+ if (i + 1 >= argv.length) {
441
+ throw new Error('--channel requires a value');
442
+ }
443
+ args.channel = argv[++i];
444
+ } else {
445
+ throw new Error('Unknown argument: ' + a);
446
+ }
447
+ }
448
+ return args;
449
+ }
450
+
451
+ function printUsage(out) {
452
+ out.write([
453
+ 'Usage: node scripts/build-distribution-bundles.cjs [--channel <id>]',
454
+ '',
455
+ 'Builds Tier-2 distribution bundles from canonical skills/ into dist/.',
456
+ '',
457
+ 'Options:',
458
+ ' --channel <id> Build only the named channel (e.g., cursor-marketplace,',
459
+ ' codex-plugin, agentskills-io). Default: all channels.',
460
+ ' --help, -h Print this message.',
461
+ '',
462
+ 'Exit codes:',
463
+ ' 0 success',
464
+ ' 1 converter error (converter ran and threw)',
465
+ ' 2 missing dependency (converter file, runtimes.cjs entry, skills/, or bad arg)',
466
+ '',
467
+ ].join('\n'));
468
+ }
469
+
470
+ function main(argv, ioOpts) {
471
+ const stdout = (ioOpts && ioOpts.stdout) || process.stdout;
472
+ const stderr = (ioOpts && ioOpts.stderr) || process.stderr;
473
+
474
+ let args;
475
+ try {
476
+ args = parseArgs(argv || []);
477
+ } catch (e) {
478
+ stderr.write('Error: ' + e.message + '\n');
479
+ printUsage(stderr);
480
+ return EXIT_CODES.MISSING_DEPENDENCY;
481
+ }
482
+ if (args.help) {
483
+ printUsage(stdout);
484
+ return EXIT_CODES.OK;
485
+ }
486
+
487
+ const repoRoot = path.resolve(__dirname, '..');
488
+ const sourceRoot = repoRoot;
489
+ const outRoot = path.join(repoRoot, 'dist');
490
+
491
+ let runtimesModule;
492
+ try {
493
+ runtimesModule = require('./lib/install/runtimes.cjs');
494
+ } catch (e) {
495
+ stderr.write('Error: failed to load runtimes.cjs: ' + e.message + '\n');
496
+ return EXIT_CODES.MISSING_DEPENDENCY;
497
+ }
498
+
499
+ let packageJson;
500
+ try {
501
+ packageJson = require(path.join(repoRoot, 'package.json'));
502
+ } catch (e) {
503
+ stderr.write('Error: failed to load package.json: ' + e.message + '\n');
504
+ return EXIT_CODES.MISSING_DEPENDENCY;
505
+ }
506
+
507
+ try {
508
+ const results = buildAllChannels({
509
+ sourceRoot,
510
+ outRoot,
511
+ runtimesModule,
512
+ packageJson,
513
+ channelFilter: args.channel,
514
+ });
515
+ for (const r of results) {
516
+ stdout.write('[bundles] ' + r.channel + ': ' + r.fileCount + ' file(s)\n');
517
+ }
518
+ return EXIT_CODES.OK;
519
+ } catch (e) {
520
+ stderr.write('Error: ' + e.message + '\n');
521
+ if (
522
+ e.code === 'MISSING_CONVERTER' ||
523
+ e.code === 'MISSING_SKILLS_ROOT' ||
524
+ e.code === 'UNKNOWN_CHANNEL' ||
525
+ e.code === 'CONVERTER_LOAD_FAILED'
526
+ ) {
527
+ return EXIT_CODES.MISSING_DEPENDENCY;
528
+ }
529
+ // CONVERTER_EXEC_FAILED, MANIFEST_BUILD_FAILED, or anything else.
530
+ return EXIT_CODES.CONVERTER_ERROR;
531
+ }
532
+ }
533
+
534
+ module.exports = {
535
+ buildAllChannels,
536
+ buildChannel,
537
+ discoverTier2Channels,
538
+ enumerateSkills,
539
+ loadAncillarySources,
540
+ main,
541
+ parseArgs,
542
+ EXIT_CODES,
543
+ PASSTHROUGH_CHANNEL,
544
+ TIER2_KINDS,
545
+ };
546
+
547
+ if (require.main === module) {
548
+ process.exitCode = main(process.argv.slice(2));
549
+ }
@@ -15,6 +15,18 @@
15
15
  //
16
16
  // Modifiers: --global (default) | --local; --uninstall; --dry-run;
17
17
  // --config-dir <path>; --help / -h.
18
+ //
19
+ // Read-only modes: --doctor (Phase 28.8 — Tier-2 distribution-channel
20
+ // status; no install side effects). Future Tier-2 channels (Codex
21
+ // plugin via Plan 28-8-C2; aggregated Tier-2 status via Plan 28-8-X2)
22
+ // plug additional sections into the same flag dispatch — see
23
+ // `runDoctor()` below for the section-module pattern.
24
+ //
25
+ // Read-only modes: --doctor (Phase 28.8 — Tier-2 distribution-channel
26
+ // status; no install side effects). Future Tier-2 channels (Codex
27
+ // plugin via Plan 28-8-C2; aggregated Tier-2 status via Plan 28-8-X2)
28
+ // plug additional sections into the same flag dispatch — see
29
+ // `runDoctor()` below for the section-module pattern.
18
30
 
19
31
  const path = require('node:path');
20
32
 
@@ -64,6 +76,7 @@ function helpText() {
64
76
  ' --no-peer-prompt Suppress the post-install peer-CLI detection nudge',
65
77
  ' --register-mcp Register gdd-mcp with detected harnesses (Claude Code, Codex). Opt-in.',
66
78
  ' --no-register-mcp Skip MCP registration (default behavior; included for symmetry).',
79
+ ' --doctor Print Tier-2 distribution-channel status (read-only; no install)',
67
80
  ' --help, -h Show this message',
68
81
  '',
69
82
  'Environment overrides (per-runtime):',
@@ -125,6 +138,47 @@ function summariseResults(results) {
125
138
  return lines.join('\n');
126
139
  }
127
140
 
141
+ // Phase 28.8 — Tier-2 distribution-channel doctor.
142
+ //
143
+ // Read-only status reporter for distribution channels (Cursor Marketplace,
144
+ // Codex Plugins, agentskills.io lint pass). Phase 28.8-X2 D-13/D-16: the
145
+ // three channels are aggregated via `scripts/lib/install/doctor-tier2.cjs`
146
+ // which composes B2's `reportCursorMarketplace`, C2's `checkCodexPlugin`,
147
+ // and A1's `lintSummary` into a single "## Tier-2 Distribution Channels"
148
+ // section with a one-line summary + per-channel subsections.
149
+ //
150
+ // Phase 28.8-X2: B2's and C2's individual doctor sections used to be
151
+ // rendered as standalone blocks here. With X2 the aggregator now owns
152
+ // the entire Tier-2 section — the B2/C2 modules remain callable internals
153
+ // (the aggregator consumes their pure `report*()` functions), but the
154
+ // individual formatters are no longer invoked directly from install.cjs.
155
+ // Rationale: a single section with a unified summary line is what the
156
+ // maintainer wants (Plan 28-8-X2 §<objective>). The aggregator handles
157
+ // throw safety internally (Cursor's malformed-state-file throw becomes
158
+ // a `not-configured` subsection rather than killing the doctor).
159
+ function runDoctor() {
160
+ const projectRoot = process.cwd();
161
+ try {
162
+ const {
163
+ readTier2Status,
164
+ formatTier2Section,
165
+ } = require('./lib/install/doctor-tier2.cjs');
166
+ const status = readTier2Status({ sourceRoot: projectRoot });
167
+ process.stdout.write(formatTier2Section(status) + '\n');
168
+ } catch (err) {
169
+ // The aggregator is throw-resistant (channel errors surface as
170
+ // `not-configured` with detail). A top-level throw here implies the
171
+ // aggregator module itself failed to load — surface inline so the
172
+ // maintainer sees the breakage without losing the whole CLI exit.
173
+ process.stdout.write(
174
+ '## Tier-2 Distribution Channels\n\n'
175
+ + ' ERROR: '
176
+ + (err && err.message ? err.message : String(err))
177
+ + '\n'
178
+ );
179
+ }
180
+ }
181
+
128
182
  async function main() {
129
183
  const { flags, configDir } = parseArgs(process.argv);
130
184
 
@@ -133,6 +187,13 @@ async function main() {
133
187
  process.exit(0);
134
188
  }
135
189
 
190
+ // Phase 28.8 D-16 + B2 — read-only Tier-2 doctor. Early dispatch BEFORE
191
+ // any runtime selection so doctor never performs install side effects.
192
+ if (flags.has('--doctor')) {
193
+ runDoctor();
194
+ process.exit(0);
195
+ }
196
+
136
197
  const dryRun = flags.has('--dry-run');
137
198
  const uninstall = flags.has('--uninstall');
138
199
  const local = flags.has('--local');
@@ -22,6 +22,23 @@ function homeDir() {
22
22
 
23
23
  function resolveConfigDir(runtimeId, opts) {
24
24
  const runtime = getRuntime(runtimeId);
25
+
26
+ // Phase 28.8 (Plan B1) — Tier-2 distribution-channel runtimes have
27
+ // configDir === null and configDirFallback === null. They are NOT
28
+ // per-user install targets; calling resolveConfigDir on them is a
29
+ // programming error (the regular install flow skips them via
30
+ // detect-runtimes). Throw a clear error rather than crashing on
31
+ // `null.split('/')` further down.
32
+ if (
33
+ runtime.configDirFallback === null
34
+ || typeof runtime.configDirFallback !== 'string'
35
+ ) {
36
+ throw new Error(
37
+ `Runtime "${runtimeId}" is a Tier-2 distribution channel (kind: ${runtime.kind}); ` +
38
+ 'it has no per-user config dir. Filter these out before calling resolveConfigDir.'
39
+ );
40
+ }
41
+
25
42
  const overrides = (opts && opts.env) || process.env;
26
43
  const explicit = opts && opts.configDir;
27
44
 
@@ -44,6 +61,15 @@ function resolveConfigDir(runtimeId, opts) {
44
61
  function resolveAllConfigDirs(opts) {
45
62
  const out = {};
46
63
  for (const runtime of listRuntimes()) {
64
+ // Phase 28.8 (Plan B1) — Tier-2 distribution channels have no per-user
65
+ // config dir. Skip them so the returned map covers only the per-user
66
+ // install targets (the 14 multi-artifact + claude-marketplace runtimes).
67
+ if (
68
+ runtime.configDirFallback === null
69
+ || typeof runtime.configDirFallback !== 'string'
70
+ ) {
71
+ continue;
72
+ }
47
73
  out[runtime.id] = resolveConfigDir(runtime.id, opts);
48
74
  }
49
75
  return out;