@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/src/registry.js CHANGED
@@ -7,25 +7,50 @@ import {
7
7
  platformFeedSlug,
8
8
  SITE_URL,
9
9
  } from "./platforms.js";
10
+ import {
11
+ DEFAULT_REMOTE_MCP_URL,
12
+ normalizeEndpointUrl,
13
+ } from "./endpoint-url.js";
14
+ import { packageName, packageVersion } from "./package-metadata.js";
10
15
  import {
11
16
  formatZodError,
12
17
  jsonSchemaForTool,
18
+ jsonSchemaForToolOutput,
13
19
  parseToolArguments,
14
20
  } from "./schemas.js";
15
21
  import {
16
22
  buildSubmissionUrlsFromSpec,
23
+ getSubmissionExamplesFromSpec,
17
24
  getCategorySubmissionGuidanceFromSpec,
25
+ prepareSubmissionDraftFromSpec,
18
26
  getSubmissionSchemaFromSpec,
27
+ reviewSubmissionDraftFromSpec,
19
28
  searchDuplicateEntries,
20
29
  validateSubmissionDraftFromSpec,
21
30
  } from "./submissions.js";
22
31
 
23
- const repoRoot = path.resolve(
24
- path.dirname(fileURLToPath(import.meta.url)),
25
- "../../..",
26
- );
27
- const defaultDataDir = path.join(repoRoot, "apps", "web", "public", "data");
28
32
  const safePathPartPattern = /^[a-z0-9-]+$/;
33
+ const jsonMimeType = "application/json";
34
+ const DISCOVERY_RESOURCE_LIMIT = 25;
35
+ const DISCOVERY_FETCH_TIMEOUT_MS = 5000;
36
+
37
+ function entryCanonicalUrl(entry) {
38
+ return (
39
+ entry.canonicalUrl ||
40
+ entry.url ||
41
+ `${SITE_URL}/entry/${entry.category}/${entry.slug}`
42
+ );
43
+ }
44
+
45
+ export const MCP_PUBLIC_POLICY = {
46
+ apiKeyRequired: false,
47
+ readOnly: true,
48
+ createsIssues: false,
49
+ createsPullRequests: false,
50
+ publishesContent: false,
51
+ writesLocalFiles: false,
52
+ note: "HeyClaude MCP tools only read public registry artifacts or prepare maintainer-reviewed submission drafts.",
53
+ };
29
54
 
