@seojin08/skill-finder 0.0.0-stage → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/bin/skill-finder.mjs +389 -0
- package/package.json +39 -4
- package/server/src/analyze/frontmatter.js +130 -0
- package/server/src/analyze/quality.js +34 -0
- package/server/src/analyze/risk.js +123 -0
- package/server/src/app.js +57 -0
- package/server/src/bootstrap.js +46 -0
- package/server/src/claude/cli.js +89 -0
- package/server/src/config.js +82 -0
- package/server/src/context.js +1 -0
- package/server/src/db/index.js +128 -0
- package/server/src/db/schema.js +95 -0
- package/server/src/db/store.js +353 -0
- package/server/src/discovery/classifyRepo.js +81 -0
- package/server/src/discovery/genericRules.js +182 -0
- package/server/src/discovery/pluginRules.js +59 -0
- package/server/src/discovery/tree.js +79 -0
- package/server/src/index.js +55 -0
- package/server/src/indexer.js +502 -0
- package/server/src/install/installed.js +206 -0
- package/server/src/install/plan.js +118 -0
- package/server/src/install/run.js +97 -0
- package/server/src/install/scan.js +70 -0
- package/server/src/install/summary.js +14 -0
- package/server/src/install/uninstall.js +59 -0
- package/server/src/install/validate.js +24 -0
- package/server/src/jobs.js +107 -0
- package/server/src/recommend/readme.js +508 -0
- package/server/src/release.js +41 -0
- package/server/src/routes/admin.js +203 -0
- package/server/src/routes/favorites.js +61 -0
- package/server/src/routes/health.js +27 -0
- package/server/src/routes/install.js +283 -0
- package/server/src/routes/presets.js +66 -0
- package/server/src/routes/recommend.js +36 -0
- package/server/src/routes/search.js +53 -0
- package/server/src/routes/skills.js +214 -0
- package/server/src/search/activity.js +13 -0
- package/server/src/search/category.js +109 -0
- package/server/src/search/koreanTerms.js +284 -0
- package/server/src/search/pipeline.js +977 -0
- package/server/src/search/rank.js +138 -0
- package/server/src/search/result.js +129 -0
- package/server/src/search/searchLog.js +53 -0
- package/server/src/security.js +53 -0
- package/server/src/settings.js +56 -0
- package/server/src/sources/gitTree.js +101 -0
- package/server/src/sources/github.js +503 -0
- package/server/src/sources/marketplace.js +78 -0
- package/server/src/sources/pluginSource.js +98 -0
- package/server/src/sources/popularRepos.js +74 -0
- package/server/src/sources/skillsSh.js +74 -0
- package/server/src/tasks.js +216 -0
- package/server/src/update.js +53 -0
- package/server/src/util/folderPicker.js +208 -0
- package/server/src/util/http.js +41 -0
- package/server/src/util/limiter.js +110 -0
- package/server/src/util/log.js +94 -0
- package/server/src/util/ndjson.js +27 -0
- package/server/src/util/proc.js +80 -0
- package/server/src/util/resolveFolder.js +78 -0
- package/server/src/util/sanitize.js +45 -0
- package/server/src/util/sizedCache.js +74 -0
- package/server/src/util/textFiles.js +9 -0
- package/server/src/util/tokens.js +21 -0
- package/server/src/version.js +12 -0
- package/web/dist/LICENSE.txt +21 -0
- package/web/dist/THIRD-PARTY-LICENSES.txt +2267 -0
- package/web/dist/assets/ArasakaGame-B0V_LZLl.css +1 -0
- package/web/dist/assets/ArasakaGame-C1Wkmqik.js +36 -0
- package/web/dist/assets/index-BgL1OaU4.css +2 -0
- package/web/dist/assets/index-Ec2XBK4-.js +66 -0
- package/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- 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
|
+
}
|