@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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -2
- package/bin/skill-finder.mjs +4 -4
- package/package.json +4 -3
- package/seed-index.json.gz +0 -0
- package/server/src/app.js +3 -0
- package/server/src/bootstrap.js +2 -1
- package/server/src/config.js +16 -4
- package/server/src/db/schema.js +3 -0
- package/server/src/db/seed.js +89 -0
- package/server/src/db/store.js +4 -3
- package/server/src/discovery/genericRules.js +3 -1
- package/server/src/index.js +11 -0
- package/server/src/indexer.js +5 -1
- package/server/src/install/installed.js +96 -26
- package/server/src/recommend/readme.js +14 -2
- package/server/src/routes/favorites.js +55 -10
- package/server/src/routes/install.js +33 -7
- package/server/src/routes/translate.js +35 -0
- package/server/src/search/pipeline.js +3 -1
- package/server/src/search/result.js +9 -1
- package/server/src/security.js +4 -1
- package/server/src/sources/gitTree.js +11 -1
- package/server/src/sources/github.js +17 -2
- package/server/src/tasks.js +45 -5
- package/server/src/translate/models.js +140 -0
- package/web/dist/THIRD-PARTY-LICENSES.txt +6 -0
- package/web/dist/assets/{ArasakaGame-C1Wkmqik.js → ArasakaGame-B2qY9Lds.js} +2 -2
- package/web/dist/assets/__vite-browser-external-BDfy7Drd.js +1 -0
- package/web/dist/assets/index-BJ6vm3xw.js +66 -0
- package/web/dist/assets/index-BiikysiH.css +2 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/translator-Cyk_ZOaM.js +2 -0
- package/web/dist/bergamot/bergamot-translator-worker.js +2919 -0
- package/web/dist/bergamot/bergamot-translator-worker.wasm +0 -0
- package/web/dist/bergamot/translator-worker.js +475 -0
- package/web/dist/index.html +3 -2
- package/web/dist/assets/index-BgL1OaU4.css +0 -2
- 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.
|
|
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.
|
|
48
|
+
필요한 것: Node.js 22.20 이상(24 권장), git, Claude Code(`claude` 명령).
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
51
|
npm install
|
package/bin/skill-finder.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 <
|
|
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.
|
|
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.
|
|
11
|
+
"node": ">=22.20"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"express": "5.3.0",
|
|
15
|
-
"skills": "1.7.
|
|
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);
|
package/server/src/bootstrap.js
CHANGED
|
@@ -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
|
package/server/src/config.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
const local = join(ROOT_DIR, 'node_modules', 'skills'
|
|
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
|
|
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) {
|
package/server/src/db/schema.js
CHANGED
|
@@ -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
|
+
}
|
package/server/src/db/store.js
CHANGED
|
@@ -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
|
-
|
|
320
|
-
|
|
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.
|
|
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',
|
package/server/src/index.js
CHANGED
|
@@ -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, () => {
|
package/server/src/indexer.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
*
|
|
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);
|