@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.
Files changed (82) hide show
  1. package/.env.example +3 -0
  2. package/CHANGELOG.md +44 -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 +82 -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 +182 -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 +977 -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,502 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { extractSkillMeta } from "./analyze/frontmatter.js";
3
+ import { qualityWarnings } from "./analyze/quality.js";
4
+ import { pluginRiskFlags, RISK_RULES_VERSION, riskLevel, skillRiskFlags, TEXT_RISK_FLAGS } from "./analyze/risk.js";
5
+ import { discoverRepo, parseJsonObject, PLUGIN_JSON } from "./discovery/classifyRepo.js";
6
+ import { genericAcceptor } from "./discovery/genericRules.js";
7
+ import { discoverPluginSkills } from "./discovery/pluginRules.js";
8
+ import { basename, joinPath, skillMdPath, TreeIndex } from "./discovery/tree.js";
9
+ import { RAW_MAX_BYTES } from "./sources/github.js";
10
+ import { normalizeSource, resolveRelativeRoot } from "./sources/pluginSource.js";
11
+ import { pool } from "./util/limiter.js";
12
+ import { sanitizeText } from "./util/sanitize.js";
13
+ import { SizedCache } from "./util/sizedCache.js";
14
+ /**
15
+ * The marketplace entry points at something that is not there (deleted/private repo, a pinned commit
16
+ * erased from history, a folder that does not exist). Claude Code could not install it either; it is
17
+ * recorded as 설치 불가 and not retried until the marketplace changes the entry.
18
+ */
19
+ export class PluginUnavailableError extends Error {
20
+ }
21
+ const sha256 = (s) => createHash('sha256').update(s).digest('hex');
22
+ const now = () => new Date().toISOString();
23
+ const MAX_FILES_STORED = 500;
24
+ export const skillId = (repo, dirPath) => `gh:${repo.toLowerCase()}:${dirPath}`;
25
+ export function normalizeSkillName(name) {
26
+ return name.toLowerCase().replace(/[\s_]+/g, '-');
27
+ }
28
+ /** The validator used in generic mode: name and description must be non-empty strings (skills CLI rule). */
29
+ export function genericValidator(raw) {
30
+ const meta = extractSkillMeta(raw, '');
31
+ if (!meta.nameFromFrontmatter || !meta.descriptionFromFrontmatter)
32
+ return null;
33
+ const internal = meta.frontmatter.metadata?.internal === true;
34
+ return internal ? null : { name: meta.nameFromFrontmatter, description: meta.descriptionFromFrontmatter };
35
+ }
36
+ export function buildSkillRow(a) {
37
+ const folderName = a.dirPath ? basename(a.dirPath) : (a.repo.split('/')[1] ?? '');
38
+ const meta = extractSkillMeta(a.raw, folderName);
39
+ const files = a.tree.filesUnder(a.dirPath);
40
+ const relPaths = files.map((f) => f.path);
41
+ const flags = [
42
+ ...new Set([
43
+ ...skillRiskFlags({
44
+ files: relPaths,
45
+ allowedTools: meta.allowedTools,
46
+ body: meta.body,
47
+ hasHooks: meta.hasHooks,
48
+ raw: a.raw,
49
+ partial: checkedInPart(meta, a.raw),
50
+ }),
51
+ ...a.extraFlags,
52
+ ]),
53
+ ];
54
+ const stored = {
55
+ files: files.slice(0, MAX_FILES_STORED).map((f) => ({ path: f.path, size: f.size })),
56
+ total: files.length,
57
+ };
58
+ return {
59
+ id: skillId(a.repo, a.dirPath),
60
+ origin: a.origin,
61
+ plugin_id: a.pluginId,
62
+ repo: a.repo,
63
+ ref: a.sha,
64
+ dir_path: a.dirPath,
65
+ name: meta.name || folderName,
66
+ description: meta.description || null,
67
+ when_to_use: meta.whenToUse,
68
+ frontmatter_json: JSON.stringify(meta.frontmatter),
69
+ body_lines: meta.bodyLines,
70
+ skill_md_sha256: sha256(a.raw.replace(/\r\n/g, '\n')),
71
+ name_desc_sha256: sha256(`${meta.name}\n${meta.description}`),
72
+ installs: null,
73
+ quality_json: JSON.stringify(qualityWarnings(meta, folderName)),
74
+ risk_json: JSON.stringify(flags),
75
+ risk_level: riskLevel(flags),
76
+ risk_rules: RISK_RULES_VERSION,
77
+ last_commit_at: a.lastCommitAt,
78
+ fetched_at: now(),
79
+ files_json: JSON.stringify(stored),
80
+ install_json: a.install ? JSON.stringify(a.install) : null,
81
+ };
82
+ }
83
+ /** A download cut at the limit (a split multi-byte character at the end costs at most 3 bytes), or a frontmatter too large to parse. */
84
+ const checkedInPart = (meta, raw) => meta.frontmatterTooLarge || Buffer.byteLength(raw) >= RAW_MAX_BYTES - 3;
85
+ /**
86
+ * A stored row's risk under the current rules, from its SKILL.md text: the text flags are computed again, the
87
+ * others (scripts from the file list, the plugin's own flags) are kept. Null when `raw` is not the file the row
88
+ * was built from (its hash differs), so nothing is judged on another version of the skill.
89
+ */
90
+ export function recheckRisk(row, raw) {
91
+ if (!row.skill_md_sha256 || sha256(raw.replace(/\r\n/g, '\n')) !== row.skill_md_sha256)
92
+ return null;
93
+ const folderName = row.dir_path ? basename(row.dir_path) : (row.repo?.split('/')[1] ?? '');
94
+ const meta = extractSkillMeta(raw, folderName);
95
+ const fromText = skillRiskFlags({ files: [], allowedTools: meta.allowedTools, body: meta.body, hasHooks: meta.hasHooks, raw, partial: checkedInPart(meta, raw) });
96
+ const old = (row.risk_json ? JSON.parse(row.risk_json) : []);
97
+ // the stored order is kept, so a row whose flags did not change is stored exactly as before
98
+ const flags = [...old.filter((f) => !TEXT_RISK_FLAGS.has(f) || fromText.includes(f)), ...fromText.filter((f) => !old.includes(f))];
99
+ return { risk_json: JSON.stringify(flags), risk_level: riskLevel(flags) };
100
+ }
101
+ export function pluginFtsText(p) {
102
+ if (!p)
103
+ return '';
104
+ return [p.name, p.display_name, p.description, p.category, p.keywords].filter(Boolean).join(' ');
105
+ }
106
+ /** Rows written per transaction when a whole repo is indexed. */
107
+ const WRITE_BATCH = 50;
108
+ const RUN_CONFIG_MAX_CHARS = 16_000;
109
+ export class Indexer {
110
+ store;
111
+ github;
112
+ /** Bounded by tree entries (perf debate 1-3): a lookup holds its tree and the SKILL.md texts read, ~770B each. */
113
+ discoveryCache = new SizedCache({ maxSize: 60_000, ttlMs: 10 * 60 * 1000, sizeOf: (l) => l.snap.index.files.size });
114
+ constructor(store, github) {
115
+ this.store = store;
116
+ this.github = github;
117
+ }
118
+ async snapshot(repo, sha) {
119
+ const tree = sha === 'HEAD' ? await this.github.getTreeAtHead(repo) : await this.github.getTree(repo, sha);
120
+ sha = tree.sha;
121
+ const index = new TreeIndex(tree.entries);
122
+ const memo = new Map();
123
+ // null only means "not there" (not in the tree, or a 404). A failed download throws, so a busy
124
+ // GitHub is never mistaken for a missing SKILL.md (that used to drop real skills from results).
125
+ const read = (path) => {
126
+ let p = memo.get(path);
127
+ if (!p) {
128
+ p = index.hasFile(path)
129
+ ? this.github.getRawText(repo, sha, path).then((r) => {
130
+ if (r.text != null || r.status === 404)
131
+ return r.text;
132
+ throw new Error(`${repo}/${path} 읽기 실패 (${r.status})`);
133
+ })
134
+ : Promise.resolve(null);
135
+ p.catch(() => memo.delete(path));
136
+ memo.set(path, p);
137
+ }
138
+ return p;
139
+ };
140
+ return { tree, index, read };
141
+ }
142
+ async lastCommitAt(repo, tree) {
143
+ const meta = await this.github.getRepoMeta(repo).catch(() => null);
144
+ return meta?.pushed_at ?? tree.commitDate;
145
+ }
146
+ /** A GitHub plugin's marketplace entry, commit, tree, folder and plugin.json, as the index reads them. */
147
+ async openPlugin(plugin) {
148
+ const repo = plugin.repo;
149
+ const stored = (plugin.entry_json ? JSON.parse(plugin.entry_json) : null);
150
+ const entry = stored?.entry ?? { name: plugin.name, source: JSON.parse(plugin.source_json) };
151
+ const src = normalizeSource(entry.source, stored?.pluginRoot);
152
+ const sha = plugin.sha ?? (await this.github.resolveRef(repo, plugin.ref))?.sha;
153
+ if (!sha)
154
+ throw new Error('커밋을 찾을 수 없습니다');
155
+ const snap = await this.snapshot(repo, sha);
156
+ const root = src.kind === 'relative' ? resolveRelativeRoot(src.raw, stored?.pluginRoot ?? null, snap.index) : (plugin.path ?? '');
157
+ if (root == null || !snap.index.hasDir(root))
158
+ throw new Error(`플러그인 경로가 없습니다: ${root ?? '?'}`);
159
+ const manifestPath = joinPath(root, PLUGIN_JSON);
160
+ const manifest = snap.index.hasFile(manifestPath) ? parseJsonObject(await snap.read(manifestPath)) : null;
161
+ return { entry, src, sha, snap, root, manifest };
162
+ }
163
+ /**
164
+ * What a plugin runs once installed, as written in it (project review F1): its hooks, MCP servers, monitors and
165
+ * bin/ files, from hooks/hooks.json, .mcp.json, monitors/monitors.json, plugin.json and the marketplace entry.
166
+ * Only read and shown; nothing here is run.
167
+ */
168
+ async pluginRunConfig(pluginId) {
169
+ const plugin = this.store.getPlugin(pluginId);
170
+ if (!plugin?.repo)
171
+ throw new Error('GitHub에 있는 플러그인이 아니라 읽을 수 없습니다');
172
+ const { entry, sha, snap, root, manifest } = await this.openPlugin(plugin);
173
+ const items = [];
174
+ const show = (kind, source, value) => {
175
+ const text = sanitizeText(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
176
+ items.push({ kind, source, text: text.slice(0, RUN_CONFIG_MAX_CHARS), truncated: text.length > RUN_CONFIG_MAX_CHARS });
177
+ };
178
+ const readFile = async (kind, rel) => {
179
+ const path = joinPath(root, rel.replace(/^\.\//, ''));
180
+ const text = await snap.read(path).catch(() => null);
181
+ if (text != null)
182
+ show(kind, path, text);
183
+ };
184
+ // a component field is inline config, or a path (or paths) to it inside the plugin
185
+ const fromField = async (kind, field, source) => {
186
+ if (field == null)
187
+ return;
188
+ if (typeof field === 'string')
189
+ await readFile(kind, field);
190
+ else if (Array.isArray(field) && field.every((f) => typeof f === 'string'))
191
+ for (const f of field)
192
+ await readFile(kind, f);
193
+ else
194
+ show(kind, source, field);
195
+ };
196
+ const manifestPath = joinPath(root, PLUGIN_JSON);
197
+ if (snap.index.hasFile(joinPath(root, 'hooks/hooks.json')))
198
+ await readFile('hooks', 'hooks/hooks.json');
199
+ await fromField('hooks', manifest?.hooks, `${manifestPath} hooks`);
200
+ await fromField('hooks', entry.hooks, 'marketplace.json hooks');
201
+ if (snap.index.hasFile(joinPath(root, '.mcp.json')))
202
+ await readFile('mcp', '.mcp.json');
203
+ await fromField('mcp', manifest?.mcpServers, `${manifestPath} mcpServers`);
204
+ await fromField('mcp', entry.mcpServers, 'marketplace.json mcpServers');
205
+ if (snap.index.hasFile(joinPath(root, 'monitors/monitors.json')))
206
+ await readFile('monitors', 'monitors/monitors.json');
207
+ await fromField('monitors', manifest?.monitors, `${manifestPath} monitors`);
208
+ const bin = [...snap.index.files.keys()].filter((f) => f.startsWith(joinPath(root, 'bin') + '/'));
209
+ if (bin.length)
210
+ show('bin', joinPath(root, 'bin'), bin.slice(0, 50).join('\n') + (bin.length > 50 ? `\n… 외 ${bin.length - 50}개` : ''));
211
+ return { commit: sha, items };
212
+ }
213
+ /** Indexes the skills of one marketplace plugin; replaces its previous skill rows. Returns the skill count. */
214
+ async indexPlugin(pluginId) {
215
+ const plugin = this.store.getPlugin(pluginId);
216
+ if (!plugin)
217
+ throw new Error(`unknown plugin ${pluginId}`);
218
+ if (!plugin.repo) {
219
+ this.store.markPluginIndexed(pluginId, null, null, 'GitHub 외 출처라 스킬을 색인하지 않습니다');
220
+ return 0;
221
+ }
222
+ try {
223
+ const { entry, src, sha, snap, root, manifest } = await this.openPlugin(plugin);
224
+ const d = discoverPluginSkills(snap.index, root, manifest, entry, {
225
+ entryAtMarketplaceRoot: src.kind === 'relative' && root === '',
226
+ });
227
+ const extraFlags = pluginRiskFlags(d.signals, plugin.source_kind);
228
+ const lastCommitAt = await this.lastCommitAt(plugin.repo, snap.tree);
229
+ const results = await pool(d.skillDirs, 8, async (dirPath) => {
230
+ const raw = await snap.read(skillMdPath(dirPath));
231
+ if (raw == null)
232
+ return null;
233
+ return buildSkillRow({
234
+ repo: plugin.repo,
235
+ sha,
236
+ dirPath,
237
+ raw,
238
+ tree: snap.index,
239
+ origin: 'plugin',
240
+ pluginId,
241
+ extraFlags,
242
+ install: null,
243
+ lastCommitAt,
244
+ });
245
+ });
246
+ const rows = results.flatMap((r) => (r?.ok && r.value ? [r.value] : []));
247
+ // A SKILL.md that could not be downloaded (busy GitHub) is not a removed skill: its old row stays, and no
248
+ // commit is recorded so the next sync reads the plugin again. Dropping it hid a high-risk sibling from the
249
+ // plugin-wide warning until the plugin changed (project review 2026-11-02), as indexRepo already avoids.
250
+ const failed = results.filter((r) => !r?.ok).length;
251
+ const text = pluginFtsText(plugin);
252
+ this.store.transaction(() => {
253
+ const keep = new Set(rows.map((r) => r.id));
254
+ if (!failed)
255
+ for (const old of this.store.listSkillsByPlugin(pluginId))
256
+ if (!keep.has(old.id))
257
+ this.store.deleteSkill(old.id);
258
+ for (const row of rows)
259
+ this.store.upsertSkill(row, text);
260
+ this.store.markPluginIndexed(pluginId, failed ? null : (plugin.sha ?? sha), { ...d.signals, strictConflict: d.strictConflict }, failed ? `SKILL.md ${failed}개를 읽지 못해 다음 동기화 때 다시 읽습니다` : null);
261
+ });
262
+ return rows.length;
263
+ }
264
+ catch (err) {
265
+ const reason = await this.unavailableReason(plugin.repo, err);
266
+ if (reason) {
267
+ // Indexed "as of" the marketplace's pinned commit, so it is skipped until the entry changes (an unpinned
268
+ // entry is read again every sync, like a working one).
269
+ this.store.markPluginIndexed(pluginId, plugin.sha, null, `설치 불가: ${reason}`);
270
+ throw new PluginUnavailableError(reason);
271
+ }
272
+ // No indexed sha → the next sync retries it.
273
+ this.store.markPluginIndexed(pluginId, null, null, String(err.message ?? err).slice(0, 300));
274
+ throw err;
275
+ }
276
+ }
277
+ /** Why a plugin can never be indexed as listed, or null for failures worth retrying (network, quota). */
278
+ async unavailableReason(repo, err) {
279
+ const msg = String(err?.message ?? err);
280
+ const path = msg.match(/^플러그인 경로가 없습니다: (.*)$/);
281
+ if (path)
282
+ return `마켓플레이스가 가리키는 폴더가 저장소에 없습니다 (${path[1]})`;
283
+ if (/^tree 422|^tree 409/.test(msg))
284
+ return '마켓플레이스가 고정한 커밋이 저장소 기록에서 지워졌습니다';
285
+ if (/^tree 404|커밋을 찾을 수 없습니다/.test(msg)) {
286
+ const exists = await this.github.repoExists(repo);
287
+ if (exists === false)
288
+ return '저장소가 삭제됐거나 비공개입니다';
289
+ if (exists === true && msg.startsWith('tree 404'))
290
+ return '마켓플레이스가 고정한 커밋이 저장소에 없습니다';
291
+ }
292
+ return null;
293
+ }
294
+ /**
295
+ * A repo at its default branch head, prepared for single-skill lookups; memoized for 10 minutes.
296
+ * Plugin layouts come from the manifests; generic folders are judged one at a time on demand.
297
+ */
298
+ discoverAtHead(repo) {
299
+ const key = repo.toLowerCase();
300
+ let p = this.discoveryCache.get(key);
301
+ if (!p) {
302
+ p = (async () => {
303
+ void this.github.getRepoMeta(repo).catch(() => null); // needed for the row's last commit; fetch alongside the tree
304
+ const snap = await this.snapshot(repo, 'HEAD');
305
+ const discovery = await discoverRepo(snap.index, snap.read, genericValidator, { genericSkills: false });
306
+ return { snap, discovery, generic: genericAcceptor(snap.index, snap.read, genericValidator) };
307
+ })();
308
+ this.discoveryCache.set(key, p);
309
+ }
310
+ return p;
311
+ }
312
+ async rowFromDiscovery(repo, snap, discovery, dirPath, raw) {
313
+ const b = await this.buildFromDiscovery(repo, snap, discovery, dirPath, raw);
314
+ if ('existing' in b)
315
+ return b.existing;
316
+ this.store.upsertSkill(b.row, b.text);
317
+ return this.store.getSkill(b.row.id);
318
+ }
319
+ /** The row for one discovered folder, unwritten; or the marketplace-indexed row already there, which wins. */
320
+ async buildFromDiscovery(repo, snap, discovery, dirPath, raw) {
321
+ const plugin = discovery.plugins.find((p) => p.skillDirs.includes(dirPath)) ?? null;
322
+ const install = {
323
+ mode: discovery.mode,
324
+ marketplaceName: discovery.marketplaceName,
325
+ marketplaceRepo: repo,
326
+ pluginName: plugin?.name || null,
327
+ signals: plugin?.signals ?? null,
328
+ };
329
+ const pluginRootDep = /\$\{?CLAUDE_PLUGIN_ROOT\}?/.test(raw);
330
+ const viaPlugin = pluginRootDep && !!discovery.marketplaceName && !!plugin?.name;
331
+ const row = buildSkillRow({
332
+ repo,
333
+ sha: snap.tree.sha,
334
+ dirPath,
335
+ raw,
336
+ tree: snap.index,
337
+ origin: 'generic',
338
+ pluginId: null,
339
+ extraFlags: viaPlugin ? pluginRiskFlags(plugin.signals) : [],
340
+ install,
341
+ lastCommitAt: await this.lastCommitAt(repo, snap.tree),
342
+ });
343
+ const existing = this.store.getSkill(row.id);
344
+ // A marketplace-indexed row for the same folder wins; only fill in what is new.
345
+ if (existing?.origin === 'plugin')
346
+ return { existing };
347
+ return { row, text: plugin ? `${plugin.name}` : '' };
348
+ }
349
+ /**
350
+ * SKILL.md text if `dir` is installable: listed by a marketplace plugin, or else by the skills CLI's
351
+ * generic rules (which is how `skills add --skill` would find it). null otherwise.
352
+ */
353
+ acceptDir(lookup, dir) {
354
+ if (lookup.discovery.plugins.some((p) => p.skillDirs.includes(dir)))
355
+ return lookup.snap.read(skillMdPath(dir));
356
+ return lookup.generic.accepts(dir);
357
+ }
358
+ /**
359
+ * Finds a skill in a repo by name or folder name (skills.sh hits). Folders named like the skill are read
360
+ * first (almost always the hit); the rest are read in small batches only if that fails.
361
+ * Returns null when the repo has no such installable skill; throws when GitHub could not be read.
362
+ */
363
+ async findInRepo(repo, name, folderHint) {
364
+ const lookup = await this.discoverAtHead(repo);
365
+ const want = new Set([normalizeSkillName(name), ...(folderHint ? [normalizeSkillName(folderHint)] : [])]);
366
+ const dirs = [...new Set([...lookup.discovery.plugins.flatMap((p) => p.skillDirs), ...lookup.generic.candidates])];
367
+ const byFolder = dirs.filter((d) => want.has(normalizeSkillName(basename(d || repo))));
368
+ const matches = (dir, raw, folderMatch) => {
369
+ const meta = extractSkillMeta(raw, basename(dir || repo));
370
+ return normalizeSkillName(meta.name) === normalizeSkillName(name) || (folderMatch && !meta.nameFromFrontmatter);
371
+ };
372
+ for (const dir of byFolder) {
373
+ const raw = await this.acceptDir(lookup, dir);
374
+ if (raw != null && matches(dir, raw, true))
375
+ return this.rowFromDiscovery(repo, lookup.snap, lookup.discovery, dir, raw);
376
+ }
377
+ // Folder named differently from the skill: read folders whose path shares a word with the name
378
+ // (or every folder in a small repo). Scanning hundreds of SKILL.md files for a name that is not
379
+ // there cost ~10s and almost never found anything.
380
+ const others = dirs.filter((d) => !byFolder.includes(d));
381
+ const words = normalizeSkillName(name)
382
+ .split(/[-.]+/)
383
+ .filter((w) => w.length >= 3);
384
+ const overlap = (d) => words.filter((w) => d.toLowerCase().includes(w)).length;
385
+ const rest = others.length <= 48
386
+ ? others
387
+ : others
388
+ .map((d) => ({ d, n: overlap(d) }))
389
+ .filter((x) => x.n > 0)
390
+ .sort((a, b) => b.n - a.n)
391
+ .slice(0, 48)
392
+ .map((x) => x.d);
393
+ for (let i = 0; i < rest.length; i += 24) {
394
+ const batch = rest.slice(i, i + 24);
395
+ const raws = await Promise.all(batch.map((d) => this.acceptDir(lookup, d)));
396
+ const hit = batch.findIndex((d, j) => raws[j] != null && matches(d, raws[j], false));
397
+ if (hit >= 0)
398
+ return this.rowFromDiscovery(repo, lookup.snap, lookup.discovery, batch[hit], raws[hit]);
399
+ }
400
+ return null;
401
+ }
402
+ /**
403
+ * Accepts a code-search hit only if a plugin or the generic rules list that folder (filters templates,
404
+ * examples, invalid SKILL.md). Returns null when not installable; throws when GitHub could not be read.
405
+ */
406
+ async findByPath(repo, skillMdFile) {
407
+ const dirPath = skillMdFile === 'SKILL.md' ? '' : skillMdFile.replace(/\/SKILL\.md$/, '');
408
+ const lookup = await this.discoverAtHead(repo);
409
+ if (!lookup.snap.index.hasFile(skillMdPath(dirPath)))
410
+ return null;
411
+ const raw = await this.acceptDir(lookup, dirPath);
412
+ return raw == null ? null : this.rowFromDiscovery(repo, lookup.snap, lookup.discovery, dirPath, raw);
413
+ }
414
+ /**
415
+ * Indexes every installable skill of one GitHub repo at its default branch (popular-repo collection):
416
+ * marketplace/plugin layouts by their manifests, otherwise the skills CLI's generic rules. Rows already
417
+ * indexed from a registered marketplace are left as they are.
418
+ * - `unchangedSince`: the head commit last indexed; when the head is still that commit nothing is read.
419
+ * - Rows are written 50 per transaction, yielding in between (20 autocommitted upserts took 111ms, one
420
+ * transaction 4.9ms, and a big repo no longer holds the event loop while it is written).
421
+ * - After an index without failures, GitHub rows whose SKILL.md is no longer in the repo are deleted
422
+ * (rows an install was logged for stay).
423
+ */
424
+ async indexRepo(repo, opts = {}) {
425
+ const head = await this.github.resolveRef(repo, null);
426
+ if (!head)
427
+ throw new Error(`저장소를 찾을 수 없습니다: ${repo}`);
428
+ if (opts.unchangedSince && head.sha === opts.unchangedSince)
429
+ return { skills: 0, sha: head.sha, unchanged: true, removed: 0 };
430
+ const snap = await this.snapshot(repo, head.sha);
431
+ if (!snap.tree.entries.some((e) => e.path === 'SKILL.md' || e.path.endsWith('/SKILL.md'))) {
432
+ return { skills: 0, sha: snap.tree.sha, unchanged: false, removed: this.removeVanished(repo, snap.index) };
433
+ }
434
+ const discovery = await discoverRepo(snap.index, snap.read, genericValidator);
435
+ const found = [
436
+ ...discovery.generic.map((g) => ({ dir: g.dirPath, raw: g.raw })),
437
+ ...[...new Set(discovery.plugins.flatMap((p) => p.skillDirs))].map((dir) => ({ dir, raw: null })),
438
+ ].slice(0, 500);
439
+ const built = await pool(found, 8, async ({ dir, raw }) => {
440
+ const text = raw ?? (await snap.read(skillMdPath(dir)));
441
+ return text == null ? null : this.buildFromDiscovery(repo, snap, discovery, dir, text);
442
+ });
443
+ const writes = built.flatMap((b) => (b?.ok && b.value && 'row' in b.value ? [b.value] : []));
444
+ for (let i = 0; i < writes.length; i += WRITE_BATCH) {
445
+ this.store.transaction(() => {
446
+ for (const w of writes.slice(i, i + WRITE_BATCH))
447
+ this.store.upsertSkill(w.row, w.text);
448
+ });
449
+ await new Promise((resolve) => setImmediate(resolve));
450
+ }
451
+ const failed = built.some((b) => !b?.ok);
452
+ return {
453
+ skills: built.filter((b) => b?.ok && b.value).length,
454
+ sha: failed ? null : snap.tree.sha,
455
+ unchanged: false,
456
+ removed: failed ? 0 : this.removeVanished(repo, snap.index),
457
+ };
458
+ }
459
+ /** Deletes this repo's GitHub-indexed rows whose SKILL.md the (complete) tree no longer has. */
460
+ removeVanished(repo, index) {
461
+ const gone = this.store
462
+ .findSkillsByRepo(repo)
463
+ .filter((s) => s.origin === 'generic' && s.dir_path != null && !index.hasFile(skillMdPath(s.dir_path)))
464
+ .map((s) => s.id);
465
+ return gone.length ? this.store.deleteSkillsUnlessInstalled(gone) : 0;
466
+ }
467
+ /** Placeholder row for a skills.sh hit served from a well-known endpoint (no preview in the MVP). */
468
+ wellKnownRow(domain, name, installs) {
469
+ const row = {
470
+ id: `wk:${domain.toLowerCase()}:${name}`,
471
+ origin: 'wellknown',
472
+ plugin_id: null,
473
+ repo: null,
474
+ ref: null,
475
+ dir_path: null,
476
+ name,
477
+ description: null,
478
+ when_to_use: null,
479
+ frontmatter_json: null,
480
+ body_lines: null,
481
+ skill_md_sha256: null,
482
+ name_desc_sha256: null,
483
+ installs,
484
+ quality_json: '[]',
485
+ risk_json: '[]',
486
+ risk_level: 'low',
487
+ last_commit_at: null,
488
+ fetched_at: now(),
489
+ files_json: null,
490
+ install_json: JSON.stringify({ domain }),
491
+ };
492
+ this.store.upsertSkill(row, '');
493
+ return this.store.getSkill(row.id);
494
+ }
495
+ /** Fresh tree for a stored skill (detail view file listing / file reads). */
496
+ async treeFor(skill) {
497
+ if (!skill.repo || !skill.ref)
498
+ return null;
499
+ const tree = await this.github.getTree(skill.repo, skill.ref);
500
+ return new TreeIndex(tree.entries);
501
+ }
502
+ }