@heyclaude/mcp 0.1.2 → 0.3.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.
- package/CHANGELOG.md +25 -0
- package/README.md +71 -11
- package/package.json +1 -1
- package/scripts/validate-endpoint.mjs +236 -12
- package/src/package-metadata.js +4 -7
- package/src/registry.d.ts +139 -0
- package/src/registry.js +2154 -61
- package/src/remote-proxy.d.ts +20 -0
- package/src/remote-proxy.js +155 -42
- package/src/schemas.d.ts +15 -0
- package/src/schemas.js +181 -1
- package/src/server.js +40 -1
- package/src/submissions.d.ts +14 -2
- package/src/submissions.js +464 -67
package/src/submissions.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
export const SUBMISSION_SITE_URL = "https://heyclau.de/submit";
|
|
2
|
-
export const GITHUB_NEW_ISSUE_URL =
|
|
3
|
-
"https://github.com/JSONbored/awesome-claude/issues/new";
|
|
4
|
-
|
|
5
|
-
const defaultLabels = ["content-submission", "needs-review"];
|
|
6
2
|
|
|
7
3
|
function normalizeText(value) {
|
|
8
4
|
return String(value || "").trim();
|
|
@@ -78,28 +74,31 @@ function isHttpsUrl(value) {
|
|
|
78
74
|
}
|
|
79
75
|
}
|
|
80
76
|
|
|
77
|
+
const TRACKING_QUERY_KEYS = new Set([
|
|
78
|
+
"aff",
|
|
79
|
+
"affiliate",
|
|
80
|
+
"affiliate_id",
|
|
81
|
+
"campaign",
|
|
82
|
+
"coupon",
|
|
83
|
+
"irclickid",
|
|
84
|
+
"partner",
|
|
85
|
+
"referral",
|
|
86
|
+
"referral_code",
|
|
87
|
+
"via",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
function isTrackingQueryKey(key) {
|
|
91
|
+
const normalized = key.trim().toLowerCase();
|
|
92
|
+
return normalized.startsWith("utm_") || TRACKING_QUERY_KEYS.has(normalized);
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
function isLikelyAffiliateUrl(value) {
|
|
82
96
|
const trimmed = normalizeText(value);
|
|
83
97
|
if (!trimmed) return false;
|
|
84
98
|
try {
|
|
85
99
|
const url = new URL(trimmed);
|
|
86
|
-
const affiliateParams = new Set([
|
|
87
|
-
"aff",
|
|
88
|
-
"affiliate",
|
|
89
|
-
"affiliate_id",
|
|
90
|
-
"campaign",
|
|
91
|
-
"coupon",
|
|
92
|
-
"irclickid",
|
|
93
|
-
"partner",
|
|
94
|
-
"referral",
|
|
95
|
-
"referral_code",
|
|
96
|
-
"via",
|
|
97
|
-
]);
|
|
98
100
|
for (const key of url.searchParams.keys()) {
|
|
99
|
-
|
|
100
|
-
if (normalized.startsWith("utm_") || affiliateParams.has(normalized)) {
|
|
101
|
-
return true;
|
|
102
|
-
}
|
|
101
|
+
if (isTrackingQueryKey(key)) return true;
|
|
103
102
|
}
|
|
104
103
|
} catch {
|
|
105
104
|
return false;
|
|
@@ -166,6 +165,32 @@ function compactWhitespace(value) {
|
|
|
166
165
|
return output.trim();
|
|
167
166
|
}
|
|
168
167
|
|
|
168
|
+
function splitList(value) {
|
|
169
|
+
const items = [];
|
|
170
|
+
let current = "";
|
|
171
|
+
for (const char of String(value || "")) {
|
|
172
|
+
if (char === "\n" || char === ",") {
|
|
173
|
+
const next = current.trim();
|
|
174
|
+
if (next) items.push(next);
|
|
175
|
+
current = "";
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
current += char;
|
|
179
|
+
}
|
|
180
|
+
const next = current.trim();
|
|
181
|
+
if (next) items.push(next);
|
|
182
|
+
return items;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isIsoDate(value) {
|
|
186
|
+
const text = normalizeText(value);
|
|
187
|
+
if (text.length !== 10) return false;
|
|
188
|
+
return [...text].every((char, index) => {
|
|
189
|
+
if (index === 4 || index === 7) return char === "-";
|
|
190
|
+
return char >= "0" && char <= "9";
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
169
194
|
function tagsToText(value) {
|
|
170
195
|
return Array.isArray(value)
|
|
171
196
|
? value.map(normalizeText).filter(Boolean).join(", ")
|
|
@@ -217,14 +242,6 @@ function modelFor(spec, category) {
|
|
|
217
242
|
return spec?.categories?.[category] || null;
|
|
218
243
|
}
|
|
219
244
|
|
|
220
|
-
function templateFor(spec, category) {
|
|
221
|
-
return spec?.issueTemplates?.[category] || null;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function labelsFor(spec, category) {
|
|
225
|
-
return templateFor(spec, category)?.labels || defaultLabels;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
245
|
function requiredFields(model) {
|
|
229
246
|
return (model?.fields || [])
|
|
230
247
|
.filter((field) => field.required)
|
|
@@ -235,6 +252,23 @@ function fieldLabels(model) {
|
|
|
235
252
|
return new Map((model?.fields || []).map((field) => [field.id, field.label]));
|
|
236
253
|
}
|
|
237
254
|
|
|
255
|
+
function fieldOptions(model, fieldId) {
|
|
256
|
+
const field = (model?.fields || []).find((item) => item.id === fieldId);
|
|
257
|
+
return new Set((field?.options || []).map(normalizeLower).filter(Boolean));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function validateSelectOptions(model, normalized, errors) {
|
|
261
|
+
for (const field of model?.fields || []) {
|
|
262
|
+
if (!Array.isArray(field.options) || !field.options.length) continue;
|
|
263
|
+
const value = normalizeLower(normalized[field.id]);
|
|
264
|
+
if (!value) continue;
|
|
265
|
+
const validValues = new Set(field.options.map(normalizeLower));
|
|
266
|
+
if (!validValues.has(value)) {
|
|
267
|
+
errors.push(`Invalid ${field.id}: ${value}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
238
272
|
function selectedCategory(spec, category) {
|
|
239
273
|
const normalized = normalizeLower(category);
|
|
240
274
|
return categoryKeys(spec).includes(normalized) ? normalized : "";
|
|
@@ -300,12 +334,21 @@ function validateAgainstSpec(spec, fields = {}) {
|
|
|
300
334
|
if (normalized.brand_domain && !isCanonicalDomain(normalized.brand_domain)) {
|
|
301
335
|
errors.push("brand_domain must be a canonical domain such as asana.com.");
|
|
302
336
|
}
|
|
337
|
+
|
|
338
|
+
validateSelectOptions(model, normalized, errors);
|
|
339
|
+
|
|
303
340
|
if (
|
|
304
341
|
category === "skills" &&
|
|
305
342
|
!normalizeText(normalized.install_command) &&
|
|
306
|
-
!normalizeText(normalized.download_url)
|
|
343
|
+
!normalizeText(normalized.download_url) &&
|
|
344
|
+
!normalizeText(normalized.github_url) &&
|
|
345
|
+
!normalizeText(normalized.docs_url) &&
|
|
346
|
+
!normalizeText(normalized.full_copyable_content) &&
|
|
347
|
+
!normalizeText(normalized.retrieval_sources)
|
|
307
348
|
) {
|
|
308
|
-
errors.push(
|
|
349
|
+
errors.push(
|
|
350
|
+
"Skills submissions require install_command, source URL, retrieval_sources, or full_copyable_content.",
|
|
351
|
+
);
|
|
309
352
|
}
|
|
310
353
|
if (category === "collections" && !normalizeText(normalized.items)) {
|
|
311
354
|
errors.push("Collections submissions require items.");
|
|
@@ -313,6 +356,32 @@ function validateAgainstSpec(spec, fields = {}) {
|
|
|
313
356
|
if (category === "guides" && !normalizeText(normalized.guide_content)) {
|
|
314
357
|
errors.push("Guide submissions require guide_content.");
|
|
315
358
|
}
|
|
359
|
+
if (category === "skills") {
|
|
360
|
+
const skillType = normalizeLower(normalized.skill_type);
|
|
361
|
+
const skillLevel = normalizeLower(normalized.skill_level);
|
|
362
|
+
const verifiedAt = normalizeText(normalized.verified_at);
|
|
363
|
+
const retrievalSources = normalizeText(normalized.retrieval_sources);
|
|
364
|
+
if (verifiedAt && !isIsoDate(verifiedAt)) {
|
|
365
|
+
errors.push("verified_at must use YYYY-MM-DD format.");
|
|
366
|
+
}
|
|
367
|
+
if (skillType === "capability-pack") {
|
|
368
|
+
if (!verifiedAt)
|
|
369
|
+
errors.push("capability-pack skills require verified_at.");
|
|
370
|
+
if (!retrievalSources) {
|
|
371
|
+
errors.push("capability-pack skills require retrieval_sources.");
|
|
372
|
+
}
|
|
373
|
+
if (skillLevel && skillLevel !== "expert") {
|
|
374
|
+
errors.push("capability-pack skills must use skill_level=expert.");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (retrievalSources) {
|
|
378
|
+
for (const url of splitList(retrievalSources)) {
|
|
379
|
+
if (!isHttpsUrl(url)) {
|
|
380
|
+
errors.push(`retrieval_sources must use https URLs: ${url}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
316
385
|
if (!normalized.github_url && !normalized.docs_url) {
|
|
317
386
|
warnings.push("No github_url/docs_url provided.");
|
|
318
387
|
}
|
|
@@ -331,7 +400,7 @@ function validateAgainstSpec(spec, fields = {}) {
|
|
|
331
400
|
};
|
|
332
401
|
}
|
|
333
402
|
|
|
334
|
-
export function
|
|
403
|
+
export function buildPrDraftFromSpec(spec, fields = {}) {
|
|
335
404
|
const validation = validateAgainstSpec(spec, fields);
|
|
336
405
|
const category = validation.category;
|
|
337
406
|
const model = modelFor(spec, category);
|
|
@@ -362,9 +431,8 @@ export function buildIssueDraftFromSpec(spec, fields = {}) {
|
|
|
362
431
|
const label = modelLabel.endsWith("s") ? modelLabel.slice(0, -1) : modelLabel;
|
|
363
432
|
|
|
364
433
|
return {
|
|
365
|
-
title: `
|
|
434
|
+
title: `Add ${label}: ${validation.normalized.name || "New directory entry"}`,
|
|
366
435
|
body,
|
|
367
|
-
labels: labelsFor(spec, category),
|
|
368
436
|
};
|
|
369
437
|
}
|
|
370
438
|
|
|
@@ -377,26 +445,16 @@ export function buildSubmissionUrlsFromSpec(spec, args = {}) {
|
|
|
377
445
|
const fields = normalizeSubmissionFields(args.fields || {});
|
|
378
446
|
const validation = validateAgainstSpec(spec, fields);
|
|
379
447
|
const category = validation.category || fields.category || "";
|
|
380
|
-
const
|
|
381
|
-
const template = templateFor(spec, category)?.template || "submit-entry.md";
|
|
382
|
-
|
|
448
|
+
const prDraft = buildPrDraftFromSpec(spec, fields);
|
|
383
449
|
const submitUrl = new URL(SUBMISSION_SITE_URL);
|
|
384
|
-
const issueUrl = new URL(GITHUB_NEW_ISSUE_URL);
|
|
385
|
-
issueUrl.searchParams.set("template", template);
|
|
386
450
|
|
|
387
451
|
for (const [key, value] of Object.entries(fields)) {
|
|
388
452
|
if (key === "source_url" && (fields.github_url || fields.docs_url))
|
|
389
453
|
continue;
|
|
390
454
|
setParam(submitUrl.searchParams, key, value);
|
|
391
|
-
if (key !== "source_url") setParam(issueUrl.searchParams, key, value);
|
|
392
455
|
}
|
|
393
456
|
if (category) {
|
|
394
457
|
submitUrl.searchParams.set("category", category);
|
|
395
|
-
issueUrl.searchParams.set("category", category);
|
|
396
|
-
}
|
|
397
|
-
if (fields.name) {
|
|
398
|
-
issueUrl.searchParams.set("title", issueDraft.title);
|
|
399
|
-
issueUrl.searchParams.set("name", fields.name);
|
|
400
458
|
}
|
|
401
459
|
|
|
402
460
|
return {
|
|
@@ -405,17 +463,17 @@ export function buildSubmissionUrlsFromSpec(spec, args = {}) {
|
|
|
405
463
|
category,
|
|
406
464
|
slug: fields.slug || "",
|
|
407
465
|
submitUrl: submitUrl.toString(),
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
? issueDraft
|
|
411
|
-
: { title: issueDraft.title, labels: issueDraft.labels },
|
|
466
|
+
reviewUrl: submitUrl.toString(),
|
|
467
|
+
prDraft: args.includePrBody ? prDraft : { title: prDraft.title },
|
|
412
468
|
validation: {
|
|
413
469
|
errors: validation.errors,
|
|
414
470
|
warnings: validation.warnings,
|
|
415
471
|
missingRequiredFields: validation.missingRequiredFields || [],
|
|
416
472
|
},
|
|
417
473
|
reviewModel:
|
|
418
|
-
"
|
|
474
|
+
"PR-first: source-backed submissions are opened as single-entry PRs. Content-only PRs may be merged automatically after content validation, Superagent, and private maintainer-agent review pass.",
|
|
475
|
+
artifactPolicy:
|
|
476
|
+
"Community ZIP/MCPB artifacts are review material only and are not published as HeyClaude-hosted downloads.",
|
|
419
477
|
};
|
|
420
478
|
}
|
|
421
479
|
|
|
@@ -437,16 +495,14 @@ export function getSubmissionSchemaFromSpec(spec, args = {}) {
|
|
|
437
495
|
categories: categoryKeys(spec),
|
|
438
496
|
category: category || "",
|
|
439
497
|
schema: category ? spec.categories[category] : spec.categories,
|
|
440
|
-
|
|
441
|
-
? spec.issueTemplates?.[category]
|
|
442
|
-
: spec.issueTemplates,
|
|
498
|
+
prIntake: spec.prIntake || null,
|
|
443
499
|
};
|
|
444
500
|
}
|
|
445
501
|
|
|
446
502
|
export function validateSubmissionDraftFromSpec(spec, args = {}) {
|
|
447
503
|
const validation = validateAgainstSpec(spec, args.fields || {});
|
|
448
|
-
const
|
|
449
|
-
?
|
|
504
|
+
const prDraft = validation.category
|
|
505
|
+
? buildPrDraftFromSpec(spec, validation.normalized)
|
|
450
506
|
: null;
|
|
451
507
|
|
|
452
508
|
return {
|
|
@@ -459,30 +515,368 @@ export function validateSubmissionDraftFromSpec(spec, args = {}) {
|
|
|
459
515
|
missingRequiredFields: validation.missingRequiredFields || [],
|
|
460
516
|
errors: validation.errors,
|
|
461
517
|
warnings: validation.warnings,
|
|
462
|
-
|
|
463
|
-
? {
|
|
518
|
+
prPreview: prDraft
|
|
519
|
+
? {
|
|
520
|
+
title: prDraft.title,
|
|
521
|
+
}
|
|
464
522
|
: null,
|
|
465
523
|
nextSteps: validation.valid
|
|
466
524
|
? [
|
|
467
525
|
"Check for duplicate registry entries.",
|
|
468
|
-
"Open the generated HeyClaude submit URL
|
|
469
|
-
"
|
|
526
|
+
"Open the generated HeyClaude submit URL and continue with GitHub.",
|
|
527
|
+
"Source-backed, non-artifact submissions are reviewed through the private PR gate.",
|
|
528
|
+
"Eligible content PRs may be merged automatically after validation, Superagent, and private maintainer-agent review pass.",
|
|
529
|
+
]
|
|
530
|
+
: ["Fix validation errors before opening a public submission PR."],
|
|
531
|
+
artifactPolicy:
|
|
532
|
+
"Do not request HeyClaude /downloads hosting for community-submitted ZIP/MCPB artifacts.",
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function singularLabel(value) {
|
|
537
|
+
const label = normalizeText(value || "Entry");
|
|
538
|
+
return label.endsWith("s") ? label.slice(0, -1) : label;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function exampleValueForField(fieldId, category, label) {
|
|
542
|
+
switch (fieldId) {
|
|
543
|
+
case "name":
|
|
544
|
+
case "title":
|
|
545
|
+
return `Example ${singularLabel(label)}`;
|
|
546
|
+
case "slug":
|
|
547
|
+
return `example-${category || "entry"}`;
|
|
548
|
+
case "category":
|
|
549
|
+
return category;
|
|
550
|
+
case "github_url":
|
|
551
|
+
return `https://github.com/example/example-${category || "entry"}`;
|
|
552
|
+
case "docs_url":
|
|
553
|
+
case "source_url":
|
|
554
|
+
return `https://example.com/${category || "entry"}/docs`;
|
|
555
|
+
case "download_url":
|
|
556
|
+
return `https://example.com/${category || "entry"}/download.zip`;
|
|
557
|
+
case "brand_name":
|
|
558
|
+
return "Example";
|
|
559
|
+
case "brand_domain":
|
|
560
|
+
return "example.com";
|
|
561
|
+
case "author":
|
|
562
|
+
return "@example";
|
|
563
|
+
case "contact_email":
|
|
564
|
+
return "@example";
|
|
565
|
+
case "tags":
|
|
566
|
+
return "claude, workflow, example";
|
|
567
|
+
case "description":
|
|
568
|
+
return `A practical ${singularLabel(label).toLowerCase()} for Claude users that includes source-backed setup and usage details.`;
|
|
569
|
+
case "card_description":
|
|
570
|
+
return `Practical ${singularLabel(label).toLowerCase()} for Claude workflows.`;
|
|
571
|
+
case "full_copyable_content":
|
|
572
|
+
return `# Example ${singularLabel(label)}\n\nUse this complete content as the copyable public artifact.`;
|
|
573
|
+
case "install_command":
|
|
574
|
+
return `npx -y example-${category || "entry"}`;
|
|
575
|
+
case "usage_snippet":
|
|
576
|
+
return `Use this ${singularLabel(label).toLowerCase()} to speed up a Claude workflow.`;
|
|
577
|
+
case "command_syntax":
|
|
578
|
+
return `/example-${category || "entry"} <input>`;
|
|
579
|
+
case "trigger":
|
|
580
|
+
return "PostToolUse";
|
|
581
|
+
case "guide_content":
|
|
582
|
+
return `# Example ${singularLabel(label)}\n\nExplain the setup, usage, verification, and troubleshooting steps.`;
|
|
583
|
+
case "items":
|
|
584
|
+
return `- Example ${singularLabel(label)} item\n- Source-backed companion resource`;
|
|
585
|
+
case "script_language":
|
|
586
|
+
return "bash";
|
|
587
|
+
case "skill_type":
|
|
588
|
+
return "workflow";
|
|
589
|
+
case "skill_level":
|
|
590
|
+
return "intermediate";
|
|
591
|
+
case "verification_status":
|
|
592
|
+
return "validated";
|
|
593
|
+
case "verified_at":
|
|
594
|
+
return "2026-05-17";
|
|
595
|
+
case "config_snippet":
|
|
596
|
+
return JSON.stringify({ example: true }, null, 2);
|
|
597
|
+
case "retrieval_sources":
|
|
598
|
+
return "- https://example.com/docs";
|
|
599
|
+
case "tested_platforms":
|
|
600
|
+
return "Claude Code, Codex, Cursor";
|
|
601
|
+
case "prerequisites":
|
|
602
|
+
return "- Node.js 20+\n- Claude-compatible MCP client";
|
|
603
|
+
case "safety_notes":
|
|
604
|
+
return "- Runs user-configured code with local workspace permissions";
|
|
605
|
+
case "privacy_notes":
|
|
606
|
+
return "- Reads local project files and may send selected context to the configured API";
|
|
607
|
+
case "troubleshooting_section":
|
|
608
|
+
return "If setup fails, verify the install command and source URL first.";
|
|
609
|
+
case "installation_order":
|
|
610
|
+
return "Install dependencies, configure the entry, then run the verification step.";
|
|
611
|
+
case "estimated_setup_time":
|
|
612
|
+
return "10 minutes";
|
|
613
|
+
case "difficulty":
|
|
614
|
+
return "intermediate";
|
|
615
|
+
default:
|
|
616
|
+
return `Example ${fieldId.replaceAll("_", " ")}`;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function exampleForCategory(spec, category) {
|
|
621
|
+
const model = modelFor(spec, category);
|
|
622
|
+
const fields = Object.fromEntries(
|
|
623
|
+
(model?.fields || []).map((field) => [
|
|
624
|
+
field.id,
|
|
625
|
+
exampleValueForField(field.id, category, model?.label),
|
|
626
|
+
]),
|
|
627
|
+
);
|
|
628
|
+
fields.category = category;
|
|
629
|
+
|
|
630
|
+
const minimalFields = {};
|
|
631
|
+
for (const field of requiredFields(model)) {
|
|
632
|
+
minimalFields[field] = fields[field];
|
|
633
|
+
}
|
|
634
|
+
minimalFields.category = category;
|
|
635
|
+
|
|
636
|
+
for (const field of [
|
|
637
|
+
"name",
|
|
638
|
+
"description",
|
|
639
|
+
"github_url",
|
|
640
|
+
"docs_url",
|
|
641
|
+
"install_command",
|
|
642
|
+
"usage_snippet",
|
|
643
|
+
"download_url",
|
|
644
|
+
"full_copyable_content",
|
|
645
|
+
"guide_content",
|
|
646
|
+
"items",
|
|
647
|
+
]) {
|
|
648
|
+
if (fields[field]) minimalFields[field] = fields[field];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
category,
|
|
653
|
+
label: model?.label || category,
|
|
654
|
+
template: model?.template || "",
|
|
655
|
+
requiredFields: requiredFields(model),
|
|
656
|
+
minimalFields,
|
|
657
|
+
completeFields: fields,
|
|
658
|
+
notes: [
|
|
659
|
+
"Use canonical source URLs and avoid affiliate/referral links.",
|
|
660
|
+
"The generated PR draft can become a reviewable submission after gates pass, but it is not automatic publication.",
|
|
661
|
+
"Community package archives are quarantine/review material, not public HeyClaude-hosted downloads.",
|
|
662
|
+
],
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function getSubmissionExamplesFromSpec(spec, args = {}) {
|
|
667
|
+
const category = selectedCategory(spec, args.category);
|
|
668
|
+
if (args.category && !category) {
|
|
669
|
+
return {
|
|
670
|
+
ok: false,
|
|
671
|
+
error: {
|
|
672
|
+
code: "not_found",
|
|
673
|
+
message: `No HeyClaude submission examples found for ${args.category}.`,
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const categories = category ? [category] : categoryKeys(spec);
|
|
679
|
+
return {
|
|
680
|
+
ok: true,
|
|
681
|
+
categories: categories.map((key) => exampleForCategory(spec, key)),
|
|
682
|
+
reviewModel:
|
|
683
|
+
"Examples are draft helpers only; maintainers review generated PRs before merge.",
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export function prepareSubmissionDraftFromSpec(spec, args = {}) {
|
|
688
|
+
const fields = normalizeSubmissionFields(args.fields || {});
|
|
689
|
+
const validation = validateAgainstSpec(spec, fields);
|
|
690
|
+
const prDraft = validation.category
|
|
691
|
+
? buildPrDraftFromSpec(spec, validation.normalized)
|
|
692
|
+
: null;
|
|
693
|
+
const urls = buildSubmissionUrlsFromSpec(spec, {
|
|
694
|
+
fields: validation.normalized,
|
|
695
|
+
includePrBody: true,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
ok: true,
|
|
700
|
+
valid: validation.valid,
|
|
701
|
+
category: validation.category,
|
|
702
|
+
slug: validation.normalized.slug || "",
|
|
703
|
+
normalizedFields: validation.normalized,
|
|
704
|
+
requiredFields: validation.requiredFields || [],
|
|
705
|
+
missingRequiredFields: validation.missingRequiredFields || [],
|
|
706
|
+
errors: validation.errors,
|
|
707
|
+
warnings: validation.warnings,
|
|
708
|
+
prDraft: prDraft ? { ...prDraft } : null,
|
|
709
|
+
submitUrl: urls.submitUrl,
|
|
710
|
+
reviewUrl: urls.reviewUrl,
|
|
711
|
+
reviewChecklist: [
|
|
712
|
+
"Confirm category fit and required fields before opening the PR.",
|
|
713
|
+
"Check for existing registry entries with the same source, slug, or title.",
|
|
714
|
+
"Verify source URLs, install commands, and copied content before maintainer approval.",
|
|
715
|
+
"Add safety_notes/privacy_notes when a submission runs code, handles credentials, reads local data, writes externally, or uses background workers.",
|
|
716
|
+
"Use source-backed or copyable-content submissions; do not request public /downloads hosting for community ZIPs.",
|
|
717
|
+
"Disclose paid, sponsored, affiliate, or commercial content separately from free community submissions.",
|
|
718
|
+
],
|
|
719
|
+
submissionPolicy:
|
|
720
|
+
"This tool prepares a PR-first submission draft only. Eligible content-only PRs may be merged automatically after content validation, Superagent, and private maintainer-agent review pass; platform, workflow, package, and generated-artifact changes are never auto-merged by this path.",
|
|
721
|
+
artifactPolicy:
|
|
722
|
+
"Community ZIP/MCPB artifacts are quarantine/review material only. Maintainer-built packages are the only HeyClaude-hosted downloads.",
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export function reviewSubmissionDraftFromSpec(spec, args = {}, entries = []) {
|
|
727
|
+
const validation = validateAgainstSpec(spec, args.fields || {});
|
|
728
|
+
const duplicateArgs = {
|
|
729
|
+
category: validation.category,
|
|
730
|
+
slug: validation.normalized.slug,
|
|
731
|
+
title: validation.normalized.title || validation.normalized.name,
|
|
732
|
+
name: validation.normalized.name,
|
|
733
|
+
// Forward every candidate URL the submission carries rather than picking a
|
|
734
|
+
// single winner via `||` — see `collectSubmissionCandidateUrls`. A
|
|
735
|
+
// submission with both `github_url` and `docs_url` was previously dropping
|
|
736
|
+
// the second candidate from duplicate detection.
|
|
737
|
+
sourceUrls: [
|
|
738
|
+
validation.normalized.github_url,
|
|
739
|
+
validation.normalized.docs_url,
|
|
740
|
+
validation.normalized.source_url,
|
|
741
|
+
validation.normalized.download_url,
|
|
742
|
+
validation.normalized.website_url,
|
|
743
|
+
].filter(Boolean),
|
|
744
|
+
brandDomain: validation.normalized.brand_domain,
|
|
745
|
+
limit: args.duplicateLimit || 5,
|
|
746
|
+
};
|
|
747
|
+
const duplicates = searchDuplicateEntries(entries, duplicateArgs);
|
|
748
|
+
const recommendedAction = validation.valid
|
|
749
|
+
? duplicates.count > 0
|
|
750
|
+
? "review_possible_duplicate"
|
|
751
|
+
: "open_review_pr"
|
|
752
|
+
: "fix_required_fields";
|
|
753
|
+
|
|
754
|
+
return {
|
|
755
|
+
ok: true,
|
|
756
|
+
valid: validation.valid,
|
|
757
|
+
category: validation.category,
|
|
758
|
+
slug: validation.normalized.slug || "",
|
|
759
|
+
recommendedAction,
|
|
760
|
+
errors: validation.errors,
|
|
761
|
+
warnings: validation.warnings,
|
|
762
|
+
missingRequiredFields: validation.missingRequiredFields || [],
|
|
763
|
+
duplicateReview: duplicates,
|
|
764
|
+
reviewChecklist: [
|
|
765
|
+
"Schema-valid is not publish-valid; maintainer review is still required.",
|
|
766
|
+
"Check category fit against the actual artifact, not only the submitter's selected category.",
|
|
767
|
+
"Verify source availability, install commands, and any credential/payment-sensitive behavior.",
|
|
768
|
+
"Keep packages source-backed unless maintainers explicitly rebuild and verify a first-party artifact.",
|
|
769
|
+
"Reject or request edits for affiliate/referral links, unsupported claims, or unclear paid/sponsored positioning.",
|
|
770
|
+
],
|
|
771
|
+
nextSteps: validation.valid
|
|
772
|
+
? [
|
|
773
|
+
"Open or update the canonical single-entry GitHub submission PR.",
|
|
774
|
+
"Let the private gate apply review labels after source, duplicate, and safety checks.",
|
|
470
775
|
]
|
|
471
|
-
: ["Fix validation errors before opening a
|
|
776
|
+
: ["Fix required fields and validation errors before opening a PR."],
|
|
777
|
+
artifactPolicy:
|
|
778
|
+
"Community-submitted ZIP/MCPB packages must not be treated as trusted public downloads.",
|
|
472
779
|
};
|
|
473
780
|
}
|
|
474
781
|
|
|
475
|
-
|
|
476
|
-
|
|
782
|
+
// Normalize a URL for duplicate-detection matching. Lowercases scheme + host,
|
|
783
|
+
// strips `www.`, drops `#hash`, drops `utm_*` and known affiliate/tracking
|
|
784
|
+
// query params (so links shared with marketing parameters still match the
|
|
785
|
+
// canonical form), and strips a trailing `/` on non-root paths. Returns "" for
|
|
786
|
+
// unparseable input — unparseable URLs simply don't match anything, which is
|
|
787
|
+
// safe (the slug/title/brand_domain matches still run).
|
|
788
|
+
function normalizeSubmissionUrlForMatch(value) {
|
|
789
|
+
const trimmed = normalizeText(value);
|
|
790
|
+
if (!trimmed) return "";
|
|
791
|
+
let url;
|
|
792
|
+
try {
|
|
793
|
+
url = new URL(trimmed);
|
|
794
|
+
} catch {
|
|
795
|
+
return "";
|
|
796
|
+
}
|
|
797
|
+
url.protocol = url.protocol.toLowerCase();
|
|
798
|
+
url.hostname = url.hostname.toLowerCase().replace(/^www\./, "");
|
|
799
|
+
url.hash = "";
|
|
800
|
+
const droppedKeys = [];
|
|
801
|
+
for (const key of url.searchParams.keys()) {
|
|
802
|
+
if (isTrackingQueryKey(key)) droppedKeys.push(key);
|
|
803
|
+
}
|
|
804
|
+
for (const key of droppedKeys) url.searchParams.delete(key);
|
|
805
|
+
// Normalize the pathname before serializing so a trailing `/` is stripped on
|
|
806
|
+
// non-root paths regardless of any query string. Checking the serialized
|
|
807
|
+
// string's `endsWith("/")` misses cases like
|
|
808
|
+
// `https://example.com/foo/?ref=x`, where the slash sits before the query and
|
|
809
|
+
// is never the last character — leaving it would break the match against the
|
|
810
|
+
// canonical `https://example.com/foo?ref=x`.
|
|
811
|
+
if (url.pathname !== "/" && url.pathname.endsWith("/")) {
|
|
812
|
+
url.pathname = url.pathname.slice(0, -1);
|
|
813
|
+
}
|
|
814
|
+
return url.toString();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Every external-publisher URL field the indexed entry may carry. Includes
|
|
818
|
+
// `downloadUrl`, `websiteUrl`, `githubUrl`, and `trustSignals.sourceUrls` —
|
|
819
|
+
// each is a real entry field today and a duplicate that lives only on one of
|
|
820
|
+
// them must still match a submitter's URL. Internal `url`/`canonicalUrl`/
|
|
821
|
+
// `llmsUrl`/`apiUrl` stay in the list because a submitter who pastes the
|
|
822
|
+
// HeyClaude canonical URL of an existing entry is still describing the same
|
|
823
|
+
// resource and should be flagged as a duplicate.
|
|
824
|
+
function collectEntrySourceUrls(entry) {
|
|
825
|
+
const candidates = [
|
|
477
826
|
entry.documentationUrl,
|
|
478
827
|
entry.repoUrl,
|
|
828
|
+
entry.githubUrl,
|
|
829
|
+
entry.websiteUrl,
|
|
830
|
+
entry.downloadUrl,
|
|
479
831
|
entry.url,
|
|
480
832
|
entry.canonicalUrl,
|
|
481
833
|
entry.llmsUrl,
|
|
482
834
|
entry.apiUrl,
|
|
483
|
-
]
|
|
484
|
-
|
|
485
|
-
|
|
835
|
+
];
|
|
836
|
+
const trustSourceUrls = entry?.trustSignals?.sourceUrls;
|
|
837
|
+
if (Array.isArray(trustSourceUrls)) {
|
|
838
|
+
for (const value of trustSourceUrls) candidates.push(value);
|
|
839
|
+
}
|
|
840
|
+
const normalized = new Set();
|
|
841
|
+
for (const candidate of candidates) {
|
|
842
|
+
const value = normalizeSubmissionUrlForMatch(candidate);
|
|
843
|
+
if (value) normalized.add(value);
|
|
844
|
+
}
|
|
845
|
+
return normalized;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Collect every URL the caller has offered as a duplicate-search candidate.
|
|
849
|
+
// Accepts the legacy scalar `sourceUrl`, the new `sourceUrls` array, and the
|
|
850
|
+
// fielded `githubUrl`/`docsUrl`/`downloadUrl`/`websiteUrl` keys so callers
|
|
851
|
+
// don't have to `||`-chain a single winner and silently drop the rest.
|
|
852
|
+
function collectSubmissionCandidateUrls(args = {}) {
|
|
853
|
+
const candidates = [
|
|
854
|
+
args.sourceUrl,
|
|
855
|
+
args.githubUrl,
|
|
856
|
+
args.docsUrl,
|
|
857
|
+
args.downloadUrl,
|
|
858
|
+
args.websiteUrl,
|
|
859
|
+
];
|
|
860
|
+
if (Array.isArray(args.sourceUrls)) {
|
|
861
|
+
for (const value of args.sourceUrls) candidates.push(value);
|
|
862
|
+
}
|
|
863
|
+
const normalized = new Set();
|
|
864
|
+
for (const candidate of candidates) {
|
|
865
|
+
const value = normalizeSubmissionUrlForMatch(candidate);
|
|
866
|
+
if (value) normalized.add(value);
|
|
867
|
+
}
|
|
868
|
+
return normalized;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// First URL that appears in both Sets, or "" if none. Returning the matched
|
|
872
|
+
// URL (rather than just a boolean) lets callers surface which URL hit if they
|
|
873
|
+
// ever want to extend the `reasons` payload — today the predicate path is
|
|
874
|
+
// boolean-only, but the value is free.
|
|
875
|
+
function findOverlappingSourceUrl(entryUrls, candidateUrls) {
|
|
876
|
+
for (const value of candidateUrls) {
|
|
877
|
+
if (entryUrls.has(value)) return value;
|
|
878
|
+
}
|
|
879
|
+
return "";
|
|
486
880
|
}
|
|
487
881
|
|
|
488
882
|
export function searchDuplicateEntries(entries = [], args = {}) {
|
|
@@ -491,7 +885,7 @@ export function searchDuplicateEntries(entries = [], args = {}) {
|
|
|
491
885
|
const slug = normalizeLower(args.slug);
|
|
492
886
|
const title = normalizeLower(args.title || args.name);
|
|
493
887
|
const brandDomain = normalizeDomain(args.brandDomain);
|
|
494
|
-
const
|
|
888
|
+
const candidateUrls = collectSubmissionCandidateUrls(args);
|
|
495
889
|
|
|
496
890
|
const matches = [];
|
|
497
891
|
for (const entry of entries) {
|
|
@@ -502,7 +896,10 @@ export function searchDuplicateEntries(entries = [], args = {}) {
|
|
|
502
896
|
if (brandDomain && normalizeDomain(entry.brandDomain) === brandDomain) {
|
|
503
897
|
reasons.push("brand_domain");
|
|
504
898
|
}
|
|
505
|
-
if (
|
|
899
|
+
if (
|
|
900
|
+
candidateUrls.size > 0 &&
|
|
901
|
+
findOverlappingSourceUrl(collectEntrySourceUrls(entry), candidateUrls)
|
|
902
|
+
) {
|
|
506
903
|
reasons.push("source_url");
|
|
507
904
|
}
|
|
508
905
|
|
|
@@ -553,7 +950,7 @@ export function getCategorySubmissionGuidanceFromSpec(spec, args = {}) {
|
|
|
553
950
|
category: key,
|
|
554
951
|
label: model?.label || key,
|
|
555
952
|
description: model?.description || "",
|
|
556
|
-
template: model?.template ||
|
|
953
|
+
template: model?.template || "",
|
|
557
954
|
requiredFields: requiredFields(model),
|
|
558
955
|
optionalFields: (model?.fields || [])
|
|
559
956
|
.filter((field) => !field.required)
|