@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,138 @@
1
+ import { COMMUNITY_MARKETPLACES, PARTNER_MARKETPLACES } from "../settings.js";
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+ const clamp01 = (x) => (Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : 0);
4
+ /** 1 within 90 days of the last commit, linearly down to 0 at 730 days; unknown → 0. */
5
+ export function freshness(lastCommitAt, now = Date.now()) {
6
+ if (!lastCommitAt)
7
+ return 0;
8
+ const t = Date.parse(lastCommitAt);
9
+ if (Number.isNaN(t))
10
+ return 0;
11
+ const days = (now - t) / DAY_MS;
12
+ if (days <= 90)
13
+ return 1;
14
+ if (days >= 730)
15
+ return 0;
16
+ return clamp01(1 - (days - 90) / (730 - 90));
17
+ }
18
+ /**
19
+ * Installs when known (skill-level), else stars × 0.6 — stars are repo-level, and one repo can hold
20
+ * hundreds of skills, so they say little about a single skill.
21
+ */
22
+ export function popularity(c, maxInstalls, maxStars) {
23
+ if (c.installs != null && c.installs > 0 && maxInstalls > 0)
24
+ return clamp01(Math.log10(1 + c.installs) / Math.log10(1 + maxInstalls));
25
+ if (c.stars != null && c.stars > 0 && maxStars > 0)
26
+ return clamp01((0.6 * Math.log10(1 + c.stars)) / Math.log10(1 + maxStars));
27
+ return 0;
28
+ }
29
+ /** Stored downloads older than this are not ranked on (U4, user decision): a copy no longer listed kept its count. */
30
+ export const INSTALLS_MAX_AGE_MS = 30 * DAY_MS;
31
+ /** A skill's stored downloads, or null when unknown or older than INSTALLS_MAX_AGE_MS. */
32
+ export function freshInstalls(s, now = Date.now()) {
33
+ if (s.installs == null || !s.installs_at)
34
+ return null;
35
+ const t = Date.parse(s.installs_at);
36
+ return Number.isNaN(t) || now - t > INSTALLS_MAX_AGE_MS ? null : s.installs;
37
+ }
38
+ export function trustTier(skill, plugin) {
39
+ if (plugin && COMMUNITY_MARKETPLACES.has(plugin.marketplace))
40
+ return 'community';
41
+ const owner = skill.repo?.split('/')[0]?.toLowerCase();
42
+ if (owner === 'anthropics' && !skill.dir_path?.startsWith('external_plugins/'))
43
+ return 'official';
44
+ if (plugin && PARTNER_MARKETPLACES.has(plugin.marketplace))
45
+ return 'partner';
46
+ return 'other';
47
+ }
48
+ export const TRUST_SCORE = { official: 1.0, partner: 0.8, community: 0.6, other: 0.4 };
49
+ export function totalScore(b, w) {
50
+ const sum = w.R + w.P + w.F + w.T;
51
+ if (sum <= 0)
52
+ return 0;
53
+ return (w.R * b.R + w.P * b.P + w.F * b.F + w.T * b.T) / sum;
54
+ }
55
+ /** Relevance from bm25 (lower = better in SQLite) normalized against the best hit. */
56
+ export function bm25ToRelevance(bm25, best, worst) {
57
+ if (!Number.isFinite(bm25))
58
+ return 0;
59
+ if (best === worst)
60
+ return 1;
61
+ return clamp01(0.3 + 0.7 * ((worst - bm25) / (worst - best)));
62
+ }
63
+ const MATCH_STOPWORDS = new Set('a an the and or for to of in on with from by using use uses skill skills claude code tool tools that this is are be my your how what into as at it its via any can will should'.split(' '));
64
+ /** Crude English stem so "extracting"/"extraction"/"extracts" and "tables"/"table" line up. */
65
+ export function stem(word) {
66
+ let w = word.toLowerCase();
67
+ // plurals first: "boxes" → "box", "tables" → "table", "libraries" → "library"
68
+ if (w.length > 4 && w.endsWith('ies'))
69
+ w = w.slice(0, -3) + 'y';
70
+ else if (w.length > 4 && /(s|x|z|ch|sh)es$/.test(w))
71
+ w = w.slice(0, -2);
72
+ else if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss'))
73
+ w = w.slice(0, -1);
74
+ for (const suffix of ['ation', 'ion', 'ing', 'er', 'ed']) {
75
+ if (w.length - suffix.length >= 3 && w.endsWith(suffix)) {
76
+ w = w.slice(0, -suffix.length);
77
+ break;
78
+ }
79
+ }
80
+ return w;
81
+ }
82
+ export function matchTokens(text) {
83
+ // A Latin word with a Korean ending attached ("pdf에서", "엑셀로") counts as the Latin word alone, as in ftsQuery.
84
+ const words = (text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).flatMap((t) => (/[a-z0-9]/.test(t) && /\p{Script=Hangul}/u.test(t) ? (t.match(/[a-z0-9]+/g) ?? []) : [t]));
85
+ return words.filter((t) => t.length >= 2 && !MATCH_STOPWORDS.has(t)).map(stem);
86
+ }
87
+ /**
88
+ * The query words a result is matched against:
89
+ * - With an English word present, Korean words are left out: the descriptions are English, so they could
90
+ * never match and only lowered every share ("react 성능 최적화" capped at 1/3, below the tier's 0.5).
91
+ * - A query of stopwords only ("skill", "claude code") keeps them, or nothing would be matched at all.
92
+ */
93
+ export function queryTokens(query) {
94
+ const words = matchTokens(query);
95
+ const latin = words.filter((t) => !/\p{Script=Hangul}/u.test(t));
96
+ if (latin.length > 0)
97
+ return [...new Set(latin)];
98
+ if (words.length > 0)
99
+ return [...new Set(words)];
100
+ return [...new Set(genericWords(query))];
101
+ }
102
+ /**
103
+ * Stopwords that still name something in this domain. Dropped from queries normally, but searched when they
104
+ * are all there is ("skill", "claude code"); grammar words ("a the") are not.
105
+ */
106
+ export const GENERIC_WORDS = new Set(['skill', 'skills', 'claude', 'code', 'tool', 'tools']);
107
+ const genericWords = (text) => (text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((t) => GENERIC_WORDS.has(t)).map(stem);
108
+ /** Words a name or description is matched on: matchTokens plus the generic words a stopword-only query uses. */
109
+ const textWords = (text) => new Set([...matchTokens(text), ...genericWords(text)]);
110
+ /**
111
+ * How directly a skill matches the query words: share of query words in the skill name, share in the
112
+ * description/when_to_use, and whether the description contains the query as a phrase.
113
+ */
114
+ export function lexicalMatch(query, name, text) {
115
+ const q = queryTokens(query);
116
+ if (q.length === 0)
117
+ return { name: 0, text: 0, any: 0, phrase: 0 };
118
+ const nameSet = textWords(name.replace(/[-_.]+/g, ' '));
119
+ const textSet = textWords(text);
120
+ // a longer query word also matches as a prefix ("docker" in "dockerfile"); short ones ("pdf", "ui") must be exact
121
+ const has = (set, t) => set.has(t) || (t.length >= 4 && [...set].some((w) => w.startsWith(t)));
122
+ const cover = (set) => q.filter((t) => has(set, t)).length / q.length;
123
+ const normalize = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
124
+ const phrase = q.length >= 2 && normalize(text).includes(normalize(query)) ? 1 : 0;
125
+ const any = q.filter((t) => has(nameSet, t) || has(textSet, t)).length / q.length;
126
+ return { name: cover(nameSet), text: cover(textSet), any, phrase };
127
+ }
128
+ /**
129
+ * Relevance tier (user decision): 1 when the name has a query word and at least half the words appear in the
130
+ * name or description. Results are ordered by tier first, then downloads → stars → R as before.
131
+ */
132
+ export function relevanceTier(m) {
133
+ return m.name > 0 && m.any >= 0.5 ? 1 : 0;
134
+ }
135
+ /** Final R: the search-engine signal blended with direct word matches, so fuzzy hits without the query words sink. */
136
+ export function blendRelevance(base, m) {
137
+ return clamp01(0.45 * base + 0.25 * m.text + 0.2 * m.name + 0.1 * m.phrase);
138
+ }
@@ -0,0 +1,129 @@
1
+ import { RISK_ORDER } from "../analyze/risk.js";
2
+ import { planInstall } from "../install/plan.js";
3
+ import { truncate } from "../util/sanitize.js";
4
+ import { categorize } from "./category.js";
5
+ import { trustTier } from "./rank.js";
6
+ export const TRUST_LABEL = { official: '공식', partner: '파트너', community: '커뮤니티' };
7
+ export function treeUrl(repo, ref, dir) {
8
+ const base = `https://github.com/${repo}`;
9
+ if (!ref)
10
+ return base;
11
+ return dir ? `${base}/tree/${ref}/${dir.split('/').map(encodeURIComponent).join('/')}` : `${base}/tree/${ref}`;
12
+ }
13
+ export function marketplaceSource(skill, plugin) {
14
+ const tier = trustTier(skill, plugin);
15
+ const label = tier === 'other' ? plugin.marketplace : TRUST_LABEL[tier];
16
+ return { kind: 'marketplace', label, url: skill.repo ? treeUrl(skill.repo, skill.ref, skill.dir_path) : '' };
17
+ }
18
+ export function githubSource(skill) {
19
+ if (!skill.repo)
20
+ return null;
21
+ return { kind: 'github', label: 'GitHub', url: treeUrl(skill.repo, skill.ref, skill.dir_path) };
22
+ }
23
+ /** `owner/repo` a GitHub or skills.sh page is about; null for other links. */
24
+ function repoOfSource(s) {
25
+ const m = s.url.match(/^https:\/\/(?:github\.com|skills\.sh)\/([^/?#]+\/[^/?#]+)/i);
26
+ return m ? decodeURIComponent(m[1]).toLowerCase() : null;
27
+ }
28
+ /** Sources of this skill's own repo, and those of copies in other repos (merged in from duplicates). */
29
+ export function splitCopies(sources, repo) {
30
+ if (!repo)
31
+ return { own: sources, copies: [] };
32
+ const own = [];
33
+ const copies = [];
34
+ for (const s of sources) {
35
+ const r = repoOfSource(s);
36
+ if (r && r !== repo.toLowerCase() && s.kind !== 'marketplace')
37
+ copies.push(s);
38
+ else
39
+ own.push(s);
40
+ }
41
+ return { own, copies };
42
+ }
43
+ /** One link per kind, label and repo: copies in other repos are kept (buildResultItem shows them apart). */
44
+ export function mergeSources(a, b) {
45
+ const out = [...a];
46
+ const same = (x, s) => x.kind === s.kind && x.label === s.label && repoOfSource(x) === repoOfSource(s);
47
+ for (const s of b)
48
+ if (!out.some((x) => same(x, s)))
49
+ out.push(s);
50
+ return out;
51
+ }
52
+ export function storeLookup(store) {
53
+ const plugins = new Map();
54
+ const mps = new Map();
55
+ const skillsOf = new Map();
56
+ const list = (id) => {
57
+ if (!skillsOf.has(id))
58
+ skillsOf.set(id, store.listSkillsByPlugin(id));
59
+ return skillsOf.get(id);
60
+ };
61
+ return {
62
+ plugin(id) {
63
+ if (!id)
64
+ return null;
65
+ if (!plugins.has(id))
66
+ plugins.set(id, store.getPlugin(id) ?? null);
67
+ return plugins.get(id);
68
+ },
69
+ marketplace(name) {
70
+ if (!name)
71
+ return null;
72
+ if (!mps.has(name))
73
+ mps.set(name, store.getMarketplace(name) ?? null);
74
+ return mps.get(name);
75
+ },
76
+ pluginSkillCount(id) {
77
+ return list(id).length;
78
+ },
79
+ pluginTopRisk(id) {
80
+ return list(id).reduce((top, s) => (RISK_ORDER[s.risk_level] > RISK_ORDER[top] ? s.risk_level : top), 'low');
81
+ },
82
+ };
83
+ }
84
+ export function buildResultItem(a) {
85
+ const { skill } = a;
86
+ const plugin = a.lookup.plugin(skill.plugin_id);
87
+ const plan = planInstall(skill, plugin, a.lookup.marketplace(plugin?.marketplace));
88
+ const desc = skill.description ?? '';
89
+ const { own, copies } = splitCopies(a.sources, skill.repo);
90
+ return {
91
+ id: skill.id,
92
+ name: skill.name,
93
+ description: truncate(desc, 300),
94
+ sources: own,
95
+ ...(copies.length ? { copies } : {}),
96
+ ...(a.installs != null ? { installs: a.installs } : {}),
97
+ ...(a.stars != null ? { stars: a.stars } : {}),
98
+ ...(skill.last_commit_at ? { lastCommitAt: skill.last_commit_at } : {}),
99
+ score: Math.round(a.score * 1000) / 1000,
100
+ breakdown: {
101
+ R: Math.round(a.breakdown.R * 1000) / 1000,
102
+ P: Math.round(a.breakdown.P * 1000) / 1000,
103
+ F: Math.round(a.breakdown.F * 1000) / 1000,
104
+ T: Math.round(a.breakdown.T * 1000) / 1000,
105
+ },
106
+ riskLevel: skill.risk_level ?? 'low',
107
+ riskFlags: skill.risk_json ? JSON.parse(skill.risk_json) : [],
108
+ qualityWarnings: skill.quality_json ? JSON.parse(skill.quality_json) : [],
109
+ install: {
110
+ method: plan.method,
111
+ command: plan.command,
112
+ ...(plan.pluginId ? { pluginId: plan.pluginId } : {}),
113
+ ...(plan.disabled ? { disabled: true } : {}),
114
+ },
115
+ ...(plugin
116
+ ? {
117
+ plugin: {
118
+ id: plugin.id,
119
+ name: plugin.name,
120
+ marketplace: plugin.marketplace,
121
+ skillCount: a.lookup.pluginSkillCount(plugin.id),
122
+ topRisk: a.lookup.pluginTopRisk(plugin.id),
123
+ },
124
+ }
125
+ : {}),
126
+ trust: trustTier(skill, plugin),
127
+ categories: categorize({ name: skill.name, description: desc, whenToUse: skill.when_to_use, marketplaceCategory: plugin?.category }),
128
+ };
129
+ }
@@ -0,0 +1,53 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /** The hidden minigame's query (docs/arasaka-game.md) is never logged: the screen opens it without searching. */
3
+ const NOT_LOGGED = new Set(['arasaka_in']);
4
+ /**
5
+ * Watches one search's events and, at `done`, writes one search-log entry (U5-2, user decision): the query, how
6
+ * many results and hidden ones, how long the first and final results took, failed sources and the filters.
7
+ * Only on this PC, never sent anywhere; the result list itself is not kept. Returns events to send (done gets `sid`).
8
+ */
9
+ export function searchLogger(store, query, filters, started) {
10
+ if (NOT_LOGGED.has(query.trim().toLowerCase()))
11
+ return (e) => e;
12
+ const sid = randomUUID();
13
+ let firstMs = null;
14
+ let results = null;
15
+ const srcFail = [];
16
+ let logged = false;
17
+ return (e) => {
18
+ if ((e.type === 'candidates' || e.type === 'results') && firstMs == null)
19
+ firstMs = Date.now() - started;
20
+ if (e.type === 'results')
21
+ results = e;
22
+ if (e.type === 'source' && (e.state === 'failed' || e.state === 'timeout'))
23
+ srcFail.push(`${e.source}:${e.state}`);
24
+ if (e.type !== 'done' || logged)
25
+ return e;
26
+ logged = true;
27
+ try {
28
+ write(e);
29
+ }
30
+ catch (err) {
31
+ console.error('[search log]', err); // the search itself must not fail over its log entry
32
+ }
33
+ return { ...e, sid };
34
+ };
35
+ function write(e) {
36
+ store.logEvent('search', null, query, {
37
+ sid,
38
+ n: results?.items.length ?? 0,
39
+ hidLow: results?.hiddenLowStars ?? 0,
40
+ // since 2026-11-02 hidLow counts only known star counts below the minimum; unknown ones are here
41
+ hidUnknown: results?.hiddenUnknownStars ?? 0,
42
+ hidNoMatch: results?.hiddenNoMatch ?? 0,
43
+ hidRisk: results?.hiddenHighRisk ?? 0,
44
+ firstMs,
45
+ finalMs: e.elapsedMs,
46
+ cached: e.cached,
47
+ srcFail,
48
+ minStars: results?.minStars ?? null,
49
+ maxRisk: filters.maxRisk ?? 'medium',
50
+ lift: filters.minStars != null,
51
+ });
52
+ }
53
+ }
@@ -0,0 +1,53 @@
1
+ import { DEV_VITE_PORT } from "./config.js";
2
+ export function allowedHosts({ port, dev }) {
3
+ const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
4
+ if (dev) {
5
+ hosts.add(`127.0.0.1:${DEV_VITE_PORT}`);
6
+ hosts.add(`localhost:${DEV_VITE_PORT}`);
7
+ }
8
+ return hosts;
9
+ }
10
+ export function allowedOrigins(opts) {
11
+ return new Set([...allowedHosts(opts)].map((h) => `http://${h}`));
12
+ }
13
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
14
+ function deny(res, message) {
15
+ res.status(403).json({ error: message });
16
+ }
17
+ /**
18
+ * - Host allow-list on every request (DNS rebinding).
19
+ * - `/api` requires `X-Skill-Finder: 1`, which forces a CORS preflight from other origins; no CORS headers are ever sent.
20
+ * - State-changing methods require an Origin that is the app's own origin.
21
+ */
22
+ export function securityMiddleware(opts) {
23
+ // `opts` may be the live config object; the port is only known for sure after listen().
24
+ return (req, res, next) => {
25
+ const hosts = allowedHosts(opts);
26
+ const origins = allowedOrigins(opts);
27
+ res.setHeader('X-Content-Type-Options', 'nosniff');
28
+ res.setHeader('Referrer-Policy', 'no-referrer');
29
+ res.setHeader('X-Frame-Options', 'DENY');
30
+ const host = (req.headers.host ?? '').toLowerCase();
31
+ if (!hosts.has(host))
32
+ return deny(res, 'host not allowed');
33
+ // Any case: routes match /API as /api unless routing is case-sensitive (it is, in createApp), and the check
34
+ // must not depend on that (project review 2026-11-02: /API/... skipped every check below).
35
+ if (req.path.toLowerCase().startsWith('/api')) {
36
+ if (req.method === 'OPTIONS')
37
+ return deny(res, 'cors not allowed');
38
+ // a page on another site; browsers send this on every request they make
39
+ if (req.get('sec-fetch-site') === 'cross-site')
40
+ return deny(res, 'cross-site request');
41
+ if (req.get('x-skill-finder') !== '1')
42
+ return deny(res, 'missing X-Skill-Finder header');
43
+ if (!SAFE_METHODS.has(req.method)) {
44
+ const origin = req.get('origin');
45
+ if (!origin || !origins.has(origin.toLowerCase()))
46
+ return deny(res, 'origin not allowed');
47
+ }
48
+ }
49
+ next();
50
+ };
51
+ }
52
+ export const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
53
+ "connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'";
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod';
2
+ export const SettingsSchema = z.object({
3
+ weights: z.object({
4
+ R: z.number().min(0).max(1),
5
+ P: z.number().min(0).max(1),
6
+ F: z.number().min(0).max(1),
7
+ T: z.number().min(0).max(1),
8
+ }),
9
+ telemetryDisabled: z.boolean(),
10
+ /** Collect skills of lazily indexed marketplaces in the background after each sync. */
11
+ autoCollect: z.boolean(),
12
+ defaultScope: z.enum(['user', 'project']),
13
+ defaultProjectPath: z.string().max(1000),
14
+ /** Search results must come from a GitHub repo with at least this many stars (0 = no limit). */
15
+ minStars: z.number().int().min(0).max(1_000_000),
16
+ /** Keep a search log on this PC (U5-2): what was searched and which results were opened, 90 days. */
17
+ searchLog: z.boolean(),
18
+ });
19
+ export const DEFAULT_SETTINGS = {
20
+ weights: { R: 0.55, P: 0.2, F: 0.1, T: 0.15 },
21
+ telemetryDisabled: true,
22
+ autoCollect: true,
23
+ defaultScope: 'user',
24
+ defaultProjectPath: '',
25
+ minStars: 5000,
26
+ searchLog: true,
27
+ };
28
+ /** Search log entries older than this are deleted (at startup and daily). */
29
+ export const SEARCH_LOG_KEEP_MS = 90 * 24 * 60 * 60 * 1000;
30
+ export const DEFAULT_MARKETPLACES = [
31
+ { name: 'claude-plugins-official', repo: 'anthropics/claude-plugins-official', enabled: true, index_mode: 'eager' },
32
+ { name: 'anthropic-agent-skills', repo: 'anthropics/skills', enabled: true, index_mode: 'eager' },
33
+ { name: 'claude-community', repo: 'anthropics/claude-plugins-community', enabled: true, index_mode: 'lazy' },
34
+ { name: 'knowledge-work-plugins', repo: 'anthropics/knowledge-work-plugins', enabled: false, index_mode: 'lazy' },
35
+ ];
36
+ /** Anthropic-run marketplaces whose external plugins count as partners for the trust score. */
37
+ export const PARTNER_MARKETPLACES = new Set(['claude-plugins-official', 'anthropic-agent-skills', 'knowledge-work-plugins']);
38
+ export const COMMUNITY_MARKETPLACES = new Set(['claude-community']);
39
+ export function getSettings(store) {
40
+ const saved = store.getSetting('app') ?? {};
41
+ // Weights are no longer a setting (UI debate D-1): they never changed the order, only the card numbers and the
42
+ // cache. Saved ones from older versions are ignored.
43
+ const merged = { ...DEFAULT_SETTINGS, ...saved, weights: DEFAULT_SETTINGS.weights };
44
+ const parsed = SettingsSchema.safeParse(merged);
45
+ return parsed.success ? parsed.data : DEFAULT_SETTINGS;
46
+ }
47
+ export function saveSettings(store, next) {
48
+ store.setSetting('app', next);
49
+ }
50
+ export function seedMarketplaces(store) {
51
+ if (store.getSetting('marketplacesSeeded'))
52
+ return;
53
+ for (const m of DEFAULT_MARKETPLACES)
54
+ store.insertMarketplaceIfMissing({ ...m });
55
+ store.setSetting('marketplacesSeeded', true);
56
+ }
@@ -0,0 +1,101 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { mkdir, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { run } from "../util/proc.js";
5
+ const REPO_RE = /^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
6
+ const SHA_RE = /^[0-9a-f]{40}$/i;
7
+ const REF_RE = /^[A-Za-z0-9._\/-]{1,200}$/;
8
+ // No prompts, no credential helpers (Git Credential Manager would pop a window for missing repos),
9
+ // and no lazy blob downloads from the partial clone.
10
+ const GIT_ENV = {
11
+ ...process.env,
12
+ GIT_TERMINAL_PROMPT: '0',
13
+ GIT_NO_LAZY_FETCH: '1',
14
+ GCM_INTERACTIVE: 'never',
15
+ GIT_ASKPASS: '',
16
+ SSH_ASKPASS: '',
17
+ };
18
+ const GIT_BASE = [
19
+ '-c', 'credential.helper=',
20
+ '-c', 'core.askPass=',
21
+ '-c', 'core.longpaths=true',
22
+ '-c', 'http.lowSpeedLimit=1000',
23
+ '-c', 'http.lowSpeedTime=30',
24
+ ];
25
+ const repoUrl = (repo) => `https://github.com/${repo}.git`;
26
+ function git(args, cwd, timeoutMs = 90_000, raw = false) {
27
+ return run('git', [...GIT_BASE, ...args], { cwd, env: GIT_ENV, timeoutMs, raw });
28
+ }
29
+ /** Resolves a ref (or HEAD) to a commit SHA with `git ls-remote`; uses no API quota. */
30
+ export async function lsRemote(repo, ref) {
31
+ if (!REPO_RE.test(repo))
32
+ throw new Error(`invalid repo: ${repo}`);
33
+ if (ref && SHA_RE.test(ref))
34
+ return { sha: ref.toLowerCase(), defaultBranch: null };
35
+ if (ref && !REF_RE.test(ref))
36
+ throw new Error(`invalid ref: ${ref}`);
37
+ const target = ref || 'HEAD';
38
+ const r = await git(['ls-remote', '--symref', repoUrl(repo), target], undefined, 30_000);
39
+ if (r.exitCode !== 0)
40
+ return null;
41
+ let sha = null;
42
+ let peeled = null;
43
+ let defaultBranch = null;
44
+ for (const line of r.stdout.split('\n')) {
45
+ const sym = line.match(/^ref: refs\/heads\/(\S+)\s+HEAD$/);
46
+ if (sym) {
47
+ defaultBranch = sym[1];
48
+ continue;
49
+ }
50
+ const m = line.match(/^([0-9a-f]{40})\s+(\S+)$/);
51
+ if (!m)
52
+ continue;
53
+ if (m[2].endsWith('^{}'))
54
+ peeled = m[1];
55
+ else if (!sha || m[2] === target || m[2] === `refs/heads/${target}`)
56
+ sha = m[1];
57
+ }
58
+ const resolved = peeled ?? sha;
59
+ return resolved ? { sha: resolved, defaultBranch } : null;
60
+ }
61
+ /**
62
+ * Lists every blob path at a commit (or the remote HEAD) using a bare, blob-less, depth-1 fetch.
63
+ * Nothing is checked out. Passing 'HEAD' saves the separate ls-remote round trip.
64
+ */
65
+ export async function gitTree(repo, sha, workRoot) {
66
+ if (!REPO_RE.test(repo))
67
+ throw new Error(`invalid repo: ${repo}`);
68
+ if (sha !== 'HEAD' && !SHA_RE.test(sha))
69
+ throw new Error(`invalid sha: ${sha}`);
70
+ await mkdir(workRoot, { recursive: true });
71
+ // Short folder names: Windows paths over 260 chars break pack writes under deep data dirs.
72
+ const dir = join(workRoot, `${createHash('sha1').update(repo).digest('hex').slice(0, 10)}-${randomBytes(3).toString('hex')}`);
73
+ try {
74
+ const init = await git(['init', '--bare', '-q', dir]);
75
+ if (init.exitCode !== 0)
76
+ throw new Error(`git init failed: ${init.stderr.trim()}`);
77
+ const fetch = await git(['fetch', '-q', '--depth', '1', '--filter=blob:none', '--no-tags', repoUrl(repo), sha], dir);
78
+ if (fetch.exitCode !== 0)
79
+ throw new Error(`git fetch failed: ${fetch.stderr.trim().split('\n')[0] || fetch.exitCode}`);
80
+ if (sha === 'HEAD') {
81
+ const rev = await git(['rev-parse', 'FETCH_HEAD'], dir);
82
+ sha = rev.stdout.trim();
83
+ if (rev.exitCode !== 0 || !SHA_RE.test(sha))
84
+ throw new Error('git rev-parse FETCH_HEAD failed');
85
+ }
86
+ const ls = await git(['ls-tree', '-r', '-z', '--full-tree', sha], dir, 90_000, true);
87
+ if (ls.exitCode !== 0)
88
+ throw new Error(`git ls-tree failed: ${ls.stderr.trim()}`);
89
+ const entries = [];
90
+ for (const rec of ls.stdout.split('\0')) {
91
+ const m = rec.match(/^\d+ (blob|commit|tree) ([0-9a-f]{40})\t(.+)$/s);
92
+ if (m && m[1] === 'blob')
93
+ entries.push({ path: m[3], size: null, sha: m[2] });
94
+ }
95
+ const log = await git(['log', '-1', '--format=%cI', sha], dir);
96
+ return { sha, entries, commitDate: log.exitCode === 0 ? log.stdout.trim() || null : null };
97
+ }
98
+ finally {
99
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
100
+ }
101
+ }