chub-dev 0.2.0-beta.3 → 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/README.md +76 -0
- package/bin/chub-mcp +2 -0
- package/package.json +11 -6
- package/skills/get-api-docs/SKILL.md +81 -0
- package/src/commands/annotate.js +83 -0
- package/src/commands/build.js +21 -4
- package/src/commands/feedback.js +12 -9
- package/src/commands/get.js +77 -12
- package/src/commands/help.js +34 -0
- package/src/commands/search.js +17 -8
- package/src/index.js +35 -67
- package/src/lib/analytics.js +13 -2
- package/src/lib/annotations.js +57 -0
- package/src/lib/bm25.js +303 -0
- package/src/lib/cache.js +108 -17
- package/src/lib/config.js +15 -2
- package/src/lib/help.js +158 -0
- package/src/lib/identity.js +12 -1
- package/src/lib/registry.js +283 -27
- package/src/lib/telemetry.js +7 -1
- package/src/lib/welcome.js +42 -0
- package/src/mcp/server.js +184 -0
- package/src/mcp/stdio-lifecycle.js +54 -0
- package/src/mcp/tools.js +286 -0
- package/dist/anthropic/docs/sdk/javascript/DOC.md +0 -499
- package/dist/anthropic/docs/sdk/python/DOC.md +0 -382
- package/dist/openai/docs/chat/javascript/DOC.md +0 -350
- package/dist/openai/docs/chat/python/DOC.md +0 -526
- package/dist/pinecone/docs/sdk/javascript/DOC.md +0 -984
- package/dist/pinecone/docs/sdk/python/DOC.md +0 -1395
- package/dist/registry.json +0 -276
- package/dist/resend/docs/sdk/DOC.md +0 -1271
- package/dist/stripe/docs/api/DOC.md +0 -1726
- package/dist/supabase/docs/sdk/DOC.md +0 -1606
- package/dist/twilio/docs/sdk/python/DOC.md +0 -469
- package/dist/twilio/docs/sdk/typescript/DOC.md +0 -946
package/src/lib/registry.js
CHANGED
|
@@ -1,8 +1,191 @@
|
|
|
1
|
-
import { loadSourceRegistry } from './cache.js';
|
|
1
|
+
import { loadSourceRegistry, loadSearchIndex } from './cache.js';
|
|
2
2
|
import { loadConfig } from './config.js';
|
|
3
3
|
import { normalizeLanguage } from './normalize.js';
|
|
4
|
+
import { buildIndexFromDocuments, compactIdentifier, search as bm25Search, tokenize } from './bm25.js';
|
|
4
5
|
|
|
5
6
|
let _merged = null;
|
|
7
|
+
let _searchIndex = null;
|
|
8
|
+
|
|
9
|
+
function getSearchLookupId(sourceName, entryId) {
|
|
10
|
+
return `${sourceName}:${entryId}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeQuery(query) {
|
|
14
|
+
return String(query || '')
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/\s+/g, ' ');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function splitCompactSegments(text) {
|
|
20
|
+
return [...new Set([
|
|
21
|
+
...String(text || '').split('/').map((segment) => compactIdentifier(segment)),
|
|
22
|
+
...String(text || '').split(/[\/_.\s-]+/).map((segment) => compactIdentifier(segment)),
|
|
23
|
+
])].filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function levenshteinDistance(a, b, maxDistance = Infinity) {
|
|
27
|
+
if (a === b) return 0;
|
|
28
|
+
if (!a.length) return b.length;
|
|
29
|
+
if (!b.length) return a.length;
|
|
30
|
+
if (Math.abs(a.length - b.length) > maxDistance) return maxDistance + 1;
|
|
31
|
+
|
|
32
|
+
let previous = Array.from({ length: b.length + 1 }, (_, idx) => idx);
|
|
33
|
+
let current = new Array(b.length + 1);
|
|
34
|
+
|
|
35
|
+
for (let i = 1; i <= a.length; i++) {
|
|
36
|
+
current[0] = i;
|
|
37
|
+
let rowMin = current[0];
|
|
38
|
+
|
|
39
|
+
for (let j = 1; j <= b.length; j++) {
|
|
40
|
+
const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
41
|
+
current[j] = Math.min(
|
|
42
|
+
previous[j] + 1,
|
|
43
|
+
current[j - 1] + 1,
|
|
44
|
+
previous[j - 1] + substitutionCost,
|
|
45
|
+
);
|
|
46
|
+
rowMin = Math.min(rowMin, current[j]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (rowMin > maxDistance) return maxDistance + 1;
|
|
50
|
+
[previous, current] = [current, previous];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return previous[b.length];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function scoreCompactCandidate(queryCompact, candidateCompact, weights) {
|
|
57
|
+
if (!queryCompact || !candidateCompact) return 0;
|
|
58
|
+
if (candidateCompact === queryCompact) return weights.exact;
|
|
59
|
+
if (queryCompact.length < 3) return 0;
|
|
60
|
+
|
|
61
|
+
const lengthPenalty = Math.abs(candidateCompact.length - queryCompact.length);
|
|
62
|
+
const lengthRatio = Math.min(candidateCompact.length, queryCompact.length)
|
|
63
|
+
/ Math.max(candidateCompact.length, queryCompact.length);
|
|
64
|
+
|
|
65
|
+
if ((candidateCompact.startsWith(queryCompact) || queryCompact.startsWith(candidateCompact)) && lengthRatio >= 0.6) {
|
|
66
|
+
return Math.max(weights.prefix - lengthPenalty, 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if ((candidateCompact.includes(queryCompact) || queryCompact.includes(candidateCompact)) && lengthRatio >= 0.75) {
|
|
70
|
+
return Math.max(weights.contains - lengthPenalty, 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (queryCompact.length < 5) return 0;
|
|
74
|
+
|
|
75
|
+
const maxDistance = queryCompact.length <= 5 ? 1 : queryCompact.length <= 8 ? 2 : 3;
|
|
76
|
+
const distance = levenshteinDistance(queryCompact, candidateCompact, maxDistance);
|
|
77
|
+
if (distance > maxDistance) return 0;
|
|
78
|
+
|
|
79
|
+
return Math.max(weights.fuzzy - (distance * 20) - lengthPenalty, 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scoreEntryLexicalVariant(entry, queryCompact) {
|
|
83
|
+
if (queryCompact.length < 2) return 0;
|
|
84
|
+
|
|
85
|
+
const nameCompact = compactIdentifier(entry.name);
|
|
86
|
+
const idCompact = compactIdentifier(entry.id);
|
|
87
|
+
const idSegments = splitCompactSegments(entry.id);
|
|
88
|
+
const nameSegments = splitCompactSegments(entry.name);
|
|
89
|
+
|
|
90
|
+
let best = 0;
|
|
91
|
+
|
|
92
|
+
best = Math.max(best, scoreCompactCandidate(queryCompact, nameCompact, {
|
|
93
|
+
exact: 620,
|
|
94
|
+
prefix: 560,
|
|
95
|
+
contains: 520,
|
|
96
|
+
fuzzy: 500,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
best = Math.max(best, scoreCompactCandidate(queryCompact, idCompact, {
|
|
100
|
+
exact: 600,
|
|
101
|
+
prefix: 540,
|
|
102
|
+
contains: 500,
|
|
103
|
+
fuzzy: 470,
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
for (let idx = 0; idx < idSegments.length; idx++) {
|
|
107
|
+
const segment = idSegments[idx];
|
|
108
|
+
const segmentScore = scoreCompactCandidate(queryCompact, segment, {
|
|
109
|
+
exact: 580,
|
|
110
|
+
prefix: 530,
|
|
111
|
+
contains: 490,
|
|
112
|
+
fuzzy: 460,
|
|
113
|
+
});
|
|
114
|
+
if (segmentScore === 0) continue;
|
|
115
|
+
|
|
116
|
+
let bonus = 0;
|
|
117
|
+
const isFirst = idx === 0;
|
|
118
|
+
const isLast = idx === idSegments.length - 1;
|
|
119
|
+
if (isFirst) bonus += 10;
|
|
120
|
+
if (isLast) bonus += 10;
|
|
121
|
+
if (queryCompact === idSegments[0]) bonus += 60;
|
|
122
|
+
if (queryCompact === idSegments[idSegments.length - 1]) bonus += 25;
|
|
123
|
+
if (idSegments.length > 1 && queryCompact === idSegments[0] && queryCompact === idSegments[idSegments.length - 1]) {
|
|
124
|
+
bonus += 40;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
best = Math.max(best, segmentScore + bonus);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const segment of nameSegments) {
|
|
131
|
+
best = Math.max(best, scoreCompactCandidate(queryCompact, segment, {
|
|
132
|
+
exact: 560,
|
|
133
|
+
prefix: 520,
|
|
134
|
+
contains: 480,
|
|
135
|
+
fuzzy: 450,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return best;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function scoreEntryLexicalBoost(entry, normalizedQuery, rescueTerms = []) {
|
|
143
|
+
const queryCompacts = [...new Set([
|
|
144
|
+
compactIdentifier(normalizedQuery),
|
|
145
|
+
...rescueTerms.map((term) => compactIdentifier(term)),
|
|
146
|
+
])].filter((queryCompact) => queryCompact.length >= 2);
|
|
147
|
+
|
|
148
|
+
let best = 0;
|
|
149
|
+
for (const queryCompact of queryCompacts) {
|
|
150
|
+
best = Math.max(best, scoreEntryLexicalVariant(entry, queryCompact));
|
|
151
|
+
}
|
|
152
|
+
return best;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getMissingQueryTerms(normalizedQuery) {
|
|
156
|
+
if (!_searchIndex?.invertedIndex) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return tokenize(normalizedQuery).filter((term) => !_searchIndex.invertedIndex[term]?.length);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function shouldRunGlobalLexicalScan(normalizedQuery, resultByKey) {
|
|
164
|
+
if (!_searchIndex || resultByKey.size === 0) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!_searchIndex.invertedIndex) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const queryTerms = tokenize(normalizedQuery);
|
|
173
|
+
if (queryTerms.length < 2) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return getMissingQueryTerms(normalizedQuery).length > 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function namespaceSearchIndex(index, sourceName) {
|
|
181
|
+
return {
|
|
182
|
+
...index,
|
|
183
|
+
documents: (index.documents || []).map((doc) => ({
|
|
184
|
+
...doc,
|
|
185
|
+
id: getSearchLookupId(sourceName, doc.id),
|
|
186
|
+
})),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
6
189
|
|
|
7
190
|
/**
|
|
8
191
|
* Load and merge entries from all configured sources.
|
|
@@ -14,11 +197,16 @@ function getMerged() {
|
|
|
14
197
|
const config = loadConfig();
|
|
15
198
|
const allDocs = [];
|
|
16
199
|
const allSkills = [];
|
|
200
|
+
const searchIndexes = [];
|
|
17
201
|
|
|
18
202
|
for (const source of config.sources) {
|
|
19
203
|
const registry = loadSourceRegistry(source);
|
|
20
204
|
if (!registry) continue;
|
|
21
205
|
|
|
206
|
+
// Load BM25 search index if available
|
|
207
|
+
const idx = loadSearchIndex(source);
|
|
208
|
+
if (idx) searchIndexes.push(namespaceSearchIndex(idx, source.name));
|
|
209
|
+
|
|
22
210
|
// Support both new format (docs/skills) and old format (entries)
|
|
23
211
|
if (registry.docs) {
|
|
24
212
|
for (const doc of registry.docs) {
|
|
@@ -46,6 +234,19 @@ function getMerged() {
|
|
|
46
234
|
}
|
|
47
235
|
}
|
|
48
236
|
|
|
237
|
+
// Merge search indexes (combine documents and recompute IDF)
|
|
238
|
+
if (searchIndexes.length > 0) {
|
|
239
|
+
if (searchIndexes.length === 1) {
|
|
240
|
+
const [singleIndex] = searchIndexes;
|
|
241
|
+
_searchIndex = singleIndex.invertedIndex
|
|
242
|
+
? singleIndex
|
|
243
|
+
: buildIndexFromDocuments(singleIndex.documents, singleIndex.params);
|
|
244
|
+
} else {
|
|
245
|
+
const allDocuments = searchIndexes.flatMap((idx) => idx.documents);
|
|
246
|
+
_searchIndex = buildIndexFromDocuments(allDocuments, searchIndexes[0].params);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
49
250
|
_merged = { docs: allDocs, skills: allSkills };
|
|
50
251
|
return _merged;
|
|
51
252
|
}
|
|
@@ -121,11 +322,11 @@ export function getDisplayId(entry) {
|
|
|
121
322
|
|
|
122
323
|
/**
|
|
123
324
|
* Search entries by query string. Searches both docs and skills.
|
|
325
|
+
* Uses BM25 when a search index is available, falls back to keyword matching.
|
|
124
326
|
*/
|
|
125
327
|
export function searchEntries(query, filters = {}) {
|
|
328
|
+
const normalizedQuery = normalizeQuery(query);
|
|
126
329
|
const entries = applySourceFilter(getAllEntries());
|
|
127
|
-
const q = query.toLowerCase();
|
|
128
|
-
const words = q.split(/\s+/);
|
|
129
330
|
|
|
130
331
|
// Deduplicate: same id+source appearing as both doc and skill → show once
|
|
131
332
|
const seen = new Set();
|
|
@@ -138,27 +339,76 @@ export function searchEntries(query, filters = {}) {
|
|
|
138
339
|
}
|
|
139
340
|
}
|
|
140
341
|
|
|
141
|
-
|
|
142
|
-
|
|
342
|
+
// Build entry lookup by id
|
|
343
|
+
const entryById = new Map();
|
|
344
|
+
for (const entry of deduped) {
|
|
345
|
+
entryById.set(getSearchLookupId(entry._source, entry.id), entry);
|
|
346
|
+
}
|
|
143
347
|
|
|
144
|
-
|
|
145
|
-
|
|
348
|
+
if (!normalizedQuery) {
|
|
349
|
+
return applyFilters(deduped, filters).map((entry) => ({ ...entry, _score: 0 }));
|
|
350
|
+
}
|
|
146
351
|
|
|
147
|
-
|
|
148
|
-
if (nameLower === q) score += 80;
|
|
149
|
-
else if (nameLower.includes(q)) score += 40;
|
|
352
|
+
const resultByKey = new Map();
|
|
150
353
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (entry
|
|
354
|
+
if (_searchIndex) {
|
|
355
|
+
// BM25 search
|
|
356
|
+
for (const match of bm25Search(normalizedQuery, _searchIndex)) {
|
|
357
|
+
const entry = entryById.get(match.id);
|
|
358
|
+
if (!entry) continue;
|
|
359
|
+
const key = getSearchLookupId(entry._source, entry.id);
|
|
360
|
+
resultByKey.set(key, { entry, score: match.score });
|
|
156
361
|
}
|
|
362
|
+
} else {
|
|
363
|
+
// Fallback: keyword matching
|
|
364
|
+
const q = normalizedQuery.toLowerCase();
|
|
365
|
+
const words = q.split(/\s+/);
|
|
366
|
+
|
|
367
|
+
for (const entry of deduped) {
|
|
368
|
+
let score = 0;
|
|
157
369
|
|
|
158
|
-
|
|
159
|
-
|
|
370
|
+
if (entry.id === q) score += 100;
|
|
371
|
+
else if (entry.id.includes(q)) score += 50;
|
|
160
372
|
|
|
161
|
-
|
|
373
|
+
const nameLower = entry.name.toLowerCase();
|
|
374
|
+
if (nameLower === q) score += 80;
|
|
375
|
+
else if (nameLower.includes(q)) score += 40;
|
|
376
|
+
|
|
377
|
+
for (const word of words) {
|
|
378
|
+
if (entry.id.includes(word)) score += 10;
|
|
379
|
+
if (nameLower.includes(word)) score += 10;
|
|
380
|
+
if (entry.description?.toLowerCase().includes(word)) score += 5;
|
|
381
|
+
if (entry.tags?.some((t) => t.toLowerCase().includes(word))) score += 15;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (score > 0) {
|
|
385
|
+
const key = getSearchLookupId(entry._source, entry.id);
|
|
386
|
+
resultByKey.set(key, { entry, score });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const lexicalCandidates = !shouldRunGlobalLexicalScan(normalizedQuery, resultByKey)
|
|
392
|
+
? [...new Set([...resultByKey.values()].map(({ entry }) => entry))]
|
|
393
|
+
: deduped;
|
|
394
|
+
const rescueTerms = resultByKey.size > 0
|
|
395
|
+
? getMissingQueryTerms(normalizedQuery).filter((term) => term.length >= 5)
|
|
396
|
+
: [];
|
|
397
|
+
|
|
398
|
+
for (const entry of lexicalCandidates) {
|
|
399
|
+
const boost = scoreEntryLexicalBoost(entry, normalizedQuery, rescueTerms);
|
|
400
|
+
if (boost === 0) continue;
|
|
401
|
+
|
|
402
|
+
const key = getSearchLookupId(entry._source, entry.id);
|
|
403
|
+
const current = resultByKey.get(key);
|
|
404
|
+
if (current) {
|
|
405
|
+
current.score += boost;
|
|
406
|
+
} else {
|
|
407
|
+
resultByKey.set(key, { entry, score: boost });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let results = [...resultByKey.values()];
|
|
162
412
|
|
|
163
413
|
const filtered = applyFilters(results.map((r) => r.entry), filters);
|
|
164
414
|
const filteredSet = new Set(filtered);
|
|
@@ -173,6 +423,7 @@ export function searchEntries(query, filters = {}) {
|
|
|
173
423
|
* type: "doc" or "skill". If null, searches both.
|
|
174
424
|
*/
|
|
175
425
|
export function getEntry(idOrNamespacedId, type = null) {
|
|
426
|
+
const normalizedId = normalizeQuery(idOrNamespacedId);
|
|
176
427
|
const { docs, skills } = getMerged();
|
|
177
428
|
let pool;
|
|
178
429
|
if (type === 'doc') pool = applySourceFilter(docs);
|
|
@@ -180,16 +431,16 @@ export function getEntry(idOrNamespacedId, type = null) {
|
|
|
180
431
|
else pool = applySourceFilter([...docs, ...skills]);
|
|
181
432
|
|
|
182
433
|
// Check for source:id format (colon separates source from id)
|
|
183
|
-
if (
|
|
184
|
-
const colonIdx =
|
|
185
|
-
const sourceName =
|
|
186
|
-
const id =
|
|
434
|
+
if (normalizedId.includes(':')) {
|
|
435
|
+
const colonIdx = normalizedId.indexOf(':');
|
|
436
|
+
const sourceName = normalizedId.slice(0, colonIdx);
|
|
437
|
+
const id = normalizedId.slice(colonIdx + 1);
|
|
187
438
|
const entry = pool.find((e) => e._source === sourceName && e.id === id);
|
|
188
439
|
return entry ? { entry, ambiguous: false } : { entry: null, ambiguous: false };
|
|
189
440
|
}
|
|
190
441
|
|
|
191
442
|
// Bare id (may contain slashes like author/name)
|
|
192
|
-
const matches = pool.filter((e) => e.id ===
|
|
443
|
+
const matches = pool.filter((e) => e.id === normalizedId);
|
|
193
444
|
if (matches.length === 0) return { entry: null, ambiguous: false };
|
|
194
445
|
if (matches.length === 1) return { entry: matches[0], ambiguous: false };
|
|
195
446
|
|
|
@@ -241,9 +492,7 @@ export function resolveDocPath(entry, language, version) {
|
|
|
241
492
|
let langObj = null;
|
|
242
493
|
if (lang) {
|
|
243
494
|
langObj = entry.languages.find((l) => l.language === lang);
|
|
244
|
-
} else
|
|
245
|
-
langObj = entry.languages[0];
|
|
246
|
-
} else if (entry.languages.length > 1) {
|
|
495
|
+
} else {
|
|
247
496
|
return {
|
|
248
497
|
needsLanguage: true,
|
|
249
498
|
available: entry.languages.map((l) => l.language),
|
|
@@ -255,6 +504,13 @@ export function resolveDocPath(entry, language, version) {
|
|
|
255
504
|
let verObj = null;
|
|
256
505
|
if (version) {
|
|
257
506
|
verObj = langObj.versions?.find((v) => v.version === version);
|
|
507
|
+
if (!verObj) {
|
|
508
|
+
return {
|
|
509
|
+
versionNotFound: true,
|
|
510
|
+
requested: version,
|
|
511
|
+
available: langObj.versions?.map((v) => v.version) || [],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
258
514
|
} else {
|
|
259
515
|
const rec = langObj.recommendedVersion;
|
|
260
516
|
verObj = langObj.versions?.find((v) => v.version === rec) || langObj.versions?.[0];
|
|
@@ -272,7 +528,7 @@ export function resolveDocPath(entry, language, version) {
|
|
|
272
528
|
* Given a resolved path and a type ("doc" or "skill"), return the entry file path.
|
|
273
529
|
*/
|
|
274
530
|
export function resolveEntryFile(resolved, type) {
|
|
275
|
-
if (!resolved || resolved.needsLanguage) return { error: 'unresolved' };
|
|
531
|
+
if (!resolved || resolved.needsLanguage || resolved.versionNotFound) return { error: 'unresolved' };
|
|
276
532
|
|
|
277
533
|
const fileName = type === 'skill' ? 'SKILL.md' : 'DOC.md';
|
|
278
534
|
|
package/src/lib/telemetry.js
CHANGED
|
@@ -8,6 +8,12 @@ export function isTelemetryEnabled() {
|
|
|
8
8
|
return config.telemetry !== false;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export function isFeedbackEnabled() {
|
|
12
|
+
if (process.env.CHUB_FEEDBACK === '0' || process.env.CHUB_FEEDBACK === 'false') return false;
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
return config.feedback !== false;
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
export function getTelemetryUrl() {
|
|
12
18
|
const url = process.env.CHUB_TELEMETRY_URL;
|
|
13
19
|
if (url) return url;
|
|
@@ -33,7 +39,7 @@ export function getTelemetryUrl() {
|
|
|
33
39
|
* @param {string} [opts.source] - Registry source name
|
|
34
40
|
*/
|
|
35
41
|
export async function sendFeedback(entryId, entryType, rating, opts = {}) {
|
|
36
|
-
if (!
|
|
42
|
+
if (!isFeedbackEnabled()) return { status: 'skipped', reason: 'feedback_disabled' };
|
|
37
43
|
|
|
38
44
|
const { getOrCreateClientId, detectAgent, detectAgentVersion } = await import('./identity.js');
|
|
39
45
|
const clientId = await getOrCreateClientId();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { getChubDir } from './config.js';
|
|
5
|
+
|
|
6
|
+
const WELCOME_MARKER = '.welcome_shown';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Show the first-run welcome notice if it hasn't been shown yet.
|
|
10
|
+
* Creates a marker file so it only displays once.
|
|
11
|
+
*/
|
|
12
|
+
export function showWelcomeIfNeeded(opts = {}) {
|
|
13
|
+
if (opts.json) return;
|
|
14
|
+
if (!process.stdout.isTTY || !process.stderr.isTTY) return;
|
|
15
|
+
|
|
16
|
+
const chubDir = getChubDir();
|
|
17
|
+
const markerPath = join(chubDir, WELCOME_MARKER);
|
|
18
|
+
const configPath = join(chubDir, 'config.yaml');
|
|
19
|
+
|
|
20
|
+
if (existsSync(markerPath)) return;
|
|
21
|
+
|
|
22
|
+
// Print to stderr so it doesn't interfere with JSON/piped output
|
|
23
|
+
console.error(`
|
|
24
|
+
${chalk.bold('Welcome to Context Hub (chub)!')} Chub helps your AI coding agents make API calls correctly, by providing \
|
|
25
|
+
the latest documentation.
|
|
26
|
+
|
|
27
|
+
By using chub, you agree to the Terms of Service at ${chalk.underline('https://www.aichub.org/tos.html')}
|
|
28
|
+
|
|
29
|
+
Chub asks agents to provide feedback on documentation, and this feedback is used to improve docs for the developer \
|
|
30
|
+
community. If you wish to disable this feedback, add ${chalk.bold('"feedback: false"')} to ${chalk.bold(configPath)}. See \
|
|
31
|
+
${chalk.underline('https://github.com/andrewyng/context-hub')} for details.
|
|
32
|
+
`);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(chubDir)) {
|
|
36
|
+
mkdirSync(chubDir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
writeFileSync(markerPath, new Date().toISOString(), 'utf8');
|
|
39
|
+
} catch {
|
|
40
|
+
// Best-effort — don't block CLI if marker can't be written
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Hub MCP Server.
|
|
3
|
+
*
|
|
4
|
+
* Exposes chub search, get, list, annotate, and feedback as MCP tools
|
|
5
|
+
* for use with Claude Code, Cursor, and other MCP-compatible agents.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { ensureRegistry } from '../lib/cache.js';
|
|
15
|
+
import { listEntries } from '../lib/registry.js';
|
|
16
|
+
import { handleSearch, handleGet, handleList, handleAnnotate, handleFeedback } from './tools.js';
|
|
17
|
+
import { attachStdioShutdownHandlers } from './stdio-lifecycle.js';
|
|
18
|
+
|
|
19
|
+
// Prevent console.log from corrupting the stdio JSON-RPC protocol.
|
|
20
|
+
// Any transitive dependency (e.g. posthog-node) that calls console.log
|
|
21
|
+
// would break the MCP transport without this redirect.
|
|
22
|
+
const _stderr = process.stderr;
|
|
23
|
+
console.log = (...args) => _stderr.write(args.join(' ') + '\n');
|
|
24
|
+
console.warn = (...args) => _stderr.write('[warn] ' + args.join(' ') + '\n');
|
|
25
|
+
console.info = (...args) => _stderr.write('[info] ' + args.join(' ') + '\n');
|
|
26
|
+
console.debug = (...args) => _stderr.write('[debug] ' + args.join(' ') + '\n');
|
|
27
|
+
|
|
28
|
+
// Read package version
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
31
|
+
|
|
32
|
+
// Create server
|
|
33
|
+
const server = new McpServer({
|
|
34
|
+
name: 'chub',
|
|
35
|
+
version: pkg.version,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// --- Register Tools ---
|
|
39
|
+
|
|
40
|
+
server.tool(
|
|
41
|
+
'chub_search',
|
|
42
|
+
'Search Context Hub for docs and skills by query, tags, or language',
|
|
43
|
+
{
|
|
44
|
+
query: z.string().optional().describe('Search query. Omit to list all entries.'),
|
|
45
|
+
tags: z.string().optional().describe('Comma-separated tag filter (e.g. "openai,chat")'),
|
|
46
|
+
lang: z.string().optional().describe('Filter by language (e.g. "python", "js")'),
|
|
47
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 20)'),
|
|
48
|
+
},
|
|
49
|
+
async (args) => handleSearch(args),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
server.tool(
|
|
53
|
+
'chub_get',
|
|
54
|
+
'Fetch the content of a doc or skill by ID from Context Hub',
|
|
55
|
+
{
|
|
56
|
+
id: z.string().describe('Entry ID (e.g. "openai/chat", "stripe/api"). Use source:id for disambiguation.'),
|
|
57
|
+
lang: z.string().optional().describe('Language variant (e.g. "python", "js"). Auto-selected if only one.'),
|
|
58
|
+
version: z.string().optional().describe('Specific version (e.g. "1.52.0"). Defaults to recommended.'),
|
|
59
|
+
full: z.boolean().optional().describe('Fetch all files, not just the entry point (default false)'),
|
|
60
|
+
file: z.string().optional().describe('Fetch a specific file by path (e.g. "references/streaming.md")'),
|
|
61
|
+
},
|
|
62
|
+
async (args) => handleGet(args),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
server.tool(
|
|
66
|
+
'chub_list',
|
|
67
|
+
'List all available docs and skills in Context Hub',
|
|
68
|
+
{
|
|
69
|
+
tags: z.string().optional().describe('Comma-separated tag filter'),
|
|
70
|
+
lang: z.string().optional().describe('Filter by language'),
|
|
71
|
+
limit: z.number().int().min(1).max(500).optional().describe('Max entries (default 50)'),
|
|
72
|
+
},
|
|
73
|
+
async (args) => handleList(args),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
server.tool(
|
|
77
|
+
'chub_annotate',
|
|
78
|
+
'Read, write, clear, or list agent annotations. Modes: (1) list=true to list all, (2) id+note to write, (3) id+clear=true to delete, (4) id alone to read. Annotations persist locally across sessions.',
|
|
79
|
+
{
|
|
80
|
+
id: z.string().optional().describe('Entry ID to annotate (e.g. "openai/chat"). Required unless using list mode.'),
|
|
81
|
+
note: z.string().optional().describe('Annotation text to save. Omit to read existing annotation.'),
|
|
82
|
+
clear: z.boolean().optional().describe('Remove the annotation for this entry (default false)'),
|
|
83
|
+
list: z.boolean().optional().describe('List all annotations (default false). When true, id is not needed.'),
|
|
84
|
+
},
|
|
85
|
+
async (args) => handleAnnotate(args),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
server.tool(
|
|
89
|
+
'chub_feedback',
|
|
90
|
+
'Send quality feedback (thumbs up/down) for a doc or skill to help authors improve content',
|
|
91
|
+
{
|
|
92
|
+
id: z.string().describe('Entry ID to rate (e.g. "openai/chat")'),
|
|
93
|
+
rating: z.enum(['up', 'down']).describe('Thumbs up or down'),
|
|
94
|
+
comment: z.string().optional().describe('Optional comment explaining the rating'),
|
|
95
|
+
type: z.enum(['doc', 'skill']).optional().describe('Entry type. Auto-detected if omitted.'),
|
|
96
|
+
lang: z.string().optional().describe('Language variant rated'),
|
|
97
|
+
version: z.string().optional().describe('Version rated'),
|
|
98
|
+
file: z.string().optional().describe('Specific file rated'),
|
|
99
|
+
labels: z.array(z.enum([
|
|
100
|
+
'accurate', 'well-structured', 'helpful', 'good-examples',
|
|
101
|
+
'outdated', 'inaccurate', 'incomplete', 'wrong-examples',
|
|
102
|
+
'wrong-version', 'poorly-structured',
|
|
103
|
+
])).optional().describe('Structured feedback labels'),
|
|
104
|
+
},
|
|
105
|
+
async (args) => handleFeedback(args),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// --- Register Resource ---
|
|
109
|
+
|
|
110
|
+
server.resource(
|
|
111
|
+
'registry',
|
|
112
|
+
'chub://registry',
|
|
113
|
+
{
|
|
114
|
+
title: 'Context Hub Registry',
|
|
115
|
+
description: 'Browse the full Context Hub registry of docs and skills',
|
|
116
|
+
mimeType: 'application/json',
|
|
117
|
+
},
|
|
118
|
+
async (uri) => {
|
|
119
|
+
try {
|
|
120
|
+
const entries = listEntries({});
|
|
121
|
+
const simplified = entries.map((entry) => ({
|
|
122
|
+
id: entry.id,
|
|
123
|
+
name: entry.name,
|
|
124
|
+
type: entry._type || (entry.languages ? 'doc' : 'skill'),
|
|
125
|
+
description: entry.description,
|
|
126
|
+
tags: entry.tags || [],
|
|
127
|
+
...(entry.languages
|
|
128
|
+
? {
|
|
129
|
+
languages: entry.languages.map((l) => ({
|
|
130
|
+
language: l.language,
|
|
131
|
+
versions: l.versions?.map((v) => v.version) || [],
|
|
132
|
+
recommended: l.recommendedVersion,
|
|
133
|
+
})),
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
136
|
+
}));
|
|
137
|
+
return {
|
|
138
|
+
contents: [{
|
|
139
|
+
uri: uri.href,
|
|
140
|
+
mimeType: 'application/json',
|
|
141
|
+
text: JSON.stringify({ entries: simplified, total: simplified.length }, null, 2),
|
|
142
|
+
}],
|
|
143
|
+
};
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.warn(`Registry resource error: ${err.message}`);
|
|
146
|
+
return {
|
|
147
|
+
contents: [{
|
|
148
|
+
uri: uri.href,
|
|
149
|
+
mimeType: 'application/json',
|
|
150
|
+
text: JSON.stringify({ error: 'Registry not loaded. Run "chub update" first.' }),
|
|
151
|
+
}],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// --- Process Safety ---
|
|
158
|
+
|
|
159
|
+
// Prevent the server from crashing on unhandled errors (long-lived process)
|
|
160
|
+
process.on('uncaughtException', (err) => {
|
|
161
|
+
_stderr.write(`[chub-mcp] Uncaught exception: ${err.message}\n`);
|
|
162
|
+
});
|
|
163
|
+
process.on('unhandledRejection', (reason) => {
|
|
164
|
+
_stderr.write(`[chub-mcp] Unhandled rejection: ${reason}\n`);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// --- Start Server ---
|
|
168
|
+
|
|
169
|
+
// Best-effort registry load — server starts even if this fails
|
|
170
|
+
try {
|
|
171
|
+
await ensureRegistry();
|
|
172
|
+
} catch (err) {
|
|
173
|
+
_stderr.write(`[chub-mcp] Warning: Registry not loaded: ${err.message}\n`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const transport = new StdioServerTransport();
|
|
177
|
+
await server.connect(transport);
|
|
178
|
+
|
|
179
|
+
// Exit promptly when MCP host disconnects stdio.
|
|
180
|
+
// Must be after server.connect() so StdioServerTransport's data handler
|
|
181
|
+
// is already wired — otherwise stdin.resume() discards incoming bytes.
|
|
182
|
+
attachStdioShutdownHandlers({ stderr: _stderr });
|
|
183
|
+
|
|
184
|
+
_stderr.write(`[chub-mcp] Server started (v${pkg.version})\n`);
|