@heyclaude/mcp 0.1.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.
@@ -0,0 +1,569 @@
1
+ export const SUBMISSION_SITE_URL = "https://heyclau.de/submit";
2
+ export const GITHUB_NEW_ISSUE_URL =
3
+ "https://github.com/JSONbored/claudepro-directory/issues/new";
4
+
5
+ const defaultLabels = ["content-submission", "needs-review"];
6
+
7
+ function normalizeText(value) {
8
+ return String(value || "").trim();
9
+ }
10
+
11
+ function normalizeLower(value) {
12
+ return normalizeText(value).toLowerCase();
13
+ }
14
+
15
+ export function slugify(value) {
16
+ let output = "";
17
+ let lastWasSeparator = false;
18
+
19
+ for (const char of normalizeLower(value)) {
20
+ const isAlphaNumeric =
21
+ (char >= "a" && char <= "z") || (char >= "0" && char <= "9");
22
+ if (isAlphaNumeric) {
23
+ output += char;
24
+ lastWasSeparator = false;
25
+ continue;
26
+ }
27
+ if (char === "'" || char === '"') continue;
28
+ if (output && !lastWasSeparator) {
29
+ output += "-";
30
+ lastWasSeparator = true;
31
+ }
32
+ }
33
+
34
+ return lastWasSeparator ? output.slice(0, -1) : output;
35
+ }
36
+
37
+ function normalizeDomain(value) {
38
+ const trimmed = normalizeText(value);
39
+ if (!trimmed) return "";
40
+ try {
41
+ const url = new URL(
42
+ trimmed.includes("://") ? trimmed : `https://${trimmed}`,
43
+ );
44
+ return stripWww(url.hostname).toLowerCase();
45
+ } catch {
46
+ return stripWww(trimmed).toLowerCase();
47
+ }
48
+ }
49
+
50
+ function stripWww(value) {
51
+ const text = normalizeText(value);
52
+ return text.toLowerCase().startsWith("www.") ? text.slice(4) : text;
53
+ }
54
+
55
+ function isCanonicalDomain(value) {
56
+ const domain = normalizeDomain(value);
57
+ const labels = domain.split(".");
58
+ if (labels.length < 2) return false;
59
+ return labels.every((label) => {
60
+ if (!label || label.length > 63) return false;
61
+ if (label.startsWith("-") || label.endsWith("-")) return false;
62
+ return [...label].every(
63
+ (char) =>
64
+ (char >= "a" && char <= "z") ||
65
+ (char >= "0" && char <= "9") ||
66
+ char === "-",
67
+ );
68
+ });
69
+ }
70
+
71
+ function isHttpsUrl(value) {
72
+ const trimmed = normalizeText(value);
73
+ if (!trimmed) return true;
74
+ try {
75
+ return new URL(trimmed).protocol === "https:";
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ function isLikelyAffiliateUrl(value) {
82
+ const trimmed = normalizeText(value);
83
+ if (!trimmed) return false;
84
+ try {
85
+ 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
+ for (const key of url.searchParams.keys()) {
99
+ const normalized = key.trim().toLowerCase();
100
+ if (normalized.startsWith("utm_") || affiliateParams.has(normalized)) {
101
+ return true;
102
+ }
103
+ }
104
+ } catch {
105
+ return false;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ function isPublicContact(value) {
111
+ const contact = normalizeText(value);
112
+ if (!contact) return true;
113
+ if (isEmailLike(contact)) return true;
114
+ if (isGitHubHandle(contact)) return true;
115
+ try {
116
+ const url = new URL(contact);
117
+ return (
118
+ url.protocol === "https:" &&
119
+ url.hostname === "github.com" &&
120
+ url.pathname.split("/").filter(Boolean).length === 1
121
+ );
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function isEmailLike(value) {
128
+ const contact = normalizeText(value);
129
+ const parts = contact.split("@");
130
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return false;
131
+ if (
132
+ [...contact].some((char) => char === " " || char === "\n" || char === "\t")
133
+ ) {
134
+ return false;
135
+ }
136
+ return parts[1].includes(".");
137
+ }
138
+
139
+ function isGitHubHandle(value) {
140
+ const handle = normalizeText(value).startsWith("@")
141
+ ? normalizeText(value).slice(1)
142
+ : normalizeText(value);
143
+ if (!handle || handle.length > 39) return false;
144
+ if (handle.startsWith("-") || handle.endsWith("-")) return false;
145
+ return [...handle].every(
146
+ (char) =>
147
+ (char >= "a" && char <= "z") ||
148
+ (char >= "A" && char <= "Z") ||
149
+ (char >= "0" && char <= "9") ||
150
+ char === "-",
151
+ );
152
+ }
153
+
154
+ function compactWhitespace(value) {
155
+ let output = "";
156
+ let lastWasWhitespace = false;
157
+ for (const char of String(value || "").trim()) {
158
+ if (char === " " || char === "\n" || char === "\t" || char === "\r") {
159
+ if (!lastWasWhitespace) output += " ";
160
+ lastWasWhitespace = true;
161
+ continue;
162
+ }
163
+ output += char;
164
+ lastWasWhitespace = false;
165
+ }
166
+ return output.trim();
167
+ }
168
+
169
+ function tagsToText(value) {
170
+ return Array.isArray(value)
171
+ ? value.map(normalizeText).filter(Boolean).join(", ")
172
+ : normalizeText(value);
173
+ }
174
+
175
+ export function normalizeSubmissionFields(fields = {}) {
176
+ const normalized = {};
177
+ for (const [key, value] of Object.entries(fields || {})) {
178
+ if (value === undefined || value === null) continue;
179
+ normalized[key] = key === "tags" ? tagsToText(value) : normalizeText(value);
180
+ }
181
+
182
+ if (!normalized.name && normalized.title) normalized.name = normalized.title;
183
+ if (!normalized.slug && normalized.name)
184
+ normalized.slug = slugify(normalized.name);
185
+ if (normalized.brand_domain) {
186
+ normalized.brand_domain = normalizeDomain(normalized.brand_domain);
187
+ }
188
+
189
+ const sourceUrl = normalized.source_url;
190
+ if (sourceUrl && !normalized.github_url && !normalized.docs_url) {
191
+ try {
192
+ const url = new URL(sourceUrl);
193
+ if (url.hostname === "github.com") {
194
+ normalized.github_url = sourceUrl;
195
+ } else {
196
+ normalized.docs_url = sourceUrl;
197
+ }
198
+ } catch {
199
+ normalized.docs_url = sourceUrl;
200
+ }
201
+ }
202
+
203
+ if (!normalized.card_description && normalized.description) {
204
+ const oneLine = compactWhitespace(normalized.description);
205
+ normalized.card_description =
206
+ oneLine.length <= 140 ? oneLine : `${oneLine.slice(0, 137).trimEnd()}...`;
207
+ }
208
+
209
+ return normalized;
210
+ }
211
+
212
+ function categoryKeys(spec) {
213
+ return Object.keys(spec?.categories || {});
214
+ }
215
+
216
+ function modelFor(spec, category) {
217
+ return spec?.categories?.[category] || null;
218
+ }
219
+
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
+ function requiredFields(model) {
229
+ return (model?.fields || [])
230
+ .filter((field) => field.required)
231
+ .map((field) => field.id);
232
+ }
233
+
234
+ function fieldLabels(model) {
235
+ return new Map((model?.fields || []).map((field) => [field.id, field.label]));
236
+ }
237
+
238
+ function selectedCategory(spec, category) {
239
+ const normalized = normalizeLower(category);
240
+ return categoryKeys(spec).includes(normalized) ? normalized : "";
241
+ }
242
+
243
+ function validateAgainstSpec(spec, fields = {}) {
244
+ const normalized = normalizeSubmissionFields(fields);
245
+ const category = selectedCategory(spec, normalized.category);
246
+ const model = modelFor(spec, category);
247
+ const errors = [];
248
+ const warnings = [];
249
+
250
+ if (!category || !model) {
251
+ errors.push("Missing or unsupported submission category.");
252
+ return {
253
+ valid: false,
254
+ category: normalized.category || "",
255
+ normalized,
256
+ errors,
257
+ warnings,
258
+ };
259
+ }
260
+
261
+ normalized.category = category;
262
+ const missingRequiredFields = requiredFields(model).filter(
263
+ (field) => !normalizeText(normalized[field]),
264
+ );
265
+ for (const field of missingRequiredFields) {
266
+ errors.push(`Missing required field: ${field}`);
267
+ }
268
+
269
+ if (normalized.slug && slugify(normalized.slug) !== normalized.slug) {
270
+ errors.push("Invalid slug format: expected kebab-case.");
271
+ }
272
+ if (normalized.description && normalized.description.length < 12) {
273
+ errors.push("Description is too short for review.");
274
+ }
275
+ if (normalized.card_description && normalized.card_description.length < 8) {
276
+ errors.push("Card description is too short for review.");
277
+ }
278
+ if (!isPublicContact(normalized.contact_email)) {
279
+ errors.push(
280
+ "Invalid public contact: use a GitHub handle, GitHub profile URL, or email.",
281
+ );
282
+ }
283
+
284
+ for (const field of [
285
+ "github_url",
286
+ "docs_url",
287
+ "download_url",
288
+ "source_url",
289
+ ]) {
290
+ if (!isHttpsUrl(normalized[field])) {
291
+ errors.push(`${field} must be a valid https URL.`);
292
+ }
293
+ if (isLikelyAffiliateUrl(normalized[field])) {
294
+ errors.push(
295
+ `Contributor submissions cannot include affiliate/referral URLs: ${field}.`,
296
+ );
297
+ }
298
+ }
299
+
300
+ if (normalized.brand_domain && !isCanonicalDomain(normalized.brand_domain)) {
301
+ errors.push("brand_domain must be a canonical domain such as asana.com.");
302
+ }
303
+ if (
304
+ category === "skills" &&
305
+ !normalizeText(normalized.install_command) &&
306
+ !normalizeText(normalized.download_url)
307
+ ) {
308
+ errors.push("Skills submissions require install_command or download_url.");
309
+ }
310
+ if (category === "collections" && !normalizeText(normalized.items)) {
311
+ errors.push("Collections submissions require items.");
312
+ }
313
+ if (category === "guides" && !normalizeText(normalized.guide_content)) {
314
+ errors.push("Guide submissions require guide_content.");
315
+ }
316
+ if (!normalized.github_url && !normalized.docs_url) {
317
+ warnings.push("No github_url/docs_url provided.");
318
+ }
319
+ if (category === "skills" && !normalized.tested_platforms) {
320
+ warnings.push("No tested_platforms provided.");
321
+ }
322
+
323
+ return {
324
+ valid: errors.length === 0,
325
+ category,
326
+ normalized,
327
+ errors,
328
+ warnings,
329
+ missingRequiredFields,
330
+ requiredFields: requiredFields(model),
331
+ };
332
+ }
333
+
334
+ export function buildIssueDraftFromSpec(spec, fields = {}) {
335
+ const validation = validateAgainstSpec(spec, fields);
336
+ const category = validation.category;
337
+ const model = modelFor(spec, category);
338
+ const labelsById = fieldLabels(model);
339
+ const fieldIds = [
340
+ ...(model?.fields || []).map((field) => field.id),
341
+ ...Object.keys(validation.normalized).filter(
342
+ (field) =>
343
+ !(model?.fields || []).some((candidate) => candidate.id === field),
344
+ ),
345
+ ];
346
+ const body = fieldIds
347
+ .filter((field) => field !== "title" && field !== "source_url")
348
+ .map((field) => {
349
+ const value = normalizeText(validation.normalized[field]);
350
+ if (!value && field !== "category") return "";
351
+ return [
352
+ `### ${labelsById.get(field) || field.replaceAll("_", " ")}`,
353
+ "",
354
+ value || category,
355
+ "",
356
+ ].join("\n");
357
+ })
358
+ .filter(Boolean)
359
+ .join("\n")
360
+ .trimEnd();
361
+ const modelLabel = model?.label || "Entry";
362
+ const label = modelLabel.endsWith("s") ? modelLabel.slice(0, -1) : modelLabel;
363
+
364
+ return {
365
+ title: `Submit ${label}: ${validation.normalized.name || "New directory entry"}`,
366
+ body,
367
+ labels: labelsFor(spec, category),
368
+ };
369
+ }
370
+
371
+ function setParam(params, key, value) {
372
+ const normalized = tagsToText(value);
373
+ if (normalized) params.set(key, normalized);
374
+ }
375
+
376
+ export function buildSubmissionUrlsFromSpec(spec, args = {}) {
377
+ const fields = normalizeSubmissionFields(args.fields || {});
378
+ const validation = validateAgainstSpec(spec, fields);
379
+ const category = validation.category || fields.category || "";
380
+ const issueDraft = buildIssueDraftFromSpec(spec, fields);
381
+ const template = templateFor(spec, category)?.template || "submit-entry.md";
382
+
383
+ const submitUrl = new URL(SUBMISSION_SITE_URL);
384
+ const issueUrl = new URL(GITHUB_NEW_ISSUE_URL);
385
+ issueUrl.searchParams.set("template", template);
386
+
387
+ for (const [key, value] of Object.entries(fields)) {
388
+ if (key === "source_url" && (fields.github_url || fields.docs_url))
389
+ continue;
390
+ setParam(submitUrl.searchParams, key, value);
391
+ if (key !== "source_url") setParam(issueUrl.searchParams, key, value);
392
+ }
393
+ if (category) {
394
+ 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
+ }
401
+
402
+ return {
403
+ ok: true,
404
+ valid: validation.valid,
405
+ category,
406
+ slug: fields.slug || "",
407
+ submitUrl: submitUrl.toString(),
408
+ githubIssueUrl: issueUrl.toString(),
409
+ issueDraft: args.includeIssueBody
410
+ ? issueDraft
411
+ : { title: issueDraft.title, labels: issueDraft.labels },
412
+ validation: {
413
+ errors: validation.errors,
414
+ warnings: validation.warnings,
415
+ missingRequiredFields: validation.missingRequiredFields || [],
416
+ },
417
+ reviewModel:
418
+ "Issue-first: maintainers review accepted submissions before an import PR is opened.",
419
+ };
420
+ }
421
+
422
+ export function getSubmissionSchemaFromSpec(spec, args = {}) {
423
+ const category = selectedCategory(spec, args.category);
424
+ if (args.category && !category) {
425
+ return {
426
+ ok: false,
427
+ error: {
428
+ code: "not_found",
429
+ message: `No HeyClaude submission schema found for ${args.category}.`,
430
+ },
431
+ };
432
+ }
433
+
434
+ return {
435
+ ok: true,
436
+ schemaVersion: spec.schemaVersion,
437
+ categories: categoryKeys(spec),
438
+ category: category || "",
439
+ schema: category ? spec.categories[category] : spec.categories,
440
+ issueTemplate: category
441
+ ? spec.issueTemplates?.[category]
442
+ : spec.issueTemplates,
443
+ };
444
+ }
445
+
446
+ export function validateSubmissionDraftFromSpec(spec, args = {}) {
447
+ const validation = validateAgainstSpec(spec, args.fields || {});
448
+ const issueDraft = validation.category
449
+ ? buildIssueDraftFromSpec(spec, validation.normalized)
450
+ : null;
451
+
452
+ return {
453
+ ok: true,
454
+ valid: validation.valid,
455
+ category: validation.category,
456
+ slug: validation.normalized.slug || "",
457
+ fields: validation.normalized,
458
+ requiredFields: validation.requiredFields || [],
459
+ missingRequiredFields: validation.missingRequiredFields || [],
460
+ errors: validation.errors,
461
+ warnings: validation.warnings,
462
+ issuePreview: issueDraft
463
+ ? { title: issueDraft.title, labels: issueDraft.labels }
464
+ : null,
465
+ nextSteps: validation.valid
466
+ ? [
467
+ "Check for duplicate registry entries.",
468
+ "Open the generated HeyClaude submit URL or GitHub issue URL.",
469
+ "Maintainers review accepted submissions before an import PR is opened.",
470
+ ]
471
+ : ["Fix validation errors before opening a public submission issue."],
472
+ };
473
+ }
474
+
475
+ function entrySourceUrls(entry) {
476
+ return [
477
+ entry.documentationUrl,
478
+ entry.repoUrl,
479
+ entry.url,
480
+ entry.canonicalUrl,
481
+ entry.llmsUrl,
482
+ entry.apiUrl,
483
+ ]
484
+ .map(normalizeText)
485
+ .filter(Boolean);
486
+ }
487
+
488
+ export function searchDuplicateEntries(entries = [], args = {}) {
489
+ const limit = Math.max(1, Math.min(10, Math.trunc(Number(args.limit) || 5)));
490
+ const category = normalizeLower(args.category);
491
+ const slug = normalizeLower(args.slug);
492
+ const title = normalizeLower(args.title || args.name);
493
+ const brandDomain = normalizeDomain(args.brandDomain);
494
+ const sourceUrl = normalizeText(args.sourceUrl);
495
+
496
+ const matches = [];
497
+ for (const entry of entries) {
498
+ const reasons = [];
499
+ if (category && entry.category !== category) continue;
500
+ if (slug && normalizeLower(entry.slug) === slug) reasons.push("slug");
501
+ if (title && normalizeLower(entry.title) === title) reasons.push("title");
502
+ if (brandDomain && normalizeDomain(entry.brandDomain) === brandDomain) {
503
+ reasons.push("brand_domain");
504
+ }
505
+ if (sourceUrl && entrySourceUrls(entry).includes(sourceUrl)) {
506
+ reasons.push("source_url");
507
+ }
508
+
509
+ if (!reasons.length) continue;
510
+ matches.push({
511
+ key: `${entry.category}:${entry.slug}`,
512
+ category: entry.category,
513
+ slug: entry.slug,
514
+ title: entry.title,
515
+ description: entry.description || entry.seoDescription || "",
516
+ canonicalUrl: entry.canonicalUrl || entry.url || "",
517
+ brandName: entry.brandName || "",
518
+ brandDomain: entry.brandDomain || "",
519
+ reasons,
520
+ });
521
+ if (matches.length >= limit) break;
522
+ }
523
+
524
+ return {
525
+ ok: true,
526
+ count: matches.length,
527
+ matches,
528
+ reviewNote:
529
+ matches.length > 0
530
+ ? "Potential duplicates require maintainer review before submitting a new entry."
531
+ : "No obvious registry duplicate found in the generated search index.",
532
+ };
533
+ }
534
+
535
+ export function getCategorySubmissionGuidanceFromSpec(spec, args = {}) {
536
+ const category = selectedCategory(spec, args.category);
537
+ if (args.category && !category) {
538
+ return {
539
+ ok: false,
540
+ error: {
541
+ code: "not_found",
542
+ message: `No HeyClaude submission guidance found for ${args.category}.`,
543
+ },
544
+ };
545
+ }
546
+
547
+ const categories = category ? [category] : categoryKeys(spec);
548
+ return {
549
+ ok: true,
550
+ categories: categories.map((key) => {
551
+ const model = modelFor(spec, key);
552
+ return {
553
+ category: key,
554
+ label: model?.label || key,
555
+ description: model?.description || "",
556
+ template: model?.template || templateFor(spec, key)?.template || "",
557
+ requiredFields: requiredFields(model),
558
+ optionalFields: (model?.fields || [])
559
+ .filter((field) => !field.required)
560
+ .map((field) => field.id),
561
+ guidance: [
562
+ "Use canonical source URLs and avoid affiliate/referral links.",
563
+ "Brand domain is optional but helps HeyClaude show accurate logos and trust signals.",
564
+ "Schema-valid submissions still require maintainer review before publication.",
565
+ ],
566
+ };
567
+ }),
568
+ };
569
+ }