@seojin08/skill-finder 0.3.2 → 0.3.4

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 (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -2
  3. package/bin/skill-finder.mjs +4 -4
  4. package/package.json +4 -3
  5. package/seed-index.json.gz +0 -0
  6. package/server/src/app.js +3 -0
  7. package/server/src/bootstrap.js +2 -1
  8. package/server/src/config.js +16 -4
  9. package/server/src/db/schema.js +3 -0
  10. package/server/src/db/seed.js +89 -0
  11. package/server/src/db/store.js +4 -3
  12. package/server/src/discovery/genericRules.js +3 -1
  13. package/server/src/index.js +11 -0
  14. package/server/src/indexer.js +5 -1
  15. package/server/src/install/installed.js +96 -26
  16. package/server/src/recommend/readme.js +14 -2
  17. package/server/src/routes/favorites.js +55 -10
  18. package/server/src/routes/install.js +33 -7
  19. package/server/src/routes/translate.js +35 -0
  20. package/server/src/search/pipeline.js +3 -1
  21. package/server/src/search/result.js +9 -1
  22. package/server/src/security.js +4 -1
  23. package/server/src/sources/gitTree.js +11 -1
  24. package/server/src/sources/github.js +17 -2
  25. package/server/src/tasks.js +45 -5
  26. package/server/src/translate/models.js +140 -0
  27. package/web/dist/THIRD-PARTY-LICENSES.txt +6 -0
  28. package/web/dist/assets/{ArasakaGame-C1Wkmqik.js → ArasakaGame-B2qY9Lds.js} +2 -2
  29. package/web/dist/assets/__vite-browser-external-BDfy7Drd.js +1 -0
  30. package/web/dist/assets/index-BJ6vm3xw.js +66 -0
  31. package/web/dist/assets/index-BiikysiH.css +2 -0
  32. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  33. package/web/dist/assets/translator-Cyk_ZOaM.js +2 -0
  34. package/web/dist/bergamot/bergamot-translator-worker.js +2919 -0
  35. package/web/dist/bergamot/bergamot-translator-worker.wasm +0 -0
  36. package/web/dist/bergamot/translator-worker.js +475 -0
  37. package/web/dist/index.html +3 -2
  38. package/web/dist/assets/index-BgL1OaU4.css +0 -2
  39. package/web/dist/assets/index-Ec2XBK4-.js +0 -66
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  새 버전의 항목은 `npm run package`가 내려받기 페이지의 meta.json에 서명과 함께 넣고, 앱의 "새 버전" 표시를 누르면 보입니다. 한 줄이 한 항목입니다.
4
4
 
5
+ ## 0.3.4 (2026-11-03)
6
+
7
+ - 처음 실행할 때 함께 들어 있는 스킬 색인을 바로 씁니다. 몇 분씩 모으기를 기다리지 않아도 스킬 1만여 개를 몇 초 만에 검색할 수 있습니다.
8
+ - 처음 실행하면 Anthropic 공식 스킬부터 모읍니다.
9
+ - 지워진 저장소나 커밋을 동기화 때마다 다시 묻지 않고, GitHub가 요청이 많다고 하면 잠깐 쉬었다가 다시 받습니다.
10
+ - GitHub가 잠깐 답하지 않아도 저장소 별 수 갱신을 멈추지 않고, 나눠서 다시 묻습니다.
11
+ - 검색에 나오는 GitHub 저장소 카드에도 별을 눌러 즐겨찾기에 넣을 수 있습니다.
12
+ - 즐겨찾기 화면 왼쪽에서 종류(스킬·GitHub 저장소)·제공자·카테고리·설치 방식으로 골라 볼 수 있습니다.
13
+ - Windows에 설치하면 터미널 명령 skill-finder가 생깁니다(사용자 PATH에 추가). 새로 연 터미널에서 skill-finder --version으로 설치된 버전을 보고, skill-finder로 앱을 켭니다.
14
+ - Firefox·Safari 등 번역 기능이 없는 브라우저에서도 스킬 설명을 화면 언어(한국어·일본어)로 번역합니다. 버튼을 누르지 않아도 처음 한 번 Firefox와 같은 Mozilla 번역 모델(약 46MB)을 받고, 번역은 이 PC 안에서만 합니다. Chrome은 페이지에서 처음 클릭하거나 입력할 때 번역 모델을 받습니다.
15
+ - 이미 한국어·일본어인 설명이나 기호뿐인 글은 번역하지 않습니다. README 추천의 요약이 엉뚱한 글자로 바뀌던 문제가 고쳐졌습니다.
16
+ - README 추천 결과에서 이미 설치한 스킬(고른 프로젝트·개인 범위)에 "설치됨"이 붙고, 칩을 눌러 숨길 수 있습니다. 왼쪽 필터로 제공자·카테고리·설치 방식을 골라 볼 수 있습니다.
17
+ - 설치됨 화면에 플러그인 안에 든 스킬도 나오고, 고른 프로젝트와 상관없는 다른 폴더의 플러그인은 빠집니다. 스킬 목록을 범위·설치 경로·위험·색인·제공자·카테고리로 골라 보고, 5·10·20개씩 페이지로 넘깁니다.
18
+ - README 추천이나 설치됨 화면에서 연 스킬은 그 화면에서 고른 프로젝트 폴더에 설치하도록 열립니다(범위는 바꿀 수 있음). 위험 등급이 주의 이상인 스킬을 개인 범위로 설치하려 하면 이 프로젝트에만 설치하는 것도 고려하라고 알려 줍니다.
19
+
20
+ ## 0.3.3 (2026-11-03)
21
+
22
+ - 스킬 설치에 쓰는 skills CLI를 1.7.6으로 올렸습니다. 저장소 안의 바로가기(symlink)를 통해 스킬 폴더 밖의 파일이 함께 복사될 수 있던 보안 문제가 막혔습니다.
23
+ - 이제 Node.js 22.20 이상이 필요합니다. 더 낮은 버전이면 실행할 때 업데이트 안내가 나옵니다.
24
+
5
25
  ## 0.3.2 (2026-11-02)
6
26
 
7
27
  - 영어·일본어 화면에서 숫자·이름·명령이 끼는 문장이 그 언어의 어순으로 자연스럽게 나옵니다.
package/README.md CHANGED
@@ -23,7 +23,7 @@ Claude Code 스킬을 한곳에서 찾아 점수·위험 신호와 함께 보고
23
23
 
24
24
  ### macOS·Linux·Windows (npx)
25
25
 
26
- Node.js 22.18 이상이 있으면 설치 없이 실행합니다. 데이터는 사용자 폴더(macOS `~/Library/Application Support/Skill Finder`, Linux `~/.local/share/skill-finder`, Windows `%LOCALAPPDATA%\SkillFinder\npx-data`)에 남습니다.
26
+ Node.js 22.20 이상이 있으면 설치 없이 실행합니다. 데이터는 사용자 폴더(macOS `~/Library/Application Support/Skill Finder`, Linux `~/.local/share/skill-finder`, Windows `%LOCALAPPDATA%\SkillFinder\npx-data`)에 남습니다.
27
27
 
28
28
  ```bash
29
29
  npx @seojin08/skill-finder
@@ -45,7 +45,7 @@ npx @seojin08/skill-finder stop # 백그라운드로 켠 앱 끄
45
45
 
46
46
  ### 소스에서
47
47
 
48
- 필요한 것: Node.js 22.18 이상(24 권장), git, Claude Code(`claude` 명령).
48
+ 필요한 것: Node.js 22.20 이상(24 권장), git, Claude Code(`claude` 명령).
49
49
 
50
50
  ```bash
51
51
  npm install
@@ -34,7 +34,7 @@ const T = {
34
34
 
35
35
  공통: --json(기계가 읽는 출력), --port N, --no-open, --lang ko|en|ja
36
36
  설치·삭제는 앱 화면의 확인 창에서만 합니다.`,
37
- node: (v) => `Node.js ${v}는 너무 오래됐습니다. 22.18 이상이 필요합니다: https://nodejs.org`,
37
+ node: (v) => `Node.js ${v}는 너무 오래됐습니다. 22.20 이상이 필요합니다: https://nodejs.org`,
38
38
  noWeb: '웹 화면이 빌드되지 않았습니다. 먼저 npm run build를 실행하세요.',
39
39
  starting: (p) => `Skill Finder를 켜는 중입니다 (127.0.0.1:${p})…`,
40
40
  background: (p) => `Skill Finder를 백그라운드로 켰습니다 (127.0.0.1:${p}). 끄려면: skill-finder stop`,
@@ -70,7 +70,7 @@ Usage:
70
70
 
71
71
  Common: --json (machine-readable), --port N, --no-open, --lang ko|en|ja
72
72
  Installing and removing happen only in the app's confirm dialog.`,
73
- node: (v) => `Node.js ${v} is too old. 22.18 or later is needed: https://nodejs.org`,
73
+ node: (v) => `Node.js ${v} is too old. 22.20 or later is needed: https://nodejs.org`,
74
74
  noWeb: 'The web app is not built. Run npm run build first.',
75
75
  starting: (p) => `Starting Skill Finder (127.0.0.1:${p})…`,
76
76
  background: (p) => `Started Skill Finder in the background (127.0.0.1:${p}). To stop it: skill-finder stop`,
@@ -106,7 +106,7 @@ Installing and removing happen only in the app's confirm dialog.`,
106
106
 
107
107
  共通: --json(機械向け出力), --port N, --no-open, --lang ko|en|ja
108
108
  インストールと削除はアプリの確認画面でのみ行います。`,
109
- node: (v) => `Node.js ${v} は古すぎます。22.18 以上が必要です: https://nodejs.org`,
109
+ node: (v) => `Node.js ${v} は古すぎます。22.20 以上が必要です: https://nodejs.org`,
110
110
  noWeb: 'Web 画面がビルドされていません。先に npm run build を実行してください。',
111
111
  starting: (p) => `Skill Finder を起動しています (127.0.0.1:${p})…`,
112
112
  background: (p) => `Skill Finder をバックグラウンドで起動しました (127.0.0.1:${p})。止めるには: skill-finder stop`,
@@ -204,7 +204,7 @@ function readPid() {
204
204
  /** Starts the server: in this terminal (`start`), or detached for the read commands. Resolves its port. */
205
205
  async function startServer(background) {
206
206
  const [major, minor] = process.versions.node.split('.').map(Number);
207
- if (major < 22 || (major === 22 && minor < 18)) throw new Error(t.node(process.versions.node));
207
+ if (major < 22 || (major === 22 && minor < 20)) throw new Error(t.node(process.versions.node));
208
208
  if (!existsSync(join(ROOT, 'web', 'dist', 'index.html'))) throw new Error(t.noWeb);
209
209
  let port = Number(portArg) || Number(process.env.PORT) || DEFAULT_PORT;
210
210
  if (!(await portFree(port))) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seojin08/skill-finder",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Find Claude Code skills across marketplaces, skills.sh and GitHub, see their risk signals, and install them after checking what comes along. Local web app and read-only CLI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,11 +8,11 @@
8
8
  "skill-finder": "bin/skill-finder.mjs"
9
9
  },
10
10
  "engines": {
11
- "node": ">=22.18"
11
+ "node": ">=22.20"
12
12
  },
13
13
  "dependencies": {
14
14
  "express": "5.3.0",
15
- "skills": "1.7.4",
15
+ "skills": "1.7.6",
16
16
  "yaml": "2.9.1",
17
17
  "zod": "4.6.7"
18
18
  },
@@ -33,6 +33,7 @@
33
33
  "bin",
34
34
  "server",
35
35
  "web/dist",
36
+ "seed-index.json.gz",
36
37
  "README.md",
37
38
  "LICENSE",
38
39
  "CHANGELOG.md",
Binary file
package/server/src/app.js CHANGED
@@ -10,6 +10,7 @@ import { favoritesRouter } from "./routes/favorites.js";
10
10
  import { recommendRouter } from "./routes/recommend.js";
11
11
  import { searchRouter } from "./routes/search.js";
12
12
  import { skillsRouter } from "./routes/skills.js";
13
+ import { translateFiles, translateRouter } from "./routes/translate.js";
13
14
  import { CSP, securityMiddleware } from "./security.js";
14
15
  export function createApp(ctx, opts = {}) {
15
16
  const app = express();
@@ -27,12 +28,14 @@ export function createApp(ctx, opts = {}) {
27
28
  app.use('/api', favoritesRouter(ctx));
28
29
  app.use('/api', recommendRouter(ctx));
29
30
  app.use('/api', adminRouter(ctx));
31
+ app.use('/api', translateRouter(ctx));
30
32
  // any other /api path, in any case (/API/... is not an API route and not a page either)
31
33
  app.use((req, res, next) => {
32
34
  if (!req.path.toLowerCase().startsWith('/api'))
33
35
  return next();
34
36
  res.status(404).json({ error: '없는 API 주소입니다' });
35
37
  });
38
+ app.use(translateFiles(ctx));
36
39
  if (existsSync(WEB_DIST_DIR)) {
37
40
  app.use((_req, res, next) => {
38
41
  res.setHeader('Content-Security-Policy', CSP);
@@ -6,13 +6,14 @@ import { Indexer } from "./indexer.js";
6
6
  import { Jobs } from "./jobs.js";
7
7
  import { seedMarketplaces } from "./settings.js";
8
8
  import { GitHub } from "./sources/github.js";
9
+ import { TranslateModels } from "./translate/models.js";
9
10
  import { APP_VERSION } from "./version.js";
10
11
  export function createContext(config, dbPath = join(config.dataDir, 'skill-finder.db')) {
11
12
  const store = new Store(openDb(dbPath, { backupLabel: dbPath === ':memory:' ? null : APP_VERSION }));
12
13
  seedMarketplaces(store);
13
14
  const github = new GitHub(store, config);
14
15
  const indexer = new Indexer(store, github);
15
- return { config, store, github, indexer, jobs: new Jobs() };
16
+ return { config, store, github, indexer, jobs: new Jobs(), translateModels: new TranslateModels(join(config.dataDir, 'translate-models')) };
16
17
  }
17
18
  /**
18
19
  * createContext for the server: a damaged database file (project review #4) is moved aside and the newest backup
@@ -7,19 +7,31 @@ export const SERVER_DIR = join(ROOT_DIR, 'server');
7
7
  export const WEB_DIST_DIR = join(ROOT_DIR, 'web', 'dist');
8
8
  /** The release launcher keeps the token outside the app folder (UI debate D-6); from the environment only. */
9
9
  export const ENV_PATH = process.env.SKILL_FINDER_ENV_PATH ? resolve(process.env.SKILL_FINDER_ENV_PATH) : join(ROOT_DIR, '.env');
10
- export const SKILLS_CLI_VERSION = '1.7.4';
11
10
  /** The bundled skills CLI: under the app, or wherever npm put it when installed as a package (npx hoists it). */
12
- export const SKILLS_CLI_PATH = (() => {
13
- const local = join(ROOT_DIR, 'node_modules', 'skills', 'bin', 'cli.mjs');
11
+ const SKILLS_DIR = (() => {
12
+ const local = join(ROOT_DIR, 'node_modules', 'skills');
14
13
  if (existsSync(local))
15
14
  return local;
16
15
  try {
17
- return join(dirname(createRequire(import.meta.url).resolve('skills/package.json')), 'bin', 'cli.mjs');
16
+ return dirname(createRequire(import.meta.url).resolve('skills/package.json'));
18
17
  }
19
18
  catch {
20
19
  return local;
21
20
  }
22
21
  })();
22
+ export const SKILLS_CLI_PATH = join(SKILLS_DIR, 'bin', 'cli.mjs');
23
+ /**
24
+ * The version the app runs, read from the bundled package so the commands it shows for running by hand name the same
25
+ * one (it was written here by hand and could fall behind the package, review 2026-11-03).
26
+ */
27
+ export const SKILLS_CLI_VERSION = (() => {
28
+ try {
29
+ return String(JSON.parse(readFileSync(join(SKILLS_DIR, 'package.json'), 'utf8')).version);
30
+ }
31
+ catch {
32
+ return 'latest'; // no bundled CLI: installing fails anyway, and the shown command still runs
33
+ }
34
+ })();
23
35
  /** Vite dev server port (web/vite.config.ts); allowed as a Host/Origin in --dev mode only. */
24
36
  export const DEV_VITE_PORT = 5174;
25
37
  function parseEnvFile(text) {
@@ -92,4 +92,7 @@ export const MIGRATIONS = [
92
92
  ALTER TABLE favorites_local RENAME TO favorites;
93
93
  DELETE FROM settings WHERE key = 'sessionKey';
94
94
  `,
95
+ // Favorite GitHub repositories (user request 2026-11-03) are `repo:owner/name` rows; the description and topics the
96
+ // search showed are kept with them, since the index has neither.
97
+ `ALTER TABLE favorites ADD COLUMN meta_json TEXT;`,
95
98
  ];
@@ -0,0 +1,89 @@
1
+ // A prebuilt index shipped with a release (first-run speed, 2026-11-03): the default marketplaces' plugins, their
2
+ // skills and the star counts of the repos they come from, taken from the index the release was built with. Nothing
3
+ // a user searched for, set, starred or installed is in it. A new install puts it into its empty database at
4
+ // startup, so the first searches need not wait ~20 minutes for 2,600 plugins to be read from GitHub (without a
5
+ // token); the normal sync then reads only what changed since.
6
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { join, resolve } from 'node:path';
9
+ import { gunzipSync, gzipSync } from 'node:zlib';
10
+ import { ROOT_DIR } from "../config.js";
11
+ import { RISK_RULES_VERSION } from "../analyze/risk.js";
12
+ import { pluginFtsText } from "../indexer.js";
13
+ import { DEFAULT_MARKETPLACES } from "../settings.js";
14
+ const FORMAT = 1;
15
+ /** Next to server/ and web/ in a release (the package scripts write it); a checkout has none. */
16
+ export const SEED_PATH = process.env.SKILL_FINDER_SEED_PATH ? resolve(process.env.SKILL_FINDER_SEED_PATH) : join(ROOT_DIR, 'seed-index.json.gz');
17
+ /** Plugin errors kept in the seed: the lasting verdicts the app shows. Others are retried anyway and may name local paths. */
18
+ const lastingError = (e) => (e && /^(설치 불가:|GitHub 외 출처)/.test(e) ? e : null);
19
+ /** The marketplaces a new install has on: one turned on later on the building PC would be searched though off. */
20
+ export function exportSeed(db, now = new Date()) {
21
+ const all = (sql, ...args) => db.prepare(sql).all(...args).map((r) => ({ ...r }));
22
+ const marketplaces = DEFAULT_MARKETPLACES.filter((d) => d.enabled).flatMap((d) => all('SELECT name, repo, commit_sha, etag, plugin_count FROM marketplaces WHERE name = ? AND repo = ? AND commit_sha IS NOT NULL', d.name, d.repo));
23
+ const names = JSON.stringify(marketplaces.map((m) => m.name));
24
+ const ofSeed = 'SELECT id FROM plugins WHERE marketplace IN (SELECT value FROM json_each(?))';
25
+ const plugins = all(`SELECT * FROM plugins WHERE id IN (${ofSeed}) ORDER BY id`, names).map((p) => ({ ...p, error: lastingError(p.error) }));
26
+ const skills = all(`SELECT * FROM skills WHERE origin = 'plugin' AND plugin_id IN (${ofSeed}) ORDER BY id`, names);
27
+ const repos = all(`SELECT * FROM repos WHERE lower(full_name) IN (
28
+ SELECT lower(repo) FROM plugins WHERE id IN (${ofSeed}) AND repo IS NOT NULL
29
+ UNION SELECT lower(repo) FROM skills WHERE origin = 'plugin' AND plugin_id IN (${ofSeed}) AND repo IS NOT NULL)
30
+ ORDER BY full_name`, names, names);
31
+ return { format: FORMAT, createdAt: now.toISOString(), marketplaces, plugins, skills, repos };
32
+ }
33
+ /**
34
+ * Writes the seed of the database at `dbPath` as gzip JSON: read-only and in one read transaction, so a running app
35
+ * may hold it and a sync it is doing meanwhile does not split plugins from their skills. Refuses an index that would
36
+ * undo the point: empty, or checked with older risk rules (every install would download all SKILL.md files again).
37
+ */
38
+ export function writeSeedFile(dbPath, outPath) {
39
+ const db = new DatabaseSync(dbPath, { readOnly: true });
40
+ try {
41
+ db.exec('BEGIN');
42
+ const seed = exportSeed(db);
43
+ db.exec('COMMIT');
44
+ if (seed.plugins.length === 0 || seed.skills.length === 0)
45
+ throw new Error('색인에 기본 마켓플레이스의 스킬이 없습니다. 앱으로 한 번 동기화한 뒤 만드세요');
46
+ const old = seed.skills.filter((s) => s.risk_rules != null && s.risk_rules < RISK_RULES_VERSION).length;
47
+ if (old > 0)
48
+ throw new Error(`스킬 ${old}개가 옛 위험 규칙으로 검사돼 있습니다. 앱을 실행해 다시 검사가 끝난 뒤 만드세요`);
49
+ const gz = gzipSync(JSON.stringify(seed), { level: 9 });
50
+ writeFileSync(outPath, gz);
51
+ return { plugins: seed.plugins.length, skills: seed.skills.length, repos: seed.repos.length, bytes: gz.length };
52
+ }
53
+ finally {
54
+ db.close();
55
+ }
56
+ }
57
+ /**
58
+ * Puts the seed at `path` into a database with no plugins and no skills yet (a new install); does nothing otherwise
59
+ * or without the file. Marketplaces whose repo differs from the seed's are left to the sync. Star counts are dated
60
+ * when the seed was made, so searches stop trusting them a week after the release as they would any other count.
61
+ */
62
+ export function importSeed(store, path) {
63
+ if (!existsSync(path) || store.countPlugins() > 0 || store.countSkills() > 0)
64
+ return null;
65
+ const seed = JSON.parse(gunzipSync(readFileSync(path)).toString('utf8'));
66
+ if (seed.format !== FORMAT)
67
+ throw new Error(`알 수 없는 색인 형식 ${seed.format}`);
68
+ const local = new Map(store.listMarketplaces().map((m) => [m.name, m.repo]));
69
+ const marketplaces = seed.marketplaces.filter((m) => local.get(m.name) === m.repo);
70
+ const names = new Set(marketplaces.map((m) => m.name));
71
+ const plugins = new Map(seed.plugins.filter((p) => names.has(p.marketplace)).map((p) => [p.id, p]));
72
+ const skills = seed.skills.filter((s) => s.plugin_id && plugins.has(s.plugin_id));
73
+ // never later than the counts themselves (the building PC refreshes them every 6 hours)
74
+ const madeAt = seed.createdAt;
75
+ store.transaction(() => {
76
+ for (const m of marketplaces)
77
+ store.updateMarketplaceSync(m.name, { etag: m.etag, plugin_count: m.plugin_count ?? undefined, commit_sha: m.commit_sha });
78
+ for (const p of plugins.values()) {
79
+ store.upsertPlugin(p);
80
+ if (p.skills_indexed_at)
81
+ store.markPluginIndexed(p.id, p.skills_indexed_sha, p.signals_json ? JSON.parse(p.signals_json) : null, p.error);
82
+ }
83
+ for (const s of skills)
84
+ store.upsertSkill(s, pluginFtsText(plugins.get(s.plugin_id)));
85
+ for (const r of seed.repos)
86
+ store.upsertRepo({ ...r, fetched_at: r.fetched_at && r.fetched_at < madeAt ? r.fetched_at : madeAt });
87
+ });
88
+ return { plugins: plugins.size, skills: skills.length, repos: seed.repos.length };
89
+ }
@@ -316,15 +316,16 @@ export class Store {
316
316
  .slice(0, limit);
317
317
  }
318
318
  // ── favorites: this PC's, no accounts (user decision 2026-11-02) ──
319
- addFavorite(skillId, skillName) {
320
- this.run('INSERT INTO favorites (skill_id, skill_name, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', skillId, skillName, now());
319
+ /** `meta`: what a repository favorite shows beyond its name (description, topics). */
320
+ addFavorite(skillId, skillName, meta = null) {
321
+ this.run('INSERT INTO favorites (skill_id, skill_name, created_at, meta_json) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING', skillId, skillName, now(), meta == null ? null : JSON.stringify(meta));
321
322
  }
322
323
  removeFavorite(skillId) {
323
324
  this.run('DELETE FROM favorites WHERE skill_id = ?', skillId);
324
325
  }
325
326
  /** Newest first. */
326
327
  listFavorites() {
327
- return this.all('SELECT skill_id, skill_name, created_at FROM favorites ORDER BY created_at DESC, rowid DESC');
328
+ return this.all('SELECT skill_id, skill_name, created_at, meta_json FROM favorites ORDER BY created_at DESC, rowid DESC');
328
329
  }
329
330
  /** Skills the app installed into a skills folder (skills CLI), newest first, to match those folders to the index. */
330
331
  listInstalledSkillLog() {
@@ -1,5 +1,7 @@
1
1
  import { basename, joinPath, skillMdPath } from "./tree.js";
2
- // Mirrors skills CLI 1.7.4 src/skills.ts (discoverSkills) so that what we list is what `skills add --skill` can install.
2
+ // Mirrors skills CLI 1.7.6 src/skills.ts (discoverSkills) so that what we list is what `skills add --skill` can install.
3
+ // Unchanged since 1.7.4 except two things not mirrored: extra search dirs a repo can list in skills.sh.json (`paths`),
4
+ // and skipping a skill whose name or description is empty once control characters are removed.
3
5
  export const SKIP_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__'];
4
6
  export const AGENT_PROJECT_SKILL_DIRS = [
5
7
  '.agents/skills',
@@ -2,6 +2,7 @@ import { config, ensureDataDirs } from "./config.js";
2
2
  import { createApp } from "./app.js";
3
3
  import { openContext } from "./bootstrap.js";
4
4
  import { DataTooNewError } from "./db/index.js";
5
+ import { importSeed, SEED_PATH } from "./db/seed.js";
5
6
  import { startUpdateChecks } from "./update.js";
6
7
  import { startLogFile } from "./util/log.js";
7
8
  import { pruneSearchLog, recheckRiskRules, refreshRepoMeta, relevelRisk, REPO_META_EVERY_MS, startSync } from "./tasks.js";
@@ -26,6 +27,16 @@ const ctx = (() => {
26
27
  process.exit(1);
27
28
  }
28
29
  })();
30
+ // A new install starts from the index shipped with the release instead of an empty one; any problem only costs speed.
31
+ try {
32
+ const t0 = Date.now();
33
+ const seeded = importSeed(ctx.store, SEED_PATH);
34
+ if (seeded)
35
+ console.log(`[seed] 미리 만든 색인을 넣었습니다: 스킬 ${seeded.skills}개, 플러그인 ${seeded.plugins}개, 저장소 ${seeded.repos}개 (${((Date.now() - t0) / 1000).toFixed(1)}초)`);
36
+ }
37
+ catch (err) {
38
+ console.warn('[seed] 미리 만든 색인을 넣지 못해 GitHub에서 처음부터 모읍니다:', String(err.message ?? err).slice(0, 200));
39
+ }
29
40
  const app = createApp(ctx);
30
41
  // Express' listen(port) binds 0.0.0.0; always pass the loopback host explicitly.
31
42
  const server = app.listen(config.port, HOST, () => {
@@ -6,6 +6,7 @@ import { discoverRepo, parseJsonObject, PLUGIN_JSON } from "./discovery/classify
6
6
  import { genericAcceptor } from "./discovery/genericRules.js";
7
7
  import { discoverPluginSkills } from "./discovery/pluginRules.js";
8
8
  import { basename, joinPath, skillMdPath, TreeIndex } from "./discovery/tree.js";
9
+ import { RepoUnreachableError } from "./sources/gitTree.js";
9
10
  import { RAW_MAX_BYTES } from "./sources/github.js";
10
11
  import { normalizeSource, resolveRelativeRoot } from "./sources/pluginSource.js";
11
12
  import { pool } from "./util/limiter.js";
@@ -280,7 +281,10 @@ export class Indexer {
280
281
  const path = msg.match(/^플러그인 경로가 없습니다: (.*)$/);
281
282
  if (path)
282
283
  return `마켓플레이스가 가리키는 폴더가 저장소에 없습니다 (${path[1]})`;
283
- if (/^tree 422|^tree 409/.test(msg))
284
+ if (err instanceof RepoUnreachableError)
285
+ return '저장소가 삭제됐거나 비공개입니다';
286
+ // "not our ref": git's answer for a pinned commit no branch holds any more (the tree API says 422)
287
+ if (/^tree 422|^tree 409|not our ref/.test(msg))
284
288
  return '마켓플레이스가 고정한 커밋이 저장소 기록에서 지워졌습니다';
285
289
  if (/^tree 404|커밋을 찾을 수 없습니다/.test(msg)) {
286
290
  const exists = await this.github.repoExists(repo);
@@ -1,9 +1,9 @@
1
1
  // What is installed on this PC and how it matches the index (UI debate stage 3: shown only, nothing is run).
2
2
  import { createHash } from 'node:crypto';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
- import { readdir, readFile } from 'node:fs/promises';
4
+ import { readdir, readFile, realpath } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
- import { join, posix } from 'node:path';
6
+ import { basename, join, posix, resolve, sep } from 'node:path';
7
7
  import { extractSkillMeta } from "../analyze/frontmatter.js";
8
8
  import { riskLevel, skillRiskFlags } from "../analyze/risk.js";
9
9
  import { runClaude } from "../claude/cli.js";
@@ -56,7 +56,7 @@ function readLock(file) {
56
56
  }
57
57
  return byFolder;
58
58
  }
59
- /** The folder the skills CLI installs a skill into (its `sanitizeName`, skills@1.7.4). */
59
+ /** The folder the skills CLI installs a skill into (its `sanitizeName`, the same in skills@1.7.4 and 1.7.6). */
60
60
  export function skillFolderName(name) {
61
61
  return name.toLowerCase().replace(/[^a-z0-9._]+/g, '-').replace(/^[.\-]+|[.\-]+$/g, '').slice(0, 255) || 'unnamed-skill';
62
62
  }
@@ -89,36 +89,106 @@ export async function scanInstalledSkills(store, scope, projectPath, onlyFolder)
89
89
  if (onlyFolder && folder.toLowerCase() !== onlyFolder.toLowerCase())
90
90
  continue;
91
91
  const path = join(dir, folder);
92
- let raw;
93
- try {
94
- raw = await readFile(join(path, 'SKILL.md'), 'utf8');
95
- }
96
- catch {
92
+ const hit = await inspectSkill(store, path, folder, (name) => {
93
+ const log = logged.get(name.toLowerCase()) ?? logged.get(folder.toLowerCase());
94
+ const fromLock = lock.get(folder.toLowerCase());
95
+ const id = (fromLock && store.getSkill(fromLock)?.id) || (log && store.getSkill(log.id) ? log.id : null);
96
+ return { id: id || null, installedRisk: log && log.id === id ? log.risk : null };
97
+ });
98
+ if (!hit)
97
99
  continue; // not a skill folder
98
- }
99
- const meta = extractSkillMeta(raw, folder);
100
- const hash = mdHash(raw);
101
- const log = logged.get(meta.name.toLowerCase()) ?? logged.get(folder.toLowerCase());
102
- const fromLock = lock.get(folder.toLowerCase());
103
- const skillId = (fromLock && store.getSkill(fromLock)?.id) || (log && store.getSkill(log.id) ? log.id : null) || store.findSkillIdByMdSha(hash);
104
- const indexed = skillId ? store.getSkill(skillId) : undefined;
105
- const flags = skillRiskFlags({ files: await listFiles(path), allowedTools: meta.allowedTools, body: meta.body, hasHooks: meta.hasHooks, raw });
106
100
  out.push({
101
+ ...hit,
107
102
  scope,
108
- path,
109
- folder,
110
- name: meta.name,
111
- description: truncate(meta.description, 200),
112
103
  removeCommand: `npx skills@${SKILLS_CLI_VERSION} remove ${/\s/.test(folder) ? `"${folder}"` : folder} -a claude-code -y${scope === 'user' ? ' -g' : ''}`,
113
- skillId,
114
- risk: { level: riskLevel(flags), flags },
115
- installedRisk: (log?.id === skillId ? log?.risk : null) ?? null,
116
- change: !indexed?.skill_md_sha256 ? 'unknown' : indexed.skill_md_sha256 === hash ? 'same' : 'changed',
117
- tokens: skillTokens(meta.name, meta.description, raw),
118
104
  });
119
105
  }
120
106
  return out;
121
107
  }
108
+ /**
109
+ * One skill folder as installed: its SKILL.md, the current risk rules run on its files, and its index row — the one
110
+ * `known` names (lock file, install log, plugin), else an indexed copy of the same SKILL.md. Null without a SKILL.md.
111
+ */
112
+ async function inspectSkill(store, path, folder, known) {
113
+ let raw;
114
+ try {
115
+ raw = await readFile(join(path, 'SKILL.md'), 'utf8');
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ const meta = extractSkillMeta(raw, folder);
121
+ const hash = mdHash(raw);
122
+ const { id, installedRisk } = known(meta.name);
123
+ const skillId = id ?? store.findSkillIdByMdSha(hash);
124
+ const indexed = skillId ? store.getSkill(skillId) : undefined;
125
+ const flags = skillRiskFlags({ files: await listFiles(path), allowedTools: meta.allowedTools, body: meta.body, hasHooks: meta.hasHooks, raw });
126
+ return {
127
+ path,
128
+ folder,
129
+ name: meta.name,
130
+ description: truncate(meta.description, 200),
131
+ skillId,
132
+ risk: { level: riskLevel(flags), flags },
133
+ installedRisk: id && id === skillId ? installedRisk : null,
134
+ change: !indexed?.skill_md_sha256 ? 'unknown' : indexed.skill_md_sha256 === hash ? 'same' : 'changed',
135
+ tokens: skillTokens(meta.name, meta.description, raw),
136
+ };
137
+ }
138
+ /**
139
+ * The skills inside an installed plugin, read from its install folder (user report 2026-11-04: the 설치됨 screen
140
+ * listed a plugin's skills only when the index knew them, folded away, so a project's skills looked incomplete).
141
+ * Claude Code reads the plugin's `skills/` folder plus the `skills` path(s) of `.claude-plugin/plugin.json`; each
142
+ * path is a skill folder or a folder of them, and must stay inside the install folder.
143
+ */
144
+ export async function scanPluginSkills(store, plugin) {
145
+ if (typeof plugin.installPath !== 'string' || !plugin.installPath)
146
+ return [];
147
+ const root = resolve(plugin.installPath);
148
+ let declared;
149
+ try {
150
+ declared = JSON.parse(await readFile(join(root, '.claude-plugin', 'plugin.json'), 'utf8')).skills;
151
+ }
152
+ catch {
153
+ // no manifest: the default folder only
154
+ }
155
+ const paths = ['skills', ...(typeof declared === 'string' ? [declared] : Array.isArray(declared) ? declared.filter((p) => typeof p === 'string') : [])];
156
+ const candidates = [];
157
+ for (const p of paths) {
158
+ const dir = resolve(root, p);
159
+ if (existsSync(join(dir, 'SKILL.md'))) {
160
+ candidates.push(dir);
161
+ continue;
162
+ }
163
+ try {
164
+ for (const e of await readdir(dir, { withFileTypes: true }))
165
+ if (e.isDirectory() || e.isSymbolicLink())
166
+ candidates.push(join(dir, e.name));
167
+ }
168
+ catch {
169
+ // no such folder
170
+ }
171
+ }
172
+ // a path or a link leading out of the plugin is not read: compared by where it really is
173
+ const realRoot = await realpath(root).catch(() => null);
174
+ if (!realRoot)
175
+ return [];
176
+ const dirs = new Set();
177
+ for (const dir of candidates) {
178
+ const real = await realpath(dir).catch(() => null);
179
+ if (real && (real === realRoot || real.startsWith(realRoot + sep)))
180
+ dirs.add(dir);
181
+ }
182
+ const indexedByName = new Map(pluginSkills(store, plugin.id).map((s) => [s.name.toLowerCase(), s.id]));
183
+ const out = [];
184
+ for (const path of [...dirs].sort()) {
185
+ const folder = basename(path);
186
+ const hit = await inspectSkill(store, path, folder, (name) => ({ id: indexedByName.get(name.toLowerCase()) ?? indexedByName.get(folder.toLowerCase()) ?? null, installedRisk: null }));
187
+ if (hit)
188
+ out.push({ ...hit, scope: plugin.scope === 'project' || plugin.scope === 'local' ? 'project' : 'user', plugin: { id: plugin.id, scope: plugin.scope, enabled: plugin.enabled } });
189
+ }
190
+ return out;
191
+ }
122
192
  export async function existingSkillFolder(store, skillId, skillName, scope, projectPath) {
123
193
  const folder = skillFolderName(skillName);
124
194
  const path = join(scope === 'user' ? homedir() : projectPath, '.claude', 'skills', folder);
@@ -181,7 +251,7 @@ export function installedPluginCommits() {
181
251
  return out;
182
252
  }
183
253
  /** Windows paths compared as Claude Code writes them: backslashes, no trailing one, any case. */
184
- const pathKey = (p) => p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase();
254
+ export const pathKey = (p) => p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase();
185
255
  export const pluginCommitKey = (id, scope, projectPath) => `${id}|${scope}|${scope === 'user' ? '' : pathKey(projectPath ?? '')}`;
186
256
  /** How a plugin install compares with the index: its skills' top risk and whether it came from the indexed commit. */
187
257
  /** The indexed skills a plugin install brought in (its own, or those whose install plan names it), for the 설치됨 screen. */
@@ -150,8 +150,20 @@ function inlineText(s) {
150
150
  .replace(/\s+/g, ' ')
151
151
  .trim();
152
152
  }
153
+ /** Markdown and HTML links with their text: what is left of a row of links ("Docs · Discord · Blog") is its separators. */
154
+ const LINKS = /\[[^[\]]*\]\((?:[^()]|\([^()]*\))*\)|\[[^[\]]*\]\[[^[\]]*\]|<a\b[^<>]*>[^<]*<\/a>/gi;
153
155
  /**
154
- * Title (first H1, else the folder name), intro (first paragraph of 20+ chars), features (first list under a
156
+ * A paragraph that reads as a sentence, for the summary's intro: mostly letters (not a row of symbols) and 20+
157
+ * characters without its links' text (not a navigation row). Others still count as body text.
158
+ */
159
+ function isProse(raw, text) {
160
+ const letters = text.match(/\p{L}/gu)?.length ?? 0;
161
+ if (letters < text.replace(/\s/g, '').length * 0.6)
162
+ return false;
163
+ return inlineText(raw.replace(LINKS, ' ')).length >= CONFIG.minParagraphChars;
164
+ }
165
+ /**
166
+ * Title (first H1, else the folder name), intro (first paragraph of 20+ chars that reads as prose), features (first list under a
155
167
  * "feature/기능/특징" heading, up to 5), other headings, body text and code-block languages. Code itself is
156
168
  * dropped: README code and badges otherwise dominate any word statistics.
157
169
  */
@@ -166,7 +178,7 @@ export function extractReadme(md, fallbackTitle) {
166
178
  const text = html ? inlineText(raw.replace(TAG, ' ')) : inlineText(raw);
167
179
  if (text.length < CONFIG.minParagraphChars)
168
180
  return; // badge rows and short lines
169
- if (!out.intro)
181
+ if (!out.intro && isProse(raw, text))
170
182
  out.intro = text;
171
183
  else
172
184
  out.body.push(text);