@heyclaude/mcp 0.2.0 → 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 +12 -0
- package/README.md +29 -14
- package/package.json +1 -1
- package/scripts/validate-endpoint.mjs +68 -10
- package/src/package-metadata.js +4 -7
- package/src/registry.d.ts +49 -1
- package/src/registry.js +1104 -36
- package/src/schemas.d.ts +3 -0
- package/src/schemas.js +62 -1
- package/src/submissions.d.ts +1 -2
- package/src/submissions.js +255 -84
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,16 +515,21 @@ 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.",
|
|
470
529
|
]
|
|
471
|
-
: ["Fix validation errors before opening a public submission
|
|
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.",
|
|
472
533
|
};
|
|
473
534
|
}
|
|
474
535
|
|
|
@@ -539,6 +600,10 @@ function exampleValueForField(fieldId, category, label) {
|
|
|
539
600
|
return "Claude Code, Codex, Cursor";
|
|
540
601
|
case "prerequisites":
|
|
541
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";
|
|
542
607
|
case "troubleshooting_section":
|
|
543
608
|
return "If setup fails, verify the install command and source URL first.";
|
|
544
609
|
case "installation_order":
|
|
@@ -576,6 +641,7 @@ function exampleForCategory(spec, category) {
|
|
|
576
641
|
"install_command",
|
|
577
642
|
"usage_snippet",
|
|
578
643
|
"download_url",
|
|
644
|
+
"full_copyable_content",
|
|
579
645
|
"guide_content",
|
|
580
646
|
"items",
|
|
581
647
|
]) {
|
|
@@ -585,13 +651,14 @@ function exampleForCategory(spec, category) {
|
|
|
585
651
|
return {
|
|
586
652
|
category,
|
|
587
653
|
label: model?.label || category,
|
|
588
|
-
template: model?.template ||
|
|
654
|
+
template: model?.template || "",
|
|
589
655
|
requiredFields: requiredFields(model),
|
|
590
656
|
minimalFields,
|
|
591
657
|
completeFields: fields,
|
|
592
658
|
notes: [
|
|
593
659
|
"Use canonical source URLs and avoid affiliate/referral links.",
|
|
594
|
-
"The generated
|
|
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.",
|
|
595
662
|
],
|
|
596
663
|
};
|
|
597
664
|
}
|
|
@@ -613,19 +680,19 @@ export function getSubmissionExamplesFromSpec(spec, args = {}) {
|
|
|
613
680
|
ok: true,
|
|
614
681
|
categories: categories.map((key) => exampleForCategory(spec, key)),
|
|
615
682
|
reviewModel:
|
|
616
|
-
"Examples are draft helpers only; maintainers review
|
|
683
|
+
"Examples are draft helpers only; maintainers review generated PRs before merge.",
|
|
617
684
|
};
|
|
618
685
|
}
|
|
619
686
|
|
|
620
687
|
export function prepareSubmissionDraftFromSpec(spec, args = {}) {
|
|
621
688
|
const fields = normalizeSubmissionFields(args.fields || {});
|
|
622
689
|
const validation = validateAgainstSpec(spec, fields);
|
|
623
|
-
const
|
|
624
|
-
?
|
|
690
|
+
const prDraft = validation.category
|
|
691
|
+
? buildPrDraftFromSpec(spec, validation.normalized)
|
|
625
692
|
: null;
|
|
626
693
|
const urls = buildSubmissionUrlsFromSpec(spec, {
|
|
627
694
|
fields: validation.normalized,
|
|
628
|
-
|
|
695
|
+
includePrBody: true,
|
|
629
696
|
});
|
|
630
697
|
|
|
631
698
|
return {
|
|
@@ -638,17 +705,21 @@ export function prepareSubmissionDraftFromSpec(spec, args = {}) {
|
|
|
638
705
|
missingRequiredFields: validation.missingRequiredFields || [],
|
|
639
706
|
errors: validation.errors,
|
|
640
707
|
warnings: validation.warnings,
|
|
641
|
-
|
|
708
|
+
prDraft: prDraft ? { ...prDraft } : null,
|
|
642
709
|
submitUrl: urls.submitUrl,
|
|
643
|
-
|
|
710
|
+
reviewUrl: urls.reviewUrl,
|
|
644
711
|
reviewChecklist: [
|
|
645
|
-
"Confirm category fit and required fields before opening the
|
|
712
|
+
"Confirm category fit and required fields before opening the PR.",
|
|
646
713
|
"Check for existing registry entries with the same source, slug, or title.",
|
|
647
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.",
|
|
648
717
|
"Disclose paid, sponsored, affiliate, or commercial content separately from free community submissions.",
|
|
649
718
|
],
|
|
650
719
|
submissionPolicy:
|
|
651
|
-
"This tool prepares a
|
|
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.",
|
|
652
723
|
};
|
|
653
724
|
}
|
|
654
725
|
|
|
@@ -659,10 +730,17 @@ export function reviewSubmissionDraftFromSpec(spec, args = {}, entries = []) {
|
|
|
659
730
|
slug: validation.normalized.slug,
|
|
660
731
|
title: validation.normalized.title || validation.normalized.name,
|
|
661
732
|
name: validation.normalized.name,
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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,
|
|
665
740
|
validation.normalized.source_url,
|
|
741
|
+
validation.normalized.download_url,
|
|
742
|
+
validation.normalized.website_url,
|
|
743
|
+
].filter(Boolean),
|
|
666
744
|
brandDomain: validation.normalized.brand_domain,
|
|
667
745
|
limit: args.duplicateLimit || 5,
|
|
668
746
|
};
|
|
@@ -670,7 +748,7 @@ export function reviewSubmissionDraftFromSpec(spec, args = {}, entries = []) {
|
|
|
670
748
|
const recommendedAction = validation.valid
|
|
671
749
|
? duplicates.count > 0
|
|
672
750
|
? "review_possible_duplicate"
|
|
673
|
-
: "
|
|
751
|
+
: "open_review_pr"
|
|
674
752
|
: "fix_required_fields";
|
|
675
753
|
|
|
676
754
|
return {
|
|
@@ -687,28 +765,118 @@ export function reviewSubmissionDraftFromSpec(spec, args = {}, entries = []) {
|
|
|
687
765
|
"Schema-valid is not publish-valid; maintainer review is still required.",
|
|
688
766
|
"Check category fit against the actual artifact, not only the submitter's selected category.",
|
|
689
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.",
|
|
690
769
|
"Reject or request edits for affiliate/referral links, unsupported claims, or unclear paid/sponsored positioning.",
|
|
691
770
|
],
|
|
692
771
|
nextSteps: validation.valid
|
|
693
772
|
? [
|
|
694
|
-
"Open or update the canonical GitHub submission
|
|
695
|
-
"
|
|
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.",
|
|
696
775
|
]
|
|
697
|
-
: ["Fix required fields and validation errors before opening
|
|
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.",
|
|
698
779
|
};
|
|
699
780
|
}
|
|
700
781
|
|
|
701
|
-
|
|
702
|
-
|
|
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 = [
|
|
703
826
|
entry.documentationUrl,
|
|
704
827
|
entry.repoUrl,
|
|
828
|
+
entry.githubUrl,
|
|
829
|
+
entry.websiteUrl,
|
|
830
|
+
entry.downloadUrl,
|
|
705
831
|
entry.url,
|
|
706
832
|
entry.canonicalUrl,
|
|
707
833
|
entry.llmsUrl,
|
|
708
834
|
entry.apiUrl,
|
|
709
|
-
]
|
|
710
|
-
|
|
711
|
-
|
|
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 "";
|
|
712
880
|
}
|
|
713
881
|
|
|
714
882
|
export function searchDuplicateEntries(entries = [], args = {}) {
|
|
@@ -717,7 +885,7 @@ export function searchDuplicateEntries(entries = [], args = {}) {
|
|
|
717
885
|
const slug = normalizeLower(args.slug);
|
|
718
886
|
const title = normalizeLower(args.title || args.name);
|
|
719
887
|
const brandDomain = normalizeDomain(args.brandDomain);
|
|
720
|
-
const
|
|
888
|
+
const candidateUrls = collectSubmissionCandidateUrls(args);
|
|
721
889
|
|
|
722
890
|
const matches = [];
|
|
723
891
|
for (const entry of entries) {
|
|
@@ -728,7 +896,10 @@ export function searchDuplicateEntries(entries = [], args = {}) {
|
|
|
728
896
|
if (brandDomain && normalizeDomain(entry.brandDomain) === brandDomain) {
|
|
729
897
|
reasons.push("brand_domain");
|
|
730
898
|
}
|
|
731
|
-
if (
|
|
899
|
+
if (
|
|
900
|
+
candidateUrls.size > 0 &&
|
|
901
|
+
findOverlappingSourceUrl(collectEntrySourceUrls(entry), candidateUrls)
|
|
902
|
+
) {
|
|
732
903
|
reasons.push("source_url");
|
|
733
904
|
}
|
|
734
905
|
|
|
@@ -779,7 +950,7 @@ export function getCategorySubmissionGuidanceFromSpec(spec, args = {}) {
|
|
|
779
950
|
category: key,
|
|
780
951
|
label: model?.label || key,
|
|
781
952
|
description: model?.description || "",
|
|
782
|
-
template: model?.template ||
|
|
953
|
+
template: model?.template || "",
|
|
783
954
|
requiredFields: requiredFields(model),
|
|
784
955
|
optionalFields: (model?.fields || [])
|
|
785
956
|
.filter((field) => !field.required)
|