@ai-kits/wp-ag-kit 1.0.0

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.
Files changed (104) hide show
  1. package/ANTIGRAVITY-README.md +47 -0
  2. package/CONTRIBUTING.md +122 -0
  3. package/README.md +135 -0
  4. package/STRUCTURE.md +200 -0
  5. package/agents/wordpress-expert.md +36 -0
  6. package/agents/wp-frontend-expert.md +21 -0
  7. package/bin/antigravity-agent.js +159 -0
  8. package/docs/authoring-guide.md +56 -0
  9. package/docs/compatibility-policy.md +18 -0
  10. package/docs/packaging.md +26 -0
  11. package/docs/principles.md +7 -0
  12. package/docs/skill-set-v1.md +21 -0
  13. package/docs/upstream-sync.md +52 -0
  14. package/package.json +47 -0
  15. package/rules/GEMINI.md +273 -0
  16. package/shared/references/.gitkeep +1 -0
  17. package/shared/references/gutenberg-releases.json +155 -0
  18. package/shared/references/wordpress-core-versions.json +208 -0
  19. package/shared/references/wp-gutenberg-version-map.json +886 -0
  20. package/shared/scripts/ai-generate-updates.mjs +458 -0
  21. package/shared/scripts/scaffold-skill.mjs +62 -0
  22. package/shared/scripts/skillpack-build.mjs +165 -0
  23. package/shared/scripts/skillpack-install.mjs +275 -0
  24. package/shared/scripts/update-upstream-indices.mjs +173 -0
  25. package/skills/wordpress-router/SKILL.md +51 -0
  26. package/skills/wordpress-router/references/decision-tree.md +55 -0
  27. package/skills/wp-abilities-api/SKILL.md +95 -0
  28. package/skills/wp-abilities-api/references/php-registration.md +67 -0
  29. package/skills/wp-abilities-api/references/rest-api.md +13 -0
  30. package/skills/wp-block-development/SKILL.md +174 -0
  31. package/skills/wp-block-development/references/attributes-and-serialization.md +22 -0
  32. package/skills/wp-block-development/references/block-json.md +49 -0
  33. package/skills/wp-block-development/references/creating-new-blocks.md +46 -0
  34. package/skills/wp-block-development/references/debugging.md +36 -0
  35. package/skills/wp-block-development/references/deprecations.md +24 -0
  36. package/skills/wp-block-development/references/dynamic-rendering.md +23 -0
  37. package/skills/wp-block-development/references/inner-blocks.md +25 -0
  38. package/skills/wp-block-development/references/registration.md +30 -0
  39. package/skills/wp-block-development/references/supports-and-wrappers.md +18 -0
  40. package/skills/wp-block-development/references/tooling-and-testing.md +21 -0
  41. package/skills/wp-block-development/scripts/list_blocks.mjs +121 -0
  42. package/skills/wp-block-themes/SKILL.md +116 -0
  43. package/skills/wp-block-themes/references/creating-new-block-theme.md +37 -0
  44. package/skills/wp-block-themes/references/debugging.md +24 -0
  45. package/skills/wp-block-themes/references/patterns.md +18 -0
  46. package/skills/wp-block-themes/references/style-variations.md +14 -0
  47. package/skills/wp-block-themes/references/templates-and-parts.md +16 -0
  48. package/skills/wp-block-themes/references/theme-json.md +59 -0
  49. package/skills/wp-block-themes/scripts/detect_block_themes.mjs +117 -0
  50. package/skills/wp-interactivity-api/SKILL.md +179 -0
  51. package/skills/wp-interactivity-api/references/debugging.md +29 -0
  52. package/skills/wp-interactivity-api/references/directives-quickref.md +30 -0
  53. package/skills/wp-interactivity-api/references/server-side-rendering.md +310 -0
  54. package/skills/wp-performance/SKILL.md +146 -0
  55. package/skills/wp-performance/references/autoload-options.md +24 -0
  56. package/skills/wp-performance/references/cron.md +20 -0
  57. package/skills/wp-performance/references/database.md +20 -0
  58. package/skills/wp-performance/references/http-api.md +15 -0
  59. package/skills/wp-performance/references/measurement.md +21 -0
  60. package/skills/wp-performance/references/object-cache.md +24 -0
  61. package/skills/wp-performance/references/query-monitor-headless.md +38 -0
  62. package/skills/wp-performance/references/server-timing.md +22 -0
  63. package/skills/wp-performance/references/wp-cli-doctor.md +24 -0
  64. package/skills/wp-performance/references/wp-cli-profile.md +32 -0
  65. package/skills/wp-performance/scripts/perf_inspect.mjs +128 -0
  66. package/skills/wp-phpstan/SKILL.md +97 -0
  67. package/skills/wp-phpstan/references/configuration.md +52 -0
  68. package/skills/wp-phpstan/references/third-party-classes.md +76 -0
  69. package/skills/wp-phpstan/references/wordpress-annotations.md +124 -0
  70. package/skills/wp-phpstan/scripts/phpstan_inspect.mjs +263 -0
  71. package/skills/wp-playground/SKILL.md +101 -0
  72. package/skills/wp-playground/references/blueprints.md +36 -0
  73. package/skills/wp-playground/references/cli-commands.md +39 -0
  74. package/skills/wp-playground/references/debugging.md +16 -0
  75. package/skills/wp-plugin-development/SKILL.md +112 -0
  76. package/skills/wp-plugin-development/references/data-and-cron.md +19 -0
  77. package/skills/wp-plugin-development/references/debugging.md +19 -0
  78. package/skills/wp-plugin-development/references/lifecycle.md +33 -0
  79. package/skills/wp-plugin-development/references/security.md +29 -0
  80. package/skills/wp-plugin-development/references/settings-api.md +22 -0
  81. package/skills/wp-plugin-development/references/structure.md +16 -0
  82. package/skills/wp-plugin-development/scripts/detect_plugins.mjs +122 -0
  83. package/skills/wp-project-triage/SKILL.md +38 -0
  84. package/skills/wp-project-triage/references/triage.schema.json +143 -0
  85. package/skills/wp-project-triage/scripts/detect_wp_project.mjs +592 -0
  86. package/skills/wp-rest-api/SKILL.md +114 -0
  87. package/skills/wp-rest-api/references/authentication.md +18 -0
  88. package/skills/wp-rest-api/references/custom-content-types.md +20 -0
  89. package/skills/wp-rest-api/references/discovery-and-params.md +20 -0
  90. package/skills/wp-rest-api/references/responses-and-fields.md +30 -0
  91. package/skills/wp-rest-api/references/routes-and-endpoints.md +36 -0
  92. package/skills/wp-rest-api/references/schema.md +22 -0
  93. package/skills/wp-wpcli-and-ops/SKILL.md +123 -0
  94. package/skills/wp-wpcli-and-ops/references/automation.md +30 -0
  95. package/skills/wp-wpcli-and-ops/references/cron-and-cache.md +23 -0
  96. package/skills/wp-wpcli-and-ops/references/debugging.md +17 -0
  97. package/skills/wp-wpcli-and-ops/references/multisite.md +22 -0
  98. package/skills/wp-wpcli-and-ops/references/packages-and-updates.md +22 -0
  99. package/skills/wp-wpcli-and-ops/references/safety.md +30 -0
  100. package/skills/wp-wpcli-and-ops/references/search-replace.md +40 -0
  101. package/skills/wp-wpcli-and-ops/scripts/wpcli_inspect.mjs +90 -0
  102. package/skills/wpds/SKILL.md +58 -0
  103. package/workflows/create-block.md +27 -0
  104. package/workflows/wp-lint.md +27 -0
