@seojin08/skill-finder 0.0.0-stage → 0.3.2

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.
Files changed (82) hide show
  1. package/.env.example +3 -0
  2. package/CHANGELOG.md +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +86 -2
  5. package/bin/skill-finder.mjs +389 -0
  6. package/package.json +39 -4
  7. package/server/src/analyze/frontmatter.js +130 -0
  8. package/server/src/analyze/quality.js +34 -0
  9. package/server/src/analyze/risk.js +123 -0
  10. package/server/src/app.js +57 -0
  11. package/server/src/bootstrap.js +46 -0
  12. package/server/src/claude/cli.js +89 -0
  13. package/server/src/config.js +70 -0
  14. package/server/src/context.js +1 -0
  15. package/server/src/db/index.js +128 -0
  16. package/server/src/db/schema.js +95 -0
  17. package/server/src/db/store.js +353 -0
  18. package/server/src/discovery/classifyRepo.js +81 -0
  19. package/server/src/discovery/genericRules.js +180 -0
  20. package/server/src/discovery/pluginRules.js +59 -0
  21. package/server/src/discovery/tree.js +79 -0
  22. package/server/src/index.js +55 -0
  23. package/server/src/indexer.js +502 -0
  24. package/server/src/install/installed.js +206 -0
  25. package/server/src/install/plan.js +118 -0
  26. package/server/src/install/run.js +97 -0
  27. package/server/src/install/scan.js +70 -0
  28. package/server/src/install/summary.js +14 -0
  29. package/server/src/install/uninstall.js +59 -0
  30. package/server/src/install/validate.js +24 -0
  31. package/server/src/jobs.js +107 -0
  32. package/server/src/recommend/readme.js +508 -0
  33. package/server/src/release.js +41 -0
  34. package/server/src/routes/admin.js +203 -0
  35. package/server/src/routes/favorites.js +61 -0
  36. package/server/src/routes/health.js +27 -0
  37. package/server/src/routes/install.js +283 -0
  38. package/server/src/routes/presets.js +66 -0
  39. package/server/src/routes/recommend.js +36 -0
  40. package/server/src/routes/search.js +53 -0
  41. package/server/src/routes/skills.js +214 -0
  42. package/server/src/search/activity.js +13 -0
  43. package/server/src/search/category.js +109 -0
  44. package/server/src/search/koreanTerms.js +284 -0
  45. package/server/src/search/pipeline.js +975 -0
  46. package/server/src/search/rank.js +138 -0
  47. package/server/src/search/result.js +129 -0
  48. package/server/src/search/searchLog.js +53 -0
  49. package/server/src/security.js +53 -0
  50. package/server/src/settings.js +56 -0
  51. package/server/src/sources/gitTree.js +101 -0
  52. package/server/src/sources/github.js +503 -0
  53. package/server/src/sources/marketplace.js +78 -0
  54. package/server/src/sources/pluginSource.js +98 -0
  55. package/server/src/sources/popularRepos.js +74 -0
  56. package/server/src/sources/skillsSh.js +74 -0
  57. package/server/src/tasks.js +216 -0
  58. package/server/src/update.js +53 -0
  59. package/server/src/util/folderPicker.js +208 -0
  60. package/server/src/util/http.js +41 -0
  61. package/server/src/util/limiter.js +110 -0
  62. package/server/src/util/log.js +94 -0
  63. package/server/src/util/ndjson.js +27 -0
  64. package/server/src/util/proc.js +80 -0
  65. package/server/src/util/resolveFolder.js +78 -0
  66. package/server/src/util/sanitize.js +45 -0
  67. package/server/src/util/sizedCache.js +74 -0
  68. package/server/src/util/textFiles.js +9 -0
  69. package/server/src/util/tokens.js +21 -0
  70. package/server/src/version.js +12 -0
  71. package/web/dist/LICENSE.txt +21 -0
  72. package/web/dist/THIRD-PARTY-LICENSES.txt +2267 -0
  73. package/web/dist/assets/ArasakaGame-B0V_LZLl.css +1 -0
  74. package/web/dist/assets/ArasakaGame-C1Wkmqik.js +36 -0
  75. package/web/dist/assets/index-BgL1OaU4.css +2 -0
  76. package/web/dist/assets/index-Ec2XBK4-.js +66 -0
  77. package/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  78. package/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  79. package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  80. package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  81. package/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  82. package/web/dist/index.html +15 -0
