@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,407 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scripts/lib/install/converters/codex-plugin.cjs — Phase 28.8 (Plan 28-8-C1).
5
+ *
6
+ * Codex Plugin distribution-channel converter. Emits a Codex-plugin-shape
7
+ * bundle (`.codex-plugin/plugin.json` + verbatim-copied `skills/` tree)
8
+ * from our `skills/` canonical source. Consumed by Plan 28-8-X1's
9
+ * scripts/build-distribution-bundles.cjs (downstream — this module is
10
+ * the contract, X1 wires the build pipeline).
11
+ *
12
+ * Per CONTEXT D-05 (additive): this is a NEW kind, alongside the
13
+ * existing scripts/lib/install/converters/codex.cjs (Phase 28.7
14
+ * file-drop AGENTS.md surface). codex.cjs is UNCHANGED. Tier-1 and
15
+ * Tier-2 surfaces coexist as documented in
16
+ * .planning/research/codex-plugins-2026-05-19.md § vs AGENTS.md.
17
+ *
18
+ * Per CONTEXT D-06 (skills are shared source): skill content is copied
19
+ * verbatim during bundle emission — Codex consumes the same SKILL.md
20
+ * shape we already produce for Phase 28.5 authoring contract. No
21
+ * per-skill content rewriting in this converter.
22
+ *
23
+ * Per CONTEXT D-14 (no new catalog): we do NOT emit a Codex-specific
24
+ * marketplace.json — Codex's legacy-compat path consumes our existing
25
+ * .claude-plugin/marketplace.json directly.
26
+ *
27
+ * GDD-original pattern (no gsd-build/get-shit-done counterpart): Tier-2
28
+ * distribution channels do not exist in the upstream multi-runtime install
29
+ * reference. Mirrors the cursor-marketplace.cjs sibling (Plan 28-8-B1).
30
+ *
31
+ * Pure / side-effect-free for `buildManifest`. `convert` performs
32
+ * filesystem writes (it's a bundle emitter) and is the impure boundary.
33
+ * All test invocations use tmpdir per CONTEXT D-10.
34
+ *
35
+ * Exports:
36
+ * - `buildManifest(sources)` — pure function, returns the Codex manifest
37
+ * object ready to `JSON.stringify(obj, null, 2)`.
38
+ * - `convert({ skillsDir, outDir, manifest })` — file-emission function
39
+ * for `build-distribution-bundles.cjs`. The only side-effect surface;
40
+ * touches only paths under `outDir`.
41
+ * - `MANIFEST_REQUIRED_FIELDS` — frozen 3-tuple of required spec fields.
42
+ * - `CURATED_KEYWORDS` — frozen 10-tag default keyword subset.
43
+ */
44
+
45
+ const fs = require('node:fs');
46
+ const path = require('node:path');
47
+
48
+ // Per research § Top-level fields: name, version, description are the only
49
+ // strictly-required spec fields. All other manifest fields are optional.
50
+ const MANIFEST_REQUIRED_FIELDS = Object.freeze(['name', 'version', 'description']);
51
+
52
+ // Curated keyword subset for Codex marketplace card display.
53
+ // Per research § Schema Mapping `keywords` row: keep to ~10 design-relevant
54
+ // terms (our package.json carries 50+ tags). The intersection of these tags
55
+ // with package.json#keywords drives `curateKeywords()` below.
56
+ const CURATED_KEYWORDS = Object.freeze([
57
+ 'design',
58
+ 'ui',
59
+ 'ux',
60
+ 'frontend',
61
+ 'pipeline',
62
+ 'design-system',
63
+ 'accessibility',
64
+ 'figma',
65
+ 'wcag',
66
+ 'agent-sdk',
67
+ ]);
68
+
69
+ // ── Private helpers ────────────────────────────────────────────────────
70
+
71
+ function stripNpmScope(name) {
72
+ if (typeof name !== 'string') return name;
73
+ return name.replace(/^@[^/]+\//, '');
74
+ }
75
+
76
+ function stripGitSuffix(url) {
77
+ if (typeof url !== 'string') return url;
78
+ return url.replace(/\.git$/, '');
79
+ }
80
+
81
+ function truncate(str, n) {
82
+ if (typeof str !== 'string') return str;
83
+ if (str.length <= n) return str;
84
+ // Prefer ending at sentence boundary, then word boundary, then hard cut.
85
+ const head = str.slice(0, n);
86
+ const sentenceEnd = head.lastIndexOf('. ');
87
+ if (sentenceEnd > n * 0.6) return head.slice(0, sentenceEnd + 1);
88
+ const wordEnd = head.lastIndexOf(' ');
89
+ if (wordEnd > n * 0.6) return head.slice(0, wordEnd);
90
+ return head;
91
+ }
92
+
93
+ function capitalize(str) {
94
+ if (typeof str !== 'string' || str.length === 0) return str;
95
+ return str.charAt(0).toUpperCase() + str.slice(1);
96
+ }
97
+
98
+ /**
99
+ * Curate keywords to the ≤10-element CURATED_KEYWORDS intersection with
100
+ * the source array. If the whitelist isn't a subset of source, fall back
101
+ * to `source.slice(0, 10)`. Always returns a fresh array (callers may
102
+ * mutate without polluting the frozen module constant).
103
+ */
104
+ function curateKeywords(arr) {
105
+ if (!Array.isArray(arr)) return CURATED_KEYWORDS.slice();
106
+ const sourceSet = new Set(arr);
107
+ const intersected = CURATED_KEYWORDS.filter((k) => sourceSet.has(k));
108
+ if (intersected.length > 0) return intersected;
109
+ return arr.slice(0, 10);
110
+ }
111
+
112
+ /**
113
+ * Copy a directory tree recursively. Vanilla fs only — no deps. Mirrors
114
+ * the helper used by cursor-marketplace.cjs (Plan 28-8-B1).
115
+ */
116
+ function copyDirRecursive(src, dest) {
117
+ fs.mkdirSync(dest, { recursive: true });
118
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
119
+ const srcPath = path.join(src, entry.name);
120
+ const destPath = path.join(dest, entry.name);
121
+ if (entry.isDirectory()) {
122
+ copyDirRecursive(srcPath, destPath);
123
+ } else if (entry.isFile()) {
124
+ fs.copyFileSync(srcPath, destPath);
125
+ }
126
+ // symlinks + other: ignored (skills tree is regular files only).
127
+ }
128
+ }
129
+
130
+ // ── Public exports ─────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Build the Codex Plugin manifest object from GDD source artifacts.
134
+ * Pure function — no fs, env, or path access.
135
+ *
136
+ * Field-by-field source mapping per research § Schema Mapping:
137
+ *
138
+ * name ← marketplaceJson.plugins[0].name (canonical, kebab-case)
139
+ * → claudePlugin.name → stripNpmScope(packageJson.name)
140
+ * version ← packageJson.version (verbatim, lockstep per D-08)
141
+ * description ← packageJson.description (verbatim)
142
+ * author ← claudePlugin.author (canonical, has url)
143
+ * → marketplaceJson.plugins[0].author → packageJson.author
144
+ * homepage ← packageJson.homepage
145
+ * repository ← stripGitSuffix(packageJson.repository.url)
146
+ * license ← packageJson.license
147
+ * keywords ← curateKeywords(packageJson.keywords) → ≤10 entries
148
+ * skills ← static "./skills/"
149
+ * mcpServers ← inline { gdd-mcp: { command: "npx", args: [...] } }
150
+ * interface ← 9 sub-fields per Schema Mapping table:
151
+ * displayName, shortDescription, longDescription,
152
+ * developerName, category, capabilities, websiteURL,
153
+ * defaultPrompt, brandColor
154
+ *
155
+ * OMITTED (per research § Manifest Format "Omitted fields"):
156
+ * apps, hooks (off-by-default), interface.privacyPolicyURL,
157
+ * interface.termsOfServiceURL, interface.composerIcon, interface.logo,
158
+ * interface.screenshots
159
+ *
160
+ * @param {Object} sources Source metadata.
161
+ * @param {Object} sources.packageJson Parsed package.json.
162
+ * @param {Object} [sources.claudePlugin] Parsed .claude-plugin/plugin.json.
163
+ * @param {Object} [sources.marketplaceJson] Parsed .claude-plugin/marketplace.json.
164
+ * @param {string} [sources.readmeFirstPara] README.md first paragraph
165
+ * for interface.longDescription.
166
+ * @returns {Object} Manifest object ready
167
+ * to JSON.stringify with 2-space indent.
168
+ */
169
+ function buildManifest(sources) {
170
+ if (!sources || typeof sources !== 'object') {
171
+ throw new Error('codex-plugin.buildManifest: sources is required');
172
+ }
173
+ const { packageJson, claudePlugin, marketplaceJson, readmeFirstPara } = sources;
174
+
175
+ if (!packageJson || typeof packageJson !== 'object') {
176
+ throw new Error('codex-plugin.buildManifest: sources.packageJson is required');
177
+ }
178
+
179
+ // name — required, kebab-case. Priority: marketplaceJson > claudePlugin >
180
+ // package.json (with scope stripped).
181
+ let name;
182
+ if (
183
+ marketplaceJson
184
+ && Array.isArray(marketplaceJson.plugins)
185
+ && marketplaceJson.plugins[0]
186
+ && typeof marketplaceJson.plugins[0].name === 'string'
187
+ ) {
188
+ name = marketplaceJson.plugins[0].name;
189
+ } else if (claudePlugin && typeof claudePlugin.name === 'string') {
190
+ name = claudePlugin.name;
191
+ } else if (typeof packageJson.name === 'string') {
192
+ name = stripNpmScope(packageJson.name);
193
+ } else {
194
+ throw new Error('codex-plugin.buildManifest: name is required (no source)');
195
+ }
196
+
197
+ // version — required, semver-shaped.
198
+ if (typeof packageJson.version !== 'string' || !/^\d+\.\d+\.\d+/.test(packageJson.version)) {
199
+ throw new Error(
200
+ 'codex-plugin.buildManifest: packageJson.version is required and must be semver-shaped'
201
+ );
202
+ }
203
+ const version = packageJson.version;
204
+
205
+ // description — required, free text.
206
+ if (typeof packageJson.description !== 'string' || packageJson.description.length === 0) {
207
+ throw new Error('codex-plugin.buildManifest: packageJson.description is required');
208
+ }
209
+ const description = packageJson.description;
210
+
211
+ // author — prefer claudePlugin (has url), then marketplace, then package.json.
212
+ let author;
213
+ if (
214
+ claudePlugin
215
+ && claudePlugin.author
216
+ && typeof claudePlugin.author === 'object'
217
+ && typeof claudePlugin.author.name === 'string'
218
+ ) {
219
+ author = Object.assign({}, claudePlugin.author);
220
+ } else if (
221
+ marketplaceJson
222
+ && Array.isArray(marketplaceJson.plugins)
223
+ && marketplaceJson.plugins[0]
224
+ && marketplaceJson.plugins[0].author
225
+ && typeof marketplaceJson.plugins[0].author === 'object'
226
+ ) {
227
+ author = Object.assign({}, marketplaceJson.plugins[0].author);
228
+ } else if (typeof packageJson.author === 'string') {
229
+ author = { name: packageJson.author };
230
+ } else if (
231
+ packageJson.author
232
+ && typeof packageJson.author === 'object'
233
+ && typeof packageJson.author.name === 'string'
234
+ ) {
235
+ author = Object.assign({}, packageJson.author);
236
+ } else {
237
+ author = { name: 'unknown' };
238
+ }
239
+
240
+ // homepage — verbatim, omit if absent.
241
+ const homepage =
242
+ typeof packageJson.homepage === 'string' && packageJson.homepage.length > 0
243
+ ? packageJson.homepage
244
+ : undefined;
245
+
246
+ // repository — string or object form, strip trailing .git for cleaner display.
247
+ let repository;
248
+ if (packageJson.repository) {
249
+ let rawUrl;
250
+ if (typeof packageJson.repository === 'string') {
251
+ rawUrl = packageJson.repository;
252
+ } else if (
253
+ typeof packageJson.repository === 'object'
254
+ && typeof packageJson.repository.url === 'string'
255
+ ) {
256
+ rawUrl = packageJson.repository.url;
257
+ }
258
+ if (rawUrl) {
259
+ repository = stripGitSuffix(rawUrl);
260
+ }
261
+ }
262
+
263
+ // license — verbatim, omit if absent.
264
+ const license =
265
+ typeof packageJson.license === 'string' && packageJson.license.length > 0
266
+ ? packageJson.license
267
+ : undefined;
268
+
269
+ // keywords — curated ≤10-tag subset.
270
+ const keywords = curateKeywords(packageJson.keywords || []);
271
+
272
+ // skills — static path string per build doc complete-manifest example.
273
+ const skills = './skills/';
274
+
275
+ // mcpServers — inline object form (D-14 minimalism: no separate .mcp.json
276
+ // artifact in this plan). The bin name `gdd-mcp` is verified against
277
+ // package.json#bin during integration.
278
+ const mcpServers = {
279
+ 'gdd-mcp': {
280
+ command: 'npx',
281
+ args: ['-y', `--package=${packageJson.name}`, 'gdd-mcp'],
282
+ },
283
+ };
284
+
285
+ // interface — 9-field install-surface metadata per Schema Mapping table.
286
+ const developerName = (author && typeof author.name === 'string')
287
+ ? author.name
288
+ : 'hegemonart';
289
+
290
+ const categoryRaw =
291
+ (marketplaceJson
292
+ && Array.isArray(marketplaceJson.plugins)
293
+ && marketplaceJson.plugins[0]
294
+ && typeof marketplaceJson.plugins[0].category === 'string')
295
+ ? marketplaceJson.plugins[0].category
296
+ : 'design';
297
+ const category = capitalize(categoryRaw);
298
+
299
+ const interfaceObj = {
300
+ displayName: 'Get Design Done',
301
+ shortDescription: truncate(description, 120),
302
+ longDescription: (typeof readmeFirstPara === 'string' && readmeFirstPara.length > 0)
303
+ ? readmeFirstPara
304
+ : description,
305
+ developerName,
306
+ category,
307
+ capabilities: ['Read', 'Write'],
308
+ websiteURL: homepage || '',
309
+ defaultPrompt: [
310
+ 'Run /gdd:brief to start a design cycle.',
311
+ 'Use $gdd-explore to audit a screen.',
312
+ ],
313
+ brandColor: '#10A37F',
314
+ };
315
+
316
+ // Assemble in documented order. Omit undefined fields so JSON.stringify
317
+ // produces a clean diff (matches cursor-marketplace.cjs convention).
318
+ const manifest = {};
319
+ manifest.name = name;
320
+ manifest.version = version;
321
+ manifest.description = description;
322
+ manifest.author = author;
323
+ if (homepage !== undefined) manifest.homepage = homepage;
324
+ if (repository !== undefined) manifest.repository = repository;
325
+ if (license !== undefined) manifest.license = license;
326
+ manifest.keywords = keywords;
327
+ manifest.skills = skills;
328
+ manifest.mcpServers = mcpServers;
329
+ manifest.interface = interfaceObj;
330
+
331
+ return manifest;
332
+ }
333
+
334
+ /**
335
+ * Convert/emit the codex-plugin bundle into a destination directory.
336
+ * Called by build-distribution-bundles.cjs (Plan 28-8-X1).
337
+ *
338
+ * Per CONTEXT D-06, `skills/` is the shared source — this converter emits
339
+ * the marketplace bundle as:
340
+ *
341
+ * <outDir>/
342
+ * .codex-plugin/
343
+ * plugin.json ← the manifest object, JSON.stringified
344
+ * skills/
345
+ * <each skill copied verbatim from input.skillsDir>
346
+ *
347
+ * Codex consumes Claude-compatible SKILL.md (Phase 28.5 contract is
348
+ * already mattpocock-shaped, which Codex accepts per research § vs
349
+ * AGENTS.md) so no per-skill content transform is required at the
350
+ * Tier-2 bundle layer. The Tier-1 codex.cjs converter remains
351
+ * responsible for any per-runtime SKILL.md rewrites needed by the
352
+ * file-drop install path; those rewrites are irrelevant to a marketplace
353
+ * bundle.
354
+ *
355
+ * Idempotent: rerunning with the same inputs produces identical files.
356
+ * Touches only paths under `outDir`. The source `skillsDir` is read-only.
357
+ *
358
+ * @param {Object} input
359
+ * @param {string} input.skillsDir Path to source skills/ tree.
360
+ * @param {string} input.outDir Path to destination bundle directory.
361
+ * @param {Object} input.manifest Manifest object from buildManifest().
362
+ * @returns {{ manifestPath: string, outDir: string }}
363
+ */
364
+ function convert(input) {
365
+ if (!input || typeof input !== 'object') {
366
+ throw new Error('codex-plugin.convert: input is required');
367
+ }
368
+ const { skillsDir, outDir, manifest } = input;
369
+ if (typeof skillsDir !== 'string' || skillsDir.length === 0) {
370
+ throw new Error('codex-plugin.convert: input.skillsDir is required');
371
+ }
372
+ if (typeof outDir !== 'string' || outDir.length === 0) {
373
+ throw new Error('codex-plugin.convert: input.outDir is required');
374
+ }
375
+ if (!manifest || typeof manifest !== 'object') {
376
+ throw new Error('codex-plugin.convert: input.manifest is required');
377
+ }
378
+
379
+ // Validate required fields before writing.
380
+ for (const field of MANIFEST_REQUIRED_FIELDS) {
381
+ if (!manifest[field]) {
382
+ throw new Error(`codex-plugin: manifest missing required field "${field}"`);
383
+ }
384
+ }
385
+
386
+ // Ensure output dir exists.
387
+ fs.mkdirSync(outDir, { recursive: true });
388
+
389
+ // Write manifest at <outDir>/.codex-plugin/plugin.json.
390
+ const manifestDir = path.join(outDir, '.codex-plugin');
391
+ fs.mkdirSync(manifestDir, { recursive: true });
392
+ const manifestPath = path.join(manifestDir, 'plugin.json');
393
+ fs.writeFileSync(
394
+ manifestPath,
395
+ JSON.stringify(manifest, null, 2) + '\n',
396
+ 'utf8'
397
+ );
398
+
399
+ // Copy skills/ tree verbatim (D-06: skills are shared source, no rewriting).
400
+ if (fs.existsSync(skillsDir)) {
401
+ copyDirRecursive(skillsDir, path.join(outDir, 'skills'));
402
+ }
403
+
404
+ return { manifestPath, outDir };
405
+ }
406
+
407
+ module.exports = { buildManifest, convert, MANIFEST_REQUIRED_FIELDS, CURATED_KEYWORDS };