skilld 1.1.2 → 1.2.1

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.
@@ -34,560 +34,292 @@ function buildFrontmatter(fields) {
34
34
  lines.push("---");
35
35
  return lines.join("\n");
36
36
  }
37
- //#endregion
38
- //#region src/sources/issues.ts
37
+ /** Check if body contains a code block */
38
+ function hasCodeBlock(text) {
39
+ return /```[\s\S]*?```/.test(text) || /`[^`]+`/.test(text);
40
+ }
41
+ /** Noise patterns in comments — filter these out */
42
+ const COMMENT_NOISE_RE = /^(?:\+1|👍|same here|any update|bump|following|is there any progress|when will this|me too|i have the same|same issue|thanks|thank you)[\s!?.]*$/i;
39
43
  /**
40
- * GitHub issues fetching via gh CLI Search API
41
- * Freshness-weighted scoring, type quotas, comment quality filtering
42
- * Categorized by labels, noise filtered out, non-technical issues detected
44
+ * Smart body truncation preserves code blocks and error messages.
45
+ * Instead of slicing at a char limit, finds a safe break point.
43
46
  */
44
- let _ghAvailable;
47
+ function truncateBody(body, limit) {
48
+ if (body.length <= limit) return body;
49
+ const codeBlockRe = /```[\s\S]*?```/g;
50
+ let lastSafeEnd = limit;
51
+ let match;
52
+ while ((match = codeBlockRe.exec(body)) !== null) {
53
+ const blockStart = match.index;
54
+ const blockEnd = blockStart + match[0].length;
55
+ if (blockStart < limit && blockEnd > limit) {
56
+ if (blockEnd <= limit + 500) lastSafeEnd = blockEnd;
57
+ else lastSafeEnd = blockStart;
58
+ break;
59
+ }
60
+ }
61
+ const slice = body.slice(0, lastSafeEnd);
62
+ const lastParagraph = slice.lastIndexOf("\n\n");
63
+ if (lastParagraph > lastSafeEnd * .6) return `${slice.slice(0, lastParagraph)}\n\n...`;
64
+ return `${slice}...`;
65
+ }
66
+ let _ghToken;
45
67
  /**
46
- * Check if gh CLI is installed and authenticated (cached)
68
+ * Get GitHub auth token from gh CLI (cached).
69
+ * Returns null if gh CLI is not available or not authenticated.
47
70
  */
48
- function isGhAvailable() {
49
- if (_ghAvailable !== void 0) return _ghAvailable;
50
- const { status } = spawnSync("gh", ["auth", "status"], { stdio: "ignore" });
51
- return _ghAvailable = status === 0;
71
+ function getGitHubToken() {
72
+ if (_ghToken !== void 0) return _ghToken;
73
+ try {
74
+ const { stdout } = spawnSync("gh", ["auth", "token"], {
75
+ encoding: "utf-8",
76
+ timeout: 5e3,
77
+ stdio: [
78
+ "ignore",
79
+ "pipe",
80
+ "ignore"
81
+ ]
82
+ });
83
+ _ghToken = stdout?.trim() || null;
84
+ } catch {
85
+ _ghToken = null;
86
+ }
87
+ return _ghToken;
88
+ }
89
+ /** Repos where ungh.cc failed but gh api succeeded (likely private) */
90
+ const _needsAuth = /* @__PURE__ */ new Set();
91
+ /** Mark a repo as needing authenticated access */
92
+ function markRepoPrivate(owner, repo) {
93
+ _needsAuth.add(`${owner}/${repo}`);
94
+ }
95
+ /** Check if a repo is known to need authenticated access */
96
+ function isKnownPrivateRepo(owner, repo) {
97
+ return _needsAuth.has(`${owner}/${repo}`);
98
+ }
99
+ const GH_API = "https://api.github.com";
100
+ const ghApiFetch = ofetch.create({
101
+ retry: 2,
102
+ retryDelay: 500,
103
+ timeout: 15e3,
104
+ headers: { "User-Agent": "skilld/1.0" }
105
+ });
106
+ const LINK_NEXT_RE = /<([^>]+)>;\s*rel="next"/;
107
+ /** Parse GitHub Link header for next page URL */
108
+ function parseLinkNext(header) {
109
+ if (!header) return null;
110
+ return header.match(LINK_NEXT_RE)?.[1] ?? null;
52
111
  }
53
- /** Labels that indicate noise — filter these out entirely */
54
- const NOISE_LABELS = new Set([
55
- "duplicate",
56
- "stale",
57
- "invalid",
58
- "wontfix",
59
- "won't fix",
60
- "spam",
61
- "off-topic",
62
- "needs triage",
63
- "triage"
64
- ]);
65
- /** Labels that indicate feature requests — deprioritize */
66
- const FEATURE_LABELS = new Set([
67
- "enhancement",
68
- "feature",
69
- "feature request",
70
- "feature-request",
71
- "proposal",
72
- "rfc",
73
- "idea",
74
- "suggestion"
75
- ]);
76
- const BUG_LABELS = new Set([
77
- "bug",
78
- "defect",
79
- "regression",
80
- "error",
81
- "crash",
82
- "fix",
83
- "confirmed",
84
- "verified"
85
- ]);
86
- const QUESTION_LABELS = new Set([
87
- "question",
88
- "help wanted",
89
- "support",
90
- "usage",
91
- "how-to",
92
- "help",
93
- "assistance"
94
- ]);
95
- const DOCS_LABELS = new Set([
96
- "documentation",
97
- "docs",
98
- "doc",
99
- "typo"
100
- ]);
101
112
  /**
102
- * Check if a label contains any keyword from a set.
103
- * Handles emoji-prefixed labels like ":sparkles: feature request" or ":lady_beetle: bug".
113
+ * Authenticated fetch against api.github.com. Returns null if no token or request fails.
114
+ * Endpoint should be relative, e.g. `repos/owner/repo/releases`.
104
115
  */
105
- function labelMatchesAny(label, keywords) {
106
- for (const keyword of keywords) if (label === keyword || label.includes(keyword)) return true;
107
- return false;
116
+ async function ghApi(endpoint) {
117
+ const token = getGitHubToken();
118
+ if (!token) return null;
119
+ return ghApiFetch(`${GH_API}/${endpoint}`, { headers: { Authorization: `token ${token}` } }).catch(() => null);
108
120
  }
109
121
  /**
110
- * Classify an issue by its labels into a type useful for skill generation
122
+ * Paginated GitHub API fetch. Follows Link headers, returns concatenated arrays.
123
+ * Endpoint should return a JSON array, e.g. `repos/owner/repo/releases`.
111
124
  */
112
- function classifyIssue(labels) {
113
- const lower = labels.map((l) => l.toLowerCase());
114
- if (lower.some((l) => labelMatchesAny(l, BUG_LABELS))) return "bug";
115
- if (lower.some((l) => labelMatchesAny(l, QUESTION_LABELS))) return "question";
116
- if (lower.some((l) => labelMatchesAny(l, DOCS_LABELS))) return "docs";
117
- if (lower.some((l) => labelMatchesAny(l, FEATURE_LABELS))) return "feature";
118
- return "other";
125
+ async function ghApiPaginated(endpoint) {
126
+ const token = getGitHubToken();
127
+ if (!token) return [];
128
+ const headers = { Authorization: `token ${token}` };
129
+ const results = [];
130
+ let url = `${GH_API}/${endpoint}`;
131
+ while (url) {
132
+ const res = await ghApiFetch.raw(url, { headers }).catch(() => null);
133
+ if (!res?.ok || !Array.isArray(res._data)) break;
134
+ results.push(...res._data);
135
+ url = parseLinkNext(res.headers.get("link"));
136
+ }
137
+ return results;
119
138
  }
139
+ //#endregion
140
+ //#region src/sources/utils.ts
120
141
  /**
121
- * Check if an issue should be filtered out entirely
142
+ * Shared utilities for doc resolution
122
143
  */
123
- function isNoiseIssue(issue) {
124
- if (issue.labels.map((l) => l.toLowerCase()).some((l) => labelMatchesAny(l, NOISE_LABELS))) return true;
125
- if (issue.title.startsWith("☂️") || issue.title.startsWith("[META]") || issue.title.startsWith("[Tracking]")) return true;
126
- return false;
144
+ const $fetch = ofetch.create({
145
+ retry: 3,
146
+ retryDelay: 500,
147
+ timeout: 15e3,
148
+ headers: { "User-Agent": "skilld/1.0" }
149
+ });
150
+ /**
151
+ * Fetch text content from URL
152
+ */
153
+ async function fetchText(url) {
154
+ return $fetch(url, { responseType: "text" }).catch(() => null);
127
155
  }
128
- /** Check if body contains a code block */
129
- function hasCodeBlock$1(text) {
130
- return /```[\s\S]*?```/.test(text) || /`[^`]+`/.test(text);
156
+ const RAW_GH_RE = /raw\.githubusercontent\.com\/([^/]+)\/([^/]+)/;
157
+ /** Extract owner/repo from a GitHub raw content URL */
158
+ function extractGitHubRepo(url) {
159
+ const match = url.match(RAW_GH_RE);
160
+ return match ? {
161
+ owner: match[1],
162
+ repo: match[2]
163
+ } : null;
131
164
  }
132
165
  /**
133
- * Detect non-technical issues: fan mail, showcases, sentiment.
134
- * Short body + no code + high reactions = likely non-technical.
135
- * Note: roadmap/tracking issues are NOT filtered they get score-boosted instead.
166
+ * Fetch text from a GitHub raw URL with auth fallback for private repos.
167
+ * Tries unauthenticated first (fast path), falls back to authenticated
168
+ * request when the repo is known to be private or unauthenticated fails.
169
+ *
170
+ * Only sends auth tokens to raw.githubusercontent.com — returns null for
171
+ * non-GitHub URLs that fail unauthenticated to prevent token leakage.
136
172
  */
137
- function isNonTechnical(issue) {
138
- const body = (issue.body || "").trim();
139
- if (body.length < 200 && !hasCodeBlock$1(body) && issue.reactions > 50) return true;
140
- if (/\b(?:love|thank|awesome|great work)\b/i.test(issue.title) && !hasCodeBlock$1(body)) return true;
141
- return false;
173
+ async function fetchGitHubRaw(url) {
174
+ const gh = extractGitHubRepo(url);
175
+ if (!(gh ? isKnownPrivateRepo(gh.owner, gh.repo) : false)) {
176
+ const content = await fetchText(url);
177
+ if (content) return content;
178
+ }
179
+ if (!gh) return null;
180
+ const token = getGitHubToken();
181
+ if (!token) return null;
182
+ const content = await $fetch(url, {
183
+ responseType: "text",
184
+ headers: { Authorization: `token ${token}` }
185
+ }).catch(() => null);
186
+ if (content) markRepoPrivate(gh.owner, gh.repo);
187
+ return content;
142
188
  }
143
189
  /**
144
- * Freshness-weighted score: reactions * decay(age_in_years)
145
- * Steep decay so recent issues dominate over old high-reaction ones.
146
- * At 0.6: 1yr=0.63x, 2yr=0.45x, 4yr=0.29x, 6yr=0.22x
190
+ * Verify URL exists and is not HTML (likely 404 page)
147
191
  */
148
- function freshnessScore(reactions, createdAt) {
149
- return reactions * (1 / (1 + (Date.now() - new Date(createdAt).getTime()) / (365.25 * 24 * 60 * 60 * 1e3) * .6));
192
+ async function verifyUrl(url) {
193
+ const res = await $fetch.raw(url, { method: "HEAD" }).catch(() => null);
194
+ if (!res) return false;
195
+ return !(res.headers.get("content-type") || "").includes("text/html");
150
196
  }
151
197
  /**
152
- * Type quotas guarantee a mix of issue types.
153
- * Bugs and questions get priority; feature requests are hard-capped.
198
+ * Check if URL points to a social media or package registry site (not real docs)
154
199
  */
155
- function applyTypeQuotas(issues, limit) {
156
- const byType = /* @__PURE__ */ new Map();
157
- for (const issue of issues) mapInsert(byType, issue.type, () => []).push(issue);
158
- for (const group of byType.values()) group.sort((a, b) => b.score - a.score);
159
- const quotas = [
160
- ["bug", Math.ceil(limit * .4)],
161
- ["question", Math.ceil(limit * .3)],
162
- ["docs", Math.ceil(limit * .15)],
163
- ["feature", Math.ceil(limit * .1)],
164
- ["other", Math.ceil(limit * .05)]
165
- ];
166
- const selected = [];
167
- const used = /* @__PURE__ */ new Set();
168
- let remaining = limit;
169
- for (const [type, quota] of quotas) {
170
- const group = byType.get(type) || [];
171
- const take = Math.min(quota, group.length, remaining);
172
- for (let i = 0; i < take; i++) {
173
- selected.push(group[i]);
174
- used.add(group[i].number);
175
- remaining--;
176
- }
177
- }
178
- if (remaining > 0) {
179
- const unused = issues.filter((i) => !used.has(i.number) && i.type !== "feature").sort((a, b) => b.score - a.score);
180
- for (const issue of unused) {
181
- if (remaining <= 0) break;
182
- selected.push(issue);
183
- remaining--;
184
- }
200
+ const USELESS_HOSTS = new Set([
201
+ "twitter.com",
202
+ "x.com",
203
+ "facebook.com",
204
+ "linkedin.com",
205
+ "youtube.com",
206
+ "instagram.com",
207
+ "npmjs.com",
208
+ "www.npmjs.com",
209
+ "yarnpkg.com"
210
+ ]);
211
+ function isUselessDocsUrl(url) {
212
+ try {
213
+ const { hostname } = new URL(url);
214
+ return USELESS_HOSTS.has(hostname);
215
+ } catch {
216
+ return false;
185
217
  }
186
- return selected.sort((a, b) => b.score - a.score);
187
218
  }
188
219
  /**
189
- * Body truncation limit based on reactions high-reaction issues deserve more space
220
+ * Check if URL is a GitHub repo URL (not a docs site)
190
221
  */
191
- function bodyLimit(reactions) {
192
- if (reactions >= 10) return 2e3;
193
- if (reactions >= 5) return 1500;
194
- return 800;
222
+ function isGitHubRepoUrl(url) {
223
+ try {
224
+ const parsed = new URL(url);
225
+ return parsed.hostname === "github.com" || parsed.hostname === "www.github.com";
226
+ } catch {
227
+ return false;
228
+ }
195
229
  }
196
230
  /**
197
- * Smart body truncation preserves code blocks and error messages.
198
- * Instead of slicing at a char limit, finds a safe break point.
231
+ * Parse owner/repo from GitHub URL
199
232
  */
200
- function truncateBody$1(body, limit) {
201
- if (body.length <= limit) return body;
202
- const codeBlockRe = /```[\s\S]*?```/g;
203
- let lastSafeEnd = limit;
204
- let match;
205
- while ((match = codeBlockRe.exec(body)) !== null) {
206
- const blockStart = match.index;
207
- const blockEnd = blockStart + match[0].length;
208
- if (blockStart < limit && blockEnd > limit) {
209
- if (blockEnd <= limit + 500) lastSafeEnd = blockEnd;
210
- else lastSafeEnd = blockStart;
211
- break;
212
- }
213
- }
214
- const slice = body.slice(0, lastSafeEnd);
215
- const lastParagraph = slice.lastIndexOf("\n\n");
216
- if (lastParagraph > lastSafeEnd * .6) return `${slice.slice(0, lastParagraph)}\n\n...`;
217
- return `${slice}...`;
233
+ function parseGitHubUrl(url) {
234
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:[/#]|$)/);
235
+ if (!match) return null;
236
+ return {
237
+ owner: match[1],
238
+ repo: match[2]
239
+ };
218
240
  }
219
241
  /**
220
- * Fetch issues for a state using GitHub Search API sorted by reactions
242
+ * Normalize git repo URL to https
221
243
  */
222
- function fetchIssuesByState(owner, repo, state, count, releasedAt, fromDate) {
223
- const fetchCount = Math.min(count * 3, 100);
224
- let datePart = "";
225
- if (fromDate) datePart = state === "closed" ? `+closed:>=${fromDate}` : `+created:>=${fromDate}`;
226
- else if (state === "closed") if (releasedAt) {
227
- const date = new Date(releasedAt);
228
- date.setMonth(date.getMonth() + 6);
229
- datePart = `+closed:<=${isoDate(date.toISOString())}`;
230
- } else datePart = `+closed:>${oneYearAgo()}`;
231
- else if (releasedAt) {
232
- const date = new Date(releasedAt);
233
- date.setMonth(date.getMonth() + 6);
234
- datePart = `+created:<=${isoDate(date.toISOString())}`;
235
- }
236
- const { stdout: result } = spawnSync("gh", [
237
- "api",
238
- `search/issues?q=${`repo:${owner}/${repo}+is:issue+is:${state}${datePart}`}&sort=reactions&order=desc&per_page=${fetchCount}`,
239
- "-q",
240
- ".items[] | {number, title, state, labels: [.labels[]?.name], body, createdAt: .created_at, url: .html_url, reactions: .reactions[\"+1\"], comments: .comments, user: .user.login, userType: .user.type, authorAssociation: .author_association}"
241
- ], {
242
- encoding: "utf-8",
243
- maxBuffer: 10 * 1024 * 1024
244
- });
245
- if (!result) return [];
246
- return result.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)).filter((issue) => !BOT_USERS.has(issue.user) && issue.userType !== "Bot").filter((issue) => !isNoiseIssue(issue)).filter((issue) => !isNonTechnical(issue)).map(({ user: _, userType: __, authorAssociation, ...issue }) => {
247
- const isMaintainer = [
248
- "OWNER",
249
- "MEMBER",
250
- "COLLABORATOR"
251
- ].includes(authorAssociation);
252
- const isRoadmap = /\broadmap\b/i.test(issue.title) || issue.labels.some((l) => /roadmap/i.test(l));
253
- return {
254
- ...issue,
255
- type: classifyIssue(issue.labels),
256
- topComments: [],
257
- score: freshnessScore(issue.reactions, issue.createdAt) * (isMaintainer && isRoadmap ? 5 : 1)
258
- };
259
- }).sort((a, b) => b.score - a.score).slice(0, count);
260
- }
261
- function oneYearAgo() {
262
- const d = /* @__PURE__ */ new Date();
263
- d.setFullYear(d.getFullYear() - 1);
264
- return isoDate(d.toISOString());
244
+ function normalizeRepoUrl(url) {
245
+ return url.replace(/^git\+/, "").replace(/#.*$/, "").replace(/\.git$/, "").replace(/^git:\/\//, "https://").replace(/^ssh:\/\/git@github\.com/, "https://github.com").replace(/^git@github\.com:/, "https://github.com/");
265
246
  }
266
- /** Noise patterns in comments — filter these out */
267
- const COMMENT_NOISE_RE$1 = /^(?:\+1|👍|same here|any update|bump|following|is there any progress|when will this|me too|i have the same|same issue)[\s!?.]*$/i;
268
247
  /**
269
- * Batch-fetch top comments for issues via GraphQL.
270
- * Enriches the top N highest-score issues with their best comments.
271
- * Prioritizes: comments with code blocks, from maintainers, with high reactions.
272
- * Filters out "+1", "any updates?", "same here" noise.
248
+ * Parse package spec with optional dist-tag or version: "vue@beta" { name: "vue", tag: "beta" }
249
+ * Handles scoped packages: "@vue/reactivity@beta" { name: "@vue/reactivity", tag: "beta" }
273
250
  */
274
- function enrichWithComments(owner, repo, issues, topN = 15) {
275
- const worth = issues.filter((i) => i.comments > 0 && (i.type === "bug" || i.type === "question" || i.reactions >= 3)).sort((a, b) => b.score - a.score).slice(0, topN);
276
- if (worth.length === 0) return;
277
- const query = `query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { ${worth.map((issue, i) => `i${i}: issue(number: ${issue.number}) { comments(first: 10) { nodes { body author { login } authorAssociation reactions { totalCount } } } }`).join(" ")} } }`;
278
- try {
279
- const { stdout: result } = spawnSync("gh", [
280
- "api",
281
- "graphql",
282
- "-f",
283
- `query=${query}`,
284
- "-f",
285
- `owner=${owner}`,
286
- "-f",
287
- `repo=${repo}`
288
- ], {
289
- encoding: "utf-8",
290
- maxBuffer: 10 * 1024 * 1024
291
- });
292
- if (!result) return;
293
- const repo_ = JSON.parse(result)?.data?.repository;
294
- if (!repo_) return;
295
- for (let i = 0; i < worth.length; i++) {
296
- const nodes = repo_[`i${i}`]?.comments?.nodes;
297
- if (!Array.isArray(nodes)) continue;
298
- const issue = worth[i];
299
- const comments = nodes.filter((c) => c.author && !BOT_USERS.has(c.author.login)).filter((c) => !COMMENT_NOISE_RE$1.test((c.body || "").trim())).map((c) => {
300
- const isMaintainer = [
301
- "OWNER",
302
- "MEMBER",
303
- "COLLABORATOR"
304
- ].includes(c.authorAssociation);
305
- const body = c.body || "";
306
- const reactions = c.reactions?.totalCount || 0;
307
- const _score = (isMaintainer ? 3 : 1) * (hasCodeBlock$1(body) ? 2 : 1) * (1 + reactions);
308
- return {
309
- body,
310
- author: c.author.login,
311
- reactions,
312
- isMaintainer,
313
- _score
314
- };
315
- }).sort((a, b) => b._score - a._score);
316
- issue.topComments = comments.slice(0, 3).map(({ _score: _, ...c }) => c);
317
- if (issue.state === "closed") issue.resolvedIn = detectResolvedVersion(comments);
251
+ function parsePackageSpec(spec) {
252
+ if (spec.startsWith("@")) {
253
+ const slashIdx = spec.indexOf("/");
254
+ if (slashIdx !== -1) {
255
+ const atIdx = spec.indexOf("@", slashIdx + 1);
256
+ if (atIdx !== -1) return {
257
+ name: spec.slice(0, atIdx),
258
+ tag: spec.slice(atIdx + 1)
259
+ };
318
260
  }
319
- } catch {}
261
+ return { name: spec };
262
+ }
263
+ const atIdx = spec.indexOf("@");
264
+ if (atIdx !== -1) return {
265
+ name: spec.slice(0, atIdx),
266
+ tag: spec.slice(atIdx + 1)
267
+ };
268
+ return { name: spec };
320
269
  }
321
270
  /**
322
- * Try to detect which version fixed a closed issue from maintainer comments.
323
- * Looks for version patterns in maintainer/collaborator comments.
271
+ * Extract branch hint from URL fragment (e.g. "git+https://...#main" "main")
324
272
  */
325
- function detectResolvedVersion(comments) {
326
- const maintainerComments = comments.filter((c) => c.isMaintainer);
327
- for (const c of maintainerComments.reverse()) {
328
- const match = c.body.match(/(?:fixed|landed|released|available|shipped|resolved|included)\s+in\s+v?(\d+\.\d+(?:\.\d+)?)/i);
329
- if (match) return match[1];
330
- if (c.body.length < 100) {
331
- const vMatch = c.body.match(/\bv?(\d+\.\d+\.\d+)\b/);
332
- if (vMatch) return vMatch[1];
333
- }
334
- }
273
+ function extractBranchHint(url) {
274
+ const hash = url.indexOf("#");
275
+ if (hash === -1) return void 0;
276
+ const fragment = url.slice(hash + 1);
277
+ if (!fragment || fragment === "readme") return void 0;
278
+ return fragment;
335
279
  }
280
+ //#endregion
281
+ //#region src/sources/releases.ts
336
282
  /**
337
- * Fetch issues from a GitHub repo with freshness-weighted scoring and type quotas.
338
- * Returns a balanced mix: bugs > questions > docs > other > features.
339
- * Filters noise, non-technical content, and enriches with quality comments.
283
+ * GitHub release notes fetching via GitHub API (preferred) with ungh.cc fallback
340
284
  */
341
- async function fetchGitHubIssues(owner, repo, limit = 30, releasedAt, fromDate) {
342
- if (!isGhAvailable()) return [];
343
- const openCount = Math.ceil(limit * .75);
344
- const closedCount = limit - openCount;
345
- try {
346
- const open = fetchIssuesByState(owner, repo, "open", Math.min(openCount * 2, 100), releasedAt, fromDate);
347
- const closed = fetchIssuesByState(owner, repo, "closed", Math.min(closedCount * 2, 50), releasedAt, fromDate);
348
- const selected = applyTypeQuotas([...open, ...closed], limit);
349
- enrichWithComments(owner, repo, selected);
350
- return selected;
351
- } catch {
352
- return [];
353
- }
285
+ function parseSemver(version) {
286
+ const clean = version.replace(/^v/, "");
287
+ const match = clean.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
288
+ if (!match) return null;
289
+ return {
290
+ major: +match[1],
291
+ minor: match[2] ? +match[2] : 0,
292
+ patch: match[3] ? +match[3] : 0,
293
+ raw: clean
294
+ };
354
295
  }
355
296
  /**
356
- * Format a single issue as markdown with YAML frontmatter
297
+ * Extract version from a release tag, handling monorepo formats:
298
+ * - `pkg@1.2.3` → `1.2.3`
299
+ * - `pkg-v1.2.3` → `1.2.3`
300
+ * - `v1.2.3` → `1.2.3`
301
+ * - `1.2.3` → `1.2.3`
357
302
  */
358
- function formatIssueAsMarkdown(issue) {
359
- const limit = bodyLimit(issue.reactions);
360
- const fmFields = {
361
- number: issue.number,
362
- title: issue.title,
363
- type: issue.type,
364
- state: issue.state,
365
- created: isoDate(issue.createdAt),
366
- url: issue.url,
367
- reactions: issue.reactions,
368
- comments: issue.comments
369
- };
370
- if (issue.resolvedIn) fmFields.resolvedIn = issue.resolvedIn;
371
- if (issue.labels.length > 0) fmFields.labels = `[${issue.labels.join(", ")}]`;
372
- const lines = [
373
- buildFrontmatter(fmFields),
374
- "",
375
- `# ${issue.title}`
376
- ];
377
- if (issue.body) {
378
- const body = truncateBody$1(issue.body, limit);
379
- lines.push("", body);
380
- }
381
- if (issue.topComments.length > 0) {
382
- lines.push("", "---", "", "## Top Comments");
383
- for (const c of issue.topComments) {
384
- const reactions = c.reactions > 0 ? ` (+${c.reactions})` : "";
385
- const maintainer = c.isMaintainer ? " [maintainer]" : "";
386
- const commentBody = truncateBody$1(c.body, 600);
387
- lines.push("", `**@${c.author}**${maintainer}${reactions}:`, "", commentBody);
388
- }
303
+ function extractVersion(tag, packageName) {
304
+ if (packageName) {
305
+ const atMatch = tag.match(new RegExp(`^${escapeRegex(packageName)}@(.+)$`));
306
+ if (atMatch) return atMatch[1];
307
+ const dashMatch = tag.match(new RegExp(`^${escapeRegex(packageName)}-v?(.+)$`));
308
+ if (dashMatch) return dashMatch[1];
389
309
  }
390
- return lines.join("\n");
310
+ return tag.replace(/^v/, "");
311
+ }
312
+ function escapeRegex(str) {
313
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
391
314
  }
392
315
  /**
393
- * Generate a summary index of all issues for quick LLM scanning.
394
- * Groups by type so the LLM can quickly find bugs vs questions.
316
+ * Check if a release tag belongs to a specific package
395
317
  */
396
- function generateIssueIndex(issues) {
397
- const byType = /* @__PURE__ */ new Map();
398
- for (const issue of issues) mapInsert(byType, issue.type, () => []).push(issue);
399
- const typeLabels = {
400
- bug: "Bugs & Regressions",
401
- question: "Questions & Usage Help",
402
- docs: "Documentation",
403
- feature: "Feature Requests",
404
- other: "Other"
405
- };
406
- const typeOrder = [
407
- "bug",
408
- "question",
409
- "docs",
410
- "other",
411
- "feature"
412
- ];
413
- const sections = [
414
- [
415
- "---",
416
- `total: ${issues.length}`,
417
- `open: ${issues.filter((i) => i.state === "open").length}`,
418
- `closed: ${issues.filter((i) => i.state !== "open").length}`,
419
- "---"
420
- ].join("\n"),
421
- "",
422
- "# Issues Index",
423
- ""
424
- ];
425
- for (const type of typeOrder) {
426
- const group = byType.get(type);
427
- if (!group?.length) continue;
428
- sections.push(`## ${typeLabels[type]} (${group.length})`, "");
429
- for (const issue of group) {
430
- const reactions = issue.reactions > 0 ? ` (+${issue.reactions})` : "";
431
- const state = issue.state === "open" ? "" : " [closed]";
432
- const resolved = issue.resolvedIn ? ` [fixed in ${issue.resolvedIn}]` : "";
433
- const date = isoDate(issue.createdAt);
434
- sections.push(`- [#${issue.number}](./issue-${issue.number}.md): ${issue.title}${reactions}${state}${resolved} (${date})`);
435
- }
436
- sections.push("");
437
- }
438
- return sections.join("\n");
318
+ function tagMatchesPackage(tag, packageName) {
319
+ return tag.startsWith(`${packageName}@`) || tag.startsWith(`${packageName}-v`) || tag.startsWith(`${packageName}-`);
439
320
  }
440
- //#endregion
441
- //#region src/sources/utils.ts
442
321
  /**
443
- * Shared utilities for doc resolution
444
- */
445
- const $fetch = ofetch.create({
446
- retry: 3,
447
- retryDelay: 500,
448
- timeout: 15e3,
449
- headers: { "User-Agent": "skilld/1.0" }
450
- });
451
- /**
452
- * Fetch text content from URL
453
- */
454
- async function fetchText(url) {
455
- return $fetch(url, { responseType: "text" }).catch(() => null);
456
- }
457
- /**
458
- * Verify URL exists and is not HTML (likely 404 page)
459
- */
460
- async function verifyUrl(url) {
461
- const res = await $fetch.raw(url, { method: "HEAD" }).catch(() => null);
462
- if (!res) return false;
463
- return !(res.headers.get("content-type") || "").includes("text/html");
464
- }
465
- /**
466
- * Check if URL points to a social media or package registry site (not real docs)
467
- */
468
- const USELESS_HOSTS = new Set([
469
- "twitter.com",
470
- "x.com",
471
- "facebook.com",
472
- "linkedin.com",
473
- "youtube.com",
474
- "instagram.com",
475
- "npmjs.com",
476
- "www.npmjs.com",
477
- "yarnpkg.com"
478
- ]);
479
- function isUselessDocsUrl(url) {
480
- try {
481
- const { hostname } = new URL(url);
482
- return USELESS_HOSTS.has(hostname);
483
- } catch {
484
- return false;
485
- }
486
- }
487
- /**
488
- * Check if URL is a GitHub repo URL (not a docs site)
489
- */
490
- function isGitHubRepoUrl(url) {
491
- try {
492
- const parsed = new URL(url);
493
- return parsed.hostname === "github.com" || parsed.hostname === "www.github.com";
494
- } catch {
495
- return false;
496
- }
497
- }
498
- /**
499
- * Parse owner/repo from GitHub URL
500
- */
501
- function parseGitHubUrl(url) {
502
- const match = url.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:[/#]|$)/);
503
- if (!match) return null;
504
- return {
505
- owner: match[1],
506
- repo: match[2]
507
- };
508
- }
509
- /**
510
- * Normalize git repo URL to https
511
- */
512
- function normalizeRepoUrl(url) {
513
- return url.replace(/^git\+/, "").replace(/#.*$/, "").replace(/\.git$/, "").replace(/^git:\/\//, "https://").replace(/^ssh:\/\/git@github\.com/, "https://github.com").replace(/^git@github\.com:/, "https://github.com/");
514
- }
515
- /**
516
- * Parse package spec with optional dist-tag or version: "vue@beta" → { name: "vue", tag: "beta" }
517
- * Handles scoped packages: "@vue/reactivity@beta" → { name: "@vue/reactivity", tag: "beta" }
518
- */
519
- function parsePackageSpec(spec) {
520
- if (spec.startsWith("@")) {
521
- const slashIdx = spec.indexOf("/");
522
- if (slashIdx !== -1) {
523
- const atIdx = spec.indexOf("@", slashIdx + 1);
524
- if (atIdx !== -1) return {
525
- name: spec.slice(0, atIdx),
526
- tag: spec.slice(atIdx + 1)
527
- };
528
- }
529
- return { name: spec };
530
- }
531
- const atIdx = spec.indexOf("@");
532
- if (atIdx !== -1) return {
533
- name: spec.slice(0, atIdx),
534
- tag: spec.slice(atIdx + 1)
535
- };
536
- return { name: spec };
537
- }
538
- /**
539
- * Extract branch hint from URL fragment (e.g. "git+https://...#main" → "main")
540
- */
541
- function extractBranchHint(url) {
542
- const hash = url.indexOf("#");
543
- if (hash === -1) return void 0;
544
- const fragment = url.slice(hash + 1);
545
- if (!fragment || fragment === "readme") return void 0;
546
- return fragment;
547
- }
548
- //#endregion
549
- //#region src/sources/releases.ts
550
- /**
551
- * GitHub release notes fetching via gh CLI (preferred) with ungh.cc fallback
552
- */
553
- function parseSemver(version) {
554
- const clean = version.replace(/^v/, "");
555
- const match = clean.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
556
- if (!match) return null;
557
- return {
558
- major: +match[1],
559
- minor: match[2] ? +match[2] : 0,
560
- patch: match[3] ? +match[3] : 0,
561
- raw: clean
562
- };
563
- }
564
- /**
565
- * Extract version from a release tag, handling monorepo formats:
566
- * - `pkg@1.2.3` → `1.2.3`
567
- * - `pkg-v1.2.3` → `1.2.3`
568
- * - `v1.2.3` → `1.2.3`
569
- * - `1.2.3` → `1.2.3`
570
- */
571
- function extractVersion(tag, packageName) {
572
- if (packageName) {
573
- const atMatch = tag.match(new RegExp(`^${escapeRegex(packageName)}@(.+)$`));
574
- if (atMatch) return atMatch[1];
575
- const dashMatch = tag.match(new RegExp(`^${escapeRegex(packageName)}-v?(.+)$`));
576
- if (dashMatch) return dashMatch[1];
577
- }
578
- return tag.replace(/^v/, "");
579
- }
580
- function escapeRegex(str) {
581
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
582
- }
583
- /**
584
- * Check if a release tag belongs to a specific package
585
- */
586
- function tagMatchesPackage(tag, packageName) {
587
- return tag.startsWith(`${packageName}@`) || tag.startsWith(`${packageName}-v`) || tag.startsWith(`${packageName}-`);
588
- }
589
- /**
590
- * Check if a version string contains a prerelease suffix (e.g. 6.0.0-beta, 1.2.3-rc.1)
322
+ * Check if a version string contains a prerelease suffix (e.g. 6.0.0-beta, 1.2.3-rc.1)
591
323
  */
592
324
  function isPrerelease(version) {
593
325
  return /^\d+\.\d+\.\d+-.+/.test(version.replace(/^v/, ""));
@@ -597,47 +329,25 @@ function compareSemver(a, b) {
597
329
  if (a.minor !== b.minor) return a.minor - b.minor;
598
330
  return a.patch - b.patch;
599
331
  }
600
- /**
601
- * Fetch releases via gh CLI (fast, authenticated, paginated)
602
- */
603
- function fetchReleasesViaGh(owner, repo) {
604
- try {
605
- const { stdout: ndjson } = spawnSync("gh", [
606
- "api",
607
- `repos/${owner}/${repo}/releases`,
608
- "--paginate",
609
- "--jq",
610
- ".[] | {id: .id, tag: .tag_name, name: .name, prerelease: .prerelease, createdAt: .created_at, publishedAt: .published_at, markdown: .body}"
611
- ], {
612
- encoding: "utf-8",
613
- timeout: 3e4,
614
- stdio: [
615
- "ignore",
616
- "pipe",
617
- "ignore"
618
- ]
619
- });
620
- if (!ndjson) return [];
621
- return ndjson.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
622
- } catch {
623
- return [];
624
- }
625
- }
626
- /**
627
- * Fetch all releases from a GitHub repo via ungh.cc (fallback)
628
- */
629
- async function fetchReleasesViaUngh(owner, repo) {
630
- return (await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`, { signal: AbortSignal.timeout(15e3) }).catch(() => null))?.releases ?? [];
332
+ /** Map GitHub API release to our GitHubRelease shape */
333
+ function mapApiRelease(r) {
334
+ return {
335
+ id: r.id,
336
+ tag: r.tag_name,
337
+ name: r.name,
338
+ prerelease: r.prerelease,
339
+ createdAt: r.created_at,
340
+ publishedAt: r.published_at,
341
+ markdown: r.body
342
+ };
631
343
  }
632
344
  /**
633
- * Fetch all releases — gh CLI first, ungh.cc fallback
345
+ * Fetch all releases — GitHub API first (authenticated, async), ungh.cc fallback
634
346
  */
635
347
  async function fetchAllReleases(owner, repo) {
636
- if (isGhAvailable()) {
637
- const releases = fetchReleasesViaGh(owner, repo);
638
- if (releases.length > 0) return releases;
639
- }
640
- return fetchReleasesViaUngh(owner, repo);
348
+ const apiReleases = await ghApiPaginated(`repos/${owner}/${repo}/releases`);
349
+ if (apiReleases.length > 0) return apiReleases.map(mapApiRelease);
350
+ return (await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`, { signal: AbortSignal.timeout(15e3) }).catch(() => null))?.releases ?? [];
641
351
  }
642
352
  /**
643
353
  * Select last 20 stable releases for a package, sorted newest first.
@@ -757,10 +467,7 @@ async function fetchChangelog(owner, repo, ref, packageName) {
757
467
  }
758
468
  paths.push("CHANGELOG.md", "changelog.md", "CHANGES.md");
759
469
  for (const path of paths) {
760
- const content = await $fetch(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`, {
761
- responseType: "text",
762
- signal: AbortSignal.timeout(1e4)
763
- }).catch(() => null);
470
+ const content = await fetchGitHubRaw(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`);
764
471
  if (content) return content;
765
472
  }
766
473
  return null;
@@ -887,116 +594,490 @@ async function fetchBlogReleases(packageName, installedVersion) {
887
594
  content: formatBlogRelease(r)
888
595
  }));
889
596
  }
890
- //#endregion
891
- //#region src/sources/crawl.ts
597
+ //#endregion
598
+ //#region src/sources/crawl.ts
599
+ /**
600
+ * Website crawl doc source — fetches docs by crawling a URL pattern
601
+ */
602
+ /**
603
+ * Crawl a URL pattern and return docs as cached doc format.
604
+ * Uses HTTP crawler (no browser needed) with sitemap discovery + glob filtering.
605
+ *
606
+ * @param url - URL with optional glob pattern (e.g. 'https://example.com/docs/**')
607
+ * @param onProgress - Optional progress callback
608
+ * @param maxPages - Max pages to crawl (default 200)
609
+ */
610
+ async function fetchCrawledDocs(url, onProgress, maxPages = 200) {
611
+ const outputDir = join(tmpdir(), "skilld-crawl", Date.now().toString());
612
+ onProgress?.(`Crawling ${url}`);
613
+ const userLang = getUserLang();
614
+ const foreignUrls = /* @__PURE__ */ new Set();
615
+ const doCrawl = () => crawlAndGenerate({
616
+ urls: [url],
617
+ outputDir,
618
+ driver: "http",
619
+ generateLlmsTxt: false,
620
+ generateIndividualMd: true,
621
+ maxRequestsPerCrawl: maxPages,
622
+ onPage: (page) => {
623
+ const lang = extractHtmlLang(page.html);
624
+ if (lang && !lang.startsWith("en") && !lang.startsWith(userLang)) foreignUrls.add(page.url);
625
+ }
626
+ }, (progress) => {
627
+ if (progress.crawling.status === "processing" && progress.crawling.total > 0) onProgress?.(`Crawling ${progress.crawling.processed}/${progress.crawling.total} pages`);
628
+ });
629
+ let results = await doCrawl().catch((err) => {
630
+ onProgress?.(`Crawl failed: ${err?.message || err}`);
631
+ return [];
632
+ });
633
+ if (results.length === 0) {
634
+ onProgress?.("Retrying crawl");
635
+ results = await doCrawl().catch(() => []);
636
+ }
637
+ rmSync(outputDir, {
638
+ recursive: true,
639
+ force: true
640
+ });
641
+ const docs = [];
642
+ let localeFiltered = 0;
643
+ for (const result of results) {
644
+ if (!result.success || !result.content) continue;
645
+ if (foreignUrls.has(result.url)) {
646
+ localeFiltered++;
647
+ continue;
648
+ }
649
+ const segments = (new URL(result.url).pathname.replace(/\/$/, "") || "/index").split("/").filter(Boolean);
650
+ if (isForeignPathPrefix(segments[0], userLang)) {
651
+ localeFiltered++;
652
+ continue;
653
+ }
654
+ const path = `docs/${segments.join("/")}.md`;
655
+ docs.push({
656
+ path,
657
+ content: result.content
658
+ });
659
+ }
660
+ if (localeFiltered > 0) onProgress?.(`Filtered ${localeFiltered} foreign locale pages`);
661
+ onProgress?.(`Crawled ${docs.length} pages`);
662
+ return docs;
663
+ }
664
+ const HTML_LANG_RE = /<html[^>]*\slang=["']([^"']+)["']/i;
665
+ /** Extract lang attribute from <html> tag */
666
+ function extractHtmlLang(html) {
667
+ return HTML_LANG_RE.exec(html)?.[1]?.toLowerCase();
668
+ }
669
+ /** Common ISO 639-1 locale codes for i18n'd doc sites */
670
+ const LOCALE_CODES = new Set([
671
+ "ar",
672
+ "de",
673
+ "es",
674
+ "fr",
675
+ "id",
676
+ "it",
677
+ "ja",
678
+ "ko",
679
+ "nl",
680
+ "pl",
681
+ "pt",
682
+ "pt-br",
683
+ "ru",
684
+ "th",
685
+ "tr",
686
+ "uk",
687
+ "vi",
688
+ "zh",
689
+ "zh-cn",
690
+ "zh-tw"
691
+ ]);
692
+ /** Check if a URL path segment is a known locale prefix foreign to both English and user's locale */
693
+ function isForeignPathPrefix(segment, userLang) {
694
+ if (!segment) return false;
695
+ const lower = segment.toLowerCase();
696
+ if (lower === "en" || lower.startsWith(userLang)) return false;
697
+ return LOCALE_CODES.has(lower);
698
+ }
699
+ /** Detect user's 2-letter language code from env (e.g. 'ja' from LANG=ja_JP.UTF-8) */
700
+ function getUserLang() {
701
+ const code = (process.env.LC_ALL || process.env.LANG || process.env.LANGUAGE || "").split(/[_.:-]/)[0]?.toLowerCase() || "";
702
+ return code.length >= 2 ? code.slice(0, 2) : "en";
703
+ }
704
+ /** Append glob pattern to a docs URL for crawling */
705
+ function toCrawlPattern(docsUrl) {
706
+ return `${docsUrl.replace(/\/+$/, "")}/**`;
707
+ }
708
+ //#endregion
709
+ //#region src/sources/issues.ts
710
+ /**
711
+ * GitHub issues fetching via gh CLI Search API
712
+ * Freshness-weighted scoring, type quotas, comment quality filtering
713
+ * Categorized by labels, noise filtered out, non-technical issues detected
714
+ */
715
+ let _ghAvailable;
716
+ /**
717
+ * Check if gh CLI is installed and authenticated (cached)
718
+ */
719
+ function isGhAvailable() {
720
+ if (_ghAvailable !== void 0) return _ghAvailable;
721
+ const { status } = spawnSync("gh", ["auth", "status"], { stdio: "ignore" });
722
+ return _ghAvailable = status === 0;
723
+ }
724
+ /** Labels that indicate noise — filter these out entirely */
725
+ const NOISE_LABELS = new Set([
726
+ "duplicate",
727
+ "stale",
728
+ "invalid",
729
+ "wontfix",
730
+ "won't fix",
731
+ "spam",
732
+ "off-topic",
733
+ "needs triage",
734
+ "triage"
735
+ ]);
736
+ /** Labels that indicate feature requests — deprioritize */
737
+ const FEATURE_LABELS = new Set([
738
+ "enhancement",
739
+ "feature",
740
+ "feature request",
741
+ "feature-request",
742
+ "proposal",
743
+ "rfc",
744
+ "idea",
745
+ "suggestion"
746
+ ]);
747
+ const BUG_LABELS = new Set([
748
+ "bug",
749
+ "defect",
750
+ "regression",
751
+ "error",
752
+ "crash",
753
+ "fix",
754
+ "confirmed",
755
+ "verified"
756
+ ]);
757
+ const QUESTION_LABELS = new Set([
758
+ "question",
759
+ "help wanted",
760
+ "support",
761
+ "usage",
762
+ "how-to",
763
+ "help",
764
+ "assistance"
765
+ ]);
766
+ const DOCS_LABELS = new Set([
767
+ "documentation",
768
+ "docs",
769
+ "doc",
770
+ "typo"
771
+ ]);
772
+ /**
773
+ * Check if a label contains any keyword from a set.
774
+ * Handles emoji-prefixed labels like ":sparkles: feature request" or ":lady_beetle: bug".
775
+ */
776
+ function labelMatchesAny(label, keywords) {
777
+ for (const keyword of keywords) if (label === keyword || label.includes(keyword)) return true;
778
+ return false;
779
+ }
780
+ /**
781
+ * Classify an issue by its labels into a type useful for skill generation
782
+ */
783
+ function classifyIssue(labels) {
784
+ const lower = labels.map((l) => l.toLowerCase());
785
+ if (lower.some((l) => labelMatchesAny(l, BUG_LABELS))) return "bug";
786
+ if (lower.some((l) => labelMatchesAny(l, QUESTION_LABELS))) return "question";
787
+ if (lower.some((l) => labelMatchesAny(l, DOCS_LABELS))) return "docs";
788
+ if (lower.some((l) => labelMatchesAny(l, FEATURE_LABELS))) return "feature";
789
+ return "other";
790
+ }
791
+ /**
792
+ * Check if an issue should be filtered out entirely
793
+ */
794
+ function isNoiseIssue(issue) {
795
+ if (issue.labels.map((l) => l.toLowerCase()).some((l) => labelMatchesAny(l, NOISE_LABELS))) return true;
796
+ if (issue.title.startsWith("☂️") || issue.title.startsWith("[META]") || issue.title.startsWith("[Tracking]")) return true;
797
+ return false;
798
+ }
799
+ /**
800
+ * Detect non-technical issues: fan mail, showcases, sentiment.
801
+ * Short body + no code + high reactions = likely non-technical.
802
+ * Note: roadmap/tracking issues are NOT filtered — they get score-boosted instead.
803
+ */
804
+ function isNonTechnical(issue) {
805
+ const body = (issue.body || "").trim();
806
+ if (body.length < 200 && !hasCodeBlock(body) && issue.reactions > 50) return true;
807
+ if (/\b(?:love|thank|awesome|great work)\b/i.test(issue.title) && !hasCodeBlock(body)) return true;
808
+ return false;
809
+ }
810
+ /**
811
+ * Freshness-weighted score: reactions * decay(age_in_years)
812
+ * Steep decay so recent issues dominate over old high-reaction ones.
813
+ * At 0.6: 1yr=0.63x, 2yr=0.45x, 4yr=0.29x, 6yr=0.22x
814
+ */
815
+ function freshnessScore(reactions, createdAt) {
816
+ return reactions * (1 / (1 + (Date.now() - new Date(createdAt).getTime()) / (365.25 * 24 * 60 * 60 * 1e3) * .6));
817
+ }
818
+ /**
819
+ * Type quotas — guarantee a mix of issue types.
820
+ * Bugs and questions get priority; feature requests are hard-capped.
821
+ */
822
+ function applyTypeQuotas(issues, limit) {
823
+ const byType = /* @__PURE__ */ new Map();
824
+ for (const issue of issues) mapInsert(byType, issue.type, () => []).push(issue);
825
+ for (const group of byType.values()) group.sort((a, b) => b.score - a.score);
826
+ const quotas = [
827
+ ["bug", Math.ceil(limit * .4)],
828
+ ["question", Math.ceil(limit * .3)],
829
+ ["docs", Math.ceil(limit * .15)],
830
+ ["feature", Math.ceil(limit * .1)],
831
+ ["other", Math.ceil(limit * .05)]
832
+ ];
833
+ const selected = [];
834
+ const used = /* @__PURE__ */ new Set();
835
+ let remaining = limit;
836
+ for (const [type, quota] of quotas) {
837
+ const group = byType.get(type) || [];
838
+ const take = Math.min(quota, group.length, remaining);
839
+ for (let i = 0; i < take; i++) {
840
+ selected.push(group[i]);
841
+ used.add(group[i].number);
842
+ remaining--;
843
+ }
844
+ }
845
+ if (remaining > 0) {
846
+ const unused = issues.filter((i) => !used.has(i.number) && i.type !== "feature").sort((a, b) => b.score - a.score);
847
+ for (const issue of unused) {
848
+ if (remaining <= 0) break;
849
+ selected.push(issue);
850
+ remaining--;
851
+ }
852
+ }
853
+ return selected.sort((a, b) => b.score - a.score);
854
+ }
855
+ /**
856
+ * Body truncation limit based on reactions — high-reaction issues deserve more space
857
+ */
858
+ function bodyLimit(reactions) {
859
+ if (reactions >= 10) return 2e3;
860
+ if (reactions >= 5) return 1500;
861
+ return 800;
862
+ }
863
+ /**
864
+ * Fetch issues for a state using GitHub Search API sorted by reactions
865
+ */
866
+ function fetchIssuesByState(owner, repo, state, count, releasedAt, fromDate) {
867
+ const fetchCount = Math.min(count * 3, 100);
868
+ let datePart = "";
869
+ if (fromDate) datePart = state === "closed" ? `+closed:>=${fromDate}` : `+created:>=${fromDate}`;
870
+ else if (state === "closed") if (releasedAt) {
871
+ const date = new Date(releasedAt);
872
+ date.setMonth(date.getMonth() + 6);
873
+ datePart = `+closed:<=${isoDate(date.toISOString())}`;
874
+ } else datePart = `+closed:>${oneYearAgo()}`;
875
+ else if (releasedAt) {
876
+ const date = new Date(releasedAt);
877
+ date.setMonth(date.getMonth() + 6);
878
+ datePart = `+created:<=${isoDate(date.toISOString())}`;
879
+ }
880
+ const { stdout: result } = spawnSync("gh", [
881
+ "api",
882
+ `search/issues?q=${`repo:${owner}/${repo}+is:issue+is:${state}${datePart}`}&sort=reactions&order=desc&per_page=${fetchCount}`,
883
+ "-q",
884
+ ".items[] | {number, title, state, labels: [.labels[]?.name], body, createdAt: .created_at, url: .html_url, reactions: .reactions[\"+1\"], comments: .comments, user: .user.login, userType: .user.type, authorAssociation: .author_association}"
885
+ ], {
886
+ encoding: "utf-8",
887
+ maxBuffer: 10 * 1024 * 1024
888
+ });
889
+ if (!result) return [];
890
+ return result.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)).filter((issue) => !BOT_USERS.has(issue.user) && issue.userType !== "Bot").filter((issue) => !isNoiseIssue(issue)).filter((issue) => !isNonTechnical(issue)).map(({ user: _, userType: __, authorAssociation, ...issue }) => {
891
+ const isMaintainer = [
892
+ "OWNER",
893
+ "MEMBER",
894
+ "COLLABORATOR"
895
+ ].includes(authorAssociation);
896
+ const isRoadmap = /\broadmap\b/i.test(issue.title) || issue.labels.some((l) => /roadmap/i.test(l));
897
+ return {
898
+ ...issue,
899
+ type: classifyIssue(issue.labels),
900
+ topComments: [],
901
+ score: freshnessScore(issue.reactions, issue.createdAt) * (isMaintainer && isRoadmap ? 5 : 1)
902
+ };
903
+ }).sort((a, b) => b.score - a.score).slice(0, count);
904
+ }
905
+ function oneYearAgo() {
906
+ const d = /* @__PURE__ */ new Date();
907
+ d.setFullYear(d.getFullYear() - 1);
908
+ return isoDate(d.toISOString());
909
+ }
910
+ /**
911
+ * Batch-fetch top comments for issues via GraphQL.
912
+ * Enriches the top N highest-score issues with their best comments.
913
+ * Prioritizes: comments with code blocks, from maintainers, with high reactions.
914
+ * Filters out "+1", "any updates?", "same here" noise.
915
+ */
916
+ function enrichWithComments(owner, repo, issues, topN = 15) {
917
+ const worth = issues.filter((i) => i.comments > 0 && (i.type === "bug" || i.type === "question" || i.reactions >= 3)).sort((a, b) => b.score - a.score).slice(0, topN);
918
+ if (worth.length === 0) return;
919
+ const query = `query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { ${worth.map((issue, i) => `i${i}: issue(number: ${issue.number}) { comments(first: 10) { nodes { body author { login } authorAssociation reactions { totalCount } } } }`).join(" ")} } }`;
920
+ try {
921
+ const { stdout: result } = spawnSync("gh", [
922
+ "api",
923
+ "graphql",
924
+ "-f",
925
+ `query=${query}`,
926
+ "-f",
927
+ `owner=${owner}`,
928
+ "-f",
929
+ `repo=${repo}`
930
+ ], {
931
+ encoding: "utf-8",
932
+ maxBuffer: 10 * 1024 * 1024
933
+ });
934
+ if (!result) return;
935
+ const repo_ = JSON.parse(result)?.data?.repository;
936
+ if (!repo_) return;
937
+ for (let i = 0; i < worth.length; i++) {
938
+ const nodes = repo_[`i${i}`]?.comments?.nodes;
939
+ if (!Array.isArray(nodes)) continue;
940
+ const issue = worth[i];
941
+ const comments = nodes.filter((c) => c.author && !BOT_USERS.has(c.author.login)).filter((c) => !COMMENT_NOISE_RE.test((c.body || "").trim())).map((c) => {
942
+ const isMaintainer = [
943
+ "OWNER",
944
+ "MEMBER",
945
+ "COLLABORATOR"
946
+ ].includes(c.authorAssociation);
947
+ const body = c.body || "";
948
+ const reactions = c.reactions?.totalCount || 0;
949
+ const _score = (isMaintainer ? 3 : 1) * (hasCodeBlock(body) ? 2 : 1) * (1 + reactions);
950
+ return {
951
+ body,
952
+ author: c.author.login,
953
+ reactions,
954
+ isMaintainer,
955
+ _score
956
+ };
957
+ }).sort((a, b) => b._score - a._score);
958
+ issue.topComments = comments.slice(0, 3).map(({ _score: _, ...c }) => c);
959
+ if (issue.state === "closed") issue.resolvedIn = detectResolvedVersion(comments);
960
+ }
961
+ } catch {}
962
+ }
963
+ /**
964
+ * Try to detect which version fixed a closed issue from maintainer comments.
965
+ * Looks for version patterns in maintainer/collaborator comments.
966
+ */
967
+ function detectResolvedVersion(comments) {
968
+ const maintainerComments = comments.filter((c) => c.isMaintainer);
969
+ for (const c of maintainerComments.reverse()) {
970
+ const match = c.body.match(/(?:fixed|landed|released|available|shipped|resolved|included)\s+in\s+v?(\d+\.\d+(?:\.\d+)?)/i);
971
+ if (match) return match[1];
972
+ if (c.body.length < 100) {
973
+ const vMatch = c.body.match(/\bv?(\d+\.\d+\.\d+)\b/);
974
+ if (vMatch) return vMatch[1];
975
+ }
976
+ }
977
+ }
892
978
  /**
893
- * Website crawl doc source fetches docs by crawling a URL pattern
979
+ * Fetch issues from a GitHub repo with freshness-weighted scoring and type quotas.
980
+ * Returns a balanced mix: bugs > questions > docs > other > features.
981
+ * Filters noise, non-technical content, and enriches with quality comments.
894
982
  */
983
+ async function fetchGitHubIssues(owner, repo, limit = 30, releasedAt, fromDate) {
984
+ if (!isGhAvailable()) return [];
985
+ const openCount = Math.ceil(limit * .75);
986
+ const closedCount = limit - openCount;
987
+ try {
988
+ const open = fetchIssuesByState(owner, repo, "open", Math.min(openCount * 2, 100), releasedAt, fromDate);
989
+ const closed = fetchIssuesByState(owner, repo, "closed", Math.min(closedCount * 2, 50), releasedAt, fromDate);
990
+ const selected = applyTypeQuotas([...open, ...closed], limit);
991
+ enrichWithComments(owner, repo, selected);
992
+ return selected;
993
+ } catch {
994
+ return [];
995
+ }
996
+ }
895
997
  /**
896
- * Crawl a URL pattern and return docs as cached doc format.
897
- * Uses HTTP crawler (no browser needed) with sitemap discovery + glob filtering.
898
- *
899
- * @param url - URL with optional glob pattern (e.g. 'https://example.com/docs/**')
900
- * @param onProgress - Optional progress callback
901
- * @param maxPages - Max pages to crawl (default 200)
998
+ * Format a single issue as markdown with YAML frontmatter
902
999
  */
903
- async function fetchCrawledDocs(url, onProgress, maxPages = 200) {
904
- const outputDir = join(tmpdir(), "skilld-crawl", Date.now().toString());
905
- onProgress?.(`Crawling ${url}`);
906
- const userLang = getUserLang();
907
- const foreignUrls = /* @__PURE__ */ new Set();
908
- const doCrawl = () => crawlAndGenerate({
909
- urls: [url],
910
- outputDir,
911
- driver: "http",
912
- generateLlmsTxt: false,
913
- generateIndividualMd: true,
914
- maxRequestsPerCrawl: maxPages,
915
- onPage: (page) => {
916
- const lang = extractHtmlLang(page.html);
917
- if (lang && !lang.startsWith("en") && !lang.startsWith(userLang)) foreignUrls.add(page.url);
918
- }
919
- }, (progress) => {
920
- if (progress.crawling.status === "processing" && progress.crawling.total > 0) onProgress?.(`Crawling ${progress.crawling.processed}/${progress.crawling.total} pages`);
921
- });
922
- let results = await doCrawl().catch((err) => {
923
- onProgress?.(`Crawl failed: ${err?.message || err}`);
924
- return [];
925
- });
926
- if (results.length === 0) {
927
- onProgress?.("Retrying crawl");
928
- results = await doCrawl().catch(() => []);
1000
+ function formatIssueAsMarkdown(issue) {
1001
+ const limit = bodyLimit(issue.reactions);
1002
+ const fmFields = {
1003
+ number: issue.number,
1004
+ title: issue.title,
1005
+ type: issue.type,
1006
+ state: issue.state,
1007
+ created: isoDate(issue.createdAt),
1008
+ url: issue.url,
1009
+ reactions: issue.reactions,
1010
+ comments: issue.comments
1011
+ };
1012
+ if (issue.resolvedIn) fmFields.resolvedIn = issue.resolvedIn;
1013
+ if (issue.labels.length > 0) fmFields.labels = `[${issue.labels.join(", ")}]`;
1014
+ const lines = [
1015
+ buildFrontmatter(fmFields),
1016
+ "",
1017
+ `# ${issue.title}`
1018
+ ];
1019
+ if (issue.body) {
1020
+ const body = truncateBody(issue.body, limit);
1021
+ lines.push("", body);
929
1022
  }
930
- rmSync(outputDir, {
931
- recursive: true,
932
- force: true
933
- });
934
- const docs = [];
935
- let localeFiltered = 0;
936
- for (const result of results) {
937
- if (!result.success || !result.content) continue;
938
- if (foreignUrls.has(result.url)) {
939
- localeFiltered++;
940
- continue;
941
- }
942
- const segments = (new URL(result.url).pathname.replace(/\/$/, "") || "/index").split("/").filter(Boolean);
943
- if (isForeignPathPrefix(segments[0], userLang)) {
944
- localeFiltered++;
945
- continue;
1023
+ if (issue.topComments.length > 0) {
1024
+ lines.push("", "---", "", "## Top Comments");
1025
+ for (const c of issue.topComments) {
1026
+ const reactions = c.reactions > 0 ? ` (+${c.reactions})` : "";
1027
+ const maintainer = c.isMaintainer ? " [maintainer]" : "";
1028
+ const commentBody = truncateBody(c.body, 600);
1029
+ lines.push("", `**@${c.author}**${maintainer}${reactions}:`, "", commentBody);
946
1030
  }
947
- const path = `docs/${segments.join("/")}.md`;
948
- docs.push({
949
- path,
950
- content: result.content
951
- });
952
1031
  }
953
- if (localeFiltered > 0) onProgress?.(`Filtered ${localeFiltered} foreign locale pages`);
954
- onProgress?.(`Crawled ${docs.length} pages`);
955
- return docs;
956
- }
957
- const HTML_LANG_RE = /<html[^>]*\slang=["']([^"']+)["']/i;
958
- /** Extract lang attribute from <html> tag */
959
- function extractHtmlLang(html) {
960
- return HTML_LANG_RE.exec(html)?.[1]?.toLowerCase();
961
- }
962
- /** Common ISO 639-1 locale codes for i18n'd doc sites */
963
- const LOCALE_CODES = new Set([
964
- "ar",
965
- "de",
966
- "es",
967
- "fr",
968
- "id",
969
- "it",
970
- "ja",
971
- "ko",
972
- "nl",
973
- "pl",
974
- "pt",
975
- "pt-br",
976
- "ru",
977
- "th",
978
- "tr",
979
- "uk",
980
- "vi",
981
- "zh",
982
- "zh-cn",
983
- "zh-tw"
984
- ]);
985
- /** Check if a URL path segment is a known locale prefix foreign to both English and user's locale */
986
- function isForeignPathPrefix(segment, userLang) {
987
- if (!segment) return false;
988
- const lower = segment.toLowerCase();
989
- if (lower === "en" || lower.startsWith(userLang)) return false;
990
- return LOCALE_CODES.has(lower);
991
- }
992
- /** Detect user's 2-letter language code from env (e.g. 'ja' from LANG=ja_JP.UTF-8) */
993
- function getUserLang() {
994
- const code = (process.env.LC_ALL || process.env.LANG || process.env.LANGUAGE || "").split(/[_.:-]/)[0]?.toLowerCase() || "";
995
- return code.length >= 2 ? code.slice(0, 2) : "en";
1032
+ return lines.join("\n");
996
1033
  }
997
- /** Append glob pattern to a docs URL for crawling */
998
- function toCrawlPattern(docsUrl) {
999
- return `${docsUrl.replace(/\/+$/, "")}/**`;
1034
+ /**
1035
+ * Generate a summary index of all issues for quick LLM scanning.
1036
+ * Groups by type so the LLM can quickly find bugs vs questions.
1037
+ */
1038
+ function generateIssueIndex(issues) {
1039
+ const byType = /* @__PURE__ */ new Map();
1040
+ for (const issue of issues) mapInsert(byType, issue.type, () => []).push(issue);
1041
+ const typeLabels = {
1042
+ bug: "Bugs & Regressions",
1043
+ question: "Questions & Usage Help",
1044
+ docs: "Documentation",
1045
+ feature: "Feature Requests",
1046
+ other: "Other"
1047
+ };
1048
+ const typeOrder = [
1049
+ "bug",
1050
+ "question",
1051
+ "docs",
1052
+ "other",
1053
+ "feature"
1054
+ ];
1055
+ const sections = [
1056
+ [
1057
+ "---",
1058
+ `total: ${issues.length}`,
1059
+ `open: ${issues.filter((i) => i.state === "open").length}`,
1060
+ `closed: ${issues.filter((i) => i.state !== "open").length}`,
1061
+ "---"
1062
+ ].join("\n"),
1063
+ "",
1064
+ "# Issues Index",
1065
+ ""
1066
+ ];
1067
+ for (const type of typeOrder) {
1068
+ const group = byType.get(type);
1069
+ if (!group?.length) continue;
1070
+ sections.push(`## ${typeLabels[type]} (${group.length})`, "");
1071
+ for (const issue of group) {
1072
+ const reactions = issue.reactions > 0 ? ` (+${issue.reactions})` : "";
1073
+ const state = issue.state === "open" ? "" : " [closed]";
1074
+ const resolved = issue.resolvedIn ? ` [fixed in ${issue.resolvedIn}]` : "";
1075
+ const date = isoDate(issue.createdAt);
1076
+ sections.push(`- [#${issue.number}](./issue-${issue.number}.md): ${issue.title}${reactions}${state}${resolved} (${date})`);
1077
+ }
1078
+ sections.push("");
1079
+ }
1080
+ return sections.join("\n");
1000
1081
  }
1001
1082
  //#endregion
1002
1083
  //#region src/sources/discussions.ts
@@ -1017,35 +1098,6 @@ const LOW_VALUE_CATEGORIES = new Set([
1017
1098
  "ideas",
1018
1099
  "polls"
1019
1100
  ]);
1020
- /** Noise patterns in comments — filter these out */
1021
- const COMMENT_NOISE_RE = /^(?:\+1|👍|same here|any update|bump|following|is there any progress|when will this|me too|i have the same|same issue|thanks|thank you)[\s!?.]*$/i;
1022
- /** Check if body contains a code block */
1023
- function hasCodeBlock(text) {
1024
- return /```[\s\S]*?```/.test(text) || /`[^`]+`/.test(text);
1025
- }
1026
- /**
1027
- * Smart body truncation — preserves code blocks and error messages.
1028
- * Instead of slicing at a char limit, finds a safe break point.
1029
- */
1030
- function truncateBody(body, limit) {
1031
- if (body.length <= limit) return body;
1032
- const codeBlockRe = /```[\s\S]*?```/g;
1033
- let lastSafeEnd = limit;
1034
- let match;
1035
- while ((match = codeBlockRe.exec(body)) !== null) {
1036
- const blockStart = match.index;
1037
- const blockEnd = blockStart + match[0].length;
1038
- if (blockStart < limit && blockEnd > limit) {
1039
- if (blockEnd <= limit + 500) lastSafeEnd = blockEnd;
1040
- else lastSafeEnd = blockStart;
1041
- break;
1042
- }
1043
- }
1044
- const slice = body.slice(0, lastSafeEnd);
1045
- const lastParagraph = slice.lastIndexOf("\n\n");
1046
- if (lastParagraph > lastSafeEnd * .6) return `${slice.slice(0, lastParagraph)}\n\n...`;
1047
- return `${slice}...`;
1048
- }
1049
1101
  /** Off-topic or spam title patterns — instant reject */
1050
1102
  const TITLE_NOISE_RE = /looking .*(?:developer|engineer|freelanc)|hiring|job post|guide me to (?:complete|finish|build)|help me (?:complete|finish|build)|seeking .* tutorial|recommend.* course/i;
1051
1103
  /** Minimum score for a discussion to be included */
@@ -1506,7 +1558,8 @@ async function downloadGitHubSkills(owner, repo, ref, skillPath, onProgress) {
1506
1558
  onProgress?.(`Downloading ${owner}/${repo}/${skillPath}@${ref}`);
1507
1559
  const { dir } = await downloadTemplate(`github:${owner}/${repo}/${skillPath}#${ref}`, {
1508
1560
  dir: tempDir,
1509
- force: true
1561
+ force: true,
1562
+ auth: getGitHubToken() || void 0
1510
1563
  });
1511
1564
  const skill = readLocalSkill(dir, skillPath);
1512
1565
  return skill ? [skill] : [];
@@ -1515,7 +1568,8 @@ async function downloadGitHubSkills(owner, repo, ref, skillPath, onProgress) {
1515
1568
  try {
1516
1569
  const { dir } = await downloadTemplate(`github:${owner}/${repo}/skills#${ref}`, {
1517
1570
  dir: tempDir,
1518
- force: true
1571
+ force: true,
1572
+ auth: getGitHubToken() || void 0
1519
1573
  });
1520
1574
  const skills = [];
1521
1575
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -1528,7 +1582,7 @@ async function downloadGitHubSkills(owner, repo, ref, skillPath, onProgress) {
1528
1582
  return skills;
1529
1583
  }
1530
1584
  } catch {}
1531
- const content = await $fetch(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/SKILL.md`, { responseType: "text" }).catch(() => null);
1585
+ const content = await fetchGitHubRaw(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/SKILL.md`);
1532
1586
  if (content) {
1533
1587
  const fm = parseSkillFrontmatterName(content);
1534
1588
  onProgress?.("Found 1 skill");
@@ -1697,10 +1751,20 @@ const MIN_GIT_DOCS = 5;
1697
1751
  /** True when git-docs exist but are too few to be useful (< MIN_GIT_DOCS) */
1698
1752
  const isShallowGitDocs = (n) => n > 0 && n < 5;
1699
1753
  /**
1700
- * List files at a git ref using ungh (no rate limits)
1754
+ * List files at a git ref. Tries ungh.cc first (fast, no rate limits),
1755
+ * falls back to GitHub API for private repos.
1701
1756
  */
1702
1757
  async function listFilesAtRef(owner, repo, ref) {
1703
- return (await $fetch(`https://ungh.cc/repos/${owner}/${repo}/files/${ref}`).catch(() => null))?.files?.map((f) => f.path) ?? [];
1758
+ if (!isKnownPrivateRepo(owner, repo)) {
1759
+ const data = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/files/${ref}`).catch(() => null);
1760
+ if (data?.files?.length) return data.files.map((f) => f.path);
1761
+ }
1762
+ const tree = await ghApi(`repos/${owner}/${repo}/git/trees/${ref}?recursive=1`);
1763
+ if (tree?.tree?.length) {
1764
+ markRepoPrivate(owner, repo);
1765
+ return tree.tree.map((f) => f.path);
1766
+ }
1767
+ return [];
1704
1768
  }
1705
1769
  /**
1706
1770
  * Find git tag for a version by checking if ungh can list files at that ref.
@@ -1738,13 +1802,29 @@ async function findGitTag(owner, repo, version, packageName, branchHint) {
1738
1802
  return null;
1739
1803
  }
1740
1804
  /**
1741
- * Find the latest release tag matching `{packageName}@*` via ungh releases API.
1742
- * Handles monorepos where npm version doesn't match git tag version.
1805
+ * Fetch releases from ungh.cc first, fall back to GitHub API for private repos.
1806
+ */
1807
+ async function fetchUnghReleases(owner, repo) {
1808
+ if (!isKnownPrivateRepo(owner, repo)) {
1809
+ const data = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`).catch(() => null);
1810
+ if (data?.releases?.length) return data.releases;
1811
+ }
1812
+ const raw = await ghApiPaginated(`repos/${owner}/${repo}/releases`);
1813
+ if (raw.length > 0) {
1814
+ markRepoPrivate(owner, repo);
1815
+ return raw.map((r) => ({
1816
+ tag: r.tag_name,
1817
+ publishedAt: r.published_at
1818
+ }));
1819
+ }
1820
+ return [];
1821
+ }
1822
+ /**
1823
+ * Find the latest release tag matching `{packageName}@*`.
1743
1824
  */
1744
1825
  async function findLatestReleaseTag(owner, repo, packageName) {
1745
- const data = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`).catch(() => null);
1746
1826
  const prefix = `${packageName}@`;
1747
- return data?.releases?.find((r) => r.tag.startsWith(prefix))?.tag ?? null;
1827
+ return (await fetchUnghReleases(owner, repo)).find((r) => r.tag.startsWith(prefix))?.tag ?? null;
1748
1828
  }
1749
1829
  /**
1750
1830
  * Filter file paths by prefix and md/mdx extension
@@ -1994,7 +2074,7 @@ async function verifyNpmRepo(owner, repo, packageName) {
1994
2074
  `packages/${packageName.replace(/^@/, "").replace("/", "-")}/package.json`
1995
2075
  ];
1996
2076
  for (const path of paths) {
1997
- const text = await fetchText(`${base}/${path}`);
2077
+ const text = await fetchGitHubRaw(`${base}/${path}`);
1998
2078
  if (!text) continue;
1999
2079
  try {
2000
2080
  if (JSON.parse(text).name === packageName) return true;
@@ -2051,38 +2131,35 @@ async function searchGitHubRepo(packageName) {
2051
2131
  async function fetchGitHubRepoMeta(owner, repo, packageName) {
2052
2132
  const override = packageName ? getDocOverride(packageName) : void 0;
2053
2133
  if (override?.homepage) return { homepage: override.homepage };
2054
- if (isGhAvailable()) try {
2055
- const { stdout: json } = spawnSync("gh", [
2056
- "api",
2057
- `repos/${owner}/${repo}`,
2058
- "-q",
2059
- "{homepage}"
2060
- ], {
2061
- encoding: "utf-8",
2062
- timeout: 1e4
2063
- });
2064
- if (!json) throw new Error("no output");
2065
- const data = JSON.parse(json);
2066
- return data?.homepage ? { homepage: data.homepage } : null;
2067
- } catch {}
2068
- const data = await $fetch(`https://api.github.com/repos/${owner}/${repo}`).catch(() => null);
2134
+ const data = await ghApi(`repos/${owner}/${repo}`) ?? await $fetch(`https://api.github.com/repos/${owner}/${repo}`).catch(() => null);
2069
2135
  return data?.homepage ? { homepage: data.homepage } : null;
2070
2136
  }
2071
2137
  /**
2072
2138
  * Resolve README URL for a GitHub repo, returns ungh:// pseudo-URL or raw URL
2073
2139
  */
2074
2140
  async function fetchReadme(owner, repo, subdir, ref) {
2075
- const unghUrl = subdir ? `https://ungh.cc/repos/${owner}/${repo}/files/${ref || "main"}/${subdir}/README.md` : `https://ungh.cc/repos/${owner}/${repo}/readme${ref ? `?ref=${ref}` : ""}`;
2076
- if ((await $fetch.raw(unghUrl).catch(() => null))?.ok) return `ungh://${owner}/${repo}${subdir ? `/${subdir}` : ""}${ref ? `@${ref}` : ""}`;
2141
+ const branch = ref || "main";
2142
+ if (!isKnownPrivateRepo(owner, repo)) {
2143
+ const unghUrl = subdir ? `https://ungh.cc/repos/${owner}/${repo}/files/${branch}/${subdir}/README.md` : `https://ungh.cc/repos/${owner}/${repo}/readme${ref ? `?ref=${ref}` : ""}`;
2144
+ if ((await $fetch.raw(unghUrl).catch(() => null))?.ok) return `ungh://${owner}/${repo}${subdir ? `/${subdir}` : ""}${ref ? `@${ref}` : ""}`;
2145
+ }
2077
2146
  const basePath = subdir ? `${subdir}/` : "";
2078
2147
  const branches = ref ? [ref] : ["main", "master"];
2148
+ const token = isKnownPrivateRepo(owner, repo) ? getGitHubToken() : null;
2149
+ const authHeaders = token ? { Authorization: `token ${token}` } : {};
2079
2150
  for (const b of branches) for (const filename of [
2080
2151
  "README.md",
2081
2152
  "Readme.md",
2082
2153
  "readme.md"
2083
2154
  ]) {
2084
2155
  const readmeUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${b}/${basePath}${filename}`;
2085
- if ((await $fetch.raw(readmeUrl).catch(() => null))?.ok) return readmeUrl;
2156
+ if ((await $fetch.raw(readmeUrl, { headers: authHeaders }).catch(() => null))?.ok) return readmeUrl;
2157
+ }
2158
+ const refParam = ref ? `?ref=${ref}` : "";
2159
+ const apiData = await ghApi(subdir ? `repos/${owner}/${repo}/contents/${subdir}/README.md${refParam}` : `repos/${owner}/${repo}/readme${refParam}`);
2160
+ if (apiData?.download_url) {
2161
+ markRepoPrivate(owner, repo);
2162
+ return apiData.download_url;
2086
2163
  }
2087
2164
  return null;
2088
2165
  }
@@ -2116,6 +2193,7 @@ async function fetchReadmeContent(url) {
2116
2193
  return text;
2117
2194
  }
2118
2195
  }
2196
+ if (url.includes("raw.githubusercontent.com")) return fetchGitHubRaw(url);
2119
2197
  return fetchText(url);
2120
2198
  }
2121
2199
  /**
@@ -2125,34 +2203,14 @@ async function fetchReadmeContent(url) {
2125
2203
  async function resolveGitHubRepo(owner, repo, onProgress) {
2126
2204
  onProgress?.("Fetching repo metadata");
2127
2205
  const repoUrl = `https://github.com/${owner}/${repo}`;
2128
- let homepage;
2129
- let description;
2130
- if (isGhAvailable()) try {
2131
- const { stdout: json } = spawnSync("gh", [
2132
- "api",
2133
- `repos/${owner}/${repo}`,
2134
- "--jq",
2135
- "{homepage: .homepage, description: .description}"
2136
- ], {
2137
- encoding: "utf-8",
2138
- timeout: 1e4
2139
- });
2140
- if (json) {
2141
- const data = JSON.parse(json);
2142
- homepage = data.homepage || void 0;
2143
- description = data.description || void 0;
2144
- }
2145
- } catch {}
2146
- if (!homepage && !description) {
2147
- const data = await $fetch(`https://api.github.com/repos/${owner}/${repo}`).catch(() => null);
2148
- homepage = data?.homepage || void 0;
2149
- description = data?.description || void 0;
2150
- }
2206
+ const meta = await ghApi(`repos/${owner}/${repo}`) ?? await $fetch(`https://api.github.com/repos/${owner}/${repo}`).catch(() => null);
2207
+ const homepage = meta?.homepage || void 0;
2208
+ const description = meta?.description || void 0;
2151
2209
  onProgress?.("Fetching latest release");
2152
- const releasesData = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`).catch(() => null);
2210
+ const releases = await fetchUnghReleases(owner, repo);
2153
2211
  let version = "main";
2154
2212
  let releasedAt;
2155
- const latestRelease = releasesData?.releases?.[0];
2213
+ const latestRelease = releases[0];
2156
2214
  if (latestRelease) {
2157
2215
  version = latestRelease.tag.replace(/^v/, "");
2158
2216
  releasedAt = latestRelease.publishedAt;
@@ -2458,7 +2516,7 @@ function parseVersionSpecifier(name, version, cwd) {
2458
2516
  };
2459
2517
  if (/^[\^~>=<\d]/.test(version)) return {
2460
2518
  name,
2461
- version: version.replace(/^[\^~>=<]/, "")
2519
+ version: version.replace(/^[\^~>=<]+/, "")
2462
2520
  };
2463
2521
  if (version.startsWith("catalog:") || version.startsWith("workspace:")) return {
2464
2522
  name,
@@ -2581,9 +2639,10 @@ async function fetchPkgDist(name, version) {
2581
2639
  } });
2582
2640
  writable.on("finish", () => {
2583
2641
  fileStream.end();
2584
- res();
2585
2642
  });
2643
+ fileStream.on("close", () => res());
2586
2644
  writable.on("error", reject);
2645
+ fileStream.on("error", reject);
2587
2646
  function pump() {
2588
2647
  reader.read().then(({ done, value }) => {
2589
2648
  if (done) {
@@ -2628,6 +2687,6 @@ function getInstalledSkillVersion(skillDir) {
2628
2687
  return readFileSync(skillPath, "utf-8").match(/^version:\s*"?([^"\n]+)"?/m)?.[1] || null;
2629
2688
  }
2630
2689
  //#endregion
2631
- export { fetchGitHubIssues as $, parseGitSkillInput as A, compareSemver as B, downloadLlmsDocs as C, normalizeLlmsLinks as D, fetchLlmsUrl as E, formatDiscussionAsMarkdown as F, $fetch as G, generateReleaseIndex as H, generateDiscussionIndex as I, isGitHubRepoUrl as J, extractBranchHint as K, fetchCrawledDocs as L, resolveEntryFiles as M, generateDocsIndex as N, parseMarkdownLinks as O, fetchGitHubDiscussions as P, verifyUrl as Q, toCrawlPattern as R, validateGitDocsWithLlms as S, fetchLlmsTxt as T, isPrerelease as U, fetchReleaseNotes as V, parseSemver as W, parseGitHubUrl as X, normalizeRepoUrl as Y, parsePackageSpec as Z, fetchReadme as _, getInstalledSkillVersion as a, isShallowGitDocs as b, readLocalPackageInfo as c, resolvePackageDocs as d, formatIssueAsMarkdown as et, resolvePackageDocsWithAttempts as f, fetchGitHubRepoMeta as g, fetchGitDocs as h, fetchPkgDist as i, parseSkillFrontmatterName as j, fetchGitSkills as k, resolveInstalledVersion as l, MIN_GIT_DOCS as m, fetchNpmPackage as n, isGhAvailable as nt, parseVersionSpecifier as o, searchNpmPackages as p, fetchText as q, fetchNpmRegistryMeta as r, readLocalDependencies as s, fetchLatestVersion as t, generateIssueIndex as tt, resolveLocalPackageDocs as u, fetchReadmeContent as v, extractSections as w, resolveGitHubRepo as x, filterFrameworkDocs as y, fetchBlogReleases as z };
2690
+ export { isGitHubRepoUrl as $, parseGitSkillInput as A, isGhAvailable as B, downloadLlmsDocs as C, normalizeLlmsLinks as D, fetchLlmsUrl as E, formatDiscussionAsMarkdown as F, fetchReleaseNotes as G, toCrawlPattern as H, generateDiscussionIndex as I, parseSemver as J, generateReleaseIndex as K, fetchGitHubIssues as L, resolveEntryFiles as M, generateDocsIndex as N, parseMarkdownLinks as O, fetchGitHubDiscussions as P, fetchText as Q, formatIssueAsMarkdown as R, validateGitDocsWithLlms as S, fetchLlmsTxt as T, fetchBlogReleases as U, fetchCrawledDocs as V, compareSemver as W, extractBranchHint as X, $fetch as Y, fetchGitHubRaw as Z, fetchReadme as _, getInstalledSkillVersion as a, isShallowGitDocs as b, readLocalPackageInfo as c, resolvePackageDocs as d, normalizeRepoUrl as et, resolvePackageDocsWithAttempts as f, fetchGitHubRepoMeta as g, fetchGitDocs as h, fetchPkgDist as i, parseSkillFrontmatterName as j, fetchGitSkills as k, resolveInstalledVersion as l, MIN_GIT_DOCS as m, fetchNpmPackage as n, parsePackageSpec as nt, parseVersionSpecifier as o, searchNpmPackages as p, isPrerelease as q, fetchNpmRegistryMeta as r, verifyUrl as rt, readLocalDependencies as s, fetchLatestVersion as t, parseGitHubUrl as tt, resolveLocalPackageDocs as u, fetchReadmeContent as v, extractSections as w, resolveGitHubRepo as x, filterFrameworkDocs as y, generateIssueIndex as z };
2632
2691
 
2633
2692
  //# sourceMappingURL=sources.mjs.map