@@ -0,0 +1,107 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ class CancelledError extends Error {
3
+ }
4
+ export class Jobs {
5
+ entries = new Map();
6
+ start(kind, label, fn) {
7
+ const job = {
8
+ id: randomUUID(),
9
+ kind,
10
+ label,
11
+ status: 'running',
12
+ done: 0,
13
+ total: 0,
14
+ errors: [],
15
+ skipped: [],
16
+ startedAt: new Date().toISOString(),
17
+ finishedAt: null,
18
+ };
19
+ const entry = { job, paused: false, cancelled: false, waiters: [] };
20
+ this.entries.set(job.id, entry);
21
+ const ctl = {
22
+ checkpoint: async () => {
23
+ while (entry.paused && !entry.cancelled)
24
+ await new Promise((r) => entry.waiters.push(r));
25
+ if (entry.cancelled)
26
+ throw new CancelledError('cancelled');
27
+ },
28
+ get cancelled() {
29
+ return entry.cancelled;
30
+ },
31
+ setTotal: (n) => {
32
+ job.total = n;
33
+ },
34
+ addTotal: (n) => {
35
+ job.total += n;
36
+ },
37
+ tick: (error) => {
38
+ job.done++;
39
+ if (error && job.errors.length < 200)
40
+ job.errors.push(error);
41
+ },
42
+ skip: (note) => {
43
+ job.done++;
44
+ if (job.skipped.length < 200)
45
+ job.skipped.push(note);
46
+ },
47
+ };
48
+ fn(ctl)
49
+ .then(() => {
50
+ job.status = entry.cancelled ? 'cancelled' : 'done';
51
+ })
52
+ .catch((err) => {
53
+ if (err instanceof CancelledError || entry.cancelled)
54
+ job.status = 'cancelled';
55
+ else {
56
+ job.status = 'error';
57
+ job.errors.push(String(err.message ?? err));
58
+ }
59
+ })
60
+ .finally(() => {
61
+ job.finishedAt = new Date().toISOString();
62
+ this.prune();
63
+ });
64
+ return job;
65
+ }
66
+ get(id) {
67
+ return this.entries.get(id)?.job;
68
+ }
69
+ list() {
70
+ return [...this.entries.values()].map((e) => e.job).reverse();
71
+ }
72
+ active(kind, label) {
73
+ return this.list().find((j) => j.kind === kind && j.label === label && (j.status === 'running' || j.status === 'paused'));
74
+ }
75
+ pause(id) {
76
+ const e = this.entries.get(id);
77
+ if (!e || e.job.status !== 'running')
78
+ return false;
79
+ e.paused = true;
80
+ e.job.status = 'paused';
81
+ return true;
82
+ }
83
+ resume(id) {
84
+ const e = this.entries.get(id);
85
+ if (!e || e.job.status !== 'paused')
86
+ return false;
87
+ e.paused = false;
88
+ e.job.status = 'running';
89
+ for (const w of e.waiters.splice(0))
90
+ w();
91
+ return true;
92
+ }
93
+ cancel(id) {
94
+ const e = this.entries.get(id);
95
+ if (!e || (e.job.status !== 'running' && e.job.status !== 'paused'))
96
+ return false;
97
+ e.cancelled = true;
98
+ for (const w of e.waiters.splice(0))
99
+ w();
100
+ return true;
101
+ }
102
+ prune() {
103
+ const finished = [...this.entries.values()].filter((e) => e.job.finishedAt);
104
+ for (const e of finished.slice(0, Math.max(0, finished.length - 20)))
105
+ this.entries.delete(e.job.id);
106
+ }
107
+ }
@@ -0,0 +1,508 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { basename, isAbsolute, join } from 'node:path';
3
+ import { koreanTerm } from "../search/koreanTerms.js";
4
+ import { freshInstalls, freshness, matchTokens, popularity, stem, totalScore, TRUST_SCORE, trustTier } from "../search/rank.js";
5
+ import { buildResultItem, githubSource, marketplaceSource, storeLookup } from "../search/result.js";
6
+ /**
7
+ * README-based skill recommendation, no AI: read the project's root README, pull out its structure,
8
+ * weight its words by where they appear, and match them against the local skill index.
9
+ * Same input + same index → same result.
10
+ */
11
+ export const CONFIG = {
12
+ maxReadmeBytes: 1024 * 1024,
13
+ minParagraphChars: 20,
14
+ maxFeatures: 5,
15
+ maxQueryTerms: 40,
16
+ weights: { title: 5, intro: 3, feature: 3, heading: 3, body: 1, lang: 1 },
17
+ nameBoost: 2,
18
+ /** A skill counts as similar when its score is at least this share of the best match… */
19
+ similarFloor: 0.35,
20
+ /** …and it shares at least this many words with the README (fewer only if the best match does). */
21
+ minSharedWords: 2,
22
+ /** FTS hits re-scored per request. */
23
+ poolSize: 300,
24
+ maxResults: 50,
25
+ };
26
+ const STATUS = { INVALID_PATH: 400, README_NOT_FOUND: 404, README_TOO_LARGE: 413 };
27
+ export class ReadmeError extends Error {
28
+ code;
29
+ status;
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.code = code;
33
+ this.status = STATUS[code];
34
+ }
35
+ }
36
+ // English stopwords, skill-description filler, and README boilerplate (install/usage/license sections).
37
+ const STOP = new Set(`a an the and or but if then else of to in on at by for with from into onto over as is are was were be been being
38
+ it its this that these those there here such any all each every can could should would will may might must do does
39
+ did done not no nor so than too very just also you your we our they their them he she his her i me my mine who whom
40
+ which what when where why how use used uses using user users skill skills claude whenever want wants trigger triggers
41
+ task tasks etc via about out up down more most other some only own same new like make makes made get gets file files
42
+ include includes including example examples see install installation installing usage license licensed mit apache
43
+ contributing contribute contributors getting started quick quickstart npm yarn pnpm bun npx run running requirements
44
+ prerequisites copyright table contents toc badge badges readme click here link links please thanks changelog
45
+ feature features overview introduction intro about faq support through within without`.split(/\s+/));
46
+ /** Code-block languages of install/run snippets: every README has them, they say nothing about the project. */
47
+ const SHELL_LANGS = new Set('bash sh shell zsh fish console terminal cmd bat powershell ps ps1 pwsh shellsession sh-session text txt plaintext output log diff'.split(' '));
48
+ /** Established names that are written in camelCase but are one word ("TypeScript" is not about "type"/"script"). */
49
+ const COMPOUND_NAMES = new Set(`javascript typescript github gitlab bitbucket postgresql mysql mongodb graphql nodejs nextjs nuxtjs webpack youtube
50
+ linkedin openai chatgpt powerpoint wordpress macos ios ipados iphone ipad vscode devops fastapi pytorch tensorflow
51
+ numpy jupyter dynamodb firebase supabase tailwindcss sveltekit sqlite mariadb redis elasticsearch kubernetes
52
+ cloudflare netlify vercel figma oauth jsonschema openapi markdown`.split(/\s+/));
53
+ /** camelCase pieces count half: they help (PptxMaker → pptx) but are often noise. */
54
+ const PART_FACTOR = 0.5;
55
+ const SPLIT = /[\n\r\p{Z}\p{P}\p{S}]+/u;
56
+ /** Each token with its weight factor: the whole token 1, camelCase pieces {@link PART_FACTOR}. */
57
+ function weightedTokens(text) {
58
+ return text
59
+ .split(SPLIT)
60
+ .filter(Boolean)
61
+ .flatMap((tok) => {
62
+ if (COMPOUND_NAMES.has(tok.toLowerCase()))
63
+ return [[tok, 1]];
64
+ const parts = tok
65
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
66
+ .replace(/([A-Z])(?=[A-Z][a-z])/g, '$1 ') // XMLParser → XML Parser (a lookahead: `[A-Z]+` rescanned every capital)
67
+ .split(' ');
68
+ return parts.length > 1 ? [[tok, 1], ...parts.map((p) => [p, PART_FACTOR])] : [[tok, 1]];
69
+ });
70
+ }
71
+ /** Splits on spaces/punctuation; camelCase tokens keep the whole word and its parts (PptxMaker → PptxMaker, Pptx, Maker). */
72
+ export function tokenize(text) {
73
+ return weightedTokens(text).map(([t]) => t);
74
+ }
75
+ /** One raw token → lowercase English word(s), or null (short, number, stopword, or non-English without an alias). */
76
+ export function processTerm(raw) {
77
+ const t = raw.toLowerCase();
78
+ if (t.length < 2 || STOP.has(t) || /^\d+$/.test(t))
79
+ return null;
80
+ const alias = koreanTerm(t, true);
81
+ if (alias)
82
+ return alias;
83
+ // the skill index is English; other scripts cannot match it
84
+ return /^[a-z][a-z0-9]*$/.test(t) ? [t] : null;
85
+ }
86
+ export function findReadme(projectPath) {
87
+ if (typeof projectPath !== 'string' || !isAbsolute(projectPath))
88
+ throw new ReadmeError('INVALID_PATH', '절대 경로를 입력해야 합니다.');
89
+ let isDir = false;
90
+ try {
91
+ isDir = statSync(projectPath).isDirectory();
92
+ }
93
+ catch {
94
+ // missing path
95
+ }
96
+ if (!isDir)
97
+ throw new ReadmeError('INVALID_PATH', '존재하는 폴더가 아닙니다.');
98
+ const name = readdirSync(projectPath, { withFileTypes: true }).find((e) => e.isFile() && /^readme(\.(md|markdown|txt))?$/i.test(e.name))?.name;
99
+ if (!name)
100
+ throw new ReadmeError('README_NOT_FOUND', '폴더 바로 아래에 README가 없습니다.');
101
+ const file = join(projectPath, name);
102
+ if (statSync(file).size > CONFIG.maxReadmeBytes)
103
+ throw new ReadmeError('README_TOO_LARGE', 'README가 1MB를 넘습니다.');
104
+ return file;
105
+ }
106
+ const TAG = /<[^<>]*>/g;
107
+ // the closing #s are trimmed by atxText: `(.*?)\s*#*\s*$` backtracked over long runs of spaces and #s
108
+ const ATX = /^\s{0,3}(#{1,6})\s+(.*)$/;
109
+ /** An ATX heading's text without its closing #s ("## Setup ##" → "Setup"). */
110
+ function atxText(s) {
111
+ let end = s.trimEnd().length;
112
+ while (end > 0 && s[end - 1] === '#')
113
+ end--;
114
+ return s.slice(0, end).trimEnd();
115
+ }
116
+ /**
117
+ * Drops `<!-- … -->` comments; an unclosed one stays, as with the regex this replaces. A regex had to rescan
118
+ * the rest of the file from every unclosed "<!--" (quadratic); indexOf moves forward only.
119
+ */
120
+ function stripHtmlComments(s) {
121
+ let out = '';
122
+ let from = 0;
123
+ for (;;) {
124
+ const open = s.indexOf('<!--', from);
125
+ const close = open < 0 ? -1 : s.indexOf('-->', open + 4);
126
+ if (close < 0)
127
+ return out + s.slice(from);
128
+ out += s.slice(from, open);
129
+ from = close + 3;
130
+ }
131
+ }
132
+ /**
133
+ * Inline markdown → plain text: images (badges) and inline HTML dropped, links reduced to their text.
134
+ * Bracket and tag contents exclude their own opening character, so a match that fails stops at the next
135
+ * opener: with `[^\]]*` a README of "[" or "<" repeated (no closers) took quadratic time. A link target may
136
+ * hold one level of parentheses (`wiki/Foo_(bar)`, `a_(1).svg`), each pair scanned once.
137
+ */
138
+ function inlineText(s) {
139
+ return s
140
+ .replace(/\[!\[[^[\]]*\]\((?:[^()]|\([^()]*\))*\)\]\((?:[^()]|\([^()]*\))*\)/g, ' ') // linked badge
141
+ .replace(/!\[[^[\]]*\]\((?:[^()]|\([^()]*\))*\)/g, ' ') // image
142
+ .replace(/!\[[^[\]]*\]\[[^[\]]*\]/g, ' ') // reference image
143
+ .replace(/\[([^[\]]*)\]\((?:[^()]|\([^()]*\))*\)/g, '$1') // link
144
+ .replace(/\[([^[\]]*)\]\[[^[\]]*\]/g, '$1') // reference link
145
+ .replace(TAG, ' ')
146
+ .replace(/\b(?:https?:\/\/|www\.)\S+/gi, ' ') // bare URLs ("https", "github", "com" are not topics)
147
+ .replace(/&[a-z]+;|&#\d+;/gi, ' ')
148
+ .replace(/`([^`]*)`/g, '$1')
149
+ .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, '$1')
150
+ .replace(/\s+/g, ' ')
151
+ .trim();
152
+ }
153
+ /**
154
+ * Title (first H1, else the folder name), intro (first paragraph of 20+ chars), features (first list under a
155
+ * "feature/기능/특징" heading, up to 5), other headings, body text and code-block languages. Code itself is
156
+ * dropped: README code and badges otherwise dominate any word statistics.
157
+ */
158
+ export function extractReadme(md, fallbackTitle) {
159
+ const out = { title: '', intro: '', features: [], headings: [], body: [], langs: [] };
160
+ // HTML comments are maintainer notes, not content (one became the "intro" of react-markdown's README).
161
+ const lines = stripHtmlComments(md.replace(/\r\n?/g, '\n')).split('\n');
162
+ let section = '';
163
+ let para = [];
164
+ let list = [];
165
+ const addText = (raw, html = false) => {
166
+ const text = html ? inlineText(raw.replace(TAG, ' ')) : inlineText(raw);
167
+ if (text.length < CONFIG.minParagraphChars)
168
+ return; // badge rows and short lines
169
+ if (!out.intro)
170
+ out.intro = text;
171
+ else
172
+ out.body.push(text);
173
+ };
174
+ const flushPara = () => {
175
+ if (para.length)
176
+ addText(para.join(' '), para[0].trimStart().startsWith('<'));
177
+ para = [];
178
+ };
179
+ const flushList = () => {
180
+ const items = list.map(inlineText).filter(Boolean);
181
+ if (items.length) {
182
+ if (/feature|기능|특징/i.test(section) && out.features.length === 0)
183
+ out.features = items.slice(0, CONFIG.maxFeatures);
184
+ else
185
+ out.body.push(...items);
186
+ }
187
+ list = [];
188
+ };
189
+ const heading = (depth, raw) => {
190
+ flushPara();
191
+ flushList();
192
+ section = inlineText(raw);
193
+ if (!section)
194
+ return;
195
+ if (depth === 1 && !out.title)
196
+ out.title = section;
197
+ else
198
+ out.headings.push(section);
199
+ };
200
+ for (let i = 0; i < lines.length; i++) {
201
+ const line = lines[i];
202
+ const fence = line.match(/^\s{0,3}(`{3,}|~{3,})\s*([\w+#.-]*)/);
203
+ if (fence) {
204
+ flushPara();
205
+ flushList();
206
+ const lang = fence[2].toLowerCase();
207
+ if (lang && !SHELL_LANGS.has(lang))
208
+ out.langs.push(lang);
209
+ while (++i < lines.length && !lines[i].trimStart().startsWith(fence[1])) {
210
+ // code is not read
211
+ }
212
+ continue;
213
+ }
214
+ const atx = line.match(ATX);
215
+ if (atx) {
216
+ heading(atx[1].length, atxText(atx[2]));
217
+ continue;
218
+ }
219
+ const next = lines[i + 1] ?? '';
220
+ if (line.trim() && !list.length && /^\s{0,3}(=+|-+)\s*$/.test(next) && !/^\s*([-*+]|\d+[.)])\s/.test(line)) {
221
+ heading(next.trim().startsWith('=') ? 1 : 2, line); // setext heading
222
+ i++;
223
+ continue;
224
+ }
225
+ const item = line.match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/);
226
+ if (item) {
227
+ flushPara();
228
+ list.push(item[1].replace(/^\[[ xX]\]\s*/, ''));
229
+ continue;
230
+ }
231
+ if (!line.trim()) {
232
+ flushPara();
233
+ flushList();
234
+ continue;
235
+ }
236
+ if (list.length && /^\s{2,}\S/.test(line)) {
237
+ list[list.length - 1] += ` ${line.trim()}`; // continuation of a list item
238
+ continue;
239
+ }
240
+ if (/^\s*\|/.test(line))
241
+ continue; // tables
242
+ if (/^\s{0,3}\[[^\]]+\]:\s*\S/.test(line))
243
+ continue; // link reference definitions (badge URL lists)
244
+ flushList();
245
+ para.push(line.replace(/^\s*>\s?/, ''));
246
+ }
247
+ flushPara();
248
+ flushList();
249
+ if (!out.title)
250
+ out.title = fallbackTitle;
251
+ return out;
252
+ }
253
+ /**
254
+ * Word weights: title 5, intro/features/headings 3, body/code languages 1, summed per word; top 40.
255
+ * Words are grouped by stem (`testing`/`tests` → `test`); `term` is the most weighted surface form.
256
+ */
257
+ export function termWeights(info) {
258
+ const byKey = new Map();
259
+ const add = (text, k) => {
260
+ for (const [raw, factor] of weightedTokens(text)) {
261
+ for (const term of processTerm(raw) ?? []) {
262
+ const key = stem(term);
263
+ const e = byKey.get(key) ?? { weight: 0, forms: new Map() };
264
+ e.weight += k * factor;
265
+ e.forms.set(term, (e.forms.get(term) ?? 0) + k * factor);
266
+ byKey.set(key, e);
267
+ }
268
+ }
269
+ };
270
+ const w = CONFIG.weights;
271
+ add(info.title, w.title);
272
+ add(info.intro, w.intro);
273
+ info.features.forEach((s) => add(s, w.feature));
274
+ info.headings.forEach((s) => add(s, w.heading));
275
+ info.body.forEach((s) => add(s, w.body));
276
+ info.langs.forEach((s) => add(s, w.lang));
277
+ return [...byKey]
278
+ .map(([key, e]) => ({ key, term: [...e.forms].sort((a, b) => b[1] - a[1])[0][0], weight: e.weight }))
279
+ .sort((a, b) => b.weight - a.weight || a.term.localeCompare(b.term))
280
+ .slice(0, CONFIG.maxQueryTerms);
281
+ }
282
+ const ftsTerm = (t) => `"${t.replace(/"/g, '')}"`;
283
+ const byPopularity = (a, b) => (b.installs ?? -1) - (a.installs ?? -1) || (b.stars ?? -1) - (a.stars ?? -1);
284
+ /**
285
+ * README keywords → similar skills in the local index, ordered by popularity (user decision: downloads,
286
+ * then stars; how closely a skill matches only decides whether it is "similar" at all).
287
+ * FTS (porter) fetches every skill that shares a word; each gets Σ log2(1 + weight) × idf over the shared
288
+ * words (×2 in the name). Similar = at least {@link CONFIG.similarFloor} of the best score and sharing
289
+ * {@link CONFIG.minSharedWords} words, so a lone common word ("api") does not let a popular unrelated skill in.
290
+ */
291
+ export function matchSkills(store, keywords, weights) {
292
+ if (keywords.length === 0)
293
+ return [];
294
+ const hits = store.searchSkillsFts(keywords.map((k) => ftsTerm(k.term)).join(' OR '), CONFIG.poolSize);
295
+ const total = Math.max(1, store.countSkills());
296
+ const idf = new Map(keywords.map((k) => {
297
+ const df = store.countSkillsFts(ftsTerm(k.term));
298
+ return [k.key, Math.log(1 + (total - df + 0.5) / (df + 0.5))];
299
+ }));
300
+ const scored = hits
301
+ .map(({ skill }) => skill)
302
+ .filter((s) => s.description && s.origin !== 'wellknown')
303
+ .map((s) => {
304
+ const name = new Set(matchTokens(s.name.replace(/[-_.]+/g, ' ')));
305
+ const text = new Set(matchTokens([s.description ?? '', s.when_to_use ?? ''].join(' ')));
306
+ let score = 0;
307
+ const matched = [];
308
+ for (const k of keywords) {
309
+ const inName = name.has(k.key);
310
+ if (!inName && !text.has(k.key))
311
+ continue;
312
+ matched.push(k.term);
313
+ score += Math.log2(1 + k.weight) * (idf.get(k.key) ?? 0) * (inName ? CONFIG.nameBoost : 1);
314
+ }
315
+ return { skill: s, score, matched };
316
+ })
317
+ .filter((x) => x.score > 0)
318
+ .sort((a, b) => b.score - a.score || a.skill.id.localeCompare(b.skill.id));
319
+ if (scored.length === 0)
320
+ return [];
321
+ const best = scored[0];
322
+ const minShared = Math.min(CONFIG.minSharedWords, best.matched.length);
323
+ const similar = scored.filter((x) => x.score >= CONFIG.similarFloor * best.score && x.matched.length >= minShared);
324
+ // One entry per skill name: the same skill is often republished elsewhere. Keep the trusted, most
325
+ // installed copy.
326
+ const lookup = storeLookup(store);
327
+ const starsOf = (repo) => (repo ? (store.getRepo(repo)?.stars ?? null) : null);
328
+ const trustOf = (s) => TRUST_SCORE[trustTier(s, lookup.plugin(s.plugin_id))];
329
+ const groups = new Map();
330
+ for (const x of similar) {
331
+ const k = x.skill.name.toLowerCase().replace(/[\s_]+/g, '-');
332
+ groups.set(k, [...(groups.get(k) ?? []), x]);
333
+ }
334
+ const unique = [...groups.values()]
335
+ .map((g) => g.sort((a, b) => trustOf(b.skill) - trustOf(a.skill) || (freshInstalls(b.skill) ?? -1) - (freshInstalls(a.skill) ?? -1) || b.score - a.score)[0])
336
+ .map((x) => ({ ...x, installs: freshInstalls(x.skill), stars: starsOf(x.skill.repo) }))
337
+ .sort((a, b) => byPopularity(a, b) || b.score - a.score || a.skill.id.localeCompare(b.skill.id))
338
+ .slice(0, CONFIG.maxResults);
339
+ const maxInstalls = Math.max(0, ...unique.map((x) => x.installs ?? 0));
340
+ const maxStars = Math.max(0, ...unique.map((x) => x.stars ?? 0));
341
+ return unique.map((x) => {
342
+ const s = x.skill;
343
+ const plugin = lookup.plugin(s.plugin_id);
344
+ const breakdown = {
345
+ R: x.score / best.score,
346
+ P: popularity({ installs: x.installs, stars: x.stars }, maxInstalls, maxStars),
347
+ F: freshness(s.last_commit_at),
348
+ T: TRUST_SCORE[trustTier(s, plugin)],
349
+ };
350
+ const source = plugin ? marketplaceSource(s, plugin) : githubSource(s);
351
+ const item = buildResultItem({
352
+ skill: s,
353
+ lookup,
354
+ sources: source ? [source] : [],
355
+ installs: x.installs,
356
+ stars: x.stars,
357
+ breakdown,
358
+ score: totalScore(breakdown, weights),
359
+ });
360
+ return { item, score: Math.round(x.score * 100) / 100, matchedTerms: x.matched };
361
+ });
362
+ }
363
+ /** Project description files read when there is no README, in this order (project review 2026-11-02). */
364
+ const MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml'];
365
+ /** First `key = "value"` / `key = [ "a", "b" ]` of a TOML file; enough for a description and keywords. */
366
+ function tomlValue(text, key) {
367
+ const m = text.match(new RegExp(`^\\s*${key}\\s*=\\s*(?:"([^"\\n]*)"|\\[([^\\]]*)\\])`, 'm'));
368
+ if (!m)
369
+ return null;
370
+ return m[1] ?? [...(m[2] ?? '').matchAll(/"([^"\n]*)"/g)].map((x) => x[1]);
371
+ }
372
+ /**
373
+ * A project without a README: its name, description and keywords from package.json, pyproject.toml or
374
+ * Cargo.toml, as a small markdown the README reader understands. Null when none has a description or keywords.
375
+ */
376
+ export function manifestAsReadme(projectPath) {
377
+ for (const name of MANIFESTS) {
378
+ const path = join(projectPath, name);
379
+ let text;
380
+ try {
381
+ if (statSync(path).size > CONFIG.maxReadmeBytes)
382
+ continue;
383
+ text = readFileSync(path, 'utf8');
384
+ }
385
+ catch {
386
+ continue;
387
+ }
388
+ let title;
389
+ let description;
390
+ let keywords;
391
+ if (name.endsWith('.json')) {
392
+ try {
393
+ ({ name: title, description, keywords } = JSON.parse(text));
394
+ }
395
+ catch {
396
+ continue;
397
+ }
398
+ }
399
+ else {
400
+ title = tomlValue(text, 'name');
401
+ description = tomlValue(text, 'description');
402
+ keywords = tomlValue(text, 'keywords');
403
+ }
404
+ const words = Array.isArray(keywords) ? keywords.filter((k) => typeof k === 'string') : [];
405
+ if (typeof description !== 'string' && !words.length)
406
+ continue;
407
+ const md = [`# ${typeof title === 'string' ? title : basename(projectPath)}`, '', typeof description === 'string' ? description : '', '', ...(words.length ? ['## Features', ...words.map((k) => `- ${k}`)] : [])].join('\n');
408
+ return { path, md };
409
+ }
410
+ return null;
411
+ }
412
+ /** Agent instruction files, read when there is no README (user decision 2026-11-02). */
413
+ const AGENT_FILES = ['CLAUDE.md', 'AGENTS.md', join('.claude', 'CLAUDE.md')];
414
+ // the first line `/init` writes: "This file provides guidance to Claude Code (claude.ai/code) when working with…"
415
+ const BOILERPLATE = /^.*\bthis file provides guidance to\b.*$/gim;
416
+ const GENERIC_TITLE = /^(?:claude|agents)(?:\.md)?$|^(?:repository|project|contributor|agent)s?\s+(?:guidelines|guide|instructions)$|^instructions$/i;
417
+ /**
418
+ * An agent instructions file as README text. It is mostly commands, paths and rules, so inline code, `/init`'s
419
+ * first line and the headings ("Build, Test, and Development Commands", "Commit & Pull Request Guidelines", which
420
+ * as headings would weigh 3 and bring in git skills) are left out, and what is left counts as plain text. Its
421
+ * title is kept when it names the project, not the file ("# CLAUDE.md", "# Repository Guidelines").
422
+ */
423
+ export function agentFileAsReadme(projectPath) {
424
+ for (const name of AGENT_FILES) {
425
+ const path = join(projectPath, name);
426
+ let text;
427
+ try {
428
+ if (statSync(path).size > CONFIG.maxReadmeBytes)
429
+ continue;
430
+ text = readFileSync(path, 'utf8');
431
+ }
432
+ catch {
433
+ continue;
434
+ }
435
+ let title = null;
436
+ const md = text
437
+ .replace(/\r\n?/g, '\n')
438
+ .replace(BOILERPLATE, '')
439
+ .replace(/`[^`\n]*`/g, ' ')
440
+ .split('\n')
441
+ .map((line) => {
442
+ const h = line.match(ATX);
443
+ if (!h)
444
+ return line;
445
+ const t = atxText(h[2]).trim();
446
+ if (h[1].length === 1 && title == null && t && !GENERIC_TITLE.test(t))
447
+ title = t;
448
+ return ''; // a heading still ends the paragraph above it
449
+ })
450
+ .join('\n')
451
+ .trim();
452
+ if (md)
453
+ return { path, md, title };
454
+ }
455
+ return null;
456
+ }
457
+ /**
458
+ * `exclude`: keywords turned off on the page (their surface form); `extra`: words added there, weighted like the
459
+ * strongest keyword so they count (project review 2026-11-02).
460
+ */
461
+ export function recommendForProject(store, projectPath, weights, opts = {}) {
462
+ let source;
463
+ try {
464
+ const readmePath = findReadme(projectPath);
465
+ source = { path: readmePath, md: readFileSync(readmePath, 'utf8') };
466
+ }
467
+ catch (err) {
468
+ if (!(err instanceof ReadmeError) || err.code !== 'README_NOT_FOUND')
469
+ throw err;
470
+ // no README: the manifest's name, description and keywords lead, the agent file adds the rest as plain text
471
+ const manifest = manifestAsReadme(projectPath);
472
+ const agent = agentFileAsReadme(projectPath);
473
+ if (manifest && agent)
474
+ source = { path: manifest.path, md: `${manifest.md}\n\n${agent.md}`, also: [agent.path] };
475
+ else if (manifest)
476
+ source = manifest;
477
+ else if (agent)
478
+ source = { path: agent.path, md: agent.title ? `# ${agent.title}\n\n${agent.md}` : agent.md };
479
+ // nothing to read: the words typed on the page are the whole description (readmePath '' says so)
480
+ else if (opts.extra?.length)
481
+ source = { path: '', md: '' };
482
+ else
483
+ throw new ReadmeError('README_NOT_FOUND', '폴더 바로 아래에 README도, 설명이 있는 package.json·pyproject.toml·Cargo.toml도, CLAUDE.md·AGENTS.md도 없습니다.');
484
+ }
485
+ const info = extractReadme(source.md, basename(projectPath));
486
+ const off = new Set((opts.exclude ?? []).map((t) => t.toLowerCase()));
487
+ // words only: the folder name shown as the title is not a keyword ("skill finder" made "finder" one)
488
+ let keywords = termWeights(source.path ? info : { ...info, title: '' }).filter((k) => !off.has(k.term.toLowerCase()));
489
+ const top = keywords[0]?.weight ?? CONFIG.weights.title;
490
+ for (const word of opts.extra ?? []) {
491
+ for (const term of processTerm(word.trim()) ?? []) {
492
+ const key = stem(term);
493
+ // an added word switched off on the page is left out like any other
494
+ if (off.has(term.toLowerCase()) || off.has(word.trim().toLowerCase()))
495
+ continue;
496
+ if (!keywords.some((k) => k.key === key))
497
+ keywords = [{ key, term, weight: top }, ...keywords];
498
+ }
499
+ }
500
+ keywords = keywords.slice(0, CONFIG.maxQueryTerms);
501
+ return {
502
+ readmePath: source.path,
503
+ ...(source.also ? { alsoRead: source.also } : {}),
504
+ summary: { title: info.title, intro: info.intro, features: info.features },
505
+ keywords: keywords.slice(0, 10).map(({ term, weight }) => ({ term, weight })),
506
+ skills: matchSkills(store, keywords, weights),
507
+ };
508
+ }
@@ -0,0 +1,41 @@
1
+ // Signed releases (user decision 2026-11-02). meta.json on the download page carries two RSA-SHA256 signatures
2
+ // made with a key that stays on the publisher's PC: one over the version, size and SHA-256 of the zip (the
3
+ // launcher refuses an update without it), one over the version's change notes (the app shows only signed notes).
4
+ // The packaging script signs with these same messages; the launcher (SkillFinder.cs) builds the first one too.
5
+ import { createPublicKey, createSign, createVerify } from 'node:crypto';
6
+ /** The public half of the update key (scripts/signing-key.mjs prints it). */
7
+ export const UPDATE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
8
+ MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA50og4Fe91nQ3nmlDU7re
9
+ kr9MMNwiBVLBCM+7vTkq+mD0wJerPGWTcoOhgGL6iv8TQxLSv2bEBBgdQUdePolH
10
+ 0y93To9hM+HrVCQMhUBPmXDcfanWzFAH2XkoXJsR+oPdoESHaiFUNq34/2CzVktB
11
+ 4/7OPqMkJd3mUEQmq3w5HehPn4CFnPiKNM5tMG54aC9xhaFQhMqINWDFDhy/Jawa
12
+ zHxMn5hBnlQNFZl8k9P1BzC2LbeDX9CYvqiXTs6uldBAXFbQqJ6vA0YkGsQlzvDD
13
+ k/rim028c+/JRoI4j5BlRFoGZRkh7JM5+btW6+vkZBrC1hEkjB9e5eEAs5RGHWiD
14
+ /IuYk06BSLFom4OsJAgN1t4eQ1hEB7gJu4rwt+RDE3NA5lkjpM+aGBEtwYC4l0P7
15
+ rkRMwcZ/RKWtiFwORX55RAe+U0QqklUAS3S3L1gEP/eUBueWCCHA9Nn7EXr34avh
16
+ hrmNQN0G+G7gvFiX5M48Rne1QAB125U5bSX6lqCxmIsdAgMBAAE=
17
+ -----END PUBLIC KEY-----`;
18
+ /** What the release signature covers; the launcher builds the same text. */
19
+ export const releaseMessage = (m) => `skill-finder-update v1\n${m.version}\n${m.bytes}\n${m.sha256}`;
20
+ /** What the notes signature covers: one line per note (line breaks inside a note become spaces). */
21
+ export const notesMessage = (version, notes) => `skill-finder-notes v1\n${version}\n${notes.map((n) => n.replace(/\s+/g, ' ').trim()).join('\n')}`;
22
+ export function signText(text, privateKey) {
23
+ return createSign('sha256').update(text).sign(privateKey, 'base64');
24
+ }
25
+ export function verifyText(text, signature, publicKey = UPDATE_PUBLIC_KEY) {
26
+ if (typeof signature !== 'string' || !/^[A-Za-z0-9+/]{100,1000}={0,2}$/.test(signature))
27
+ return false;
28
+ try {
29
+ return createVerify('sha256').update(text).verify(typeof publicKey === 'string' ? createPublicKey(publicKey) : publicKey, signature, 'base64');
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /** The notes of a meta.json, when they are signed; anything else is not shown. */
36
+ export function signedNotes(meta, version, publicKey) {
37
+ const notes = meta.notes;
38
+ if (!Array.isArray(notes) || notes.length > 30 || !notes.every((n) => typeof n === 'string' && n.length <= 300))
39
+ return [];
40
+ return verifyText(notesMessage(version, notes), meta.notesSignature, publicKey) ? notes : [];
41
+ }