30
55
  const platformAliases = new Map([
31
56
  ["claude", "Claude"],
@@ -43,7 +68,16 @@ const platformAliases = new Map([
43
68
 
44
69
  export const READ_ONLY_TOOL_NAMES = [
45
70
  "search_registry",
71
+ "plan_workflow_toolbox",
72
+ "server_info",
73
+ "list_category_entries",
74
+ "get_recent_updates",
75
+ "get_related_entries",
46
76
  "get_entry_detail",
77
+ "get_copyable_asset",
78
+ "compare_entries",
79
+ "get_registry_stats",
80
+ "get_client_setup",
47
81
  "get_compatibility",
48
82
  "get_install_guidance",
49
83
  "get_platform_adapter",
@@ -53,6 +87,12 @@ export const READ_ONLY_TOOL_NAMES = [
53
87
  "search_duplicate_entries",
54
88
  "build_submission_urls",
55
89
  "get_category_submission_guidance",
90
+ "prepare_submission_draft",
91
+ "get_submission_examples",
92
+ "review_submission_draft",
93
+ "get_submission_policy",
94
+ "explain_entry_trust",
95
+ "review_entry_safety",
56
96
  ];
57
97
 
58
98
  export const TOOL_DEFINITIONS = [
@@ -61,6 +101,50 @@ export const TOOL_DEFINITIONS = [
61
101
  description:
62
102
  "Search read-only HeyClaude registry entries by query, category, and skill platform compatibility.",
63
103
  inputSchema: jsonSchemaForTool("search_registry"),
104
+ outputSchema: jsonSchemaForToolOutput("search_registry"),
105
+ annotations: {
106
+ readOnlyHint: true,
107
+ destructiveHint: false,
108
+ idempotentHint: true,
109
+ openWorldHint: false,
110
+ },
111
+ },
112
+ {
113
+ name: "plan_workflow_toolbox",
114
+ description:
115
+ "Plan a read-only Claude or Codex workflow toolbox from ranked HeyClaude registry entries with trust, install, and follow-up guidance.",
116
+ inputSchema: jsonSchemaForTool("plan_workflow_toolbox"),
117
+ outputSchema: jsonSchemaForToolOutput("plan_workflow_toolbox"),
118
+ annotations: {
119
+ readOnlyHint: true,
120
+ destructiveHint: false,
121
+ idempotentHint: true,
122
+ openWorldHint: false,
123
+ },
124
+ },
125
+ {
126
+ name: "server_info",
127
+ description:
128
+ "Fetch read-only HeyClaude MCP package, registry, tool, and public rate-limit metadata.",
129
+ inputSchema: jsonSchemaForTool("server_info"),
130
+ },
131
+ {
132
+ name: "list_category_entries",
133
+ description:
134
+ "List read-only HeyClaude entries with bounded pagination and optional category, platform, tag, and query filters.",
135
+ inputSchema: jsonSchemaForTool("list_category_entries"),
136
+ },
137
+ {
138
+ name: "get_recent_updates",
139
+ description:
140
+ "List recently added or upstream-updated HeyClaude entries from generated registry metadata.",
141
+ inputSchema: jsonSchemaForTool("get_recent_updates"),
142
+ },
143
+ {
144
+ name: "get_related_entries",
145
+ description:
146
+ "Fetch read-only related HeyClaude entries based on category, tags, platforms, keywords, and source metadata.",
147
+ inputSchema: jsonSchemaForTool("get_related_entries"),
64
148
  },
65
149
  {
66
150
  name: "get_entry_detail",
@@ -68,6 +152,30 @@ export const TOOL_DEFINITIONS = [
68
152
  "Fetch a read-only HeyClaude registry entry detail payload by category and slug.",
69
153
  inputSchema: jsonSchemaForTool("get_entry_detail"),
70
154
  },
155
+ {
156
+ name: "get_copyable_asset",
157
+ description:
158
+ "Fetch the category-aware copy/install asset for a HeyClaude entry without writing local files.",
159
+ inputSchema: jsonSchemaForTool("get_copyable_asset"),
160
+ },
161
+ {
162
+ name: "compare_entries",
163
+ description:
164
+ "Compare 2-5 read-only HeyClaude entries by fit, category, platforms, source metadata, and install complexity.",
165
+ inputSchema: jsonSchemaForTool("compare_entries"),
166
+ },
167
+ {
168
+ name: "get_registry_stats",
169
+ description:
170
+ "Fetch aggregate read-only registry stats, freshness, category counts, and real source-signal coverage.",
171
+ inputSchema: jsonSchemaForTool("get_registry_stats"),
172
+ },
173
+ {
174
+ name: "get_client_setup",
175
+ description:
176
+ "Fetch read-only MCP client setup snippets for Codex, Claude Desktop, Cursor, Windsurf, or remote HTTP clients.",
177
+ inputSchema: jsonSchemaForTool("get_client_setup"),
178
+ },
71
179
  {
72
180
  name: "get_compatibility",
73
181
  description:
@@ -95,25 +203,25 @@ export const TOOL_DEFINITIONS = [
95
203
  {
96
204
  name: "get_submission_schema",
97
205
  description:
98
- "Fetch read-only HeyClaude submission schemas and GitHub issue template fields by category.",
206
+ "Fetch read-only HeyClaude submission schemas for PR-first intake by category.",
99
207
  inputSchema: jsonSchemaForTool("get_submission_schema"),
100
208
  },
101
209
  {
102
210
  name: "validate_submission_draft",
103
211
  description:
104
- "Validate a HeyClaude content submission draft locally without creating GitHub issues or publishing content.",
212
+ "Validate a HeyClaude content submission draft locally without creating GitHub issues, pull requests, or publishing content.",
105
213
  inputSchema: jsonSchemaForTool("validate_submission_draft"),
106
214
  },
107
215
  {
108
216
  name: "search_duplicate_entries",
109
217
  description:
110
- "Search generated registry artifacts for likely duplicate entries before a user opens a submission issue.",
218
+ "Search generated registry artifacts for likely duplicate entries before a user opens a submission PR.",
111
219
  inputSchema: jsonSchemaForTool("search_duplicate_entries"),
112
220
  },
113
221
  {
114
222
  name: "build_submission_urls",
115
223
  description:
116
- "Build prefilled HeyClaude submit and GitHub issue URLs for a validated submission draft without making write calls.",
224
+ "Build prefilled HeyClaude submit and review URLs for a validated PR-first submission draft without making write calls.",
117
225
  inputSchema: jsonSchemaForTool("build_submission_urls"),
118
226
  },
119
227
  {
@@ -122,10 +230,73 @@ export const TOOL_DEFINITIONS = [
122
230
  "Fetch category-specific HeyClaude contribution guidance, required fields, and review expectations.",
123
231
  inputSchema: jsonSchemaForTool("get_category_submission_guidance"),
124
232
  },
233
+ {
234
+ name: "prepare_submission_draft",
235
+ description:
236
+ "Build a read-only maintainer-reviewed HeyClaude submission draft with canonical PR text and URLs.",
237
+ inputSchema: jsonSchemaForTool("prepare_submission_draft"),
238
+ },
239
+ {
240
+ name: "get_submission_examples",
241
+ description:
242
+ "Fetch read-only category examples and templates for faster, more accurate HeyClaude submissions.",
243
+ inputSchema: jsonSchemaForTool("get_submission_examples"),
244
+ },
245
+ {
246
+ name: "review_submission_draft",
247
+ description:
248
+ "Review a HeyClaude submission draft locally for schema errors, duplicate risk, and maintainer checklist items without writing to GitHub.",
249
+ inputSchema: jsonSchemaForTool("review_submission_draft"),
250
+ },
251
+ {
252
+ name: "get_submission_policy",
253
+ description:
254
+ "Fetch HeyClaude's read-only submission, artifact, import, and maintainer-review policy for contributors and agents.",
255
+ inputSchema: jsonSchemaForTool("get_submission_policy"),
256
+ },
257
+ {
258
+ name: "explain_entry_trust",
259
+ description:
260
+ "Explain deterministic trust, source, package, safety, privacy, and review metadata signals for one HeyClaude entry. This is a metadata review only and does not provide malware scanning, automatic safety guarantees, or installation approval.",
261
+ inputSchema: jsonSchemaForTool("explain_entry_trust"),
262
+ },
263
+ {
264
+ name: "review_entry_safety",
265
+ description:
266
+ "Review 1-5 HeyClaude entries for source, package, safety, and privacy metadata fit before install or recommendation. This is a metadata review only and does not provide malware scanning, automatic safety guarantees, or installation approval.",
267
+ inputSchema: jsonSchemaForTool("review_entry_safety"),
268
+ },
125
269
  ];
126
270
 
271
+ for (const tool of TOOL_DEFINITIONS) {
272
+ tool.outputSchema ||= jsonSchemaForToolOutput(tool.name);
273
+ tool.annotations ||= {
274
+ readOnlyHint: true,
275
+ destructiveHint: false,
276
+ idempotentHint: true,
277
+ openWorldHint: false,
278
+ };
279
+ }
280
+
127
281
  function dataDirFromOptions(options = {}) {
128
- return options.dataDir || process.env.HEYCLAUDE_DATA_DIR || defaultDataDir;
282
+ const envDataDir =
283
+ typeof process !== "undefined" ? process.env?.HEYCLAUDE_DATA_DIR : "";
284
+ if (options.dataDir || envDataDir) {
285
+ return options.dataDir || envDataDir;
286
+ }
287
+
288
+ const moduleUrl = import.meta.url;
289
+ if (!moduleUrl) {
290
+ throw new Error(
291
+ "HEYCLAUDE_DATA_DIR or readTextArtifact is required outside the Node package runtime.",
292
+ );
293
+ }
294
+
295
+ const repoRoot = path.resolve(
296
+ path.dirname(fileURLToPath(moduleUrl)),
297
+ "../../..",
298
+ );
299
+ return path.join(repoRoot, "apps", "web", "public", "data");
129
300
  }
130
301
 
131
302
  function isSafePathPart(value) {
@@ -180,6 +351,12 @@ function normalizeLimit(value, fallback = 10) {
180
351
  return Math.max(1, Math.min(25, Math.trunc(numeric)));
181
352
  }
182
353
 
354
+ function normalizeOffset(value) {
355
+ const numeric = Number(value);
356
+ if (!Number.isFinite(numeric)) return 0;
357
+ return Math.max(0, Math.min(5000, Math.trunc(numeric)));
358
+ }
359
+
183
360
  function normalizePlatform(value) {
184
361
  const normalized = normalizeText(value).replace(/[^a-z0-9]+/g, "-");
185
362
  if (!normalized) return "";
@@ -198,6 +375,8 @@ function entryMatchesQuery(entry, query) {
198
375
  entry.submittedBy,
199
376
  entry.brandName,
200
377
  entry.brandDomain,
378
+ ...notes(entry.safetyNotes),
379
+ ...notes(entry.privacyNotes),
201
380
  ...(entry.tags || []),
202
381
  ...(entry.keywords || []),
203
382
  ]
@@ -206,12 +385,212 @@ function entryMatchesQuery(entry, query) {
206
385
  return haystack.includes(query);
207
386
  }
208
387
 
388
+ function searchTokens(query) {
389
+ return normalizeText(query)
390
+ .split(/[^a-z0-9+#.-]+/i)
391
+ .map((token) => token.trim())
392
+ .filter((token) => token.length >= 2)
393
+ .slice(0, 12);
394
+ }
395
+
396
+ function entrySearchText(entry) {
397
+ return [
398
+ entry.title,
399
+ entry.description,
400
+ entry.cardDescription,
401
+ entry.category,
402
+ entry.slug,
403
+ entry.author,
404
+ entry.submittedBy,
405
+ entry.brandName,
406
+ entry.brandDomain,
407
+ ...notes(entry.safetyNotes),
408
+ ...notes(entry.privacyNotes),
409
+ ...(entry.tags || []),
410
+ ...(entry.keywords || []),
411
+ ]
412
+ .map(normalizeText)
413
+ .join(" ")
414
+ .toLowerCase();
415
+ }
416
+
417
+ function scoreSearchEntry(entry, query) {
418
+ const normalizedQuery = normalizeText(query);
419
+ const tokens = searchTokens(normalizedQuery);
420
+ if (!tokens.length) return { score: 0, reasons: [] };
421
+
422
+ const title = normalizeText(entry.title);
423
+ const category = normalizeText(entry.category);
424
+ const tags = new Set((entry.tags || []).map(normalizeText));
425
+ const keywords = new Set((entry.keywords || []).map(normalizeText));
426
+ const haystack = entrySearchText(entry);
427
+ const reasons = new Set();
428
+ let score = 0;
429
+
430
+ if (title.includes(normalizedQuery)) {
431
+ score += 90;
432
+ reasons.add("title phrase");
433
+ }
434
+ if (category === normalizedQuery) {
435
+ score += 45;
436
+ reasons.add("category match");
437
+ }
438
+
439
+ for (const token of tokens) {
440
+ if (title.includes(token)) {
441
+ score += 35;
442
+ reasons.add("title term");
443
+ }
444
+ if (tags.has(token)) {
445
+ score += 24;
446
+ reasons.add("tag match");
447
+ }
448
+ if (keywords.has(token)) {
449
+ score += 18;
450
+ reasons.add("keyword match");
451
+ }
452
+ if (category.includes(token)) {
453
+ score += 12;
454
+ reasons.add("category term");
455
+ }
456
+ if (haystack.includes(token)) score += 4;
457
+ }
458
+
459
+ if (entrySourceStatus(entry) === "available") {
460
+ score += 8;
461
+ reasons.add("source-backed");
462
+ }
463
+ if (
464
+ entryPackageTrust(entry) === "first-party" ||
465
+ entry.packageVerified ||
466
+ entry.trustSignals?.packageVerified
467
+ ) {
468
+ score += 8;
469
+ reasons.add("trusted package");
470
+ }
471
+ if (notes(entry.safetyNotes).length) {
472
+ score += 4;
473
+ reasons.add("safety notes");
474
+ }
475
+ if (notes(entry.privacyNotes).length) {
476
+ score += 4;
477
+ reasons.add("privacy notes");
478
+ }
479
+ if (entry.claimStatus === "verified" || entry.reviewedBy) {
480
+ score += 4;
481
+ reasons.add("reviewed");
482
+ }
483
+
484
+ return { score, reasons: [...reasons].slice(0, 6) };
485
+ }
486
+
487
+ function rankSearchEntries(entries, query) {
488
+ return entries
489
+ .map((entry, index) => ({
490
+ entry,
491
+ index,
492
+ ...scoreSearchEntry(entry, query),
493
+ }))
494
+ .sort((left, right) => {
495
+ if (left.score !== right.score) return right.score - left.score;
496
+ const dateCompare = String(right.entry.dateAdded || "").localeCompare(
497
+ String(left.entry.dateAdded || ""),
498
+ );
499
+ if (dateCompare !== 0) return dateCompare;
500
+ return left.index - right.index;
501
+ });
502
+ }
503
+
209
504
  function entryMatchesPlatform(entry, platform) {
210
505
  if (!platform) return true;
211
506
  return (entry.platforms || []).some((candidate) => candidate === platform);
212
507
  }
213
508
 
214
- function toSearchResult(entry) {
509
+ function entryMatchesTag(entry, tag) {
510
+ if (!tag) return true;
511
+ return (entry.tags || []).some(
512
+ (candidate) => normalizeText(candidate) === tag,
513
+ );
514
+ }
515
+
516
+ function booleanFilterMatches(value, filter = "all") {
517
+ if (!filter || filter === "all") return true;
518
+ return filter === "true" ? Boolean(value) : !value;
519
+ }
520
+
521
+ function entryPackageTrust(entry) {
522
+ return entry.downloadTrust || (entry.downloadUrl ? "external" : "none");
523
+ }
524
+
525
+ function entryClaimStatus(entry) {
526
+ return entry.claimStatus || "unclaimed";
527
+ }
528
+
529
+ function entrySourceStatus(entry) {
530
+ const sourceUrls = [
531
+ entry.documentationUrl,
532
+ entry.repoUrl,
533
+ entry.githubUrl,
534
+ entry.sourceUrl,
535
+ ].filter((value) => String(value || "").trim());
536
+ return (
537
+ entry.trustSignals?.sourceStatus ||
538
+ (sourceUrls.length ? "available" : "missing")
539
+ );
540
+ }
541
+
542
+ function entryMatchesTrustFilters(entry, args = {}) {
543
+ if (
544
+ !booleanFilterMatches(
545
+ notes(entry.safetyNotes).length > 0,
546
+ args.hasSafetyNotes,
547
+ )
548
+ ) {
549
+ return false;
550
+ }
551
+ if (
552
+ !booleanFilterMatches(
553
+ notes(entry.privacyNotes).length > 0,
554
+ args.hasPrivacyNotes,
555
+ )
556
+ ) {
557
+ return false;
558
+ }
559
+ if (
560
+ args.downloadTrust &&
561
+ args.downloadTrust !== "all" &&
562
+ entryPackageTrust(entry) !== args.downloadTrust
563
+ ) {
564
+ return false;
565
+ }
566
+ if (
567
+ args.claimStatus &&
568
+ args.claimStatus !== "all" &&
569
+ entryClaimStatus(entry) !== args.claimStatus
570
+ ) {
571
+ return false;
572
+ }
573
+ if (
574
+ args.sourceStatus &&
575
+ args.sourceStatus !== "all" &&
576
+ entrySourceStatus(entry) !== args.sourceStatus
577
+ ) {
578
+ return false;
579
+ }
580
+ return true;
581
+ }
582
+
583
+ function parsedTrustArgs(args = {}) {
584
+ return {
585
+ hasSafetyNotes: args.hasSafetyNotes || "all",
586
+ hasPrivacyNotes: args.hasPrivacyNotes || "all",
587
+ downloadTrust: args.downloadTrust || "all",
588
+ claimStatus: args.claimStatus || "all",
589
+ sourceStatus: args.sourceStatus || "all",
590
+ };
591
+ }
592
+
593
+ function toSearchResult(entry, ranking = null) {
215
594
  return {
216
595
  key: `${entry.category}:${entry.slug}`,
217
596
  category: entry.category,
@@ -224,12 +603,268 @@ function toSearchResult(entry) {
224
603
  brandDomain: entry.brandDomain || "",
225
604
  submittedBy: entry.submittedBy || "",
226
605
  claimStatus: entry.claimStatus || "",
227
- url: entry.url || `${SITE_URL}/${entry.category}/${entry.slug}`,
228
- canonicalUrl:
229
- entry.canonicalUrl ||
230
- entry.url ||
231
- `${SITE_URL}/${entry.category}/${entry.slug}`,
606
+ downloadTrust: entry.downloadTrust || null,
607
+ safetyNotes: notes(entry.safetyNotes),
608
+ privacyNotes: notes(entry.privacyNotes),
609
+ url: entry.url || entryCanonicalUrl(entry),
610
+ canonicalUrl: entryCanonicalUrl(entry),
611
+ searchScore: ranking?.score ?? 0,
612
+ searchReasons: ranking?.reasons ?? [],
613
+ trust: entryTrustSummary(entry),
614
+ };
615
+ }
616
+
617
+ function toEntrySummary(entry) {
618
+ return {
619
+ ...toSearchResult(entry),
620
+ dateAdded: entry.dateAdded || "",
621
+ repoUpdatedAt: entry.repoUpdatedAt || null,
622
+ verificationStatus: entry.verificationStatus || "",
623
+ installable: Boolean(entry.installable),
624
+ safetyNotes: notes(entry.safetyNotes),
625
+ privacyNotes: notes(entry.privacyNotes),
626
+ supportLevels: entry.supportLevels || [],
627
+ };
628
+ }
629
+
630
+ function entryUpdatedAt(entry) {
631
+ return String(
632
+ entry.repoUpdatedAt || entry.updatedAt || entry.dateAdded || "",
633
+ );
634
+ }
635
+
636
+ function sourceHost(value) {
637
+ const text = String(value || "").trim();
638
+ if (!text) return "";
639
+ try {
640
+ return new URL(text).hostname.toLowerCase().replace(/^www\./, "");
641
+ } catch {
642
+ return "";
643
+ }
644
+ }
645
+
646
+ function entrySourceHosts(entry) {
647
+ return [
648
+ entry.documentationUrl,
649
+ entry.repoUrl,
650
+ entry.url,
651
+ entry.canonicalUrl,
652
+ entry.llmsUrl,
653
+ entry.apiUrl,
654
+ ]
655
+ .map(sourceHost)
656
+ .filter(Boolean);
657
+ }
658
+
659
+ function intersection(left = [], right = [], normalize = normalizeText) {
660
+ const rightValues = new Set((right || []).map(normalize).filter(Boolean));
661
+ return (left || [])
662
+ .map(normalize)
663
+ .filter((value, index, values) => value && values.indexOf(value) === index)
664
+ .filter((value) => rightValues.has(value));
665
+ }
666
+
667
+ function unique(values = []) {
668
+ return values.filter(
669
+ (value, index, list) => value && list.indexOf(value) === index,
670
+ );
671
+ }
672
+
673
+ function notes(values) {
674
+ return Array.isArray(values)
675
+ ? values.map((value) => String(value || "").trim()).filter(Boolean)
676
+ : [];
677
+ }
678
+
679
+ function normalizeDateFloor(value) {
680
+ const text = String(value || "").trim();
681
+ if (!text) return "";
682
+ const timestamp = Date.parse(text);
683
+ if (!Number.isFinite(timestamp)) return "";
684
+ return new Date(timestamp).toISOString().slice(0, 10);
685
+ }
686
+
687
+ function withPublicPolicy(result) {
688
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
689
+ return result;
690
+ }
691
+ if (result.policy) return result;
692
+ return { ...result, policy: MCP_PUBLIC_POLICY };
693
+ }
694
+
695
+ function sourceSummary(entry) {
696
+ return {
697
+ repoUrl: entry.repoUrl || entry.githubUrl || "",
698
+ documentationUrl: entry.documentationUrl || "",
699
+ downloadUrl: entry.downloadUrl || "",
700
+ sourceHosts: unique(entrySourceHosts(entry)),
701
+ githubStars:
702
+ typeof entry.githubStars === "number" ? entry.githubStars : null,
703
+ githubForks:
704
+ typeof entry.githubForks === "number" ? entry.githubForks : null,
705
+ repoUpdatedAt: entry.repoUpdatedAt || null,
706
+ downloadTrust: entry.downloadTrust || null,
707
+ };
708
+ }
709
+
710
+ function entryTrustRecommendations(entry) {
711
+ const recommendations = [];
712
+ const safetyNotes = notes(entry.safetyNotes);
713
+ const privacyNotes = notes(entry.privacyNotes);
714
+ const packageTrust = entryPackageTrust(entry);
715
+ const source = sourceSummary(entry);
716
+
717
+ if (!source.repoUrl && !source.documentationUrl) {
718
+ recommendations.push(
719
+ "Verify a canonical source before relying on this entry.",
720
+ );
721
+ }
722
+ if (packageTrust === "external") {
723
+ recommendations.push(
724
+ "Review the upstream package source and checksum before installing.",
725
+ );
726
+ }
727
+ if (entry.downloadUrl && packageTrust !== "first-party") {
728
+ recommendations.push(
729
+ "Treat the download as external unless maintainers have rebuilt and verified it.",
730
+ );
731
+ }
732
+ if (!safetyNotes.length) {
733
+ recommendations.push(
734
+ "No structured safety notes are present; inspect commands and permissions manually.",
735
+ );
736
+ }
737
+ if (!privacyNotes.length) {
738
+ recommendations.push(
739
+ "No structured privacy notes are present; review file, credential, telemetry, and network behavior manually.",
740
+ );
741
+ }
742
+ return unique(recommendations).slice(0, 6);
743
+ }
744
+
745
+ function entryTrustSummary(entry) {
746
+ const safetyNotes = notes(entry.safetyNotes);
747
+ const privacyNotes = notes(entry.privacyNotes);
748
+ const source = sourceSummary(entry);
749
+ const packageTrust = entryPackageTrust(entry);
750
+ const claimStatus = entryClaimStatus(entry);
751
+ return {
752
+ source: {
753
+ status: entrySourceStatus(entry),
754
+ repoUrl: source.repoUrl,
755
+ documentationUrl: source.documentationUrl,
756
+ sourceHosts: source.sourceHosts,
757
+ githubStars: source.githubStars,
758
+ githubForks: source.githubForks,
759
+ repoUpdatedAt: source.repoUpdatedAt,
760
+ },
761
+ package: {
762
+ downloadUrl: source.downloadUrl,
763
+ downloadTrust: packageTrust,
764
+ packageVerified: Boolean(
765
+ entry.packageVerified || entry.trustSignals?.packageVerified,
766
+ ),
767
+ checksum:
768
+ entry.checksum ||
769
+ entry.packageChecksum ||
770
+ entry.downloadSha256 ||
771
+ entry.skillPackage?.sha256 ||
772
+ "",
773
+ },
774
+ disclosures: {
775
+ safetyNotes,
776
+ privacyNotes,
777
+ hasSafetyNotes: safetyNotes.length > 0,
778
+ hasPrivacyNotes: privacyNotes.length > 0,
779
+ },
780
+ review: {
781
+ claimStatus,
782
+ reviewedBy: entry.reviewedBy || "",
783
+ reviewedAt: entry.reviewedAt || "",
784
+ submittedBy: entry.submittedBy || "",
785
+ sourceSubmissionUrl: entry.sourceSubmissionUrl || "",
786
+ },
787
+ recommendations: entryTrustRecommendations(entry),
788
+ };
789
+ }
790
+
791
+ function contentAsset(type, label, content, format = "markdown") {
792
+ const text =
793
+ content && typeof content === "object"
794
+ ? JSON.stringify(content, null, 2)
795
+ : String(content || "").trim();
796
+ if (!text) return null;
797
+ return {
798
+ type,
799
+ label,
800
+ format,
801
+ content: text,
802
+ length: text.length,
803
+ };
804
+ }
805
+
806
+ function categoryPrimaryAsset(entry) {
807
+ const assets = [
808
+ contentAsset(
809
+ "full_content",
810
+ "Full usable entry content",
811
+ entry.fullCopyableContent || entry.copySnippet || entry.body,
812
+ ),
813
+ contentAsset(
814
+ "install_command",
815
+ "Install command",
816
+ entry.installCommand,
817
+ "shell",
818
+ ),
819
+ contentAsset(
820
+ "config_snippet",
821
+ "Configuration snippet",
822
+ entry.configSnippet,
823
+ "text",
824
+ ),
825
+ contentAsset("script", "Script body", entry.scriptBody, "text"),
826
+ contentAsset(
827
+ "command_syntax",
828
+ "Command syntax",
829
+ entry.commandSyntax,
830
+ "text",
831
+ ),
832
+ contentAsset("usage", "Usage snippet", entry.usageSnippet, "markdown"),
833
+ contentAsset("items", "Collection items", entry.items, "json"),
834
+ ].filter(Boolean);
835
+
836
+ const preferredByCategory = {
837
+ agents: ["full_content", "usage"],
838
+ rules: ["full_content", "script", "usage"],
839
+ hooks: ["config_snippet", "script", "install_command", "usage"],
840
+ mcp: ["config_snippet", "install_command", "usage"],
841
+ skills: ["install_command", "full_content", "usage"],
842
+ statuslines: ["config_snippet", "script", "full_content", "usage"],
843
+ commands: ["command_syntax", "install_command", "full_content", "usage"],
844
+ collections: ["items", "full_content", "usage"],
845
+ guides: ["full_content", "usage"],
232
846
  };
847
+ const preferred = preferredByCategory[entry.category] || ["full_content"];
848
+ return (
849
+ preferred
850
+ .map((type) => assets.find((asset) => asset.type === type))
851
+ .find(Boolean) ||
852
+ assets[0] ||
853
+ null
854
+ );
855
+ }
856
+
857
+ function entryInstallComplexity(entry) {
858
+ const pieces = [
859
+ entry.installCommand,
860
+ entry.configSnippet,
861
+ entry.downloadUrl,
862
+ entry.prerequisites,
863
+ ].filter((value) => String(value || "").trim());
864
+ if (pieces.length >= 3) return "higher";
865
+ if (pieces.length === 2) return "medium";
866
+ if (pieces.length === 1) return "low";
867
+ return "unknown";
233
868
  }
234
869
 
235
870
  async function readEntry(category, slug, options = {}) {
@@ -264,16 +899,19 @@ export async function searchRegistry(args = {}, options = {}) {
264
899
  const category = normalizeText(args.category);
265
900
  const platform = normalizePlatform(args.platform);
266
901
  const limit = normalizeLimit(args.limit);
902
+ const trustFilters = parsedTrustArgs(args);
267
903
  const searchIndex = unwrapEntries(
268
904
  await readJsonArtifact("search-index.json", options),
269
905
  );
270
906
 
271
- const entries = searchIndex
907
+ const matched = searchIndex
272
908
  .filter((entry) => !category || entry.category === category)
273
909
  .filter((entry) => entryMatchesPlatform(entry, platform))
274
910
  .filter((entry) => entryMatchesQuery(entry, query))
911
+ .filter((entry) => entryMatchesTrustFilters(entry, trustFilters));
912
+ const entries = rankSearchEntries(matched, query)
275
913
  .slice(0, limit)
276
- .map(toSearchResult);
914
+ .map((item) => toSearchResult(item.entry, item));
277
915
 
278
916
  return {
279
917
  ok: true,
@@ -281,56 +919,1324 @@ export async function searchRegistry(args = {}, options = {}) {
281
919
  query: args.query || "",
282
920
  category: category || "",
283
921
  platform: platform || "",
922
+ filters: trustFilters,
284
923
  entries,
285
924
  };
286
925
  }
287
926
 
288
- export async function getEntryDetail(args = {}, options = {}) {
289
- const category = normalizeText(args.category);
290
- const slug = normalizeText(args.slug);
291
- if (!category || !slug) {
292
- return invalid("category and slug are required.");
927
+ function selectDiverseRankedEntries(ranked, limit) {
928
+ const selected = [];
929
+ const byCategory = new Map();
930
+
931
+ for (const item of ranked) {
932
+ const category = item.entry.category || "";
933
+ const current = byCategory.get(category) || 0;
934
+ if (current >= 2) continue;
935
+ selected.push(item);
936
+ byCategory.set(category, current + 1);
937
+ if (selected.length >= limit) return selected;
293
938
  }
294
939
 
295
- const entry = await readEntry(category, slug, options);
296
- if (!entry) {
297
- return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
940
+ for (const item of ranked) {
941
+ if (selected.includes(item)) continue;
942
+ selected.push(item);
943
+ if (selected.length >= limit) return selected;
298
944
  }
299
945
 
300
- return {
301
- ok: true,
302
- key: `${entry.category}:${entry.slug}`,
303
- canonicalUrl: `${SITE_URL}/${entry.category}/${entry.slug}`,
304
- entry,
305
- };
946
+ return selected;
306
947
  }
307
948
 
308
- export async function getCompatibility(args = {}, options = {}) {
309
- const category = normalizeText(args.category || "skills");
310
- const slug = normalizeText(args.slug);
311
- if (!slug) return invalid("slug is required.");
949
+ function toolboxFitReasons(entry, ranking) {
950
+ const reasons = [...(ranking.reasons || []).slice(0, 4)];
951
+ if (entry.category) {
952
+ reasons.push(`${entry.category} workflow surface`);
953
+ }
954
+ if (entrySourceStatus(entry) === "available") {
955
+ reasons.push("source-backed metadata");
956
+ }
957
+ if (
958
+ entryPackageTrust(entry) === "first-party" ||
959
+ entry.packageVerified ||
960
+ entry.trustSignals?.packageVerified
961
+ ) {
962
+ reasons.push("first-party or verified package signal");
963
+ }
964
+ if (notes(entry.safetyNotes).length && notes(entry.privacyNotes).length) {
965
+ reasons.push("safety and privacy notes present");
966
+ } else if (notes(entry.safetyNotes).length) {
967
+ reasons.push("safety notes present");
968
+ } else if (notes(entry.privacyNotes).length) {
969
+ reasons.push("privacy notes present");
970
+ }
971
+ if (entry.installCommand || entry.downloadUrl || entry.configSnippet) {
972
+ reasons.push("actionable setup surface");
973
+ }
974
+ if ((entry.platforms || []).length) {
975
+ reasons.push(
976
+ `platform compatibility: ${(entry.platforms || []).slice(0, 3).join(", ")}`,
977
+ );
978
+ }
979
+ if ((entry.supportLevels || []).length) {
980
+ reasons.push("support levels documented");
981
+ }
982
+ if (entry.claimStatus === "verified" || entry.reviewedBy) {
983
+ reasons.push("review/provenance metadata");
984
+ }
985
+ return unique(reasons).slice(0, 8);
986
+ }
312
987
 
313
- const entry = await readEntry(category, slug, options);
314
- if (!entry) {
315
- return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
988
+ function toolboxCaveats(entry) {
989
+ const caveats = [];
990
+ const packageTrust = entryPackageTrust(entry);
991
+ const safetyNotes = notes(entry.safetyNotes);
992
+ const privacyNotes = notes(entry.privacyNotes);
993
+ if (entrySourceStatus(entry) !== "available") {
994
+ caveats.push("Source metadata is missing or incomplete.");
995
+ }
996
+ if (packageTrust === "external") {
997
+ caveats.push("Package/download is external; verify upstream before use.");
998
+ }
999
+ if (entry.downloadUrl && !entryTrustSummary(entry).package.checksum) {
1000
+ caveats.push("Download checksum metadata is not present.");
1001
+ }
1002
+ if (!safetyNotes.length) {
1003
+ caveats.push("No structured safety notes are present.");
1004
+ }
1005
+ if (!privacyNotes.length) {
1006
+ caveats.push("No structured privacy notes are present.");
1007
+ }
1008
+ if (
1009
+ ["mcp", "hooks", "commands", "skills", "statuslines"].includes(
1010
+ entry.category,
1011
+ )
1012
+ ) {
1013
+ caveats.push(
1014
+ "Risk-bearing workflow surface; inspect commands, permissions, and data access before use.",
1015
+ );
1016
+ }
1017
+ return unique(caveats).slice(0, 5);
1018
+ }
1019
+
1020
+ function toolboxNextActions(entry) {
1021
+ return [
1022
+ `Inspect get_entry_detail with category=${entry.category} and slug=${entry.slug}.`,
1023
+ `Run explain_entry_trust with category=${entry.category} and slug=${entry.slug}; this is still metadata review only.`,
1024
+ "Use compare_entries with nearby candidates before recommending a final stack.",
1025
+ `Use get_copyable_asset with category=${entry.category} and slug=${entry.slug} only after trust review.`,
1026
+ ];
1027
+ }
1028
+
1029
+ function toolboxCategoryMix(entries) {
1030
+ const counts = new Map();
1031
+ for (const entry of entries) {
1032
+ const category = entry.category || "unknown";
1033
+ counts.set(category, (counts.get(category) || 0) + 1);
316
1034
  }
1035
+ return [...counts]
1036
+ .map(([category, count]) => ({ category, count }))
1037
+ .sort((left, right) => left.category.localeCompare(right.category));
1038
+ }
317
1039
 
1040
+ function toolboxTrustSummary(entries) {
318
1041
  return {
319
- ok: true,
320
- key: `${entry.category}:${entry.slug}`,
321
- category: entry.category,
322
- slug: entry.slug,
323
- platformCompatibility: buildSkillPlatformCompatibility(entry),
1042
+ sourceBacked: entries.filter(
1043
+ (entry) => entry.trust?.source?.status === "available",
1044
+ ).length,
1045
+ firstPartyOrVerifiedPackages: entries.filter(
1046
+ (entry) =>
1047
+ entry.trust?.package?.downloadTrust === "first-party" ||
1048
+ entry.trust?.package?.packageVerified,
1049
+ ).length,
1050
+ entriesWithSafetyNotes: entries.filter(
1051
+ (entry) => entry.trust?.disclosures?.hasSafetyNotes,
1052
+ ).length,
1053
+ entriesWithPrivacyNotes: entries.filter(
1054
+ (entry) => entry.trust?.disclosures?.hasPrivacyNotes,
1055
+ ).length,
1056
+ externalPackages: entries.filter(
1057
+ (entry) => entry.trust?.package?.downloadTrust === "external",
1058
+ ).length,
1059
+ missingSource: entries.filter(
1060
+ (entry) => entry.trust?.source?.status !== "available",
1061
+ ).length,
324
1062
  };
325
1063
  }
326
1064
 
327
- export async function getInstallGuidance(args = {}, options = {}) {
1065
+ export async function planWorkflowToolbox(args = {}, options = {}) {
1066
+ const goal = String(args.goal || "").trim();
1067
+ if (goal.length < 2) {
1068
+ return invalid("Planner goal must be at least 2 characters.");
1069
+ }
1070
+ const query = normalizeText(goal);
328
1071
  const category = normalizeText(args.category);
329
- const slug = normalizeText(args.slug);
330
1072
  const platform = normalizePlatform(args.platform);
331
- if (!category || !slug) {
332
- return invalid("category and slug are required.");
333
- }
1073
+ const limit = Math.min(10, normalizeLimit(args.limit, 6));
1074
+ const searchIndex = unwrapEntries(
1075
+ await readJsonArtifact("search-index.json", options),
1076
+ );
1077
+ const scoped = searchIndex
1078
+ .filter((entry) => !category || entry.category === category)
1079
+ .filter((entry) => entryMatchesPlatform(entry, platform));
1080
+ let matched = scoped.filter((entry) => entryMatchesQuery(entry, query));
1081
+ const queryTokens = searchTokens(query);
1082
+ if (!matched.length && queryTokens.length) {
1083
+ matched = scoped.filter((entry) =>
1084
+ queryTokens.some((token) => entrySearchText(entry).includes(token)),
1085
+ );
1086
+ }
1087
+ const ranked = rankSearchEntries(matched, query);
1088
+ const selected = selectDiverseRankedEntries(ranked, limit).map((item) => ({
1089
+ ...toEntrySummary(item.entry),
1090
+ searchScore: item.score,
1091
+ searchReasons: item.reasons,
1092
+ toolboxReasons: toolboxFitReasons(item.entry, item),
1093
+ caveats: toolboxCaveats(item.entry),
1094
+ nextActions: toolboxNextActions(item.entry),
1095
+ }));
1096
+
1097
+ return {
1098
+ ok: true,
1099
+ goal,
1100
+ category: category || "",
1101
+ platform: platform || "",
1102
+ count: selected.length,
1103
+ entries: selected,
1104
+ categoryMix: toolboxCategoryMix(selected),
1105
+ trustSummary: toolboxTrustSummary(selected),
1106
+ recommendedNextTools: [
1107
+ "get_entry_detail",
1108
+ "explain_entry_trust",
1109
+ "compare_entries",
1110
+ "get_copyable_asset",
1111
+ ],
1112
+ plannerNotes: [
1113
+ "This planner is metadata review only; it is not install approval or malware scanning, and it does not execute or install entries.",
1114
+ "Recommendations are bounded and category-diverse where matching entries allow it.",
1115
+ "Prefer source-backed entries with safety/privacy notes for risk-bearing MCP, hooks, skills, commands, and statuslines.",
1116
+ "Use get_entry_detail, explain_entry_trust, compare_entries, and get_copyable_asset before relying on any entry.",
1117
+ ],
1118
+ };
1119
+ }
1120
+
1121
+ export async function getServerInfo(args = {}, options = {}) {
1122
+ const manifest = await readJsonArtifact("registry-manifest.json", options);
1123
+ return {
1124
+ ok: true,
1125
+ package: {
1126
+ name: packageName,
1127
+ version: packageVersion,
1128
+ },
1129
+ endpoint: {
1130
+ url: DEFAULT_REMOTE_MCP_URL,
1131
+ auth: "none",
1132
+ transport: "streamable-http",
1133
+ stdioBridge: "npx -y @heyclaude/mcp",
1134
+ requestBodyLimitBytes: 64 * 1024,
1135
+ rateLimit: {
1136
+ scope: "mcp-streamable",
1137
+ limit: 60,
1138
+ windowSeconds: 60,
1139
+ binding: "API_MCP_RATE_LIMIT",
1140
+ note: "Cloudflare enforces the durable production limit when the binding is available; local/dev falls back to an in-process limiter.",
1141
+ },
1142
+ },
1143
+ registry: {
1144
+ schemaVersion: manifest.schemaVersion,
1145
+ generatedAt: manifest.generatedAt,
1146
+ totalEntries: manifest.totalEntries,
1147
+ categories: manifest.categories || {},
1148
+ },
1149
+ tools: READ_ONLY_TOOL_NAMES,
1150
+ policy: MCP_PUBLIC_POLICY,
1151
+ };
1152
+ }
1153
+
1154
+ export async function listCategoryEntries(args = {}, options = {}) {
1155
+ const category = normalizeText(args.category);
1156
+ const platform = normalizePlatform(args.platform);
1157
+ const tag = normalizeText(args.tag);
1158
+ const query = normalizeText(args.query);
1159
+ const offset = normalizeOffset(args.offset);
1160
+ const limit = normalizeLimit(args.limit, 20);
1161
+ const searchIndex = unwrapEntries(
1162
+ await readJsonArtifact("search-index.json", options),
1163
+ );
1164
+
1165
+ const entries = searchIndex
1166
+ .filter((entry) => !category || entry.category === category)
1167
+ .filter((entry) => entryMatchesPlatform(entry, platform))
1168
+ .filter((entry) => entryMatchesTag(entry, tag))
1169
+ .filter((entry) => entryMatchesQuery(entry, query));
1170
+ const page = entries.slice(offset, offset + limit).map(toEntrySummary);
1171
+
1172
+ return {
1173
+ ok: true,
1174
+ category: category || "",
1175
+ platform: platform || "",
1176
+ tag: tag || "",
1177
+ query: args.query || "",
1178
+ total: entries.length,
1179
+ count: page.length,
1180
+ offset,
1181
+ limit,
1182
+ nextOffset: offset + limit < entries.length ? offset + limit : null,
1183
+ entries: page,
1184
+ };
1185
+ }
1186
+
1187
+ export async function getRecentUpdates(args = {}, options = {}) {
1188
+ const category = normalizeText(args.category);
1189
+ const since = args.since ? normalizeDateFloor(args.since) : "";
1190
+ if (args.since && !since) {
1191
+ return invalid("since must be a parseable date such as 2026-05-01.");
1192
+ }
1193
+ const limit = normalizeLimit(args.limit, 10);
1194
+ const searchIndex = unwrapEntries(
1195
+ await readJsonArtifact("search-index.json", options),
1196
+ );
1197
+ const entries = searchIndex
1198
+ .filter((entry) => !category || entry.category === category)
1199
+ .filter((entry) => !since || entryUpdatedAt(entry) >= since)
1200
+ .slice()
1201
+ .sort((left, right) => {
1202
+ const dateCompare = entryUpdatedAt(right).localeCompare(
1203
+ entryUpdatedAt(left),
1204
+ );
1205
+ if (dateCompare !== 0) return dateCompare;
1206
+ return String(left.title || "").localeCompare(String(right.title || ""));
1207
+ })
1208
+ .slice(0, limit)
1209
+ .map((entry) => ({
1210
+ ...toEntrySummary(entry),
1211
+ updatedAt: entryUpdatedAt(entry),
1212
+ updateKind: entry.repoUpdatedAt ? "upstream_update" : "added",
1213
+ }));
1214
+
1215
+ return {
1216
+ ok: true,
1217
+ category: category || "",
1218
+ since,
1219
+ count: entries.length,
1220
+ entries,
1221
+ };
1222
+ }
1223
+
1224
+ function scoreRelatedEntry(target, candidate) {
1225
+ if (
1226
+ target.category === candidate.category &&
1227
+ target.slug === candidate.slug
1228
+ ) {
1229
+ return null;
1230
+ }
1231
+
1232
+ const sharedTags = intersection(target.tags, candidate.tags);
1233
+ const sharedKeywords = intersection(target.keywords, candidate.keywords);
1234
+ const sharedPlatforms = intersection(
1235
+ target.platforms,
1236
+ candidate.platforms,
1237
+ (value) => String(value || ""),
1238
+ );
1239
+ const sharedHosts = intersection(
1240
+ entrySourceHosts(target),
1241
+ entrySourceHosts(candidate),
1242
+ (value) => String(value || ""),
1243
+ );
1244
+ const score =
1245
+ (target.category === candidate.category ? 4 : 0) +
1246
+ sharedTags.length * 3 +
1247
+ Math.min(sharedKeywords.length, 6) +
1248
+ sharedPlatforms.length +
1249
+ sharedHosts.length * 2;
1250
+
1251
+ if (score <= 0) return null;
1252
+ return {
1253
+ score,
1254
+ reasons: [
1255
+ ...(target.category === candidate.category ? ["same_category"] : []),
1256
+ ...sharedTags.map((tag) => `tag:${tag}`),
1257
+ ...sharedPlatforms.map((platform) => `platform:${platform}`),
1258
+ ...sharedHosts.map((host) => `source:${host}`),
1259
+ ],
1260
+ };
1261
+ }
1262
+
1263
+ export async function getRelatedEntries(args = {}, options = {}) {
1264
+ const category = normalizeText(args.category);
1265
+ const slug = normalizeText(args.slug);
1266
+ const limit = normalizeLimit(args.limit, 8);
1267
+ const searchIndex = unwrapEntries(
1268
+ await readJsonArtifact("search-index.json", options),
1269
+ );
1270
+ const target = searchIndex.find(
1271
+ (entry) => entry.category === category && entry.slug === slug,
1272
+ );
1273
+ if (!target) {
1274
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
1275
+ }
1276
+
1277
+ const graph = await readJsonArtifact("relation-graph.json", options).catch(
1278
+ () => null,
1279
+ );
1280
+ const graphRow = Array.isArray(graph?.entries)
1281
+ ? graph.entries.find((entry) => entry.key === `${category}:${slug}`)
1282
+ : null;
1283
+ if (graphRow?.related?.length) {
1284
+ const searchByKey = new Map(
1285
+ searchIndex.map((entry) => [`${entry.category}:${entry.slug}`, entry]),
1286
+ );
1287
+ const entries = graphRow.related
1288
+ .map((relation) => {
1289
+ const entry = searchByKey.get(relation.key);
1290
+ if (!entry) return null;
1291
+ return {
1292
+ ...toEntrySummary(entry),
1293
+ relation: relation.relation,
1294
+ relatedScore: relation.score,
1295
+ relatedReasons: relation.reasons || [],
1296
+ };
1297
+ })
1298
+ .filter(Boolean)
1299
+ .slice(0, limit);
1300
+
1301
+ return {
1302
+ ok: true,
1303
+ key: `${target.category}:${target.slug}`,
1304
+ relationGraph: true,
1305
+ count: entries.length,
1306
+ entries,
1307
+ };
1308
+ }
1309
+
1310
+ const entries = searchIndex
1311
+ .map((entry) => {
1312
+ const related = scoreRelatedEntry(target, entry);
1313
+ return related ? { entry, related } : null;
1314
+ })
1315
+ .filter(Boolean)
1316
+ .sort((left, right) => {
1317
+ const scoreCompare = right.related.score - left.related.score;
1318
+ if (scoreCompare !== 0) return scoreCompare;
1319
+ return entryUpdatedAt(right.entry).localeCompare(
1320
+ entryUpdatedAt(left.entry),
1321
+ );
1322
+ })
1323
+ .slice(0, limit)
1324
+ .map(({ entry, related }) => ({
1325
+ ...toEntrySummary(entry),
1326
+ relatedScore: related.score,
1327
+ relatedReasons: related.reasons,
1328
+ }));
1329
+
1330
+ return {
1331
+ ok: true,
1332
+ key: `${target.category}:${target.slug}`,
1333
+ count: entries.length,
1334
+ entries,
1335
+ };
1336
+ }
1337
+
1338
+ export async function getEntryDetail(args = {}, options = {}) {
1339
+ const category = normalizeText(args.category);
1340
+ const slug = normalizeText(args.slug);
1341
+ if (!category || !slug) {
1342
+ return invalid("category and slug are required.");
1343
+ }
1344
+
1345
+ const entry = await readEntry(category, slug, options);
1346
+ if (!entry) {
1347
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
1348
+ }
1349
+
1350
+ return {
1351
+ ok: true,
1352
+ key: `${entry.category}:${entry.slug}`,
1353
+ canonicalUrl: entryCanonicalUrl(entry),
1354
+ entry: {
1355
+ ...entry,
1356
+ safetyNotes: notes(entry.safetyNotes),
1357
+ privacyNotes: notes(entry.privacyNotes),
1358
+ },
1359
+ trust: entryTrustSummary(entry),
1360
+ };
1361
+ }
1362
+
1363
+ export async function getCopyableAsset(args = {}, options = {}) {
1364
+ const category = normalizeText(args.category);
1365
+ const slug = normalizeText(args.slug);
1366
+ const platform = normalizePlatform(args.platform);
1367
+ if (!category || !slug) {
1368
+ return invalid("category and slug are required.");
1369
+ }
1370
+
1371
+ const entry = await readEntry(category, slug, options);
1372
+ if (!entry) {
1373
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
1374
+ }
1375
+
1376
+ const primary = categoryPrimaryAsset(entry);
1377
+ const assets = [
1378
+ contentAsset(
1379
+ "full_content",
1380
+ "Full usable entry content",
1381
+ entry.fullCopyableContent || entry.copySnippet || entry.body,
1382
+ ),
1383
+ contentAsset(
1384
+ "install_command",
1385
+ "Install command",
1386
+ entry.installCommand,
1387
+ "shell",
1388
+ ),
1389
+ contentAsset(
1390
+ "config_snippet",
1391
+ "Configuration snippet",
1392
+ entry.configSnippet,
1393
+ "text",
1394
+ ),
1395
+ contentAsset("script", "Script body", entry.scriptBody, "text"),
1396
+ contentAsset(
1397
+ "command_syntax",
1398
+ "Command syntax",
1399
+ entry.commandSyntax,
1400
+ "text",
1401
+ ),
1402
+ contentAsset("usage", "Usage snippet", entry.usageSnippet, "markdown"),
1403
+ contentAsset("items", "Collection items", entry.items, "json"),
1404
+ ].filter(Boolean);
1405
+ const compatibility = buildSkillPlatformCompatibility(entry);
1406
+
1407
+ return {
1408
+ ok: true,
1409
+ key: `${entry.category}:${entry.slug}`,
1410
+ category: entry.category,
1411
+ slug: entry.slug,
1412
+ title: entry.title,
1413
+ canonicalUrl: entryCanonicalUrl(entry),
1414
+ platform: platform || "",
1415
+ primaryAsset: primary,
1416
+ assets,
1417
+ installCommand: entry.installCommand || "",
1418
+ configSnippet: entry.configSnippet || "",
1419
+ usageSnippet: entry.usageSnippet || "",
1420
+ downloadUrl: entry.downloadUrl || "",
1421
+ safetyNotes: notes(entry.safetyNotes),
1422
+ privacyNotes: notes(entry.privacyNotes),
1423
+ platformCompatibility: compatibility,
1424
+ source: sourceSummary(entry),
1425
+ trust: entryTrustSummary(entry),
1426
+ };
1427
+ }
1428
+
1429
+ export async function compareEntries(args = {}, options = {}) {
1430
+ const platform = normalizePlatform(args.platform);
1431
+ const entries = [];
1432
+ for (const target of args.entries || []) {
1433
+ const category = normalizeText(target.category);
1434
+ const slug = normalizeText(target.slug);
1435
+ const entry = await readEntry(category, slug, options);
1436
+ if (!entry) {
1437
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
1438
+ }
1439
+ entries.push(entry);
1440
+ }
1441
+
1442
+ const compared = entries.map((entry) => {
1443
+ const compatibility = buildSkillPlatformCompatibility(entry);
1444
+ const selectedCompatibility = platform
1445
+ ? compatibility.find((item) => item.platform === platform) || null
1446
+ : null;
1447
+ return {
1448
+ key: `${entry.category}:${entry.slug}`,
1449
+ category: entry.category,
1450
+ slug: entry.slug,
1451
+ title: entry.title,
1452
+ description: entry.description,
1453
+ canonicalUrl: entryCanonicalUrl(entry),
1454
+ tags: entry.tags || [],
1455
+ platforms: entry.platforms || [],
1456
+ selectedCompatibility,
1457
+ installComplexity: entryInstallComplexity(entry),
1458
+ copyableAssetTypes: [
1459
+ categoryPrimaryAsset(entry)?.type,
1460
+ entry.configSnippet ? "config_snippet" : "",
1461
+ entry.installCommand ? "install_command" : "",
1462
+ entry.scriptBody ? "script" : "",
1463
+ ].filter(Boolean),
1464
+ source: sourceSummary(entry),
1465
+ trust: entryTrustSummary(entry),
1466
+ };
1467
+ });
1468
+ const sharedTags = compared.length
1469
+ ? compared
1470
+ .slice(1)
1471
+ .reduce(
1472
+ (tags, entry) => intersection(tags, entry.tags || []),
1473
+ compared[0].tags || [],
1474
+ )
1475
+ : [];
1476
+
1477
+ return {
1478
+ ok: true,
1479
+ platform: platform || "",
1480
+ count: compared.length,
1481
+ sharedTags,
1482
+ entries: compared,
1483
+ comparisonNotes: [
1484
+ "Prefer exact category fit before source popularity.",
1485
+ "Treat GitHub stars/forks as source signals only when present; absence is not a negative ranking.",
1486
+ "Install complexity is derived from available install/config/download/prerequisite metadata.",
1487
+ "Safety/privacy notes are disclosure metadata, not a malware verdict.",
1488
+ ],
1489
+ };
1490
+ }
1491
+
1492
+ export async function getRegistryStats(args = {}, options = {}) {
1493
+ const [manifest, searchIndexPayload] = await Promise.all([
1494
+ readJsonArtifact("registry-manifest.json", options),
1495
+ readJsonArtifact("search-index.json", options),
1496
+ ]);
1497
+ const entries = unwrapEntries(searchIndexPayload);
1498
+ const platformCounts = new Map();
1499
+ const tagCounts = new Map();
1500
+ for (const entry of entries) {
1501
+ for (const platform of entry.platforms || []) {
1502
+ platformCounts.set(platform, (platformCounts.get(platform) || 0) + 1);
1503
+ }
1504
+ for (const tag of entry.tags || []) {
1505
+ tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
1506
+ }
1507
+ }
1508
+
1509
+ return {
1510
+ ok: true,
1511
+ package: {
1512
+ name: packageName,
1513
+ version: packageVersion,
1514
+ },
1515
+ registry: {
1516
+ schemaVersion: manifest.schemaVersion,
1517
+ generatedAt: manifest.generatedAt,
1518
+ totalEntries: manifest.totalEntries,
1519
+ categories: manifest.categories || {},
1520
+ },
1521
+ freshness: {
1522
+ entriesWithRepoUpdatedAt: entries.filter((entry) => entry.repoUpdatedAt)
1523
+ .length,
1524
+ entriesAddedLast30Days: entries.filter((entry) => {
1525
+ const added = Date.parse(entry.dateAdded || "");
1526
+ return (
1527
+ Number.isFinite(added) &&
1528
+ Date.now() - added <= 30 * 24 * 60 * 60 * 1000
1529
+ );
1530
+ }).length,
1531
+ },
1532
+ sourceSignals: {
1533
+ entriesWithGithubStats: entries.filter(
1534
+ (entry) => typeof entry.githubStars === "number",
1535
+ ).length,
1536
+ installableEntries: entries.filter((entry) => entry.installable).length,
1537
+ },
1538
+ platforms: Object.fromEntries(
1539
+ [...platformCounts.entries()].sort((left, right) =>
1540
+ left[0].localeCompare(right[0]),
1541
+ ),
1542
+ ),
1543
+ topTags: [...tagCounts.entries()]
1544
+ .sort(
1545
+ (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
1546
+ )
1547
+ .slice(0, 20)
1548
+ .map(([tag, count]) => ({ tag, count })),
1549
+ };
1550
+ }
1551
+
1552
+ export async function getClientSetup(args = {}) {
1553
+ let endpointUrl;
1554
+ try {
1555
+ const rawEndpointUrl = Object.prototype.hasOwnProperty.call(
1556
+ args,
1557
+ "endpointUrl",
1558
+ )
1559
+ ? args.endpointUrl
1560
+ : DEFAULT_REMOTE_MCP_URL;
1561
+ endpointUrl = normalizeEndpointUrl(rawEndpointUrl).toString();
1562
+ } catch (error) {
1563
+ return invalid(error?.message || "Invalid endpoint URL.");
1564
+ }
1565
+ const snippets = {
1566
+ codex: {
1567
+ label: "Codex stdio bridge",
1568
+ config: {
1569
+ mcpServers: {
1570
+ heyclaude: {
1571
+ command: "npx",
1572
+ args: ["-y", "@heyclaude/mcp"],
1573
+ },
1574
+ },
1575
+ },
1576
+ },
1577
+ "claude-desktop": {
1578
+ label: "Claude Desktop stdio bridge",
1579
+ config: {
1580
+ mcpServers: {
1581
+ heyclaude: {
1582
+ command: "npx",
1583
+ args: ["-y", "@heyclaude/mcp"],
1584
+ },
1585
+ },
1586
+ },
1587
+ },
1588
+ cursor: {
1589
+ label: "Cursor remote MCP",
1590
+ config: {
1591
+ mcpServers: {
1592
+ heyclaude: {
1593
+ url: endpointUrl,
1594
+ },
1595
+ },
1596
+ },
1597
+ },
1598
+ windsurf: {
1599
+ label: "Windsurf remote MCP",
1600
+ config: {
1601
+ mcpServers: {
1602
+ heyclaude: {
1603
+ serverUrl: endpointUrl,
1604
+ },
1605
+ },
1606
+ },
1607
+ },
1608
+ "remote-http": {
1609
+ label: "Streamable HTTP endpoint",
1610
+ endpointUrl,
1611
+ headers: {
1612
+ accept: "application/json, text/event-stream",
1613
+ "content-type": "application/json",
1614
+ },
1615
+ },
1616
+ };
1617
+ const client = args.client || "";
1618
+ return {
1619
+ ok: true,
1620
+ endpointUrl,
1621
+ apiKeyRequired: false,
1622
+ selectedClient: client,
1623
+ snippets: client ? { [client]: snippets[client] } : snippets,
1624
+ notes: [
1625
+ "The public endpoint is read-only and does not need an API key.",
1626
+ "Submission tools prepare maintainer-reviewed PR-first drafts; they do not open GitHub issues.",
1627
+ "Use --url only when testing a custom preview or deployment.",
1628
+ ],
1629
+ };
1630
+ }
1631
+
1632
+ export const RESOURCE_TEMPLATES = [
1633
+ {
1634
+ uriTemplate: "heyclaude://entry/{category}/{slug}",
1635
+ name: "HeyClaude entry detail",
1636
+ title: "HeyClaude entry detail",
1637
+ description:
1638
+ "Read a single generated HeyClaude entry detail artifact as JSON.",
1639
+ mimeType: jsonMimeType,
1640
+ },
1641
+ {
1642
+ uriTemplate: "heyclaude://category/{category}",
1643
+ name: "HeyClaude category entries",
1644
+ title: "HeyClaude category entries",
1645
+ description:
1646
+ "Read generated summary entries for one HeyClaude category as JSON.",
1647
+ mimeType: jsonMimeType,
1648
+ },
1649
+ ];
1650
+
1651
+ /**
1652
+ * Static MCP resource descriptors for the bounded discovery surfaces
1653
+ * exposed alongside the directory and category feeds. Appended to
1654
+ * {@link listRegistryResources} output and routed by
1655
+ * {@link readRegistryResource}.
1656
+ *
1657
+ * @type {Array<{ uri: string, name: string, title: string, description: string, mimeType: string }>}
1658
+ */
1659
+ const DISCOVERY_RESOURCES = [
1660
+ {
1661
+ uri: "heyclaude://registry/recent",
1662
+ name: "HeyClaude recent registry updates",
1663
+ title: "HeyClaude recent registry updates",
1664
+ description:
1665
+ "Bounded list of recently added or upstream-updated HeyClaude entries from the generated search index.",
1666
+ mimeType: jsonMimeType,
1667
+ },
1668
+ {
1669
+ uri: "heyclaude://registry/trending",
1670
+ name: "HeyClaude trending registry entries",
1671
+ title: "HeyClaude trending registry entries",
1672
+ description:
1673
+ "Bounded list of trending HeyClaude entries from the public /api/registry/trending endpoint; degrades gracefully when dynamic state is unavailable.",
1674
+ mimeType: jsonMimeType,
1675
+ },
1676
+ {
1677
+ uri: "heyclaude://jobs/active",
1678
+ name: "HeyClaude active jobs",
1679
+ title: "HeyClaude active jobs",
1680
+ description:
1681
+ "Bounded list of active public job listings from the public /api/jobs endpoint; degrades gracefully when dynamic state is unavailable.",
1682
+ mimeType: jsonMimeType,
1683
+ },
1684
+ ];
1685
+
1686
+ /**
1687
+ * Resolve the public HeyClaude API base URL. Prefers an explicit override
1688
+ * on `options.publicApiBaseUrl`, then the `HEYCLAUDE_PUBLIC_API_URL`
1689
+ * environment variable, then falls back to the canonical site URL.
1690
+ *
1691
+ * @param {{ publicApiBaseUrl?: string }} [options]
1692
+ * @returns {string} Base URL used to build `/api/...` requests.
1693
+ */
1694
+ function publicApiBaseUrl(options = {}) {
1695
+ return (
1696
+ options.publicApiBaseUrl || process.env.HEYCLAUDE_PUBLIC_API_URL || SITE_URL
1697
+ );
1698
+ }
1699
+
1700
+ /**
1701
+ * Remove trailing slashes without using a potentially expensive regex on
1702
+ * caller-controlled API base URL overrides.
1703
+ *
1704
+ * @param {string} value
1705
+ * @returns {string}
1706
+ */
1707
+ function stripTrailingSlashes(value) {
1708
+ let end = value.length;
1709
+ while (end > 0 && value.charCodeAt(end - 1) === 47) {
1710
+ end -= 1;
1711
+ }
1712
+ return value.slice(0, end);
1713
+ }
1714
+
1715
+ /**
1716
+ * Fetch JSON from a public HeyClaude API path. Tests inject a deterministic
1717
+ * fetcher via `options.fetchPublicApi`; production uses `fetch()` with a
1718
+ * bounded {@link DISCOVERY_FETCH_TIMEOUT_MS} timeout, `redirect: "error"`,
1719
+ * and a JSON `accept` header. Throws on non-2xx responses so callers can
1720
+ * convert failures into the "unavailable" graceful-degradation envelope.
1721
+ *
1722
+ * @param {string} apiPath API path beginning with `/api/...`.
1723
+ * @param {{
1724
+ * publicApiBaseUrl?: string,
1725
+ * fetchPublicApi?: (apiPath: string) => Promise<unknown>,
1726
+ * }} [options]
1727
+ * @returns {Promise<unknown>} Parsed JSON body from the upstream response.
1728
+ */
1729
+ async function fetchPublicApiJson(apiPath, options = {}) {
1730
+ if (typeof options.fetchPublicApi === "function") {
1731
+ return options.fetchPublicApi(apiPath);
1732
+ }
1733
+ const baseUrl = stripTrailingSlashes(publicApiBaseUrl(options));
1734
+ const url = `${baseUrl}${apiPath.startsWith("/") ? "" : "/"}${apiPath}`;
1735
+ const controller = new AbortController();
1736
+ const timeout = setTimeout(
1737
+ () => controller.abort(),
1738
+ DISCOVERY_FETCH_TIMEOUT_MS,
1739
+ );
1740
+ try {
1741
+ const response = await fetch(url, {
1742
+ method: "GET",
1743
+ headers: { accept: jsonMimeType },
1744
+ redirect: "error",
1745
+ signal: controller.signal,
1746
+ });
1747
+ if (!response.ok) {
1748
+ throw new Error(`Public API ${apiPath} returned ${response.status}.`);
1749
+ }
1750
+ return await response.json();
1751
+ } finally {
1752
+ clearTimeout(timeout);
1753
+ }
1754
+ }
1755
+
1756
+ /**
1757
+ * Build the standard "unavailable" error envelope used when a dynamic
1758
+ * resource cannot be loaded. Distinct from `notFound` / `invalid` so MCP
1759
+ * clients can tell apart "endpoint failed" from "no such resource" and
1760
+ * keep the surface read-only.
1761
+ *
1762
+ * @param {string} message Human-readable explanation.
1763
+ * @param {string} [details] Optional underlying error message.
1764
+ * @returns {{ ok: false, error: { code: "unavailable", message: string, details?: string } }}
1765
+ */
1766
+ function unavailable(message, details) {
1767
+ return {
1768
+ ok: false,
1769
+ error: {
1770
+ code: "unavailable",
1771
+ message,
1772
+ ...(details ? { details } : {}),
1773
+ },
1774
+ };
1775
+ }
1776
+
1777
+ /**
1778
+ * Build the `heyclaude://registry/recent` resource payload. Reads the
1779
+ * generated `search-index.json` artifact, sorts entries by `repoUpdatedAt`
1780
+ * (falling back to `updatedAt` / `dateAdded`) descending, and bounds
1781
+ * output to {@link DISCOVERY_RESOURCE_LIMIT} entries. Each entry carries
1782
+ * the standard `toEntrySummary` shape plus `updatedAt` and `updateKind`.
1783
+ *
1784
+ * @param {import("./registry.d.ts").RegistryArtifactLoaders} [options]
1785
+ * @returns {Promise<import("./registry.d.ts").RegistryToolResult>}
1786
+ */
1787
+ export async function listRegistryRecent(options = {}) {
1788
+ const searchIndex = unwrapEntries(
1789
+ await readJsonArtifact("search-index.json", options),
1790
+ );
1791
+ const entries = searchIndex
1792
+ .slice()
1793
+ .sort((left, right) => {
1794
+ const dateCompare = entryUpdatedAt(right).localeCompare(
1795
+ entryUpdatedAt(left),
1796
+ );
1797
+ if (dateCompare !== 0) return dateCompare;
1798
+ return String(left.title || "").localeCompare(String(right.title || ""));
1799
+ })
1800
+ .slice(0, DISCOVERY_RESOURCE_LIMIT)
1801
+ .map((entry) => ({
1802
+ ...toEntrySummary(entry),
1803
+ updatedAt: entryUpdatedAt(entry),
1804
+ updateKind: entry.repoUpdatedAt ? "upstream_update" : "added",
1805
+ }));
1806
+
1807
+ return {
1808
+ ok: true,
1809
+ kind: "registry-recent",
1810
+ schemaVersion: 1,
1811
+ limit: DISCOVERY_RESOURCE_LIMIT,
1812
+ count: entries.length,
1813
+ entries,
1814
+ };
1815
+ }
1816
+
1817
+ /**
1818
+ * Normalize a raw `/api/registry/trending` entry into the small, stable
1819
+ * shape published by the MCP `registry/trending` resource. Defends against
1820
+ * upstream field churn (missing arrays, non-numeric scores, dropped
1821
+ * `trustSignals`) so MCP clients see a predictable schema.
1822
+ *
1823
+ * @param {Record<string, unknown> & { category: string, slug: string }} entry
1824
+ * @returns {Record<string, unknown>} Normalized trending entry.
1825
+ */
1826
+ function toTrendingEntry(entry) {
1827
+ return {
1828
+ key: `${entry.category}:${entry.slug}`,
1829
+ category: entry.category,
1830
+ slug: entry.slug,
1831
+ title: entry.title || "",
1832
+ description: entry.description || "",
1833
+ canonicalUrl: entryCanonicalUrl(entry),
1834
+ platforms: Array.isArray(entry.platforms) ? entry.platforms : [],
1835
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
1836
+ dateAdded: entry.dateAdded || "",
1837
+ score: typeof entry.score === "number" ? entry.score : 0,
1838
+ reasons: Array.isArray(entry.reasons) ? entry.reasons : [],
1839
+ trustSignals: entry.trustSignals || { sourceStatus: "missing" },
1840
+ };
1841
+ }
1842
+
1843
+ /**
1844
+ * Build the `heyclaude://registry/trending` resource payload. Reuses the
1845
+ * public `/api/registry/trending` endpoint (no DB access from the MCP
1846
+ * package). Returns an `unavailable` envelope when the upstream fetch
1847
+ * fails so MCP clients degrade gracefully. Output is bounded to
1848
+ * {@link DISCOVERY_RESOURCE_LIMIT} entries and forwards `signalsAvailable`
1849
+ * when present so consumers can tell which scoring signals applied.
1850
+ *
1851
+ * @param {import("./registry.d.ts").RegistryArtifactLoaders & {
1852
+ * publicApiBaseUrl?: string,
1853
+ * fetchPublicApi?: (apiPath: string) => Promise<unknown>,
1854
+ * }} [options]
1855
+ * @returns {Promise<import("./registry.d.ts").RegistryToolResult>}
1856
+ */
1857
+ export async function listRegistryTrending(options = {}) {
1858
+ let payload;
1859
+ try {
1860
+ payload = await fetchPublicApiJson(
1861
+ `/api/registry/trending?limit=${DISCOVERY_RESOURCE_LIMIT}`,
1862
+ options,
1863
+ );
1864
+ } catch (error) {
1865
+ return unavailable(
1866
+ "Trending registry state is currently unavailable.",
1867
+ String(error?.message || error || ""),
1868
+ );
1869
+ }
1870
+
1871
+ if (!payload || !Array.isArray(payload.entries)) {
1872
+ return unavailable(
1873
+ "Trending registry state is currently unavailable.",
1874
+ "Upstream payload is missing the expected entries array.",
1875
+ );
1876
+ }
1877
+ const entries = payload.entries
1878
+ .slice(0, DISCOVERY_RESOURCE_LIMIT)
1879
+ .map(toTrendingEntry);
1880
+
1881
+ return {
1882
+ ok: true,
1883
+ kind: "registry-trending",
1884
+ schemaVersion: payload?.schemaVersion ?? 1,
1885
+ category: payload?.category || "all",
1886
+ platform: payload?.platform || "all",
1887
+ limit: DISCOVERY_RESOURCE_LIMIT,
1888
+ count: entries.length,
1889
+ signalsAvailable:
1890
+ payload?.signalsAvailable && typeof payload.signalsAvailable === "object"
1891
+ ? payload.signalsAvailable
1892
+ : null,
1893
+ source: "public-api",
1894
+ entries,
1895
+ };
1896
+ }
1897
+
1898
+ /**
1899
+ * Normalize a raw `/api/jobs` entry into the small, stable shape published
1900
+ * by the MCP `jobs/active` resource. Defends against upstream field churn
1901
+ * and never exposes private/admin-only fields (we only project the public
1902
+ * subset already returned by `buildPublicJobsIndex`).
1903
+ *
1904
+ * @param {Record<string, unknown>} job
1905
+ * @returns {Record<string, unknown>} Normalized public job entry.
1906
+ */
1907
+ function toJobEntry(job) {
1908
+ return {
1909
+ id: job.id || job.slug || "",
1910
+ title: job.title || "",
1911
+ company: job.company || "",
1912
+ location: job.location || "",
1913
+ type: job.type || "",
1914
+ isRemote: Boolean(job.isRemote),
1915
+ tier: job.tier || "",
1916
+ applyUrl: job.applyUrl || job.url || "",
1917
+ sourceLabel: job.sourceLabel || "",
1918
+ postedAt: job.postedAt || job.publishedAt || "",
1919
+ labels: Array.isArray(job.labels) ? job.labels : [],
1920
+ };
1921
+ }
1922
+
1923
+ /**
1924
+ * Build the `heyclaude://jobs/active` resource payload. Reuses the public
1925
+ * `/api/jobs` endpoint (no DB access from the MCP package) and returns an
1926
+ * `unavailable` envelope when the upstream fetch fails. Output is bounded
1927
+ * to {@link DISCOVERY_RESOURCE_LIMIT} entries and forwards `totalAvailable`
1928
+ * when the upstream reports it.
1929
+ *
1930
+ * @param {import("./registry.d.ts").RegistryArtifactLoaders & {
1931
+ * publicApiBaseUrl?: string,
1932
+ * fetchPublicApi?: (apiPath: string) => Promise<unknown>,
1933
+ * }} [options]
1934
+ * @returns {Promise<import("./registry.d.ts").RegistryToolResult>}
1935
+ */
1936
+ export async function listJobsActive(options = {}) {
1937
+ let payload;
1938
+ try {
1939
+ payload = await fetchPublicApiJson(
1940
+ `/api/jobs?limit=${DISCOVERY_RESOURCE_LIMIT}`,
1941
+ options,
1942
+ );
1943
+ } catch (error) {
1944
+ return unavailable(
1945
+ "Active jobs state is currently unavailable.",
1946
+ String(error?.message || error || ""),
1947
+ );
1948
+ }
1949
+
1950
+ if (!payload || !Array.isArray(payload.entries)) {
1951
+ return unavailable(
1952
+ "Active jobs state is currently unavailable.",
1953
+ "Upstream payload is missing the expected entries array.",
1954
+ );
1955
+ }
1956
+ const entries = payload.entries
1957
+ .slice(0, DISCOVERY_RESOURCE_LIMIT)
1958
+ .map(toJobEntry);
1959
+
1960
+ return {
1961
+ ok: true,
1962
+ kind: "jobs-active",
1963
+ schemaVersion: payload?.schemaVersion ?? 1,
1964
+ limit: DISCOVERY_RESOURCE_LIMIT,
1965
+ count: entries.length,
1966
+ totalAvailable:
1967
+ typeof payload?.totalAvailable === "number"
1968
+ ? payload.totalAvailable
1969
+ : null,
1970
+ source: "public-api",
1971
+ entries,
1972
+ };
1973
+ }
1974
+
1975
+ export const PROMPT_DEFINITIONS = [
1976
+ {
1977
+ name: "find_best_asset",
1978
+ title: "Find the best Claude asset",
1979
+ description:
1980
+ "Guide a client through searching, comparing, and recommending HeyClaude entries for a use case.",
1981
+ arguments: [
1982
+ {
1983
+ name: "use_case",
1984
+ description: "The task, workflow, or problem the user wants to solve.",
1985
+ required: true,
1986
+ },
1987
+ {
1988
+ name: "category",
1989
+ description: "Optional HeyClaude category to constrain discovery.",
1990
+ },
1991
+ {
1992
+ name: "platform",
1993
+ description:
1994
+ "Optional client/platform such as Claude, Codex, Cursor, or Windsurf.",
1995
+ },
1996
+ ],
1997
+ },
1998
+ {
1999
+ name: "prepare_submission",
2000
+ title: "Prepare a HeyClaude submission",
2001
+ description:
2002
+ "Guide a user through drafting a maintainer-reviewed HeyClaude submission without opening a PR automatically.",
2003
+ arguments: [
2004
+ { name: "category", description: "Submission category.", required: true },
2005
+ { name: "name", description: "Submission name or title." },
2006
+ {
2007
+ name: "source_url",
2008
+ description: "Primary source, docs, package, or repo URL.",
2009
+ },
2010
+ ],
2011
+ },
2012
+ {
2013
+ name: "review_submission_before_pr",
2014
+ title: "Review submission before opening PR",
2015
+ description:
2016
+ "Check a draft for schema gaps, duplicate risk, source review, and maintainer checklist items.",
2017
+ arguments: [
2018
+ {
2019
+ name: "draft",
2020
+ description: "A concise description or JSON-shaped draft fields.",
2021
+ required: true,
2022
+ },
2023
+ ],
2024
+ },
2025
+ {
2026
+ name: "install_asset_safely",
2027
+ title: "Install a HeyClaude asset safely",
2028
+ description:
2029
+ "Guide installation/use of one entry while keeping source and secret-handling checks explicit.",
2030
+ arguments: [
2031
+ { name: "category", description: "Entry category.", required: true },
2032
+ { name: "slug", description: "Entry slug.", required: true },
2033
+ { name: "platform", description: "Optional target client/platform." },
2034
+ ],
2035
+ },
2036
+ ];
2037
+
2038
+ export async function listRegistryResources(args = {}, options = {}) {
2039
+ const manifest = await readJsonArtifact("registry-manifest.json", options);
2040
+ const categories = Object.keys(manifest.categories || {}).sort();
2041
+ return {
2042
+ resources: [
2043
+ {
2044
+ uri: "heyclaude://feeds/directory",
2045
+ name: "HeyClaude directory index",
2046
+ title: "HeyClaude directory index",
2047
+ description: "Generated public directory index artifact.",
2048
+ mimeType: jsonMimeType,
2049
+ },
2050
+ ...categories.map((category) => ({
2051
+ uri: `heyclaude://category/${category}`,
2052
+ name: `HeyClaude ${category} category`,
2053
+ title: `HeyClaude ${category}`,
2054
+ description: `Generated public ${category} category summary entries.`,
2055
+ mimeType: jsonMimeType,
2056
+ })),
2057
+ ...DISCOVERY_RESOURCES,
2058
+ ],
2059
+ };
2060
+ }
2061
+
2062
+ export function listRegistryResourceTemplates() {
2063
+ return {
2064
+ resourceTemplates: RESOURCE_TEMPLATES,
2065
+ };
2066
+ }
2067
+
2068
+ export async function readRegistryResource(args = {}, options = {}) {
2069
+ const uri = String(args.uri || "");
2070
+ const resourcePayload = (payload) => ({
2071
+ contents: [
2072
+ {
2073
+ uri: uri || "heyclaude://error",
2074
+ mimeType: jsonMimeType,
2075
+ text: JSON.stringify(withPublicPolicy(payload), null, 2),
2076
+ },
2077
+ ],
2078
+ });
2079
+ let parsed;
2080
+ try {
2081
+ parsed = new URL(uri);
2082
+ } catch {
2083
+ return resourcePayload(
2084
+ notFound(`Unsupported HeyClaude resource URI: ${uri}`),
2085
+ );
2086
+ }
2087
+ if (parsed.protocol !== "heyclaude:") {
2088
+ return resourcePayload(
2089
+ notFound(`Unsupported HeyClaude resource URI: ${uri}`),
2090
+ );
2091
+ }
2092
+
2093
+ const parts = parsed.pathname.split("/").filter(Boolean);
2094
+ let payload;
2095
+ if (parsed.hostname === "feeds" && parts[0] === "directory") {
2096
+ payload = await readJsonArtifact("directory-index.json", options);
2097
+ } else if (parsed.hostname === "category" && parts.length === 1) {
2098
+ const category = normalizeText(parts[0]);
2099
+ if (!isSafePathPart(category)) {
2100
+ return resourcePayload(
2101
+ invalid("Category resource path is not slug-safe."),
2102
+ );
2103
+ }
2104
+ const entries = unwrapEntries(
2105
+ await readJsonArtifact("search-index.json", options),
2106
+ )
2107
+ .filter((entry) => entry.category === category)
2108
+ .map(toEntrySummary);
2109
+ payload = {
2110
+ ok: true,
2111
+ category,
2112
+ total: entries.length,
2113
+ entries,
2114
+ };
2115
+ } else if (parsed.hostname === "entry" && parts.length === 2) {
2116
+ const [category, slug] = parts.map(normalizeText);
2117
+ const detail = await getEntryDetail({ category, slug }, options);
2118
+ payload = detail;
2119
+ } else if (
2120
+ parsed.hostname === "registry" &&
2121
+ parts.length === 1 &&
2122
+ parts[0] === "recent"
2123
+ ) {
2124
+ payload = await listRegistryRecent(options);
2125
+ } else if (
2126
+ parsed.hostname === "registry" &&
2127
+ parts.length === 1 &&
2128
+ parts[0] === "trending"
2129
+ ) {
2130
+ payload = await listRegistryTrending(options);
2131
+ } else if (
2132
+ parsed.hostname === "jobs" &&
2133
+ parts.length === 1 &&
2134
+ parts[0] === "active"
2135
+ ) {
2136
+ payload = await listJobsActive(options);
2137
+ } else {
2138
+ return resourcePayload(
2139
+ notFound(`Unsupported HeyClaude resource URI: ${uri}`),
2140
+ );
2141
+ }
2142
+
2143
+ return resourcePayload(payload);
2144
+ }
2145
+
2146
+ function promptArgument(args, name) {
2147
+ return String(args?.[name] || "").trim();
2148
+ }
2149
+
2150
+ export function listRegistryPrompts() {
2151
+ return {
2152
+ prompts: PROMPT_DEFINITIONS,
2153
+ };
2154
+ }
2155
+
2156
+ export function getRegistryPrompt(args = {}) {
2157
+ const name = String(args.name || "");
2158
+ const prompt = PROMPT_DEFINITIONS.find(
2159
+ (candidate) => candidate.name === name,
2160
+ );
2161
+ if (!prompt) {
2162
+ return {
2163
+ description: "Unknown HeyClaude MCP prompt.",
2164
+ messages: [
2165
+ {
2166
+ role: "user",
2167
+ content: {
2168
+ type: "text",
2169
+ text: `Unknown HeyClaude MCP prompt: ${name}`,
2170
+ },
2171
+ },
2172
+ ],
2173
+ };
2174
+ }
2175
+ const values = args.arguments || {};
2176
+ const useCase = promptArgument(values, "use_case");
2177
+ const category = promptArgument(values, "category");
2178
+ const platform = promptArgument(values, "platform");
2179
+ const slug = promptArgument(values, "slug");
2180
+ const sourceUrl = promptArgument(values, "source_url");
2181
+ const draft = promptArgument(values, "draft");
2182
+
2183
+ const promptTextByName = {
2184
+ find_best_asset: `Find the best HeyClaude asset for this use case: ${useCase || "(not provided)"}.
2185
+
2186
+ Use the read-only HeyClaude MCP tools. Start with search_registry or list_category_entries${category ? ` in category ${category}` : ""}${platform ? ` for platform ${platform}` : ""}. Compare credible candidates with compare_entries, inspect details with get_entry_detail, and cite exact category/slug pairs. Do not invent popularity metrics when source stats are absent.`,
2187
+ prepare_submission: `Prepare a HeyClaude submission draft${category ? ` for category ${category}` : ""}${promptArgument(values, "name") ? ` named ${promptArgument(values, "name")}` : ""}${sourceUrl ? ` from ${sourceUrl}` : ""}.
2188
+
2189
+ Use get_submission_schema, get_submission_examples, prepare_submission_draft, review_submission_draft, and search_duplicate_entries. Return missing fields and the canonical PR-first submit URL/body. Do not create GitHub issues or publish content.`,
2190
+ review_submission_before_pr: `Review this HeyClaude submission draft before a PR is opened:
2191
+
2192
+ ${draft || "(draft not provided)"}
2193
+
2194
+ Use review_submission_draft and search_duplicate_entries where structured fields are available. Treat schema-valid as not publish-valid, call out source-review needs, and keep the result maintainer-reviewed.`,
2195
+ install_asset_safely: `Help install or use the HeyClaude entry ${category || "(category)"}/${slug || "(slug)"}${platform ? ` for ${platform}` : ""}.
2196
+
2197
+ Use get_install_guidance and get_copyable_asset. Include source links, config/install text exactly as returned, and secret-handling cautions where relevant. Do not write local files or claim the install was completed.`,
2198
+ };
2199
+
2200
+ return {
2201
+ description: prompt.description,
2202
+ messages: [
2203
+ {
2204
+ role: "user",
2205
+ content: {
2206
+ type: "text",
2207
+ text: promptTextByName[name],
2208
+ },
2209
+ },
2210
+ ],
2211
+ };
2212
+ }
2213
+
2214
+ export async function getCompatibility(args = {}, options = {}) {
2215
+ const category = normalizeText(args.category || "skills");
2216
+ const slug = normalizeText(args.slug);
2217
+ if (!slug) return invalid("slug is required.");
2218
+
2219
+ const entry = await readEntry(category, slug, options);
2220
+ if (!entry) {
2221
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
2222
+ }
2223
+
2224
+ return {
2225
+ ok: true,
2226
+ key: `${entry.category}:${entry.slug}`,
2227
+ category: entry.category,
2228
+ slug: entry.slug,
2229
+ platformCompatibility: buildSkillPlatformCompatibility(entry),
2230
+ };
2231
+ }
2232
+
2233
+ export async function getInstallGuidance(args = {}, options = {}) {
2234
+ const category = normalizeText(args.category);
2235
+ const slug = normalizeText(args.slug);
2236
+ const platform = normalizePlatform(args.platform);
2237
+ if (!category || !slug) {
2238
+ return invalid("category and slug are required.");
2239
+ }
334
2240
 
335
2241
  const entry = await readEntry(category, slug, options);
336
2242
  if (!entry) {
@@ -345,7 +2251,7 @@ export async function getInstallGuidance(args = {}, options = {}) {
345
2251
  return {
346
2252
  ok: true,
347
2253
  key: `${entry.category}:${entry.slug}`,
348
- canonicalUrl: `${SITE_URL}/${entry.category}/${entry.slug}`,
2254
+ canonicalUrl: entryCanonicalUrl(entry),
349
2255
  title: entry.title,
350
2256
  installCommand: entry.installCommand || entry.commandSyntax || "",
351
2257
  configSnippet: entry.configSnippet || "",
@@ -353,6 +2259,9 @@ export async function getInstallGuidance(args = {}, options = {}) {
353
2259
  downloadUrl: entry.downloadUrl || "",
354
2260
  documentationUrl: entry.documentationUrl || "",
355
2261
  repoUrl: entry.repoUrl || "",
2262
+ safetyNotes: notes(entry.safetyNotes),
2263
+ privacyNotes: notes(entry.privacyNotes),
2264
+ trust: entryTrustSummary(entry),
356
2265
  platform: platform || "",
357
2266
  selectedCompatibility,
358
2267
  platformCompatibility: compatibility,
@@ -450,6 +2359,131 @@ export async function getCategorySubmissionGuidance(args = {}, options = {}) {
450
2359
  );
451
2360
  }
452
2361
 
2362
+ export async function prepareSubmissionDraft(args = {}, options = {}) {
2363
+ return prepareSubmissionDraftFromSpec(
2364
+ await readSubmissionSpec(options),
2365
+ args,
2366
+ );
2367
+ }
2368
+
2369
+ export async function getSubmissionExamples(args = {}, options = {}) {
2370
+ return getSubmissionExamplesFromSpec(await readSubmissionSpec(options), args);
2371
+ }
2372
+
2373
+ export async function reviewSubmissionDraft(args = {}, options = {}) {
2374
+ const [spec, searchIndex] = await Promise.all([
2375
+ readSubmissionSpec(options),
2376
+ readJsonArtifact("search-index.json", options),
2377
+ ]);
2378
+ return reviewSubmissionDraftFromSpec(spec, args, unwrapEntries(searchIndex));
2379
+ }
2380
+
2381
+ export async function getSubmissionPolicy() {
2382
+ return {
2383
+ ok: true,
2384
+ publicPolicy: MCP_PUBLIC_POLICY,
2385
+ reviewModel: {
2386
+ prFirst: true,
2387
+ maintainerReviewRequired: true,
2388
+ autoMerge: "content_only_private_gate",
2389
+ autoMergeRequires: [
2390
+ "single content file only",
2391
+ "validate-content",
2392
+ "Superagent Security Scan",
2393
+ "private maintainer-agent review",
2394
+ ],
2395
+ mutatingAutomationOwner: "private submission gate",
2396
+ },
2397
+ artifactPolicy: {
2398
+ communityHostedArchivesAllowed: false,
2399
+ communityZipHostingAllowed: false,
2400
+ communityMcpbHostingAllowed: false,
2401
+ maintainerBuiltDownloadsOnly: true,
2402
+ firstPartyDownloadsRequireVerification: true,
2403
+ },
2404
+ submissionGuidance: [
2405
+ "Use source-backed or copyable-content submissions for community content.",
2406
+ "Do not request public HeyClaude /downloads hosting for community ZIP/MCPB artifacts.",
2407
+ "Add safety_notes when a submission runs code, writes externally, uses permissions, or starts background workers.",
2408
+ "Add privacy_notes when a submission reads local files, logs, credentials, telemetry, or third-party user data.",
2409
+ "Commercial, affiliate, sponsored, or paid product listings go through maintainer review and disclosure, not the free content queue.",
2410
+ ],
2411
+ };
2412
+ }
2413
+
2414
+ export async function explainEntryTrust(args = {}, options = {}) {
2415
+ const category = normalizeText(args.category);
2416
+ const slug = normalizeText(args.slug);
2417
+ if (!category || !slug) {
2418
+ return invalid("category and slug are required.");
2419
+ }
2420
+
2421
+ const entry = await readEntry(category, slug, options);
2422
+ if (!entry) {
2423
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
2424
+ }
2425
+
2426
+ return {
2427
+ ok: true,
2428
+ key: `${entry.category}:${entry.slug}`,
2429
+ title: entry.title,
2430
+ canonicalUrl: entryCanonicalUrl(entry),
2431
+ trust: entryTrustSummary(entry),
2432
+ };
2433
+ }
2434
+
2435
+ export async function reviewEntrySafety(args = {}, options = {}) {
2436
+ const platform = normalizePlatform(args.platform);
2437
+ const entries = [];
2438
+ for (const target of args.entries || []) {
2439
+ const category = normalizeText(target.category);
2440
+ const slug = normalizeText(target.slug);
2441
+ const entry = await readEntry(category, slug, options);
2442
+ if (!entry) {
2443
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
2444
+ }
2445
+ const compatibility = buildSkillPlatformCompatibility(entry);
2446
+ entries.push({
2447
+ key: `${entry.category}:${entry.slug}`,
2448
+ category: entry.category,
2449
+ slug: entry.slug,
2450
+ title: entry.title,
2451
+ canonicalUrl: entryCanonicalUrl(entry),
2452
+ selectedCompatibility: platform
2453
+ ? compatibility.find((item) => item.platform === platform) || null
2454
+ : null,
2455
+ trust: entryTrustSummary(entry),
2456
+ });
2457
+ }
2458
+
2459
+ const entriesWithNotes = entries.filter(
2460
+ (entry) =>
2461
+ entry.trust.disclosures.hasSafetyNotes ||
2462
+ entry.trust.disclosures.hasPrivacyNotes,
2463
+ );
2464
+
2465
+ return {
2466
+ ok: true,
2467
+ platform: platform || "",
2468
+ count: entries.length,
2469
+ entries,
2470
+ summary: {
2471
+ entriesWithSafetyOrPrivacyNotes: entriesWithNotes.length,
2472
+ firstPartyPackages: entries.filter(
2473
+ (entry) => entry.trust.package.downloadTrust === "first-party",
2474
+ ).length,
2475
+ sourceBacked: entries.filter(
2476
+ (entry) => entry.trust.source.status === "available",
2477
+ ).length,
2478
+ },
2479
+ reviewNotes: [
2480
+ "This is a metadata review, not a malware scan or install verdict.",
2481
+ "Prefer source-backed entries and first-party maintainer-built downloads when installing packages.",
2482
+ "Inspect commands, requested permissions, and external writes before running any copied content.",
2483
+ ],
2484
+ };
2485
+ }
2486
+
453
2487
  export async function callRegistryTool(name, args = {}, options = {}) {
454
2488
  if (!READ_ONLY_TOOL_NAMES.includes(name)) {
455
2489
  return invalid(`Unknown read-only HeyClaude MCP tool: ${name}`);
@@ -469,28 +2503,87 @@ export async function callRegistryTool(name, args = {}, options = {}) {
469
2503
  throw error;
470
2504
  }
471
2505
 
2506
+ let result;
472
2507
  switch (name) {
473
2508
  case "search_registry":
474
- return searchRegistry(parsedArgs, options);
2509
+ result = await searchRegistry(parsedArgs, options);
2510
+ break;
2511
+ case "plan_workflow_toolbox":
2512
+ result = await planWorkflowToolbox(parsedArgs, options);
2513
+ break;
2514
+ case "server_info":
2515
+ result = await getServerInfo(parsedArgs, options);
2516
+ break;
2517
+ case "list_category_entries":
2518
+ result = await listCategoryEntries(parsedArgs, options);
2519
+ break;
2520
+ case "get_recent_updates":
2521
+ result = await getRecentUpdates(parsedArgs, options);
2522
+ break;
2523
+ case "get_related_entries":
2524
+ result = await getRelatedEntries(parsedArgs, options);
2525
+ break;
475
2526
  case "get_entry_detail":
476
- return getEntryDetail(parsedArgs, options);
2527
+ result = await getEntryDetail(parsedArgs, options);
2528
+ break;
2529
+ case "get_copyable_asset":
2530
+ result = await getCopyableAsset(parsedArgs, options);
2531
+ break;
2532
+ case "compare_entries":
2533
+ result = await compareEntries(parsedArgs, options);
2534
+ break;
2535
+ case "get_registry_stats":
2536
+ result = await getRegistryStats(parsedArgs, options);
2537
+ break;
2538
+ case "get_client_setup":
2539
+ result = await getClientSetup(parsedArgs, options);
2540
+ break;
477
2541
  case "get_compatibility":
478
- return getCompatibility(parsedArgs, options);
2542
+ result = await getCompatibility(parsedArgs, options);
2543
+ break;
479
2544
  case "get_install_guidance":
480
- return getInstallGuidance(parsedArgs, options);
2545
+ result = await getInstallGuidance(parsedArgs, options);
2546
+ break;
481
2547
  case "get_platform_adapter":
482
- return getPlatformAdapter(parsedArgs, options);
2548
+ result = await getPlatformAdapter(parsedArgs, options);
2549
+ break;
483
2550
  case "list_distribution_feeds":
484
- return listDistributionFeeds(parsedArgs, options);
2551
+ result = await listDistributionFeeds(parsedArgs, options);
2552
+ break;
485
2553
  case "get_submission_schema":
486
- return getSubmissionSchema(parsedArgs, options);
2554
+ result = await getSubmissionSchema(parsedArgs, options);
2555
+ break;
487
2556
  case "validate_submission_draft":
488
- return validateSubmissionDraft(parsedArgs, options);
2557
+ result = await validateSubmissionDraft(parsedArgs, options);
2558
+ break;
489
2559
  case "search_duplicate_entries":
490
- return searchDuplicateRegistryEntries(parsedArgs, options);
2560
+ result = await searchDuplicateRegistryEntries(parsedArgs, options);
2561
+ break;
491
2562
  case "build_submission_urls":
492
- return buildSubmissionUrls(parsedArgs, options);
2563
+ result = await buildSubmissionUrls(parsedArgs, options);
2564
+ break;
493
2565
  case "get_category_submission_guidance":
494
- return getCategorySubmissionGuidance(parsedArgs, options);
2566
+ result = await getCategorySubmissionGuidance(parsedArgs, options);
2567
+ break;
2568
+ case "prepare_submission_draft":
2569
+ result = await prepareSubmissionDraft(parsedArgs, options);
2570
+ break;
2571
+ case "get_submission_examples":
2572
+ result = await getSubmissionExamples(parsedArgs, options);
2573
+ break;
2574
+ case "review_submission_draft":
2575
+ result = await reviewSubmissionDraft(parsedArgs, options);
2576
+ break;
2577
+ case "get_submission_policy":
2578
+ result = await getSubmissionPolicy(parsedArgs, options);
2579
+ break;
2580
+ case "explain_entry_trust":
2581
+ result = await explainEntryTrust(parsedArgs, options);
2582
+ break;
2583
+ case "review_entry_safety":
2584
+ result = await reviewEntrySafety(parsedArgs, options);
2585
+ break;
495
2586
  }
2587
+
2588
+ return withPublicPolicy(result);
496
2589
  }