@seojin08/skill-finder 0.3.3 → 0.3.5
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 +1 -1
- package/package.json +3 -2
- package/seed-index.json.gz +0 -0
- package/server/src/app.js +3 -0
- package/server/src/bootstrap.js +2 -1
- 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/index.js +11 -0
- package/server/src/indexer.js +5 -1
- package/server/src/install/installed.js +95 -25
- 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/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/server/src/update.js +1 -1
- package/web/dist/THIRD-PARTY-LICENSES.txt +6 -0
- package/web/dist/assets/{ArasakaGame-C1Wkmqik.js → ArasakaGame-zvqzlnfW.js} +2 -2
- package/web/dist/assets/__vite-browser-external-BDfy7Drd.js +1 -0
- package/web/dist/assets/index-BGfauLB6.css +2 -0
- package/web/dist/assets/index-Bhi_rUeJ.js +66 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/translator-CCFr2ykW.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.5 (2026-11-04)
|
|
6
|
+
|
|
7
|
+
- 소개 페이지 주소가 https://seojin-skill-finder.pages.dev 로 바뀌었습니다. 업데이트와 공유 링크도 새 주소를 씁니다.
|
|
8
|
+
- 설치 확인 창이 화면 왼쪽 위에 붙지 않고 가운데에 뜹니다.
|
|
9
|
+
|
|
10
|
+
## 0.3.4 (2026-11-03)
|
|
11
|
+
|
|
12
|
+
- 처음 실행할 때 함께 들어 있는 스킬 색인을 바로 씁니다. 몇 분씩 모으기를 기다리지 않아도 스킬 1만여 개를 몇 초 만에 검색할 수 있습니다.
|
|
13
|
+
- 처음 실행하면 Anthropic 공식 스킬부터 모읍니다.
|
|
14
|
+
- 지워진 저장소나 커밋을 동기화 때마다 다시 묻지 않고, GitHub가 요청이 많다고 하면 잠깐 쉬었다가 다시 받습니다.
|
|
15
|
+
- GitHub가 잠깐 답하지 않아도 저장소 별 수 갱신을 멈추지 않고, 나눠서 다시 묻습니다.
|
|
16
|
+
- 검색에 나오는 GitHub 저장소 카드에도 별을 눌러 즐겨찾기에 넣을 수 있습니다.
|
|
17
|
+
- 즐겨찾기 화면 왼쪽에서 종류(스킬·GitHub 저장소)·제공자·카테고리·설치 방식으로 골라 볼 수 있습니다.
|
|
18
|
+
- Windows에 설치하면 터미널 명령 skill-finder가 생깁니다(사용자 PATH에 추가). 새로 연 터미널에서 skill-finder --version으로 설치된 버전을 보고, skill-finder로 앱을 켭니다.
|
|
19
|
+
- Firefox·Safari 등 번역 기능이 없는 브라우저에서도 스킬 설명을 화면 언어(한국어·일본어)로 번역합니다. 버튼을 누르지 않아도 처음 한 번 Firefox와 같은 Mozilla 번역 모델(약 46MB)을 받고, 번역은 이 PC 안에서만 합니다. Chrome은 페이지에서 처음 클릭하거나 입력할 때 번역 모델을 받습니다.
|
|
20
|
+
- 이미 한국어·일본어인 설명이나 기호뿐인 글은 번역하지 않습니다. README 추천의 요약이 엉뚱한 글자로 바뀌던 문제가 고쳐졌습니다.
|
|
21
|
+
- README 추천 결과에서 이미 설치한 스킬(고른 프로젝트·개인 범위)에 "설치됨"이 붙고, 칩을 눌러 숨길 수 있습니다. 왼쪽 필터로 제공자·카테고리·설치 방식을 골라 볼 수 있습니다.
|
|
22
|
+
- 설치됨 화면에 플러그인 안에 든 스킬도 나오고, 고른 프로젝트와 상관없는 다른 폴더의 플러그인은 빠집니다. 스킬 목록을 범위·설치 경로·위험·색인·제공자·카테고리로 골라 보고, 5·10·20개씩 페이지로 넘깁니다.
|
|
23
|
+
- README 추천이나 설치됨 화면에서 연 스킬은 그 화면에서 고른 프로젝트 폴더에 설치하도록 열립니다(범위는 바꿀 수 있음). 위험 등급이 주의 이상인 스킬을 개인 범위로 설치하려 하면 이 프로젝트에만 설치하는 것도 고려하라고 알려 줍니다.
|
|
24
|
+
|
|
5
25
|
## 0.3.3 (2026-11-03)
|
|
6
26
|
|
|
7
27
|
- 스킬 설치에 쓰는 skills CLI를 1.7.6으로 올렸습니다. 저장소 안의 바로가기(symlink)를 통해 스킬 폴더 밖의 파일이 함께 복사될 수 있던 보안 문제가 막혔습니다.
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Claude Code 스킬을 한곳에서 찾아 점수·위험 신호와 함께 보고
|
|
|
19
19
|
|
|
20
20
|
### Windows
|
|
21
21
|
|
|
22
|
-
[소개 페이지](https://skill-finder.
|
|
22
|
+
[소개 페이지](https://seojin-skill-finder.pages.dev)에서 zip을 받아 압축을 풀고 `SkillFinder.exe`를 실행합니다. 서버를 켜고 브라우저를 열며, 새 버전이 나오면 서명을 확인한 뒤 업데이트합니다.
|
|
23
23
|
|
|
24
24
|
### macOS·Linux·Windows (npx)
|
|
25
25
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seojin08/skill-finder",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"type": "git",
|
|
21
21
|
"url": "git+https://github.com/Parkseojin08/skill-finder.git"
|
|
22
22
|
},
|
|
23
|
-
"homepage": "https://skill-finder.
|
|
23
|
+
"homepage": "https://seojin-skill-finder.pages.dev",
|
|
24
24
|
"bugs": "https://github.com/Parkseojin08/skill-finder/issues",
|
|
25
25
|
"keywords": [
|
|
26
26
|
"claude-code",
|
|
@@ -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/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() {
|
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";
|
|
@@ -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);
|
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { installSummary } from "../install/summary.js";
|
|
4
|
+
import { isRepo } from "../install/validate.js";
|
|
5
|
+
import { indexItem, storeLookup } from "../search/result.js";
|
|
6
|
+
import { sanitizeLine } from "../util/sanitize.js";
|
|
5
7
|
const FavoriteBody = z.object({ id: z.string().min(1).max(400) });
|
|
6
8
|
/**
|
|
7
9
|
* `name`: a search result not in the index yet (still being checked) is kept by it until it is. Unusable (empty,
|
|
8
10
|
* not text) it is left out rather than refused, so an indexed skill still gets its star.
|
|
9
11
|
*/
|
|
10
|
-
const FavoriteAdd = FavoriteBody.extend({ name: z.unknown().optional() });
|
|
12
|
+
const FavoriteAdd = FavoriteBody.extend({ name: z.unknown().optional(), repo: z.unknown().optional() });
|
|
11
13
|
const usableName = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : undefined);
|
|
14
|
+
/** A favorite GitHub repository (user request 2026-11-03): `repo:` and the name in lower case, so one per repo. */
|
|
15
|
+
const REPO_PREFIX = 'repo:';
|
|
16
|
+
/** What the search showed of the repo; anything unusable is left out rather than refused. */
|
|
17
|
+
function repoMeta(v) {
|
|
18
|
+
const o = (v && typeof v === 'object' ? v : {});
|
|
19
|
+
const description = typeof o.description === 'string' && o.description.trim() ? sanitizeLine(o.description).slice(0, 500) : null;
|
|
20
|
+
const topics = Array.isArray(o.topics) ? o.topics.filter((x) => typeof x === 'string' && /^[a-z0-9][a-z0-9-]{0,49}$/.test(x)).slice(0, 30) : [];
|
|
21
|
+
return { description, topics };
|
|
22
|
+
}
|
|
23
|
+
/** One damaged row must not take the whole favorites list down with it. */
|
|
24
|
+
function parseJson(text) {
|
|
25
|
+
try {
|
|
26
|
+
return text ? JSON.parse(text) : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
12
32
|
/**
|
|
13
33
|
* This PC's favorite skills (user decision 2026-11-02). No accounts: the app runs for one person on 127.0.0.1, so a
|
|
14
34
|
* login kept only here protected nothing. The API's header and same-origin checks apply as everywhere.
|
|
@@ -19,28 +39,52 @@ export function favoritesRouter(ctx) {
|
|
|
19
39
|
r.get('/favorites/ids', (_req, res) => {
|
|
20
40
|
res.json({ ids: store.listFavorites().map((f) => f.skill_id) });
|
|
21
41
|
});
|
|
22
|
-
/** The favorites as result cards;
|
|
42
|
+
/** The favorites as result cards and repository cards; skills that left the index are listed by name. */
|
|
23
43
|
r.get('/favorites', (_req, res) => {
|
|
24
44
|
const lookup = storeLookup(store);
|
|
25
45
|
const items = [];
|
|
46
|
+
const repos = [];
|
|
26
47
|
const gone = [];
|
|
27
48
|
for (const f of store.listFavorites()) {
|
|
49
|
+
if (f.skill_id.startsWith(REPO_PREFIX)) {
|
|
50
|
+
const repo = f.skill_name;
|
|
51
|
+
const meta = repoMeta(parseJson(f.meta_json));
|
|
52
|
+
repos.push({
|
|
53
|
+
repo,
|
|
54
|
+
url: `https://github.com/${repo}`,
|
|
55
|
+
...meta,
|
|
56
|
+
stars: store.getRepo(repo)?.stars ?? null,
|
|
57
|
+
// as the search's repository card lists them
|
|
58
|
+
skills: store
|
|
59
|
+
.findSkillsByRepo(repo)
|
|
60
|
+
.filter((s) => s.description && s.origin !== 'wellknown')
|
|
61
|
+
.slice(0, 30)
|
|
62
|
+
.map((s) => installSummary(s, lookup)),
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
28
66
|
const s = store.getSkill(f.skill_id);
|
|
29
67
|
if (!s) {
|
|
30
68
|
gone.push({ id: f.skill_id, name: f.skill_name });
|
|
31
69
|
continue;
|
|
32
70
|
}
|
|
33
|
-
|
|
34
|
-
const source = plugin ? marketplaceSource(s, plugin) : githubSource(s);
|
|
35
|
-
const breakdown = { R: 0, P: 0, F: freshness(s.last_commit_at), T: TRUST_SCORE[trustTier(s, plugin)] };
|
|
36
|
-
items.push(buildResultItem({ skill: s, lookup, sources: source ? [source] : [], installs: freshInstalls(s), stars: s.repo ? (store.getRepo(s.repo)?.stars ?? null) : null, breakdown, score: 0 }));
|
|
71
|
+
items.push(indexItem(store, lookup, s));
|
|
37
72
|
}
|
|
38
|
-
res.json({ items, gone });
|
|
73
|
+
res.json({ items, repos, gone });
|
|
39
74
|
});
|
|
40
75
|
r.post('/favorites', (req, res) => {
|
|
41
76
|
const parsed = FavoriteAdd.safeParse(req.body);
|
|
42
77
|
if (!parsed.success)
|
|
43
78
|
return void res.status(400).json({ error: '스킬 id가 없습니다' });
|
|
79
|
+
if (parsed.data.id.startsWith(REPO_PREFIX)) {
|
|
80
|
+
const repo = parsed.data.id.slice(REPO_PREFIX.length);
|
|
81
|
+
if (!isRepo(repo))
|
|
82
|
+
return void res.status(400).json({ error: '저장소 이름이 올바르지 않습니다' });
|
|
83
|
+
// the name as GitHub writes it (morluto/rea), when it is a repo name and the same repo
|
|
84
|
+
const name = usableName(parsed.data.name);
|
|
85
|
+
store.addFavorite(REPO_PREFIX + repo.toLowerCase(), isRepo(name) && name.toLowerCase() === repo.toLowerCase() ? name : repo, repoMeta(parsed.data.repo));
|
|
86
|
+
return void res.json({ ok: true });
|
|
87
|
+
}
|
|
44
88
|
const skill = store.getSkill(parsed.data.id);
|
|
45
89
|
// not indexed: listed by name on the favorites page, as a card once the index has it
|
|
46
90
|
const name = skill?.name ?? usableName(parsed.data.name);
|
|
@@ -54,7 +98,8 @@ export function favoritesRouter(ctx) {
|
|
|
54
98
|
const parsed = FavoriteBody.safeParse({ id: req.query.id });
|
|
55
99
|
if (!parsed.success)
|
|
56
100
|
return void res.status(400).json({ error: '스킬 id가 없습니다' });
|
|
57
|
-
|
|
101
|
+
// repository favorites are kept in lower case (POST)
|
|
102
|
+
store.removeFavorite(parsed.data.id.startsWith(REPO_PREFIX) ? parsed.data.id.toLowerCase() : parsed.data.id);
|
|
58
103
|
res.json({ ok: true });
|
|
59
104
|
});
|
|
60
105
|
return r;
|
|
@@ -5,7 +5,8 @@ import { Router } from 'express';
|
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { buildSteps, planInstall } from "../install/plan.js";
|
|
7
7
|
import { executeInstall } from "../install/run.js";
|
|
8
|
-
import { existingSkillFolder, forgetPlugins, installedPluginCommits, listInstalledPlugins, pluginCommitKey, pluginIndexInfo, pluginSkills, scanInstalledSkills } from "../install/installed.js";
|
|
8
|
+
import { existingSkillFolder, forgetPlugins, installedPluginCommits, listInstalledPlugins, pathKey, pluginCommitKey, pluginIndexInfo, pluginSkills, scanInstalledSkills, scanPluginSkills } from "../install/installed.js";
|
|
9
|
+
import { indexItem, storeLookup } from "../search/result.js";
|
|
9
10
|
import { uninstallPlugin, uninstallSkill } from "../install/uninstall.js";
|
|
10
11
|
import { isProjectDir } from "../install/validate.js";
|
|
11
12
|
import { getSettings } from "../settings.js";
|
|
@@ -241,7 +242,8 @@ export function installRouter(ctx) {
|
|
|
241
242
|
}
|
|
242
243
|
const listed = await listInstalledPlugins(projectPath ?? homedir(), true);
|
|
243
244
|
const commits = installedPluginCommits();
|
|
244
|
-
|
|
245
|
+
// with a folder, another project's project and local plugins are not this one's (user report 2026-11-04)
|
|
246
|
+
const plugins = listed.plugins.filter((o) => !projectPath || (o.scope !== 'project' && o.scope !== 'local') || pathKey(o.projectPath ?? '') === pathKey(projectPath)).map((o) => {
|
|
245
247
|
const { scope, id: pid } = o;
|
|
246
248
|
const ownProject = o.projectPath;
|
|
247
249
|
// `claude plugin list` reports enabled for the folder it runs in; read the settings file that owns this install instead.
|
|
@@ -263,19 +265,43 @@ export function installRouter(ctx) {
|
|
|
263
265
|
...(await scanInstalledSkills(store, 'user', null)),
|
|
264
266
|
...(projectPath ? await scanInstalledSkills(store, 'project', projectPath) : []),
|
|
265
267
|
];
|
|
266
|
-
|
|
268
|
+
// The skills of the plugins that apply here: everyone's (user, synced) and this folder's. Without a folder the
|
|
269
|
+
// project plugins listed above belong to other folders. One row per folder: a plugin installed for the user and
|
|
270
|
+
// for a project shares its install folder (code review 2026-11-04).
|
|
271
|
+
const applying = plugins.filter((p) => (p.scope !== 'project' && p.scope !== 'local') || projectPath);
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
const pluginSkillRows = (await Promise.all(applying.map((p) => scanPluginSkills(store, { ...p, enabled: typeof p.enabled === 'boolean' ? p.enabled : null }))))
|
|
274
|
+
.flat()
|
|
275
|
+
.filter((s) => !seen.has(s.path) && seen.add(s.path));
|
|
276
|
+
// the index's card for each indexed one: provider, category and install method for the side filters
|
|
277
|
+
const lookup = storeLookup(store);
|
|
278
|
+
const items = {};
|
|
279
|
+
for (const s of [...skills, ...pluginSkillRows]) {
|
|
280
|
+
const row = s.skillId ? store.getSkill(s.skillId) : undefined;
|
|
281
|
+
if (row && !items[row.id])
|
|
282
|
+
items[row.id] = indexItem(store, lookup, row);
|
|
283
|
+
}
|
|
284
|
+
res.json({ plugins, pluginError: listed.error, skills, pluginSkills: pluginSkillRows, items, projects: store.listInstalledProjects() });
|
|
267
285
|
});
|
|
268
286
|
// What is installed, by index id, for the "설치됨" badges on results (UI debate 3-1). Project skills are those of
|
|
269
|
-
// the
|
|
270
|
-
|
|
271
|
-
|
|
287
|
+
// `projectPath` (the folder README 추천 analyzed, user request 2026-11-04), else of the default project folder in
|
|
288
|
+
// the settings; with `projectPath`, project and local plugins of other folders are left out. Unknown ones are
|
|
289
|
+
// simply absent: no badge, never "not installed".
|
|
290
|
+
r.get('/installed/ids', async (req, res) => {
|
|
291
|
+
const asked = typeof req.query.projectPath === 'string' && req.query.projectPath.trim() ? req.query.projectPath.trim() : null;
|
|
292
|
+
if (asked && !isProjectDir(asked)) {
|
|
293
|
+
res.status(400).json({ error: '프로젝트 경로는 존재하는 디렉터리의 절대경로여야 합니다' });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const project = asked ?? getSettings(store).defaultProjectPath;
|
|
272
297
|
const listed = await listInstalledPlugins();
|
|
273
298
|
const skills = [
|
|
274
299
|
...(await scanInstalledSkills(store, 'user', null)),
|
|
275
300
|
...(isProjectDir(project) ? await scanInstalledSkills(store, 'project', project) : []),
|
|
276
301
|
];
|
|
302
|
+
const inFolder = (p) => !asked || (p.scope !== 'project' && p.scope !== 'local') || pathKey(p.projectPath ?? '') === pathKey(asked);
|
|
277
303
|
res.json({
|
|
278
|
-
plugins: listed.plugins.map((p) => ({ id: p.id, scope: p.scope })),
|
|
304
|
+
plugins: listed.plugins.filter(inFolder).map((p) => ({ id: p.id, scope: p.scope })),
|
|
279
305
|
skills: skills.filter((s) => s.skillId).map((s) => ({ id: s.skillId, scope: s.scope })),
|
|
280
306
|
});
|
|
281
307
|
});
|