@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,36 @@
1
+ import { Router } from 'express';
2
+ import { z } from 'zod';
3
+ import { ReadmeError, recommendForProject } from "../recommend/readme.js";
4
+ import { getSettings } from "../settings.js";
5
+ const Body = z.object({
6
+ projectPath: z.string().trim().min(1).max(1000),
7
+ /** Keywords turned off on the page, and words added there. */
8
+ exclude: z.array(z.string().max(60)).max(50).optional(),
9
+ extra: z.array(z.string().max(60)).max(20).optional(),
10
+ });
11
+ /**
12
+ * README-based recommendation: reads only `<projectPath>/README*` (≤1MB), or without one its package.json,
13
+ * pyproject.toml or Cargo.toml, and matches the local skill index.
14
+ */
15
+ export function recommendRouter(ctx) {
16
+ const r = Router();
17
+ r.post('/readme-skills', (req, res) => {
18
+ const parsed = Body.safeParse(req.body);
19
+ if (!parsed.success) {
20
+ res.status(400).json({ error: '프로젝트 폴더 경로를 입력하세요.', code: 'INVALID_PATH' });
21
+ return;
22
+ }
23
+ try {
24
+ const { projectPath, exclude, extra } = parsed.data;
25
+ res.json(recommendForProject(ctx.store, projectPath, getSettings(ctx.store).weights, { exclude, extra }));
26
+ }
27
+ catch (err) {
28
+ if (err instanceof ReadmeError) {
29
+ res.status(err.status).json({ error: err.message, code: err.code });
30
+ return;
31
+ }
32
+ throw err;
33
+ }
34
+ });
35
+ return r;
36
+ }
@@ -0,0 +1,53 @@
1
+ import { Router } from 'express';
2
+ import { z } from 'zod';
3
+ import { searchActivity } from "../search/activity.js";
4
+ import { runSearch } from "../search/pipeline.js";
5
+ import { searchLogger } from "../search/searchLog.js";
6
+ import { getSettings } from "../settings.js";
7
+ import { searchSkillsSh } from "../sources/skillsSh.js";
8
+ import { ndjson } from "../util/ndjson.js";
9
+ const Body = z.object({
10
+ query: z.string().trim().min(1).max(300),
11
+ filters: z
12
+ .object({
13
+ sources: z.array(z.enum(['marketplace', 'skillssh', 'github'])).max(3).optional(),
14
+ maxRisk: z.enum(['low', 'medium', 'high']).optional(),
15
+ marketplaces: z.array(z.string().max(128)).max(20).optional(),
16
+ // "별 N개 미만 숨김 · 보기": this search only, the setting stays
17
+ minStars: z.number().int().min(0).max(10_000_000).optional(),
18
+ })
19
+ .optional(),
20
+ });
21
+ export function liveSearchDeps(ctx) {
22
+ return { ctx, skillsSh: (q) => searchSkillsSh(ctx.store, q, 30) };
23
+ }
24
+ export function searchRouter(ctx, deps = liveSearchDeps(ctx)) {
25
+ const r = Router();
26
+ r.post('/search', async (req, res) => {
27
+ const parsed = Body.safeParse(req.body);
28
+ if (!parsed.success) {
29
+ res.status(400).json({ error: '검색어는 1–300자여야 합니다' });
30
+ return;
31
+ }
32
+ const out = ndjson(res);
33
+ const started = Date.now();
34
+ const filters = parsed.data.filters ?? {};
35
+ const log = getSettings(ctx.store).searchLog ? searchLogger(ctx.store, parsed.data.query, filters, started) : null;
36
+ const send = (e) => out.send(log ? log(e) : e);
37
+ searchActivity.begin();
38
+ try {
39
+ await runSearch(deps, parsed.data.query, filters, send);
40
+ }
41
+ catch (err) {
42
+ console.error('[search]', err);
43
+ send({ type: 'warning', source: 'server', severity: 'urgent', message: `검색 중 오류: ${String(err.message ?? err).slice(0, 200)}` });
44
+ send({ type: 'results', items: [], hiddenHighRisk: 0, hiddenNoDescription: 0 });
45
+ send({ type: 'done', cached: false, elapsedMs: Date.now() - started });
46
+ }
47
+ finally {
48
+ searchActivity.end();
49
+ out.end();
50
+ }
51
+ });
52
+ return r;
53
+ }
@@ -0,0 +1,214 @@
1
+ import { Router } from 'express';
2
+ import { existingSkillFolder } from "../install/installed.js";
3
+ import { scanSkillFiles } from "../install/scan.js";
4
+ import { buildSteps, installLocation, planInstall } from "../install/plan.js";
5
+ import { isProjectDir } from "../install/validate.js";
6
+ import { freshness, TRUST_SCORE, trustTier } from "../search/rank.js";
7
+ import { githubSource, marketplaceSource, treeUrl } from "../search/result.js";
8
+ import { getSettings } from "../settings.js";
9
+ import { looksBinary } from "../util/http.js";
10
+ import { sanitizeText } from "../util/sanitize.js";
11
+ import { isTextPath, MAX_FILE_BYTES } from "../util/textFiles.js";
12
+ import { skillTokens } from "../util/tokens.js";
13
+ /**
14
+ * Where an opened result was in its search (U5-2): the search's `sid`, its 1-based place in the list, the page,
15
+ * and whether it was still an early card. Only well-formed values are kept; undefined when there are none.
16
+ */
17
+ function viewMeta(q) {
18
+ const int = (v, max) => (typeof v === 'string' && /^\d{1,4}$/.test(v) && Number(v) >= 1 && Number(v) <= max ? Number(v) : undefined);
19
+ const meta = {
20
+ sid: typeof q.sid === 'string' && /^[0-9a-f-]{36}$/.test(q.sid) ? q.sid : undefined,
21
+ pos: int(q.pos, 1000),
22
+ page: int(q.page, 1000),
23
+ provisional: q.prov === '1' ? true : undefined,
24
+ };
25
+ return Object.values(meta).some((v) => v !== undefined) ? meta : undefined;
26
+ }
27
+ export function skillsRouter(ctx) {
28
+ const r = Router();
29
+ const { store, github } = ctx;
30
+ r.get('/skills/:id', async (req, res) => {
31
+ const skill = store.getSkill(req.params.id);
32
+ if (!skill) {
33
+ res.status(404).json({ error: '스킬을 찾을 수 없습니다' });
34
+ return;
35
+ }
36
+ // Only results opened from a logged search (UI debate 1-6): a page reload, a pasted link or a script reading
37
+ // the API would otherwise fill the log with views no search led to.
38
+ const meta = viewMeta(req.query);
39
+ if (getSettings(store).searchLog && meta?.sid)
40
+ store.logEvent('view', skill.id, typeof req.query.q === 'string' ? req.query.q.slice(0, 300) : null, meta);
41
+ const plugin = skill.plugin_id ? (store.getPlugin(skill.plugin_id) ?? null) : null;
42
+ const marketplace = plugin ? (store.getMarketplace(plugin.marketplace) ?? null) : null;
43
+ const plan = planInstall(skill, plugin, marketplace);
44
+ const files = (skill.files_json ? JSON.parse(skill.files_json) : { files: [], total: 0 });
45
+ let skillMd = null;
46
+ let skillMdError = null;
47
+ if (skill.repo && skill.ref && skill.dir_path != null) {
48
+ try {
49
+ const res2 = await github.getRawText(skill.repo, skill.ref, skill.dir_path ? `${skill.dir_path}/SKILL.md` : 'SKILL.md', MAX_FILE_BYTES);
50
+ if (res2.text == null)
51
+ skillMdError = `SKILL.md를 가져오지 못했습니다 (${res2.status})`;
52
+ else
53
+ skillMd = sanitizeText(res2.text) + (res2.truncated ? '\n\n…(256KB에서 잘림)' : '');
54
+ }
55
+ catch (err) {
56
+ skillMdError = `SKILL.md를 가져오지 못했습니다 (${String(err.message ?? err).slice(0, 100)})`;
57
+ }
58
+ }
59
+ const signals = plugin?.signals_json ? JSON.parse(plugin.signals_json) : null;
60
+ const repoMeta = skill.repo ? (store.getRepo(skill.repo) ?? null) : null;
61
+ // skills.sh lists it (it has a download count from there): its page also shows Gen·Socket·Snyk audits.
62
+ // Its page is named after the skill's folder (skillId), not the frontmatter name.
63
+ const skillsShId = skill.dir_path ? (skill.dir_path.split('/').pop() ?? skill.name) : skill.name;
64
+ const skillsSh = skill.installs != null && skill.repo
65
+ ? { kind: 'skillssh', label: 'skills.sh', url: `https://skills.sh/${[...skill.repo.split('/'), skillsShId].map(encodeURIComponent).join('/')}` }
66
+ : null;
67
+ const sources = [plugin ? marketplaceSource(skill, plugin) : null, githubSource(skill), skillsSh].filter(Boolean);
68
+ res.json({
69
+ id: skill.id,
70
+ name: skill.name,
71
+ description: skill.description,
72
+ whenToUse: skill.when_to_use,
73
+ // context taken: name and description with every request once installed, SKILL.md when used (estimate)
74
+ tokens: skillTokens(skill.name, skill.description, skillMd),
75
+ origin: skill.origin,
76
+ repo: skill.repo,
77
+ ref: skill.ref,
78
+ dirPath: skill.dir_path,
79
+ url: skill.repo ? treeUrl(skill.repo, skill.ref, skill.dir_path) : null,
80
+ sources,
81
+ installs: skill.installs,
82
+ stars: repoMeta?.stars ?? null,
83
+ license: repoMeta?.license ?? null,
84
+ archived: repoMeta?.archived === 1,
85
+ lastCommitAt: skill.last_commit_at,
86
+ frontmatter: skill.frontmatter_json ? JSON.parse(skill.frontmatter_json) : {},
87
+ bodyLines: skill.body_lines,
88
+ /** Absolute parts of the score; R and P are relative to a result set and come from the result card. */
89
+ partialBreakdown: { F: freshness(skill.last_commit_at), T: TRUST_SCORE[trustTier(skill, plugin)] },
90
+ riskLevel: skill.risk_level,
91
+ riskFlags: skill.risk_json ? JSON.parse(skill.risk_json) : [],
92
+ qualityWarnings: skill.quality_json ? JSON.parse(skill.quality_json) : [],
93
+ skillMd,
94
+ skillMdError,
95
+ files: files.files.map((f) => ({ path: f.path, size: f.size, isText: isTextPath(f.path) })),
96
+ filesTotal: files.total,
97
+ plugin: plugin
98
+ ? {
99
+ id: plugin.id,
100
+ name: plugin.name,
101
+ displayName: plugin.display_name,
102
+ description: plugin.description,
103
+ marketplace: plugin.marketplace,
104
+ sourceKind: plugin.source_kind,
105
+ // with their risk: installing any one of them installs all (UI debate 1-1)
106
+ skills: store.listSkillsByPlugin(plugin.id).map((s) => ({
107
+ id: s.id,
108
+ name: s.name,
109
+ riskLevel: s.risk_level,
110
+ riskFlags: s.risk_json ? JSON.parse(s.risk_json) : [],
111
+ })),
112
+ hooks: !!signals?.hooks,
113
+ mcp: !!signals?.mcp,
114
+ bin: !!signals?.bin,
115
+ monitors: !!signals?.monitors,
116
+ strictConflict: !!signals?.strictConflict,
117
+ }
118
+ : null,
119
+ install: plan,
120
+ });
121
+ });
122
+ r.get('/skills/:id/file', async (req, res) => {
123
+ const skill = store.getSkill(req.params.id);
124
+ const path = typeof req.query.path === 'string' ? req.query.path : '';
125
+ if (!skill || !skill.repo || !skill.ref || skill.dir_path == null) {
126
+ res.status(404).json({ error: '스킬을 찾을 수 없습니다' });
127
+ return;
128
+ }
129
+ const files = (skill.files_json ? JSON.parse(skill.files_json) : { files: [] });
130
+ const entry = files.files.find((f) => f.path === path);
131
+ if (!entry) {
132
+ res.status(404).json({ error: '스킬 폴더에 없는 파일입니다' });
133
+ return;
134
+ }
135
+ if (!isTextPath(path)) {
136
+ res.status(415).json({ error: '텍스트 파일만 열 수 있습니다' });
137
+ return;
138
+ }
139
+ if (entry.size != null && entry.size > MAX_FILE_BYTES) {
140
+ res.status(413).json({ error: '256KB를 넘는 파일은 열 수 없습니다' });
141
+ return;
142
+ }
143
+ const full = skill.dir_path ? `${skill.dir_path}/${path}` : path;
144
+ const out = await github.getRawText(skill.repo, skill.ref, full, MAX_FILE_BYTES).catch(() => null);
145
+ if (!out || out.text == null) {
146
+ res.status(502).json({ error: '파일을 가져오지 못했습니다' });
147
+ return;
148
+ }
149
+ if (out.truncated) {
150
+ res.status(413).json({ error: '256KB를 넘는 파일은 열 수 없습니다' });
151
+ return;
152
+ }
153
+ if (looksBinary(out.text)) {
154
+ res.status(415).json({ error: '텍스트 파일이 아닙니다' });
155
+ return;
156
+ }
157
+ res.json({ path, content: sanitizeText(out.text) });
158
+ });
159
+ // What the install brings besides SKILL.md (project review F1): the folder's other text files checked with the
160
+ // same rules, or what a plugin runs (hooks, MCP servers). Read only; asked once when the install tab opens.
161
+ r.get('/skills/:id/install-scan', async (req, res) => {
162
+ const skill = store.getSkill(req.params.id);
163
+ if (!skill) {
164
+ res.status(404).json({ error: '스킬을 찾을 수 없습니다' });
165
+ return;
166
+ }
167
+ const plugin = skill.plugin_id ? (store.getPlugin(skill.plugin_id) ?? null) : null;
168
+ const plan = planInstall(skill, plugin, plugin ? (store.getMarketplace(plugin.marketplace) ?? null) : null);
169
+ try {
170
+ if (plan.method === 'skills-cli' && skill.repo && skill.ref && skill.dir_path != null) {
171
+ res.json({ kind: 'files', pinned: !!plan.pinnedRef, scan: await scanSkillFiles(github, skill) });
172
+ }
173
+ else if (plan.method === 'plugin' && plan.pluginId && store.getPlugin(plan.pluginId)?.repo) {
174
+ res.json({ kind: 'plugin', pluginId: plan.pluginId, config: await ctx.indexer.pluginRunConfig(plan.pluginId) });
175
+ }
176
+ else {
177
+ res.json({ kind: 'none', reason: plan.method === 'plugin' ? '색인한 마켓플레이스에 없는 플러그인이라 실행 설정을 읽지 못했습니다' : '앱에서 설치하지 않는 스킬입니다' });
178
+ }
179
+ }
180
+ catch (err) {
181
+ res.status(502).json({ error: `설치될 파일을 읽지 못했습니다: ${String(err.message ?? err).slice(0, 200)}` });
182
+ }
183
+ });
184
+ r.get('/install/preview', async (req, res) => {
185
+ const id = typeof req.query.id === 'string' ? req.query.id : '';
186
+ const scope = req.query.scope;
187
+ const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.trim() ? req.query.projectPath.trim() : null;
188
+ const skill = store.getSkill(id);
189
+ if (!skill) {
190
+ res.status(404).json({ error: '스킬을 찾을 수 없습니다' });
191
+ return;
192
+ }
193
+ const plugin = skill.plugin_id ? (store.getPlugin(skill.plugin_id) ?? null) : null;
194
+ const plan = planInstall(skill, plugin, plugin ? (store.getMarketplace(plugin.marketplace) ?? null) : null);
195
+ if (!plan.scopes.includes(scope)) {
196
+ res.json({ plan, steps: [], location: '', error: plan.method === 'manual-only' ? plan.reason : '이 방식에서 지원하지 않는 범위입니다' });
197
+ return;
198
+ }
199
+ if (scope !== 'user' && !isProjectDir(projectPath)) {
200
+ res.json({ plan, steps: [], location: '', error: '프로젝트 경로는 존재하는 디렉터리의 절대경로여야 합니다' });
201
+ return;
202
+ }
203
+ const steps = buildSteps(plan, scope, projectPath);
204
+ res.json({
205
+ plan,
206
+ steps: steps.map((s) => ({ display: s.display, cwd: s.cwd, note: s.note })),
207
+ location: installLocation(plan, scope, projectPath),
208
+ createsLockFile: plan.method === 'skills-cli' && scope === 'project',
209
+ // the skills CLI replaces a folder of the same name without asking (-y, D-5): say so before the install
210
+ existing: plan.method === 'skills-cli' && scope !== 'local' ? await existingSkillFolder(store, skill.id, plan.skillName, scope, projectPath) : null,
211
+ });
212
+ });
213
+ return r;
214
+ }
@@ -0,0 +1,13 @@
1
+ // Counts searches in flight so background skill collection can step aside while the user is searching.
2
+ let active = 0;
3
+ export const searchActivity = {
4
+ begin() {
5
+ active++;
6
+ },
7
+ end() {
8
+ active = Math.max(0, active - 1);
9
+ },
10
+ busy() {
11
+ return active > 0;
12
+ },
13
+ };
@@ -0,0 +1,109 @@
1
+ import { matchTokens, stem } from "./rank.js";
2
+ /** Result categories for the search sidebar. Korean labels live in the web app (labels.ts). */
3
+ export const CATEGORIES = ['testing', 'review', 'git', 'ops', 'security', 'data', 'docs', 'design', 'ai', 'productivity', 'business', 'dev', 'other'];
4
+ /**
5
+ * Keyword rules, no AI: a strong keyword scores 1, a weak one (common in unrelated descriptions) 0.5,
6
+ * doubled when it is in the skill name. Words are stemmed with the search stemmer so "tests"/"testing"
7
+ * and "test" line up.
8
+ */
9
+ const RULES = [
10
+ { cat: 'testing', strong: 'test testing tests e2e playwright cypress jest vitest pytest mocha unittest coverage qa tdd mock mocks', weak: 'assertion fixture' },
11
+ { cat: 'review', strong: 'review reviewer lint linter eslint refactor refactoring smell smells', weak: 'quality clean cleanup readability' },
12
+ { cat: 'git', strong: 'git github gitlab commit commits branch branches merge rebase pr changelog worktree', weak: 'release issue issues' },
13
+ {
14
+ cat: 'ops',
15
+ strong: 'deploy deployment docker dockerfile container kubernetes k8s helm aws gcp azure terraform ansible ci cd devops infrastructure infra serverless vercel netlify cloudflare monitoring observability sentry datadog grafana prometheus incident sre nginx',
16
+ weak: 'cloud pipeline server log logs logging',
17
+ },
18
+ {
19
+ cat: 'security',
20
+ strong: 'security secure vulnerability vulnerabilities owasp cve authentication oauth secret secrets encryption threat pentest penetration exploit malware xss csrf compliance',
21
+ weak: 'auth audit',
22
+ },
23
+ {
24
+ cat: 'data',
25
+ strong: 'database sql postgres postgresql mysql sqlite mongodb redis supabase prisma migration migrations analytics csv pandas etl warehouse bigquery snowflake dbt dashboard visualization statistics',
26
+ weak: 'data query queries schema chart charts analysis',
27
+ },
28
+ {
29
+ cat: 'docs',
30
+ strong: 'pdf docx document documents documentation readme excel xlsx pptx powerpoint slide slides presentation markdown blog article copywriting spreadsheet translate translation resume',
31
+ weak: 'writing write content report reports docs',
32
+ },
33
+ {
34
+ cat: 'design',
35
+ // "design" is weak: "designed to …" (same stem) is common filler in descriptions.
36
+ strong: 'ui ux figma css tailwind styling theme accessibility a11y brand branding logo typography illustration animation canva responsive frontend landing',
37
+ weak: 'design designer style layout component components color colors icon icons',
38
+ },
39
+ { cat: 'ai', strong: 'llm llms subagent subagents prompt prompts mcp rag embedding embeddings openai anthropic gpt gemini chatbot eval evals', weak: 'agent agents ai model models' },
40
+ {
41
+ cat: 'productivity',
42
+ strong: 'productivity email gmail calendar meeting meetings notion obsidian slack jira linear asana todo standup',
43
+ weak: 'note notes task tasks project planning plan schedule workflow automation organize',
44
+ },
45
+ {
46
+ cat: 'business',
47
+ strong: 'seo marketing sales crm finance financial accounting legal contract contracts ecommerce shopify stripe payment payments pricing invoice recruiting',
48
+ weak: 'customer customers support ads growth product startup',
49
+ },
50
+ {
51
+ cat: 'dev',
52
+ strong: 'typescript javascript python rust golang java kotlin swift ruby php node nodejs react vue angular svelte nextjs django flask rails spring api apis backend debug debugging bug bugs cli sdk mobile ios android flutter',
53
+ weak: 'library framework architecture implement implementation function',
54
+ },
55
+ ];
56
+ const KEYWORDS = new Map();
57
+ for (const r of RULES) {
58
+ for (const [words, w] of [
59
+ [r.strong, 1],
60
+ [r.weak ?? '', 0.5],
61
+ ]) {
62
+ for (const word of words.split(' ').filter(Boolean)) {
63
+ const key = stem(word);
64
+ const list = KEYWORDS.get(key) ?? [];
65
+ if (!list.some((x) => x.cat === r.cat))
66
+ list.push({ cat: r.cat, w });
67
+ KEYWORDS.set(key, list);
68
+ }
69
+ }
70
+ }
71
+ /** Marketplace `category` values seen in the index. Generic ones only count when no keyword matches. */
72
+ const MARKETPLACE_SPECIFIC = {
73
+ productivity: 'productivity',
74
+ automation: 'productivity',
75
+ database: 'data',
76
+ math: 'data',
77
+ migration: 'data',
78
+ monitoring: 'ops',
79
+ deployment: 'ops',
80
+ security: 'security',
81
+ design: 'design',
82
+ testing: 'testing',
83
+ finance: 'business',
84
+ };
85
+ const MARKETPLACE_GENERIC = { development: 'dev', 'developer tools': 'dev' };
86
+ const MAX_CATEGORIES = 2;
87
+ export function categorize(skill) {
88
+ const scores = new Map();
89
+ const name = new Set(matchTokens(skill.name.replace(/[-_.]+/g, ' ')));
90
+ const text = new Set(matchTokens([skill.description ?? '', skill.whenToUse ?? ''].join(' ')));
91
+ for (const token of new Set([...name, ...text])) {
92
+ for (const { cat, w } of KEYWORDS.get(token) ?? [])
93
+ scores.set(cat, (scores.get(cat) ?? 0) + w * (name.has(token) ? 2 : 1));
94
+ }
95
+ const mp = skill.marketplaceCategory?.trim().toLowerCase() ?? '';
96
+ const out = [];
97
+ if (MARKETPLACE_SPECIFIC[mp])
98
+ out.push(MARKETPLACE_SPECIFIC[mp]);
99
+ const ranked = [...scores].filter(([, s]) => s >= 1).sort((a, b) => b[1] - a[1] || CATEGORIES.indexOf(a[0]) - CATEGORIES.indexOf(b[0]));
100
+ // A second category must be more than half as strong as the first: one stray word in the
101
+ // description ("encrypting" in the pdf skill) should not add 보안 next to a name match.
102
+ const top = ranked[0]?.[1] ?? 0;
103
+ for (const [c, s] of ranked)
104
+ if (out.length < MAX_CATEGORIES && !out.includes(c) && (s === top || s > top / 2))
105
+ out.push(c);
106
+ if (out.length === 0)
107
+ out.push(MARKETPLACE_GENERIC[mp] ?? 'other');
108
+ return out;
109
+ }