@@ -0,0 +1,458 @@
1
+ /**
2
+ * AI-powered skill update generator
3
+ *
4
+ * Analyzes upstream changes and generates updates to affected skills.
5
+ * Designed to run in GitHub Actions with ANTHROPIC_API_KEY env var.
6
+ *
7
+ * Usage:
8
+ * node shared/scripts/ai-generate-updates.mjs
9
+ *
10
+ * Environment:
11
+ * ANTHROPIC_API_KEY - Required
12
+ * AI_DRY_RUN - Set to "true" to skip writing files
13
+ */
14
+
15
+ import Anthropic from "@anthropic-ai/sdk";
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ const REPO_ROOT = process.cwd();
20
+ const REFERENCES_DIR = path.join(REPO_ROOT, "shared", "references");
21
+ const SKILLS_DIR = path.join(REPO_ROOT, "skills");
22
+ const STATE_FILE = path.join(REPO_ROOT, ".github", "state", "last-sync.json");
23
+
24
+ // Model selection: Sonnet 4 for balanced cost/performance
25
+ const MODEL = "claude-sonnet-4-20250514";
26
+
27
+ /**
28
+ * Load JSON file safely
29
+ */
30
+ function loadJson(filePath) {
31
+ try {
32
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Write JSON file with directory creation
40
+ */
41
+ function writeJson(filePath, data) {
42
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
43
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
44
+ }
45
+
46
+ /**
47
+ * Get current upstream state hash for comparison
48
+ */
49
+ function getUpstreamStateHash(indices) {
50
+ const state = {
51
+ wpLatest: indices.wordpress?.latest ?? null,
52
+ gbLatest: indices.gutenberg?.latest?.tag ?? null,
53
+ gbRecentCount: indices.gutenberg?.recent?.length ?? 0,
54
+ mapRowCount: indices.map?.rows?.length ?? 0,
55
+ };
56
+ // Simple hash: JSON stringify and take first 16 chars of base64
57
+ const hash = Buffer.from(JSON.stringify(state)).toString("base64").slice(0, 16);
58
+ return { hash, state };
59
+ }
60
+
61
+ /**
62
+ * Load all current upstream indices
63
+ */
64
+ function loadUpstreamIndices() {
65
+ return {
66
+ wordpress: loadJson(path.join(REFERENCES_DIR, "wordpress-core-versions.json")),
67
+ gutenberg: loadJson(path.join(REFERENCES_DIR, "gutenberg-releases.json")),
68
+ map: loadJson(path.join(REFERENCES_DIR, "wp-gutenberg-version-map.json")),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Load last sync state
74
+ */
75
+ function loadLastSyncState() {
76
+ return loadJson(STATE_FILE) ?? { hash: null, state: null, lastSync: null };
77
+ }
78
+
79
+ /**
80
+ * Save current sync state
81
+ */
82
+ function saveSyncState(hash, state, changes) {
83
+ writeJson(STATE_FILE, {
84
+ hash,
85
+ state,
86
+ lastSync: new Date().toISOString(),
87
+ lastChanges: changes,
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Detect what changed between last sync and current state
93
+ */
94
+ function detectChanges(lastState, currentState) {
95
+ const changes = [];
96
+
97
+ if (!lastState.state) {
98
+ changes.push({
99
+ type: "initial-sync",
100
+ description: "First sync - no previous state",
101
+ riskLevel: "low",
102
+ });
103
+ return changes;
104
+ }
105
+
106
+ const last = lastState.state;
107
+ const current = currentState.state;
108
+
109
+ // WordPress version change
110
+ if (last.wpLatest !== current.wpLatest) {
111
+ changes.push({
112
+ type: "wordpress-release",
113
+ description: `WordPress updated: ${last.wpLatest} → ${current.wpLatest}`,
114
+ oldVersion: last.wpLatest,
115
+ newVersion: current.wpLatest,
116
+ riskLevel: "medium",
117
+ affectedSkills: [
118
+ "wp-block-themes",
119
+ "wp-block-development",
120
+ "wp-plugin-development",
121
+ ],
122
+ });
123
+ }
124
+
125
+ // Gutenberg version change
126
+ if (last.gbLatest !== current.gbLatest) {
127
+ changes.push({
128
+ type: "gutenberg-release",
129
+ description: `Gutenberg updated: ${last.gbLatest} → ${current.gbLatest}`,
130
+ oldVersion: last.gbLatest,
131
+ newVersion: current.gbLatest,
132
+ riskLevel: "medium",
133
+ affectedSkills: [
134
+ "wp-interactivity-api",
135
+ "wp-abilities-api",
136
+ "wp-block-development",
137
+ ],
138
+ });
139
+ }
140
+
141
+ // Map table updated (new mappings added)
142
+ if (last.mapRowCount !== current.mapRowCount) {
143
+ changes.push({
144
+ type: "version-map-update",
145
+ description: `WP↔Gutenberg mapping updated: ${last.mapRowCount} → ${current.mapRowCount} entries`,
146
+ riskLevel: "low",
147
+ affectedSkills: ["wordpress-router"],
148
+ });
149
+ }
150
+
151
+ return changes;
152
+ }
153
+
154
+ /**
155
+ * Load a skill's SKILL.md content
156
+ */
157
+ function loadSkillContent(skillName) {
158
+ const skillPath = path.join(SKILLS_DIR, skillName, "SKILL.md");
159
+ try {
160
+ return fs.readFileSync(skillPath, "utf8");
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Load all reference docs for a skill
168
+ */
169
+ function loadSkillReferences(skillName) {
170
+ const refsDir = path.join(SKILLS_DIR, skillName, "references");
171
+ const refs = {};
172
+ try {
173
+ const files = fs.readdirSync(refsDir);
174
+ for (const file of files) {
175
+ if (file.endsWith(".md")) {
176
+ refs[file] = fs.readFileSync(path.join(refsDir, file), "utf8");
177
+ }
178
+ }
179
+ } catch {
180
+ // No references dir
181
+ }
182
+ return refs;
183
+ }
184
+
185
+ /**
186
+ * Build the prompt for AI analysis
187
+ */
188
+ function buildAnalysisPrompt(changes, indices) {
189
+ const changesSummary = changes
190
+ .map((c) => `- ${c.type}: ${c.description} (risk: ${c.riskLevel})`)
191
+ .join("\n");
192
+
193
+ return `You are analyzing upstream changes to WordPress/Gutenberg to determine if skills need updates.
194
+
195
+ ## Detected Changes
196
+ ${changesSummary}
197
+
198
+ ## Current Upstream State
199
+ - WordPress latest: ${indices.wordpress?.latest ?? "unknown"}
200
+ - Gutenberg latest: ${indices.gutenberg?.latest?.tag ?? "unknown"}
201
+ - Gutenberg release URL: ${indices.gutenberg?.latest?.url ?? "unknown"}
202
+
203
+ ## Task
204
+ Analyze these changes and determine:
205
+ 1. Which skills are likely affected and why
206
+ 2. What specific updates might be needed (procedures, references, examples)
207
+ 3. Risk assessment for each potential update
208
+
209
+ Respond in JSON format:
210
+ {
211
+ "analysis": "Brief overall analysis",
212
+ "skillUpdates": [
213
+ {
214
+ "skill": "skill-name",
215
+ "reason": "Why this skill is affected",
216
+ "suggestedChanges": ["List of specific changes to make"],
217
+ "riskLevel": "low|medium|high",
218
+ "priority": 1-5
219
+ }
220
+ ],
221
+ "skipUpdate": true/false,
222
+ "skipReason": "If skipping, explain why no updates are needed"
223
+ }`;
224
+ }
225
+
226
+ /**
227
+ * Build prompt for generating skill updates
228
+ */
229
+ function buildUpdatePrompt(skillName, skillContent, references, changes, indices) {
230
+ const relevantChanges = changes.filter(
231
+ (c) => c.affectedSkills?.includes(skillName)
232
+ );
233
+
234
+ const refsSummary = Object.entries(references)
235
+ .map(([name, content]) => `### ${name}\n${content.slice(0, 2000)}...`)
236
+ .join("\n\n");
237
+
238
+ return `You are updating a WordPress development skill based on upstream changes.
239
+
240
+ ## Skill: ${skillName}
241
+
242
+ ## Current SKILL.md
243
+ ${skillContent}
244
+
245
+ ## Current References (truncated)
246
+ ${refsSummary || "(no references)"}
247
+
248
+ ## Relevant Changes
249
+ ${relevantChanges.map((c) => `- ${c.description}`).join("\n")}
250
+
251
+ ## Upstream Context
252
+ - WordPress latest: ${indices.wordpress?.latest ?? "unknown"}
253
+ - Gutenberg latest: ${indices.gutenberg?.latest?.tag ?? "unknown"}
254
+
255
+ ## Instructions
256
+ 1. Review the current skill content
257
+ 2. Identify what needs to change based on the upstream updates
258
+ 3. Generate updated content that:
259
+ - Preserves the existing structure and tone
260
+ - Updates version references if needed
261
+ - Adds notes about new features/changes if relevant
262
+ - Does NOT remove existing content unless it's deprecated
263
+
264
+ Respond in JSON format:
265
+ {
266
+ "skillUpdated": true/false,
267
+ "changes": [
268
+ {
269
+ "file": "SKILL.md or references/filename.md",
270
+ "description": "What changed",
271
+ "newContent": "Full new file content (only if changed)"
272
+ }
273
+ ],
274
+ "summary": "Brief summary of changes for PR description",
275
+ "noChangeReason": "If no changes needed, explain why"
276
+ }`;
277
+ }
278
+
279
+ /**
280
+ * Call Claude API
281
+ */
282
+ async function callClaude(client, prompt, systemPrompt = null) {
283
+ const messages = [{ role: "user", content: prompt }];
284
+
285
+ const response = await client.messages.create({
286
+ model: MODEL,
287
+ max_tokens: 8192,
288
+ system: systemPrompt ?? "You are a technical writer maintaining WordPress development skills documentation. Always respond with valid JSON.",
289
+ messages,
290
+ });
291
+
292
+ const text = response.content
293
+ .filter((b) => b.type === "text")
294
+ .map((b) => b.text)
295
+ .join("");
296
+
297
+ // Extract JSON from response (handle markdown code blocks)
298
+ const jsonMatch = text.match(/```json\s*([\s\S]*?)\s*```/) ||
299
+ text.match(/```\s*([\s\S]*?)\s*```/) ||
300
+ [null, text];
301
+
302
+ try {
303
+ return JSON.parse(jsonMatch[1] || text);
304
+ } catch (e) {
305
+ console.error("Failed to parse Claude response as JSON:", text.slice(0, 500));
306
+ throw new Error(`Invalid JSON response from Claude: ${e.message}`);
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Main execution
312
+ */
313
+ async function main() {
314
+ const dryRun = process.env.AI_DRY_RUN === "true";
315
+
316
+ if (!process.env.ANTHROPIC_API_KEY) {
317
+ console.error("ERROR: ANTHROPIC_API_KEY environment variable is required");
318
+ process.exit(1);
319
+ }
320
+
321
+ const client = new Anthropic();
322
+
323
+ console.log("=== AI Skill Update Generator ===\n");
324
+
325
+ // 1. Load current state
326
+ console.log("Loading upstream indices...");
327
+ const indices = loadUpstreamIndices();
328
+ const currentState = getUpstreamStateHash(indices);
329
+ const lastState = loadLastSyncState();
330
+
331
+ console.log(`Current state hash: ${currentState.hash}`);
332
+ console.log(`Last sync hash: ${lastState.hash ?? "(none)"}\n`);
333
+
334
+ // 2. Detect changes
335
+ console.log("Detecting changes...");
336
+ const changes = detectChanges(lastState, currentState);
337
+
338
+ if (changes.length === 0) {
339
+ console.log("No changes detected. Exiting.");
340
+ process.exit(0);
341
+ }
342
+
343
+ console.log(`Found ${changes.length} change(s):`);
344
+ for (const c of changes) {
345
+ console.log(` - ${c.type}: ${c.description}`);
346
+ }
347
+ console.log();
348
+
349
+ // 3. AI analysis of impact
350
+ console.log("Analyzing impact with AI...");
351
+ const analysisPrompt = buildAnalysisPrompt(changes, indices);
352
+ const analysis = await callClaude(client, analysisPrompt);
353
+
354
+ console.log(`Analysis: ${analysis.analysis}\n`);
355
+
356
+ if (analysis.skipUpdate) {
357
+ console.log(`Skipping updates: ${analysis.skipReason}`);
358
+ saveSyncState(currentState.hash, currentState.state, changes);
359
+ process.exit(0);
360
+ }
361
+
362
+ // 4. Generate updates for affected skills
363
+ const updates = [];
364
+ const skillsToUpdate = analysis.skillUpdates
365
+ .filter((s) => s.priority >= 3 || s.riskLevel !== "low")
366
+ .sort((a, b) => b.priority - a.priority);
367
+
368
+ console.log(`Skills to update: ${skillsToUpdate.map((s) => s.skill).join(", ")}\n`);
369
+
370
+ for (const skillUpdate of skillsToUpdate) {
371
+ const { skill } = skillUpdate;
372
+ console.log(`Processing ${skill}...`);
373
+
374
+ const skillContent = loadSkillContent(skill);
375
+ if (!skillContent) {
376
+ console.log(` Skill ${skill} not found, skipping.`);
377
+ continue;
378
+ }
379
+
380
+ const references = loadSkillReferences(skill);
381
+ const updatePrompt = buildUpdatePrompt(
382
+ skill,
383
+ skillContent,
384
+ references,
385
+ changes,
386
+ indices
387
+ );
388
+
389
+ const updateResult = await callClaude(client, updatePrompt);
390
+
391
+ if (updateResult.skillUpdated && updateResult.changes?.length > 0) {
392
+ updates.push({
393
+ skill,
394
+ ...updateResult,
395
+ });
396
+ console.log(` ${updateResult.changes.length} file(s) to update`);
397
+ } else {
398
+ console.log(` No changes needed: ${updateResult.noChangeReason}`);
399
+ }
400
+ }
401
+
402
+ // 5. Write updates
403
+ if (updates.length === 0) {
404
+ console.log("\nNo skill updates generated.");
405
+ saveSyncState(currentState.hash, currentState.state, changes);
406
+ process.exit(0);
407
+ }
408
+
409
+ console.log(`\n=== Writing ${updates.length} skill update(s) ===\n`);
410
+
411
+ for (const update of updates) {
412
+ for (const change of update.changes) {
413
+ const filePath = path.join(SKILLS_DIR, update.skill, change.file);
414
+ console.log(`Writing: ${filePath}`);
415
+ console.log(` ${change.description}`);
416
+
417
+ if (!dryRun && change.newContent) {
418
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
419
+ fs.writeFileSync(filePath, change.newContent, "utf8");
420
+ }
421
+ }
422
+ }
423
+
424
+ // 6. Save state
425
+ saveSyncState(currentState.hash, currentState.state, changes);
426
+
427
+ // 7. Output summary for GitHub Actions
428
+ const summary = updates
429
+ .map((u) => `- **${u.skill}**: ${u.summary}`)
430
+ .join("\n");
431
+
432
+ console.log("\n=== Summary ===");
433
+ console.log(summary);
434
+
435
+ // Write summary to file for GitHub Actions to pick up
436
+ const summaryFile = path.join(REPO_ROOT, ".github", "state", "update-summary.md");
437
+ writeJson(summaryFile.replace(".md", ".json"), {
438
+ timestamp: new Date().toISOString(),
439
+ changes,
440
+ analysis: analysis.analysis,
441
+ updates: updates.map((u) => ({
442
+ skill: u.skill,
443
+ summary: u.summary,
444
+ files: u.changes.map((c) => c.file),
445
+ })),
446
+ });
447
+
448
+ if (dryRun) {
449
+ console.log("\n(Dry run - no files were written)");
450
+ }
451
+
452
+ console.log("\nDone.");
453
+ }
454
+
455
+ main().catch((err) => {
456
+ console.error("Fatal error:", err);
457
+ process.exit(1);
458
+ });
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function usage() {
5
+ process.stderr.write(
6
+ [
7
+ "Usage:",
8
+ ' node shared/scripts/scaffold-skill.mjs <skill-name> "<description>"',
9
+ "",
10
+ "Notes:",
11
+ "- <skill-name> must be lowercase unicode letters/digits with hyphens (no leading/trailing hyphen, no --).",
12
+ "- Creates skills/<skill-name>/SKILL.md and eval/scenarios/<skill-name>.md",
13
+ "",
14
+ ].join("\n")
15
+ );
16
+ }
17
+
18
+ function assert(condition, message) {
19
+ if (!condition) throw new Error(message);
20
+ }
21
+
22
+ function validateSkillName(name) {
23
+ if (!name || typeof name !== "string") return "Missing skill name";
24
+ if (name.length > 64) return `Skill name exceeds 64 chars (${name.length})`;
25
+ if (name !== name.toLowerCase()) return "Skill name must be lowercase";
26
+ if (name.startsWith("-") || name.endsWith("-")) return "Skill name cannot start or end with hyphen";
27
+ if (name.includes("--")) return "Skill name cannot contain consecutive hyphens";
28
+ const ok = /^[\p{Ll}\p{Nd}]+(?:-[\p{Ll}\p{Nd}]+)*$/u.test(name);
29
+ if (!ok) return "Skill name contains invalid characters";
30
+ return null;
31
+ }
32
+
33
+ function main() {
34
+ const [, , skillName, description] = process.argv;
35
+ if (!skillName || !description) {
36
+ usage();
37
+ process.exit(2);
38
+ }
39
+
40
+ const nameError = validateSkillName(skillName);
41
+ assert(!nameError, nameError);
42
+ assert(description.length > 0 && description.length <= 1024, "Description must be 1-1024 characters");
43
+
44
+ const repoRoot = process.cwd();
45
+ const skillDir = path.join(repoRoot, "skills", skillName);
46
+ const skillMd = path.join(skillDir, "SKILL.md");
47
+ const scenarioPath = path.join(repoRoot, "eval", "scenarios", `${skillName}.md`);
48
+
49
+ assert(!fs.existsSync(skillDir), `Skill directory already exists: ${path.relative(repoRoot, skillDir)}`);
50
+ fs.mkdirSync(skillDir, { recursive: true });
51
+
52
+ const skillBody = `---\nname: ${skillName}\ndescription: ${description}\ncompatibility: Targets WordPress 6.9+ (PHP 7.2.24+). Filesystem-based agent with bash + node.\n---\n\n# ${skillName}\n\n## When to use\n\n## Inputs required\n\n## Procedure\n\n## Verification\n\n## Failure modes / debugging\n\n## Escalation\n`;
53
+ fs.writeFileSync(skillMd, skillBody, "utf8");
54
+
55
+ fs.mkdirSync(path.dirname(scenarioPath), { recursive: true });
56
+ const scenario = `# Scenario: ${skillName}\n\n## Prompt\n\n## Expected behavior\n\n- Uses \`${skillName}\` when the prompt matches its description.\n- Follows the skill procedure and verifies results.\n`;
57
+ fs.writeFileSync(scenarioPath, scenario, "utf8");
58
+
59
+ process.stdout.write(`OK: created ${path.relative(repoRoot, skillMd)} and ${path.relative(repoRoot, scenarioPath)}\n`);
60
+ }
61
+
62
+ main();
@@ -0,0 +1,165 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function usage() {
5
+ process.stderr.write(
6
+ [
7
+ "Usage:",
8
+ " node shared/scripts/skillpack-build.mjs [--out=dist] [--targets=codex,vscode,claude] [--skills=skill1,skill2] [--clean]",
9
+ "",
10
+ "Outputs:",
11
+ " - <out>/codex/.codex/skills/<skill>/SKILL.md",
12
+ " - <out>/vscode/.github/skills/<skill>/SKILL.md",
13
+ " - <out>/claude/.claude/skills/<skill>/SKILL.md",
14
+ "",
15
+ "Options:",
16
+ " --targets Comma-separated list of targets (codex, vscode, claude). Default: codex,vscode,claude",
17
+ " --skills Comma-separated list of skill names to build. Default: all skills",
18
+ " --clean Remove target directories before building",
19
+ "",
20
+ "Notes:",
21
+ "- Avoids symlinks (Codex ignores symlinked directories).",
22
+ "",
23
+ ].join("\n")
24
+ );
25
+ }
26
+
27
+ function parseArgs(argv) {
28
+ const args = { out: "dist", targets: ["codex", "vscode", "claude"], skills: [], clean: false };
29
+ for (const a of argv) {
30
+ if (a === "--help" || a === "-h") args.help = true;
31
+ else if (a === "--clean") args.clean = true;
32
+ else if (a.startsWith("--out=")) args.out = a.slice("--out=".length);
33
+ else if (a.startsWith("--targets=")) args.targets = a.slice("--targets=".length).split(",").filter(Boolean);
34
+ else if (a.startsWith("--skills=")) args.skills = a.slice("--skills=".length).split(",").filter(Boolean);
35
+ else {
36
+ process.stderr.write(`Unknown arg: ${a}\n`);
37
+ args.help = true;
38
+ }
39
+ }
40
+ return args;
41
+ }
42
+
43
+ function assert(condition, message) {
44
+ if (!condition) throw new Error(message);
45
+ }
46
+
47
+ function isSymlink(p) {
48
+ try {
49
+ return fs.lstatSync(p).isSymbolicLink();
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ function copyFileSyncPreserveMode(src, dest) {
56
+ const st = fs.statSync(src);
57
+ fs.copyFileSync(src, dest);
58
+ fs.chmodSync(dest, st.mode);
59
+ }
60
+
61
+ function copyDir({ srcDir, destDir }) {
62
+ assert(!isSymlink(srcDir), `Refusing to copy symlink dir: ${srcDir}`);
63
+ fs.mkdirSync(destDir, { recursive: true });
64
+
65
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
66
+ for (const ent of entries) {
67
+ if (ent.name === ".DS_Store") continue;
68
+ const src = path.join(srcDir, ent.name);
69
+ const dest = path.join(destDir, ent.name);
70
+
71
+ if (isSymlink(src)) {
72
+ throw new Error(`Refusing to copy symlink: ${src}`);
73
+ }
74
+
75
+ if (ent.isDirectory()) {
76
+ copyDir({ srcDir: src, destDir: dest });
77
+ continue;
78
+ }
79
+ if (ent.isFile()) {
80
+ copyFileSyncPreserveMode(src, dest);
81
+ continue;
82
+ }
83
+ // Ignore sockets, devices, etc.
84
+ }
85
+ }
86
+
87
+ function listSkillDirs(skillsRoot) {
88
+ if (!fs.existsSync(skillsRoot)) return [];
89
+ const dirs = fs
90
+ .readdirSync(skillsRoot, { withFileTypes: true })
91
+ .filter((d) => d.isDirectory())
92
+ .map((d) => path.join(skillsRoot, d.name));
93
+
94
+ return dirs.filter((d) => fs.existsSync(path.join(d, "SKILL.md")));
95
+ }
96
+
97
+ function buildTarget({ repoRoot, outDir, target, skillDirs }) {
98
+ const rootByTarget = {
99
+ codex: path.join(outDir, "codex", ".codex", "skills"),
100
+ vscode: path.join(outDir, "vscode", ".github", "skills"),
101
+ claude: path.join(outDir, "claude", ".claude", "skills"),
102
+ };
103
+ const destSkillsRoot = rootByTarget[target];
104
+ assert(destSkillsRoot, `Unknown target: ${target}`);
105
+
106
+ fs.mkdirSync(destSkillsRoot, { recursive: true });
107
+
108
+ for (const srcSkillDir of skillDirs) {
109
+ const name = path.basename(srcSkillDir);
110
+ const destSkillDir = path.join(destSkillsRoot, name);
111
+ copyDir({ srcDir: srcSkillDir, destDir: destSkillDir });
112
+ }
113
+
114
+ const rel = path.relative(repoRoot, destSkillsRoot);
115
+ process.stdout.write(`OK: built ${target} skillpack at ${rel}\n`);
116
+ }
117
+
118
+ const VALID_TARGETS = ["codex", "vscode", "claude"];
119
+
120
+ function main() {
121
+ const args = parseArgs(process.argv.slice(2));
122
+ if (args.help) {
123
+ usage();
124
+ process.exit(2);
125
+ }
126
+
127
+ const repoRoot = process.cwd();
128
+ const skillsRoot = path.join(repoRoot, "skills");
129
+ const outDir = path.isAbsolute(args.out) ? args.out : path.join(repoRoot, args.out);
130
+
131
+ let skillDirs = listSkillDirs(skillsRoot);
132
+ assert(skillDirs.length > 0, "No skills found under ./skills");
133
+
134
+ // Filter skills if --skills was specified
135
+ if (args.skills.length > 0) {
136
+ const requestedSkills = new Set(args.skills);
137
+ const availableSkills = skillDirs.map((d) => path.basename(d));
138
+
139
+ // Validate requested skills exist
140
+ for (const s of requestedSkills) {
141
+ assert(availableSkills.includes(s), `Unknown skill: ${s}. Available: ${availableSkills.join(", ")}`);
142
+ }
143
+
144
+ skillDirs = skillDirs.filter((d) => requestedSkills.has(path.basename(d)));
145
+ }
146
+
147
+ const targets = [...new Set(args.targets)];
148
+ for (const t of targets) {
149
+ assert(VALID_TARGETS.includes(t), `Invalid target: ${t}. Valid targets: ${VALID_TARGETS.join(", ")}`);
150
+ }
151
+
152
+ if (args.clean) {
153
+ for (const t of targets) {
154
+ const targetDir = path.join(outDir, t);
155
+ fs.rmSync(targetDir, { recursive: true, force: true });
156
+ }
157
+ }
158
+
159
+ for (const target of targets) {
160
+ buildTarget({ repoRoot, outDir, target, skillDirs });
161
+ }
162
+ }
163
+
164
+ main();
165
+