@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +35 -0
- package/README.de.md +14 -0
- package/README.fr.md +14 -0
- package/README.it.md +14 -0
- package/README.ja.md +14 -0
- package/README.ko.md +14 -0
- package/README.md +16 -0
- package/README.zh-CN.md +14 -0
- package/SKILL.md +10 -10
- package/package.json +3 -1
- package/scripts/build-distribution-bundles.cjs +549 -0
- package/scripts/install.cjs +61 -0
- package/scripts/lib/install/config-dir.cjs +26 -0
- package/scripts/lib/install/converters/codex-plugin.cjs +407 -0
- package/scripts/lib/install/converters/cursor-marketplace.cjs +309 -0
- package/scripts/lib/install/doctor-codex-plugin.cjs +388 -0
- package/scripts/lib/install/doctor-cursor-marketplace.cjs +366 -0
- package/scripts/lib/install/doctor-tier2.cjs +586 -0
- package/scripts/lib/install/runtimes.cjs +48 -0
- package/scripts/lint-agentskills-spec.cjs +457 -0
- package/skills/compare/SKILL.md +2 -2
- package/skills/compare/compare-rubric.md +1 -1
- package/skills/darkmode/SKILL.md +2 -2
- package/skills/darkmode/darkmode-audit-procedure.md +1 -1
- package/skills/figma-write/SKILL.md +2 -2
- package/skills/graphify/SKILL.md +2 -2
- package/skills/style/SKILL.md +2 -2
- package/skills/style/style-doc-procedure.md +1 -1
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scripts/lib/install/converters/cursor-marketplace.cjs — Phase 28.8 (Plan B1).
|
|
5
|
+
*
|
|
6
|
+
* Cursor Marketplace Tier-2 distribution-channel converter. SEPARATE from
|
|
7
|
+
* Phase 28.7's `cursor.cjs` SKILL.md file-drop converter — that one rewrites
|
|
8
|
+
* per-skill content for the runtime install path. THIS one builds the
|
|
9
|
+
* `.cursor-plugin/plugin.json` manifest and emits the marketplace bundle
|
|
10
|
+
* layout consumed by `build-distribution-bundles.cjs` (Plan 28-8-X1).
|
|
11
|
+
*
|
|
12
|
+
* Architecture note: per CONTEXT D-05 (additive), Tier-1 file-drop and
|
|
13
|
+
* Tier-2 marketplace coexist — `cursor.cjs` is UNCHANGED. Per CONTEXT D-06,
|
|
14
|
+
* `skills/` is the SHARED source; this converter passes skill content
|
|
15
|
+
* through verbatim (Cursor accepts Claude-compatible SKILL.md per Wave A
|
|
16
|
+
* research, so no per-skill content transform is required at the Tier-2
|
|
17
|
+
* bundle layer).
|
|
18
|
+
*
|
|
19
|
+
* Source mapping: see `.planning/research/cursor-marketplace-2026-05-19.md`
|
|
20
|
+
* § Schema Mapping (lines 234-256) for the authoritative field-by-field spec.
|
|
21
|
+
*
|
|
22
|
+
* GDD-original pattern (no gsd-build/get-shit-done counterpart): Tier-2
|
|
23
|
+
* distribution channels do not exist in the upstream multi-runtime install
|
|
24
|
+
* reference (CONTEXT line 34). No port attribution required.
|
|
25
|
+
*
|
|
26
|
+
* Exports:
|
|
27
|
+
* - `buildManifest(sources, opts)` — pure function, returns the manifest
|
|
28
|
+
* object ready to `JSON.stringify(obj, null, 2)`.
|
|
29
|
+
* - `convert({ skillsDir, outDir, manifest }, opts)` — file-emission
|
|
30
|
+
* function for `build-distribution-bundles.cjs`. The only side-effect
|
|
31
|
+
* surface; touches only paths under `outDir`.
|
|
32
|
+
* - `CURATED_KEYWORDS` — frozen 8-tag default keyword subset.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require('node:fs');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
|
|
38
|
+
// Curated keyword subset for Cursor's marketplace card display.
|
|
39
|
+
// Per Wave A research § Schema Mapping `keywords` row: marketplace card
|
|
40
|
+
// surfaces ~5-8 tags — picking the most Cursor-user-relevant subset out
|
|
41
|
+
// of the 60+ tags in package.json.keywords.
|
|
42
|
+
const CURATED_KEYWORDS = Object.freeze([
|
|
43
|
+
'design',
|
|
44
|
+
'ui',
|
|
45
|
+
'ux',
|
|
46
|
+
'frontend',
|
|
47
|
+
'design-system',
|
|
48
|
+
'accessibility',
|
|
49
|
+
'figma',
|
|
50
|
+
'skill',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the .cursor-plugin/plugin.json manifest object from GDD source
|
|
55
|
+
* artifacts. Pure function — no fs, env, or path access.
|
|
56
|
+
*
|
|
57
|
+
* Field-by-field source mapping per Wave A research § Schema Mapping:
|
|
58
|
+
*
|
|
59
|
+
* name ← claudePluginJson.name (canonical, kebab-case)
|
|
60
|
+
* description ← packageJson.description (verbatim)
|
|
61
|
+
* version ← packageJson.version (verbatim, lockstep per D-08)
|
|
62
|
+
* author ← {name: claudePluginJson.author.name} (transform)
|
|
63
|
+
* homepage ← packageJson.homepage (verbatim, omit if absent)
|
|
64
|
+
* repository ← packageJson.repository.url with trailing .git stripped
|
|
65
|
+
* license ← packageJson.license (verbatim, omit if absent)
|
|
66
|
+
* keywords ← opts.keywords || CURATED_KEYWORDS
|
|
67
|
+
*
|
|
68
|
+
* OMITTED (per research § Schema Mapping rationale):
|
|
69
|
+
* logo, rules, agents, skills, commands, hooks, mcpServers
|
|
70
|
+
*
|
|
71
|
+
* @param {Object} sources Source metadata.
|
|
72
|
+
* @param {Object} sources.packageJson Parsed package.json.
|
|
73
|
+
* @param {Object} [sources.claudePluginJson] Parsed .claude-plugin/plugin.json.
|
|
74
|
+
* @param {Object} [opts]
|
|
75
|
+
* @param {string[]} [opts.keywords] Override keyword subset
|
|
76
|
+
* (defaults to CURATED_KEYWORDS).
|
|
77
|
+
* @returns {Object} Manifest object,
|
|
78
|
+
* keys in documented order, ready to JSON.stringify with 2-space indent.
|
|
79
|
+
*/
|
|
80
|
+
function buildManifest(sources, opts) {
|
|
81
|
+
const opts2 = opts || {};
|
|
82
|
+
const pkg = sources && sources.packageJson;
|
|
83
|
+
const claudePlugin = sources && sources.claudePluginJson;
|
|
84
|
+
|
|
85
|
+
if (!pkg || typeof pkg !== 'object') {
|
|
86
|
+
throw new Error('cursor-marketplace: sources.packageJson is required');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// name — prefer .claude-plugin/plugin.json.name (canonical, already
|
|
90
|
+
// kebab-case as "get-design-done"); fall back to stripping npm scope
|
|
91
|
+
// prefix from package.json.name.
|
|
92
|
+
let name;
|
|
93
|
+
if (claudePlugin && typeof claudePlugin.name === 'string') {
|
|
94
|
+
name = claudePlugin.name;
|
|
95
|
+
} else if (typeof pkg.name === 'string') {
|
|
96
|
+
name = pkg.name.replace(/^@[^/]+\//, '');
|
|
97
|
+
} else {
|
|
98
|
+
throw new Error('cursor-marketplace: name is required (no source)');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// description — required (we want predictable failure if package.json
|
|
102
|
+
// is malformed).
|
|
103
|
+
if (typeof pkg.description !== 'string' || pkg.description.length === 0) {
|
|
104
|
+
throw new Error('cursor-marketplace: packageJson.description is required');
|
|
105
|
+
}
|
|
106
|
+
const description = pkg.description;
|
|
107
|
+
|
|
108
|
+
// version — required, semver-shaped.
|
|
109
|
+
if (typeof pkg.version !== 'string' || !/^\d+\.\d+\.\d+/.test(pkg.version)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
'cursor-marketplace: packageJson.version is required and must be semver-shaped'
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const version = pkg.version;
|
|
115
|
+
|
|
116
|
+
// author — resolve in order: claudePluginJson.author.name → pkg.author
|
|
117
|
+
// (string form) → pkg.author.name. Email only if claudePluginJson source
|
|
118
|
+
// carries one (GDD does not today).
|
|
119
|
+
let authorName;
|
|
120
|
+
let authorEmail;
|
|
121
|
+
if (
|
|
122
|
+
claudePlugin
|
|
123
|
+
&& claudePlugin.author
|
|
124
|
+
&& typeof claudePlugin.author === 'object'
|
|
125
|
+
&& typeof claudePlugin.author.name === 'string'
|
|
126
|
+
) {
|
|
127
|
+
authorName = claudePlugin.author.name;
|
|
128
|
+
if (typeof claudePlugin.author.email === 'string') {
|
|
129
|
+
authorEmail = claudePlugin.author.email;
|
|
130
|
+
}
|
|
131
|
+
} else if (typeof pkg.author === 'string' && pkg.author.length > 0) {
|
|
132
|
+
authorName = pkg.author;
|
|
133
|
+
} else if (
|
|
134
|
+
pkg.author
|
|
135
|
+
&& typeof pkg.author === 'object'
|
|
136
|
+
&& typeof pkg.author.name === 'string'
|
|
137
|
+
) {
|
|
138
|
+
authorName = pkg.author.name;
|
|
139
|
+
if (typeof pkg.author.email === 'string') {
|
|
140
|
+
authorEmail = pkg.author.email;
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
throw new Error('cursor-marketplace: author.name is required (no source)');
|
|
144
|
+
}
|
|
145
|
+
const author = authorEmail
|
|
146
|
+
? { name: authorName, email: authorEmail }
|
|
147
|
+
: { name: authorName };
|
|
148
|
+
|
|
149
|
+
// homepage — verbatim, omit if absent.
|
|
150
|
+
const homepage =
|
|
151
|
+
typeof pkg.homepage === 'string' && pkg.homepage.length > 0
|
|
152
|
+
? pkg.homepage
|
|
153
|
+
: undefined;
|
|
154
|
+
|
|
155
|
+
// repository — package.json may store as object {type, url} or string.
|
|
156
|
+
// Strip trailing .git for cleaner display.
|
|
157
|
+
let repository;
|
|
158
|
+
if (pkg.repository) {
|
|
159
|
+
let rawUrl;
|
|
160
|
+
if (typeof pkg.repository === 'string') {
|
|
161
|
+
rawUrl = pkg.repository;
|
|
162
|
+
} else if (typeof pkg.repository === 'object'
|
|
163
|
+
&& typeof pkg.repository.url === 'string') {
|
|
164
|
+
rawUrl = pkg.repository.url;
|
|
165
|
+
}
|
|
166
|
+
if (rawUrl) {
|
|
167
|
+
repository = rawUrl.replace(/\.git$/, '');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// license — verbatim, omit if absent.
|
|
172
|
+
const license =
|
|
173
|
+
typeof pkg.license === 'string' && pkg.license.length > 0
|
|
174
|
+
? pkg.license
|
|
175
|
+
: undefined;
|
|
176
|
+
|
|
177
|
+
// keywords — opts override → CURATED_KEYWORDS default. Always materialize
|
|
178
|
+
// a fresh array (don't expose the frozen module-level constant directly
|
|
179
|
+
// in user-mutable output).
|
|
180
|
+
const keywords =
|
|
181
|
+
Array.isArray(opts2.keywords) && opts2.keywords.length > 0
|
|
182
|
+
? opts2.keywords.slice()
|
|
183
|
+
: CURATED_KEYWORDS.slice();
|
|
184
|
+
|
|
185
|
+
// Assemble in documented order: name, description, version, author,
|
|
186
|
+
// homepage, repository, license, keywords. Omit undefined fields so
|
|
187
|
+
// JSON.stringify produces a clean diff.
|
|
188
|
+
const manifest = {};
|
|
189
|
+
manifest.name = name;
|
|
190
|
+
manifest.description = description;
|
|
191
|
+
manifest.version = version;
|
|
192
|
+
manifest.author = author;
|
|
193
|
+
if (homepage !== undefined) manifest.homepage = homepage;
|
|
194
|
+
if (repository !== undefined) manifest.repository = repository;
|
|
195
|
+
if (license !== undefined) manifest.license = license;
|
|
196
|
+
manifest.keywords = keywords;
|
|
197
|
+
|
|
198
|
+
return manifest;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Copy a directory tree recursively. Vanilla fs only — no deps.
|
|
203
|
+
* Returns the list of relative paths written (relative to `dest`).
|
|
204
|
+
*/
|
|
205
|
+
function copyDirRecursive(src, dest, relPrefix) {
|
|
206
|
+
const written = [];
|
|
207
|
+
const stack = [{ s: src, d: dest, rel: relPrefix || '' }];
|
|
208
|
+
while (stack.length > 0) {
|
|
209
|
+
const { s, d, rel } = stack.pop();
|
|
210
|
+
fs.mkdirSync(d, { recursive: true });
|
|
211
|
+
const entries = fs.readdirSync(s);
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const sp = path.join(s, entry);
|
|
214
|
+
const dp = path.join(d, entry);
|
|
215
|
+
const relPath = rel ? `${rel}/${entry}` : entry;
|
|
216
|
+
const stat = fs.statSync(sp);
|
|
217
|
+
if (stat.isDirectory()) {
|
|
218
|
+
stack.push({ s: sp, d: dp, rel: relPath });
|
|
219
|
+
} else if (stat.isFile()) {
|
|
220
|
+
fs.copyFileSync(sp, dp);
|
|
221
|
+
written.push(relPath);
|
|
222
|
+
}
|
|
223
|
+
// symlinks + other: ignored (skills tree is regular files only)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return written;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Convert/emit the cursor-marketplace bundle into a destination directory.
|
|
231
|
+
* Called by build-distribution-bundles.cjs (Plan 28-8-X1).
|
|
232
|
+
*
|
|
233
|
+
* Per CONTEXT D-06, `skills/` is the shared source — this converter emits
|
|
234
|
+
* the marketplace bundle as:
|
|
235
|
+
*
|
|
236
|
+
* <outDir>/
|
|
237
|
+
* .cursor-plugin/
|
|
238
|
+
* plugin.json ← the manifest object, JSON.stringified
|
|
239
|
+
* skills/
|
|
240
|
+
* <each skill copied verbatim from input.skillsDir>
|
|
241
|
+
*
|
|
242
|
+
* Cursor accepts Claude-compatible SKILL.md so no per-skill content
|
|
243
|
+
* transform is required at this layer. The Tier-1 cursor.cjs converter
|
|
244
|
+
* remains responsible for the per-runtime SKILL.md rewrites needed by the
|
|
245
|
+
* file-drop install path; those rewrites are irrelevant to a marketplace
|
|
246
|
+
* bundle (Cursor's marketplace reads the SKILL.md content directly).
|
|
247
|
+
*
|
|
248
|
+
* Idempotent: rerunning with the same inputs produces identical files
|
|
249
|
+
* (no partial-state corruption, no append-only emissions).
|
|
250
|
+
*
|
|
251
|
+
* Touches only paths under `outDir`. The source `skillsDir` is read-only.
|
|
252
|
+
*
|
|
253
|
+
* @param {Object} input
|
|
254
|
+
* @param {string} input.skillsDir Path to source skills/ tree.
|
|
255
|
+
* @param {string} input.outDir Path to destination bundle directory.
|
|
256
|
+
* @param {Object} input.manifest Manifest object from buildManifest().
|
|
257
|
+
* @param {Object} [opts]
|
|
258
|
+
* @returns {{ filesWritten: string[] }} Sorted relative paths under outDir.
|
|
259
|
+
*/
|
|
260
|
+
function convert(input, opts) {
|
|
261
|
+
if (!input || typeof input !== 'object') {
|
|
262
|
+
throw new Error('cursor-marketplace.convert: input is required');
|
|
263
|
+
}
|
|
264
|
+
const { skillsDir, outDir, manifest } = input;
|
|
265
|
+
if (typeof skillsDir !== 'string' || skillsDir.length === 0) {
|
|
266
|
+
throw new Error('cursor-marketplace.convert: input.skillsDir is required');
|
|
267
|
+
}
|
|
268
|
+
if (typeof outDir !== 'string' || outDir.length === 0) {
|
|
269
|
+
throw new Error('cursor-marketplace.convert: input.outDir is required');
|
|
270
|
+
}
|
|
271
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
272
|
+
throw new Error('cursor-marketplace.convert: input.manifest is required');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const skillsStat = fs.statSync(skillsDir);
|
|
276
|
+
if (!skillsStat.isDirectory()) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`cursor-marketplace.convert: skillsDir is not a directory: ${skillsDir}`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const written = [];
|
|
283
|
+
|
|
284
|
+
// Create outDir if absent.
|
|
285
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
286
|
+
|
|
287
|
+
// Write manifest at <outDir>/.cursor-plugin/plugin.json.
|
|
288
|
+
const manifestDir = path.join(outDir, '.cursor-plugin');
|
|
289
|
+
fs.mkdirSync(manifestDir, { recursive: true });
|
|
290
|
+
const manifestPath = path.join(manifestDir, 'plugin.json');
|
|
291
|
+
fs.writeFileSync(
|
|
292
|
+
manifestPath,
|
|
293
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
294
|
+
'utf8'
|
|
295
|
+
);
|
|
296
|
+
written.push('.cursor-plugin/plugin.json');
|
|
297
|
+
|
|
298
|
+
// Copy skills/ tree verbatim under <outDir>/skills.
|
|
299
|
+
const skillsDest = path.join(outDir, 'skills');
|
|
300
|
+
const copied = copyDirRecursive(skillsDir, skillsDest, 'skills');
|
|
301
|
+
for (const rel of copied) {
|
|
302
|
+
written.push(rel);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
written.sort();
|
|
306
|
+
return { filesWritten: written };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = { buildManifest, convert, CURATED_KEYWORDS };
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scripts/lib/install/doctor-codex-plugin.cjs — Phase 28.8 (Plan 28-8-C2).
|
|
5
|
+
*
|
|
6
|
+
* Codex Plugin doctor-mode reporter. Pure, read-only function that
|
|
7
|
+
* surfaces the maintainer's local Codex Plugin readiness state to
|
|
8
|
+
* `scripts/install.cjs --doctor`.
|
|
9
|
+
*
|
|
10
|
+
* Phase 28.8 D-03: Codex install-by-URL works today — `codex plugin
|
|
11
|
+
* marketplace add hegemonart/get-design-done` is a single command per
|
|
12
|
+
* developers.openai.com/codex/plugins/build. This reporter inspects the
|
|
13
|
+
* local repo for the artifacts that the Codex CLI consumes during that
|
|
14
|
+
* single step:
|
|
15
|
+
* - `.codex-plugin/plugin.json` (manifest, built by Plan 28-8-C1)
|
|
16
|
+
* - `.claude-plugin/marketplace.json` (catalog reused per D-14)
|
|
17
|
+
*
|
|
18
|
+
* Phase 28.8 D-10: tmpdir-safe. Read-only fs access; no writes anywhere;
|
|
19
|
+
* no `codex` CLI invocation; no access to `~/.codex/`. The cache install
|
|
20
|
+
* path is COMPUTED (pure string composition via `os.homedir()`), NOT
|
|
21
|
+
* verified. The maintainer verifies the cache after running the field-
|
|
22
|
+
* test command on a Codex-installed machine (see
|
|
23
|
+
* docs/codex-plugin-field-test.md).
|
|
24
|
+
*
|
|
25
|
+
* Phase 28.8 D-14: the `.claude-plugin/marketplace.json` catalog file is
|
|
26
|
+
* reused from Claude Code's marketplace per Codex's legacy-compatible
|
|
27
|
+
* catalog path. Whenever the catalog is present, `reusedFromClaude` is
|
|
28
|
+
* true — there is no separate Codex-specific catalog artifact.
|
|
29
|
+
*
|
|
30
|
+
* Phase 28.8 D-16: Codex is single-step (D-03). The multi-step pattern
|
|
31
|
+
* is Cursor Marketplace's domain (see doctor-cursor-marketplace.cjs).
|
|
32
|
+
* No review-window state machine here — verdict is binary:
|
|
33
|
+
* `ready-to-install` or `manifest-only-not-ready`.
|
|
34
|
+
*
|
|
35
|
+
* Design pattern (mirrors doctor-cursor-marketplace.cjs from Plan B2):
|
|
36
|
+
* - `checkCodexPlugin(projectRoot)` returns a structured result object.
|
|
37
|
+
* - `renderCodexPluginSection(result)` formats it as text.
|
|
38
|
+
* - `computeCacheSimulationPath(...)` is pure string composition.
|
|
39
|
+
*
|
|
40
|
+
* Exports:
|
|
41
|
+
* - `checkCodexPlugin(projectRoot)` — structured readiness status.
|
|
42
|
+
* - `computeCacheSimulationPath(marketplaceName, pluginName, version)` —
|
|
43
|
+
* pure path composition; no fs access.
|
|
44
|
+
* - `renderCodexPluginSection(result)` — text formatter for the doctor
|
|
45
|
+
* section.
|
|
46
|
+
* - `MARKETPLACE_NAME` / `PLUGIN_NAME` / `MANIFEST_REL_PATH` /
|
|
47
|
+
* `CATALOG_REL_PATH` — exposed for test cross-checks.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const fs = require('node:fs');
|
|
51
|
+
const os = require('node:os');
|
|
52
|
+
const path = require('node:path');
|
|
53
|
+
|
|
54
|
+
const MARKETPLACE_NAME = 'get-design-done';
|
|
55
|
+
const PLUGIN_NAME = 'get-design-done';
|
|
56
|
+
const MANIFEST_REL_PATH = '.codex-plugin/plugin.json';
|
|
57
|
+
const CATALOG_REL_PATH = '.claude-plugin/marketplace.json';
|
|
58
|
+
|
|
59
|
+
// Sentinel rendered when neither manifest nor package.json yields a version.
|
|
60
|
+
const VERSION_PLACEHOLDER = '<version-from-package.json>';
|
|
61
|
+
|
|
62
|
+
// Reuse C1's required-fields tuple. Lazy-require keeps the doctor module
|
|
63
|
+
// independent of the converter's runtime cost when only the formatter is
|
|
64
|
+
// imported (e.g., for a unit test of `computeCacheSimulationPath`).
|
|
65
|
+
function loadConverterRequiredFields() {
|
|
66
|
+
// eslint-disable-next-line global-require
|
|
67
|
+
const c1 = require('./converters/codex-plugin.cjs');
|
|
68
|
+
return c1.MANIFEST_REQUIRED_FIELDS;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate a parsed `.codex-plugin/plugin.json` object against the C1
|
|
73
|
+
* spec (required fields + kebab-case name + semver version). Returns
|
|
74
|
+
* `{valid, errors}` — never throws. Mirrors the validateManifest helper
|
|
75
|
+
* in doctor-cursor-marketplace.cjs but uses Codex schema rules.
|
|
76
|
+
*
|
|
77
|
+
* @param {*} parsed Parsed JSON value.
|
|
78
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
79
|
+
*/
|
|
80
|
+
function validateCodexManifest(parsed) {
|
|
81
|
+
const errors = [];
|
|
82
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
83
|
+
return { valid: false, errors: ['manifest is not a JSON object'] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const required = loadConverterRequiredFields();
|
|
87
|
+
for (const field of required) {
|
|
88
|
+
if (parsed[field] === undefined || parsed[field] === null) {
|
|
89
|
+
errors.push(`missing required field "${field}"`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (parsed.name !== undefined && parsed.name !== null) {
|
|
94
|
+
if (typeof parsed.name !== 'string' || parsed.name.length === 0) {
|
|
95
|
+
errors.push('name must be a non-empty string');
|
|
96
|
+
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(parsed.name)) {
|
|
97
|
+
errors.push('name must be kebab-case (lowercase letters, digits, single hyphens)');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (parsed.version !== undefined && parsed.version !== null) {
|
|
102
|
+
if (typeof parsed.version !== 'string' || !/^\d+\.\d+\.\d+/.test(parsed.version)) {
|
|
103
|
+
errors.push('version must be semver-shaped (x.y.z)');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (parsed.description !== undefined && parsed.description !== null) {
|
|
108
|
+
if (typeof parsed.description !== 'string' || parsed.description.length === 0) {
|
|
109
|
+
errors.push('description must be a non-empty string');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { valid: errors.length === 0, errors };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Safely read + parse a JSON file. Returns `{exists, parsed, error}`.
|
|
118
|
+
* @param {string} filePath
|
|
119
|
+
* @returns {{ exists: boolean, parsed: *, error: string|null }}
|
|
120
|
+
*/
|
|
121
|
+
function readJsonFileSafe(filePath) {
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
125
|
+
} catch (e) {
|
|
126
|
+
if (e && e.code === 'ENOENT') {
|
|
127
|
+
return { exists: false, parsed: null, error: null };
|
|
128
|
+
}
|
|
129
|
+
return { exists: false, parsed: null, error: 'read failed: ' + e.message };
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
return { exists: true, parsed: JSON.parse(raw), error: null };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return { exists: true, parsed: null, error: 'JSON parse error: ' + e.message };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Compute the install cache path WITHOUT touching the filesystem outside
|
|
140
|
+
* `projectRoot` (or anywhere, actually — this is pure string composition).
|
|
141
|
+
* The path schema is documented in research § Plugin cache layout:
|
|
142
|
+
*
|
|
143
|
+
* ~/.codex/plugins/cache/$MARKETPLACE_NAME/$PLUGIN_NAME/$VERSION/
|
|
144
|
+
*
|
|
145
|
+
* Per D-10 we do NOT verify this path exists — `codex` CLI may not be
|
|
146
|
+
* installed locally. The maintainer field-test (post-merge, on a Codex-
|
|
147
|
+
* installed machine) is the only place this path is actually checked.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} marketplaceName Catalog `.name` field (default
|
|
150
|
+
* 'get-design-done' for GDD).
|
|
151
|
+
* @param {string} pluginName Manifest `.name` field (same as
|
|
152
|
+
* marketplaceName for GDD).
|
|
153
|
+
* @param {string|null|undefined} version Manifest `.version` field, or
|
|
154
|
+
* null/undefined to render the
|
|
155
|
+
* `<version-from-package.json>`
|
|
156
|
+
* placeholder.
|
|
157
|
+
* @returns {string} Absolute path with `~` expanded to
|
|
158
|
+
* `os.homedir()`. Forward slashes per
|
|
159
|
+
* Codex docs convention.
|
|
160
|
+
*/
|
|
161
|
+
function computeCacheSimulationPath(marketplaceName, pluginName, version) {
|
|
162
|
+
const home = os.homedir().replace(/\\/g, '/');
|
|
163
|
+
const ver = (typeof version === 'string' && version.length > 0)
|
|
164
|
+
? version
|
|
165
|
+
: VERSION_PLACEHOLDER;
|
|
166
|
+
return home + '/.codex/plugins/cache/' + marketplaceName + '/' + pluginName + '/' + ver + '/';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Read-only Codex Plugin readiness reporter. Inspects `projectRoot` for
|
|
171
|
+
* the manifest + catalog artifacts and returns a structured verdict.
|
|
172
|
+
*
|
|
173
|
+
* No writes, no network, no `codex` CLI invocation. Tmpdir-safe per D-10.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} projectRoot Path to inspect.
|
|
176
|
+
* @returns {{
|
|
177
|
+
* manifest: {
|
|
178
|
+
* present: boolean,
|
|
179
|
+
* path: string,
|
|
180
|
+
* valid: boolean | null,
|
|
181
|
+
* version: string | null,
|
|
182
|
+
* errors: string[],
|
|
183
|
+
* },
|
|
184
|
+
* catalog: {
|
|
185
|
+
* present: boolean,
|
|
186
|
+
* path: string,
|
|
187
|
+
* referencesCodexPlugin: boolean,
|
|
188
|
+
* reusedFromClaude: boolean,
|
|
189
|
+
* },
|
|
190
|
+
* cacheSimulation: {
|
|
191
|
+
* path: string,
|
|
192
|
+
* verified: false,
|
|
193
|
+
* note: string,
|
|
194
|
+
* },
|
|
195
|
+
* verdict: 'ready-to-install' | 'manifest-only-not-ready',
|
|
196
|
+
* verdictReasons: string[],
|
|
197
|
+
* }}
|
|
198
|
+
*/
|
|
199
|
+
function checkCodexPlugin(projectRoot) {
|
|
200
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
201
|
+
throw new Error('checkCodexPlugin: projectRoot is required');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const manifestPath = path.join(projectRoot, MANIFEST_REL_PATH);
|
|
205
|
+
const catalogPath = path.join(projectRoot, CATALOG_REL_PATH);
|
|
206
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
207
|
+
|
|
208
|
+
const manifestRead = readJsonFileSafe(manifestPath);
|
|
209
|
+
const catalogRead = readJsonFileSafe(catalogPath);
|
|
210
|
+
const pkgRead = readJsonFileSafe(pkgPath);
|
|
211
|
+
|
|
212
|
+
// ── Manifest ────────────────────────────────────────────────────────
|
|
213
|
+
const manifest = {
|
|
214
|
+
present: false,
|
|
215
|
+
path: manifestPath,
|
|
216
|
+
valid: null,
|
|
217
|
+
version: null,
|
|
218
|
+
errors: [],
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
if (manifestRead.exists) {
|
|
222
|
+
manifest.present = true;
|
|
223
|
+
if (manifestRead.error) {
|
|
224
|
+
manifest.valid = false;
|
|
225
|
+
manifest.errors = [manifestRead.error];
|
|
226
|
+
} else {
|
|
227
|
+
const validation = validateCodexManifest(manifestRead.parsed);
|
|
228
|
+
manifest.valid = validation.valid;
|
|
229
|
+
manifest.errors = validation.errors;
|
|
230
|
+
if (manifestRead.parsed && typeof manifestRead.parsed.version === 'string') {
|
|
231
|
+
manifest.version = manifestRead.parsed.version;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Catalog ─────────────────────────────────────────────────────────
|
|
237
|
+
const catalog = {
|
|
238
|
+
present: false,
|
|
239
|
+
path: catalogPath,
|
|
240
|
+
referencesCodexPlugin: false,
|
|
241
|
+
reusedFromClaude: false,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
if (catalogRead.exists && !catalogRead.error
|
|
245
|
+
&& catalogRead.parsed && typeof catalogRead.parsed === 'object') {
|
|
246
|
+
catalog.present = true;
|
|
247
|
+
catalog.reusedFromClaude = true;
|
|
248
|
+
// Reference check: any entry in `plugins[]` with name === manifest.name
|
|
249
|
+
// (or PLUGIN_NAME if manifest absent / unparsed) signals an explicit
|
|
250
|
+
// reference. Per D-14 the catalog is reused regardless.
|
|
251
|
+
const refName = (manifestRead.exists
|
|
252
|
+
&& manifestRead.parsed
|
|
253
|
+
&& typeof manifestRead.parsed.name === 'string'
|
|
254
|
+
&& manifestRead.parsed.name.length > 0)
|
|
255
|
+
? manifestRead.parsed.name
|
|
256
|
+
: PLUGIN_NAME;
|
|
257
|
+
if (Array.isArray(catalogRead.parsed.plugins)) {
|
|
258
|
+
catalog.referencesCodexPlugin = catalogRead.parsed.plugins.some(
|
|
259
|
+
(entry) => entry && typeof entry === 'object' && entry.name === refName
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
} else if (catalogRead.exists && catalogRead.error) {
|
|
263
|
+
// Malformed catalog — mark present but unreusable. Keep
|
|
264
|
+
// referencesCodexPlugin false. reusedFromClaude stays false since
|
|
265
|
+
// we couldn't actually parse it. This is a Rule 1 safety: the
|
|
266
|
+
// doctor should not lie about reusable catalogs.
|
|
267
|
+
catalog.present = true;
|
|
268
|
+
catalog.reusedFromClaude = false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Version (prefer manifest, fall back to package.json) ───────────
|
|
272
|
+
let resolvedVersion = manifest.version;
|
|
273
|
+
if (!resolvedVersion
|
|
274
|
+
&& pkgRead.exists && !pkgRead.error
|
|
275
|
+
&& pkgRead.parsed && typeof pkgRead.parsed.version === 'string') {
|
|
276
|
+
resolvedVersion = pkgRead.parsed.version;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Cache simulation (computed, never verified) ─────────────────────
|
|
280
|
+
const cacheSimulation = {
|
|
281
|
+
path: computeCacheSimulationPath(MARKETPLACE_NAME, PLUGIN_NAME, resolvedVersion),
|
|
282
|
+
verified: false,
|
|
283
|
+
note: 'codex CLI may not be installed locally — path computed not verified',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// ── Verdict ─────────────────────────────────────────────────────────
|
|
287
|
+
const verdictReasons = [];
|
|
288
|
+
if (!manifest.present) {
|
|
289
|
+
verdictReasons.push('manifest absent');
|
|
290
|
+
} else if (manifest.valid === false) {
|
|
291
|
+
if (manifest.errors.length > 0 && /JSON parse error/.test(manifest.errors[0])) {
|
|
292
|
+
verdictReasons.push('manifest JSON parse error: ' + manifest.errors[0].replace(/^JSON parse error:\s*/, ''));
|
|
293
|
+
} else {
|
|
294
|
+
verdictReasons.push('manifest schema invalid: ' + (manifest.errors[0] || 'unknown reason'));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!catalog.present) {
|
|
298
|
+
verdictReasons.push('catalog absent');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const verdict = (manifest.present && manifest.valid === true && catalog.present)
|
|
302
|
+
? 'ready-to-install'
|
|
303
|
+
: 'manifest-only-not-ready';
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
manifest,
|
|
307
|
+
catalog,
|
|
308
|
+
cacheSimulation,
|
|
309
|
+
verdict,
|
|
310
|
+
verdictReasons,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Render the inspection result as the doctor section text. Pure — no IO.
|
|
316
|
+
*
|
|
317
|
+
* Output shape (per plan <interfaces>):
|
|
318
|
+
*
|
|
319
|
+
* Codex Plugin status
|
|
320
|
+
* manifest .codex-plugin/plugin.json: present (version 1.28.8) — schema valid
|
|
321
|
+
* catalog .claude-plugin/marketplace.json: present — referenced by codex-plugin per D-14 (legacy-compatible catalog reuse)
|
|
322
|
+
* install path (computed, not verified): ~/.codex/plugins/cache/get-design-done/get-design-done/1.28.8/
|
|
323
|
+
* verdict: ready-to-install
|
|
324
|
+
*
|
|
325
|
+
* @param {ReturnType<checkCodexPlugin>} result
|
|
326
|
+
* @returns {string} Multi-line text ending with a trailing newline.
|
|
327
|
+
*/
|
|
328
|
+
function renderCodexPluginSection(result) {
|
|
329
|
+
if (!result || typeof result !== 'object') {
|
|
330
|
+
throw new Error('renderCodexPluginSection: result is required');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const lines = ['Codex Plugin status'];
|
|
334
|
+
|
|
335
|
+
// Manifest line
|
|
336
|
+
let manifestState;
|
|
337
|
+
if (!result.manifest.present) {
|
|
338
|
+
manifestState = 'absent';
|
|
339
|
+
} else if (result.manifest.errors.length > 0
|
|
340
|
+
&& /^JSON parse error/.test(result.manifest.errors[0])) {
|
|
341
|
+
manifestState = 'present — ' + result.manifest.errors[0];
|
|
342
|
+
} else {
|
|
343
|
+
const ver = result.manifest.version
|
|
344
|
+
? '(version ' + result.manifest.version + ')'
|
|
345
|
+
: '(version unknown)';
|
|
346
|
+
if (result.manifest.valid === true) {
|
|
347
|
+
manifestState = 'present ' + ver + ' — schema valid';
|
|
348
|
+
} else {
|
|
349
|
+
const firstErr = result.manifest.errors[0] || 'unknown error';
|
|
350
|
+
manifestState = 'present ' + ver + ' — schema invalid: ' + firstErr;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
lines.push(' manifest .codex-plugin/plugin.json: ' + manifestState);
|
|
354
|
+
|
|
355
|
+
// Catalog line
|
|
356
|
+
let catalogState;
|
|
357
|
+
if (!result.catalog.present) {
|
|
358
|
+
catalogState = 'absent';
|
|
359
|
+
} else if (result.catalog.referencesCodexPlugin) {
|
|
360
|
+
catalogState = 'present — referenced by codex-plugin per D-14 (legacy-compatible catalog reuse)';
|
|
361
|
+
} else {
|
|
362
|
+
catalogState = 'present — would be reused per D-14 (legacy-compatible catalog reuse)';
|
|
363
|
+
}
|
|
364
|
+
lines.push(' catalog .claude-plugin/marketplace.json: ' + catalogState);
|
|
365
|
+
|
|
366
|
+
// Install path line — always shows the "computed, not verified" guarantee
|
|
367
|
+
lines.push(' install path (computed, not verified): ' + result.cacheSimulation.path);
|
|
368
|
+
|
|
369
|
+
// Verdict line — parenthetical reasons only when non-ready
|
|
370
|
+
let verdictLine = ' verdict: ' + result.verdict;
|
|
371
|
+
if (result.verdict !== 'ready-to-install' && result.verdictReasons.length > 0) {
|
|
372
|
+
verdictLine += ' (' + result.verdictReasons.join('; ') + ')';
|
|
373
|
+
}
|
|
374
|
+
lines.push(verdictLine);
|
|
375
|
+
|
|
376
|
+
return lines.join('\n') + '\n';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = {
|
|
380
|
+
checkCodexPlugin,
|
|
381
|
+
computeCacheSimulationPath,
|
|
382
|
+
renderCodexPluginSection,
|
|
383
|
+
validateCodexManifest,
|
|
384
|
+
MARKETPLACE_NAME,
|
|
385
|
+
PLUGIN_NAME,
|
|
386
|
+
MANIFEST_REL_PATH,
|
|
387
|
+
CATALOG_REL_PATH,
|
|
388
|
+
};
|