@seojin08/skill-finder 0.0.0-stage → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/bin/skill-finder.mjs +389 -0
- package/package.json +39 -4
- package/server/src/analyze/frontmatter.js +130 -0
- package/server/src/analyze/quality.js +34 -0
- package/server/src/analyze/risk.js +123 -0
- package/server/src/app.js +57 -0
- package/server/src/bootstrap.js +46 -0
- package/server/src/claude/cli.js +89 -0
- package/server/src/config.js +82 -0
- package/server/src/context.js +1 -0
- package/server/src/db/index.js +128 -0
- package/server/src/db/schema.js +95 -0
- package/server/src/db/store.js +353 -0
- package/server/src/discovery/classifyRepo.js +81 -0
- package/server/src/discovery/genericRules.js +182 -0
- package/server/src/discovery/pluginRules.js +59 -0
- package/server/src/discovery/tree.js +79 -0
- package/server/src/index.js +55 -0
- package/server/src/indexer.js +502 -0
- package/server/src/install/installed.js +206 -0
- package/server/src/install/plan.js +118 -0
- package/server/src/install/run.js +97 -0
- package/server/src/install/scan.js +70 -0
- package/server/src/install/summary.js +14 -0
- package/server/src/install/uninstall.js +59 -0
- package/server/src/install/validate.js +24 -0
- package/server/src/jobs.js +107 -0
- package/server/src/recommend/readme.js +508 -0
- package/server/src/release.js +41 -0
- package/server/src/routes/admin.js +203 -0
- package/server/src/routes/favorites.js +61 -0
- package/server/src/routes/health.js +27 -0
- package/server/src/routes/install.js +283 -0
- package/server/src/routes/presets.js +66 -0
- package/server/src/routes/recommend.js +36 -0
- package/server/src/routes/search.js +53 -0
- package/server/src/routes/skills.js +214 -0
- package/server/src/search/activity.js +13 -0
- package/server/src/search/category.js +109 -0
- package/server/src/search/koreanTerms.js +284 -0
- package/server/src/search/pipeline.js +977 -0
- package/server/src/search/rank.js +138 -0
- package/server/src/search/result.js +129 -0
- package/server/src/search/searchLog.js +53 -0
- package/server/src/security.js +53 -0
- package/server/src/settings.js +56 -0
- package/server/src/sources/gitTree.js +101 -0
- package/server/src/sources/github.js +503 -0
- package/server/src/sources/marketplace.js +78 -0
- package/server/src/sources/pluginSource.js +98 -0
- package/server/src/sources/popularRepos.js +74 -0
- package/server/src/sources/skillsSh.js +74 -0
- package/server/src/tasks.js +216 -0
- package/server/src/update.js +53 -0
- package/server/src/util/folderPicker.js +208 -0
- package/server/src/util/http.js +41 -0
- package/server/src/util/limiter.js +110 -0
- package/server/src/util/log.js +94 -0
- package/server/src/util/ndjson.js +27 -0
- package/server/src/util/proc.js +80 -0
- package/server/src/util/resolveFolder.js +78 -0
- package/server/src/util/sanitize.js +45 -0
- package/server/src/util/sizedCache.js +74 -0
- package/server/src/util/textFiles.js +9 -0
- package/server/src/util/tokens.js +21 -0
- package/server/src/version.js +12 -0
- package/web/dist/LICENSE.txt +21 -0
- package/web/dist/THIRD-PARTY-LICENSES.txt +2267 -0
- package/web/dist/assets/ArasakaGame-B0V_LZLl.css +1 -0
- package/web/dist/assets/ArasakaGame-C1Wkmqik.js +36 -0
- package/web/dist/assets/index-BgL1OaU4.css +2 -0
- package/web/dist/assets/index-Ec2XBK4-.js +66 -0
- package/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { arch, platform, release } from 'node:os';
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { claudeVersion } from "../claude/cli.js";
|
|
5
|
+
import { ENV_PATH, saveGithubToken, SKILLS_CLI_VERSION } from "../config.js";
|
|
6
|
+
import { isPluginName, isRepo } from "../install/validate.js";
|
|
7
|
+
import { getSettings, saveSettings, SettingsSchema } from "../settings.js";
|
|
8
|
+
import { fetchMarketplaceManifest } from "../sources/marketplace.js";
|
|
9
|
+
import { parseGithubRepo } from "../sources/pluginSource.js";
|
|
10
|
+
import { popularState } from "../sources/popularRepos.js";
|
|
11
|
+
import { startCollect, startPopularCollect, startSync } from "../tasks.js";
|
|
12
|
+
import { addSecret, logFilePath, logTail, maskSecrets } from "../util/log.js";
|
|
13
|
+
import { APP_VERSION } from "../version.js";
|
|
14
|
+
const SettingsPut = z.object({
|
|
15
|
+
settings: SettingsSchema.partial().optional(),
|
|
16
|
+
githubToken: z.string().max(400).regex(/^[A-Za-z0-9_\-]*$/, '토큰 형식이 올바르지 않습니다').optional(),
|
|
17
|
+
});
|
|
18
|
+
const ClientError = z.object({ message: z.string().max(2000), stack: z.string().max(8000).optional() });
|
|
19
|
+
const AddMarketplace = z.object({ repo: z.string().min(3).max(200), indexMode: z.enum(['eager', 'lazy']).default('lazy') });
|
|
20
|
+
const PatchMarketplace = z.object({ enabled: z.boolean().optional(), indexMode: z.enum(['eager', 'lazy']).optional() });
|
|
21
|
+
export function adminRouter(ctx) {
|
|
22
|
+
const r = Router();
|
|
23
|
+
const { store, jobs } = ctx;
|
|
24
|
+
const marketplaces = () => store.listMarketplaces().map((m) => ({
|
|
25
|
+
name: m.name,
|
|
26
|
+
repo: m.repo,
|
|
27
|
+
enabled: m.enabled === 1,
|
|
28
|
+
indexMode: m.index_mode,
|
|
29
|
+
syncedAt: m.synced_at,
|
|
30
|
+
pluginCount: m.plugin_count,
|
|
31
|
+
error: m.error,
|
|
32
|
+
pendingSkillIndex: store.listPluginsNeedingIndex(m.name).length,
|
|
33
|
+
}));
|
|
34
|
+
const settingsResponse = () => ({
|
|
35
|
+
settings: getSettings(store),
|
|
36
|
+
githubToken: !!ctx.config.githubToken,
|
|
37
|
+
/** Where the index, search log and token live, so people know what to keep or delete (UI debate D-6). */
|
|
38
|
+
dataDir: ctx.config.dataDir,
|
|
39
|
+
envPath: ENV_PATH,
|
|
40
|
+
marketplaces: marketplaces(),
|
|
41
|
+
searchLogCount: store.countSearchLog(),
|
|
42
|
+
});
|
|
43
|
+
r.get('/settings', (_req, res) => {
|
|
44
|
+
res.json(settingsResponse());
|
|
45
|
+
});
|
|
46
|
+
r.put('/settings', (req, res) => {
|
|
47
|
+
const parsed = SettingsPut.safeParse(req.body);
|
|
48
|
+
if (!parsed.success) {
|
|
49
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? '설정 형식이 올바르지 않습니다' });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (parsed.data.settings) {
|
|
53
|
+
const cur = getSettings(store);
|
|
54
|
+
const next = SettingsSchema.safeParse({ ...cur, ...parsed.data.settings, weights: cur.weights });
|
|
55
|
+
if (!next.success) {
|
|
56
|
+
res.status(400).json({ error: next.error.issues[0]?.message ?? '설정 형식이 올바르지 않습니다' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
saveSettings(store, next.data);
|
|
60
|
+
store.clearSearchCache();
|
|
61
|
+
}
|
|
62
|
+
if (parsed.data.githubToken !== undefined) {
|
|
63
|
+
addSecret(parsed.data.githubToken);
|
|
64
|
+
saveGithubToken(parsed.data.githubToken || null);
|
|
65
|
+
store.clearSearchCache();
|
|
66
|
+
}
|
|
67
|
+
res.json(settingsResponse());
|
|
68
|
+
});
|
|
69
|
+
// "진단 정보 복사" in the settings (project review F2): what helps tell a problem, without the token, searches or
|
|
70
|
+
// the home folder's name (shown as ~). The page copies it to the clipboard; nothing is sent anywhere.
|
|
71
|
+
r.get('/diagnostics', async (_req, res) => {
|
|
72
|
+
const s = getSettings(store);
|
|
73
|
+
const broken = marketplaces().filter((m) => m.error || m.pendingSkillIndex);
|
|
74
|
+
const lines = [
|
|
75
|
+
`Skill Finder ${APP_VERSION ?? '?'} · Node ${process.version} · ${platform()} ${release()} ${arch()}`,
|
|
76
|
+
`Claude Code ${(await claudeVersion()) ?? '찾지 못함'} · skills CLI ${SKILLS_CLI_VERSION}`,
|
|
77
|
+
`GitHub 토큰 ${ctx.config.githubToken ? '있음' : '없음'} · 포트 ${ctx.config.port}`,
|
|
78
|
+
`데이터 ${ctx.config.dataDir} · 로그 ${logFilePath() ?? '없음'}`,
|
|
79
|
+
`색인 플러그인 ${store.countPlugins()} · 스킬 ${store.countSkills()} · 마켓플레이스 ${store.listMarketplaces().length}`,
|
|
80
|
+
...broken.map((m) => ` ${m.name}: ${m.error ?? ''}${m.pendingSkillIndex ? ` (색인 대기 ${m.pendingSkillIndex})` : ''}`),
|
|
81
|
+
`설정 최소 별 ${s.minStars} · 자동 수집 ${s.autoCollect ? '켬' : '끔'} · 기본 범위 ${s.defaultScope} · 검색 기록 ${s.searchLog ? '켬' : '끔'}`,
|
|
82
|
+
...jobs
|
|
83
|
+
.list()
|
|
84
|
+
.slice(-5)
|
|
85
|
+
.map((j) => `작업 ${j.kind} ${j.status} ${j.done}/${j.total} ${j.label}${j.errors.length ? ` · 오류 ${j.errors.length} (${j.errors[0]})` : ''}`),
|
|
86
|
+
'',
|
|
87
|
+
'최근 로그',
|
|
88
|
+
logTail(80) || '(없음)',
|
|
89
|
+
];
|
|
90
|
+
res.json({ text: maskSecrets(lines.join('\n')) });
|
|
91
|
+
});
|
|
92
|
+
// A screen error caught by the page (its ErrorBoundary) goes into the log file, so the diagnostics carry it.
|
|
93
|
+
r.post('/client-error', (req, res) => {
|
|
94
|
+
const parsed = ClientError.safeParse(req.body);
|
|
95
|
+
if (parsed.success)
|
|
96
|
+
console.error('[화면 오류]', parsed.data.message, parsed.data.stack ?? '');
|
|
97
|
+
res.status(204).end();
|
|
98
|
+
});
|
|
99
|
+
// "기록 지우기" in the settings (U5-2)
|
|
100
|
+
r.delete('/search-log', (_req, res) => {
|
|
101
|
+
store.clearSearchLog();
|
|
102
|
+
res.json(settingsResponse());
|
|
103
|
+
});
|
|
104
|
+
r.get('/marketplaces', (_req, res) => {
|
|
105
|
+
res.json({ marketplaces: marketplaces() });
|
|
106
|
+
});
|
|
107
|
+
r.post('/marketplaces', async (req, res) => {
|
|
108
|
+
const parsed = AddMarketplace.safeParse(req.body);
|
|
109
|
+
const repo = parsed.success ? parseGithubRepo(parsed.data.repo) : null;
|
|
110
|
+
if (!parsed.success || !repo || !isRepo(repo)) {
|
|
111
|
+
res.status(400).json({ error: 'GitHub 저장소(owner/repo 또는 URL)를 입력하세요' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const { manifest } = await fetchMarketplaceManifest(ctx.github, repo);
|
|
116
|
+
if (!isPluginName(manifest.name))
|
|
117
|
+
throw new Error('마켓플레이스 이름 형식이 올바르지 않습니다');
|
|
118
|
+
if (store.getMarketplace(manifest.name))
|
|
119
|
+
throw new Error(`이미 있는 마켓플레이스입니다: ${manifest.name}`);
|
|
120
|
+
store.upsertMarketplace({ name: manifest.name, repo, enabled: true, index_mode: parsed.data.indexMode });
|
|
121
|
+
const job = startSync(ctx, manifest.name, true);
|
|
122
|
+
res.json({ marketplaces: marketplaces(), jobId: job.id });
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
res.status(400).json({ error: String(err.message ?? err).slice(0, 300) });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
r.patch('/marketplaces/:name', (req, res) => {
|
|
129
|
+
const parsed = PatchMarketplace.safeParse(req.body);
|
|
130
|
+
const m = store.getMarketplace(req.params.name);
|
|
131
|
+
if (!parsed.success || !m) {
|
|
132
|
+
res.status(m ? 400 : 404).json({ error: m ? '요청 형식이 올바르지 않습니다' : '마켓플레이스가 없습니다' });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
store.upsertMarketplace({
|
|
136
|
+
name: m.name,
|
|
137
|
+
repo: m.repo,
|
|
138
|
+
enabled: parsed.data.enabled ?? m.enabled === 1,
|
|
139
|
+
index_mode: parsed.data.indexMode ?? m.index_mode,
|
|
140
|
+
});
|
|
141
|
+
store.clearSearchCache();
|
|
142
|
+
res.json({ marketplaces: marketplaces() });
|
|
143
|
+
});
|
|
144
|
+
r.delete('/marketplaces/:name', (req, res) => {
|
|
145
|
+
if (!store.getMarketplace(req.params.name)) {
|
|
146
|
+
res.status(404).json({ error: '마켓플레이스가 없습니다' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
store.deleteMarketplace(req.params.name);
|
|
150
|
+
store.clearSearchCache();
|
|
151
|
+
res.json({ marketplaces: marketplaces() });
|
|
152
|
+
});
|
|
153
|
+
r.post('/sync', (req, res) => {
|
|
154
|
+
const name = typeof req.body?.marketplace === 'string' ? req.body.marketplace : undefined;
|
|
155
|
+
if (name && !store.getMarketplace(name)) {
|
|
156
|
+
res.status(404).json({ error: '마켓플레이스가 없습니다' });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
res.json({ jobId: startSync(ctx, name, true).id });
|
|
160
|
+
});
|
|
161
|
+
r.post('/collect', (req, res) => {
|
|
162
|
+
const name = typeof req.body?.marketplace === 'string' ? req.body.marketplace : '';
|
|
163
|
+
if (!store.getMarketplace(name)) {
|
|
164
|
+
res.status(404).json({ error: '마켓플레이스가 없습니다' });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
res.json({ jobId: startCollect(ctx, name).id });
|
|
168
|
+
});
|
|
169
|
+
r.get('/popular-repos', (_req, res) => {
|
|
170
|
+
const s = popularState(ctx);
|
|
171
|
+
const repos = Object.values(s.repos);
|
|
172
|
+
res.json({ lastRunAt: s.lastRunAt, repos: repos.length, withSkills: repos.filter((r) => r.skills > 0).length, skills: repos.reduce((n, r) => n + r.skills, 0) });
|
|
173
|
+
});
|
|
174
|
+
r.post('/popular-repos/collect', (req, res) => {
|
|
175
|
+
res.json({ jobId: startPopularCollect(ctx, false, req.body?.force === true).id });
|
|
176
|
+
});
|
|
177
|
+
r.get('/jobs', (_req, res) => {
|
|
178
|
+
res.json({ jobs: jobs.list() });
|
|
179
|
+
});
|
|
180
|
+
r.get('/jobs/:id', (req, res) => {
|
|
181
|
+
const job = jobs.get(req.params.id);
|
|
182
|
+
if (!job) {
|
|
183
|
+
res.status(404).json({ error: '작업이 없습니다' });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
res.json(job);
|
|
187
|
+
});
|
|
188
|
+
r.post('/jobs/:id/:action', (req, res) => {
|
|
189
|
+
const { id, action } = req.params;
|
|
190
|
+
const ok = action === 'pause' ? jobs.pause(id) : action === 'resume' ? jobs.resume(id) : action === 'cancel' ? jobs.cancel(id) : null;
|
|
191
|
+
if (ok === null) {
|
|
192
|
+
res.status(404).json({ error: '알 수 없는 동작입니다' });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const job = jobs.get(id);
|
|
196
|
+
if (!job) {
|
|
197
|
+
res.status(404).json({ error: '작업이 없습니다' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
res.status(ok ? 200 : 409).json(job);
|
|
201
|
+
});
|
|
202
|
+
return r;
|
|
203
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { freshInstalls, freshness, TRUST_SCORE, trustTier } from "../search/rank.js";
|
|
4
|
+
import { buildResultItem, githubSource, marketplaceSource, storeLookup } from "../search/result.js";
|
|
5
|
+
const FavoriteBody = z.object({ id: z.string().min(1).max(400) });
|
|
6
|
+
/**
|
|
7
|
+
* `name`: a search result not in the index yet (still being checked) is kept by it until it is. Unusable (empty,
|
|
8
|
+
* not text) it is left out rather than refused, so an indexed skill still gets its star.
|
|
9
|
+
*/
|
|
10
|
+
const FavoriteAdd = FavoriteBody.extend({ name: z.unknown().optional() });
|
|
11
|
+
const usableName = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : undefined);
|
|
12
|
+
/**
|
|
13
|
+
* 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
|
+
* login kept only here protected nothing. The API's header and same-origin checks apply as everywhere.
|
|
15
|
+
*/
|
|
16
|
+
export function favoritesRouter(ctx) {
|
|
17
|
+
const r = Router();
|
|
18
|
+
const { store } = ctx;
|
|
19
|
+
r.get('/favorites/ids', (_req, res) => {
|
|
20
|
+
res.json({ ids: store.listFavorites().map((f) => f.skill_id) });
|
|
21
|
+
});
|
|
22
|
+
/** The favorites as result cards; ones whose skill left the index are listed by name. */
|
|
23
|
+
r.get('/favorites', (_req, res) => {
|
|
24
|
+
const lookup = storeLookup(store);
|
|
25
|
+
const items = [];
|
|
26
|
+
const gone = [];
|
|
27
|
+
for (const f of store.listFavorites()) {
|
|
28
|
+
const s = store.getSkill(f.skill_id);
|
|
29
|
+
if (!s) {
|
|
30
|
+
gone.push({ id: f.skill_id, name: f.skill_name });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const plugin = lookup.plugin(s.plugin_id);
|
|
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 }));
|
|
37
|
+
}
|
|
38
|
+
res.json({ items, gone });
|
|
39
|
+
});
|
|
40
|
+
r.post('/favorites', (req, res) => {
|
|
41
|
+
const parsed = FavoriteAdd.safeParse(req.body);
|
|
42
|
+
if (!parsed.success)
|
|
43
|
+
return void res.status(400).json({ error: '스킬 id가 없습니다' });
|
|
44
|
+
const skill = store.getSkill(parsed.data.id);
|
|
45
|
+
// not indexed: listed by name on the favorites page, as a card once the index has it
|
|
46
|
+
const name = skill?.name ?? usableName(parsed.data.name);
|
|
47
|
+
if (!name)
|
|
48
|
+
return void res.status(404).json({ error: '스킬을 찾을 수 없습니다' });
|
|
49
|
+
store.addFavorite(parsed.data.id, name);
|
|
50
|
+
res.json({ ok: true });
|
|
51
|
+
});
|
|
52
|
+
// the id in the address: some clients send no body with DELETE
|
|
53
|
+
r.delete('/favorites', (req, res) => {
|
|
54
|
+
const parsed = FavoriteBody.safeParse({ id: req.query.id });
|
|
55
|
+
if (!parsed.success)
|
|
56
|
+
return void res.status(400).json({ error: '스킬 id가 없습니다' });
|
|
57
|
+
store.removeFavorite(parsed.data.id);
|
|
58
|
+
res.json({ ok: true });
|
|
59
|
+
});
|
|
60
|
+
return r;
|
|
61
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { SKILLS_CLI_VERSION } from "../config.js";
|
|
3
|
+
import { claudeVersion } from "../claude/cli.js";
|
|
4
|
+
import { updateInfo } from "../update.js";
|
|
5
|
+
import { APP_VERSION } from "../version.js";
|
|
6
|
+
export function healthRouter(ctx) {
|
|
7
|
+
const r = Router();
|
|
8
|
+
r.get('/health', async (_req, res) => {
|
|
9
|
+
res.json({
|
|
10
|
+
ok: true,
|
|
11
|
+
/** Fixed: the launcher tells this server from any other dev server answering /api/health on the port. */
|
|
12
|
+
app: 'skill-finder',
|
|
13
|
+
appVersion: APP_VERSION,
|
|
14
|
+
/** The server's process: `skill-finder stop` ends only the one its own pid file names. */
|
|
15
|
+
pid: process.pid,
|
|
16
|
+
/** A newer version on the download page (installed app only); the launcher installs it at the next start. */
|
|
17
|
+
update: updateInfo(APP_VERSION),
|
|
18
|
+
node: process.version,
|
|
19
|
+
claudeVersion: await claudeVersion(),
|
|
20
|
+
skillsCli: SKILLS_CLI_VERSION,
|
|
21
|
+
githubToken: !!ctx.config.githubToken,
|
|
22
|
+
plugins: ctx.store.countPlugins(),
|
|
23
|
+
skills: ctx.store.countSkills(),
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { buildSteps, planInstall } from "../install/plan.js";
|
|
7
|
+
import { executeInstall } from "../install/run.js";
|
|
8
|
+
import { existingSkillFolder, forgetPlugins, installedPluginCommits, listInstalledPlugins, pluginCommitKey, pluginIndexInfo, pluginSkills, scanInstalledSkills } from "../install/installed.js";
|
|
9
|
+
import { uninstallPlugin, uninstallSkill } from "../install/uninstall.js";
|
|
10
|
+
import { isProjectDir } from "../install/validate.js";
|
|
11
|
+
import { getSettings } from "../settings.js";
|
|
12
|
+
import { maskSecrets } from "../util/log.js";
|
|
13
|
+
import { FolderPickerUnavailable, pickFolder } from "../util/folderPicker.js";
|
|
14
|
+
import { ndjson } from "../util/ndjson.js";
|
|
15
|
+
import { isPlainFolderName, resolveDroppedFolder } from "../util/resolveFolder.js";
|
|
16
|
+
const Body = z.object({
|
|
17
|
+
id: z.string().min(1).max(500),
|
|
18
|
+
scope: z.enum(['user', 'project', 'local']),
|
|
19
|
+
projectPath: z.string().max(1000).optional(),
|
|
20
|
+
/**
|
|
21
|
+
* The same-name folder the confirm dialog showed as replaced. Only that folder may be replaced without another
|
|
22
|
+
* check: a dialog opened on an earlier preview (another scope or folder) names another one (review 2026-11-02).
|
|
23
|
+
*/
|
|
24
|
+
overwrite: z.string().max(2000).optional(),
|
|
25
|
+
});
|
|
26
|
+
const UninstallBody = z.object({
|
|
27
|
+
kind: z.enum(['plugin', 'skill']),
|
|
28
|
+
/** plugin id (name@marketplace) or skill folder name */
|
|
29
|
+
id: z.string().min(1).max(300),
|
|
30
|
+
scope: z.enum(['user', 'project', 'local']),
|
|
31
|
+
projectPath: z.string().max(1000).optional(),
|
|
32
|
+
});
|
|
33
|
+
/** enabledPlugins[id] from the settings file that owns an install (user / project / local scope); null when unknown. */
|
|
34
|
+
function enabledInSettings(id, scope, projectPath) {
|
|
35
|
+
const file = scope === 'user'
|
|
36
|
+
? join(homedir(), '.claude', 'settings.json')
|
|
37
|
+
: scope === 'project' && projectPath
|
|
38
|
+
? join(projectPath, '.claude', 'settings.json')
|
|
39
|
+
: scope === 'local' && projectPath
|
|
40
|
+
? join(projectPath, '.claude', 'settings.local.json')
|
|
41
|
+
: null;
|
|
42
|
+
if (!file)
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
const value = JSON.parse(readFileSync(file, 'utf8')).enabledPlugins?.[id];
|
|
46
|
+
return typeof value === 'boolean' ? value : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
let installing = false;
|
|
53
|
+
let picking = false;
|
|
54
|
+
export function installRouter(ctx) {
|
|
55
|
+
const r = Router();
|
|
56
|
+
const { store } = ctx;
|
|
57
|
+
r.post('/install', async (req, res) => {
|
|
58
|
+
const parsed = Body.safeParse(req.body);
|
|
59
|
+
if (!parsed.success) {
|
|
60
|
+
res.status(400).json({ error: '요청 형식이 올바르지 않습니다' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const { id, scope } = parsed.data;
|
|
64
|
+
const projectPath = parsed.data.projectPath?.trim() || null;
|
|
65
|
+
const skill = store.getSkill(id);
|
|
66
|
+
if (!skill) {
|
|
67
|
+
res.status(404).json({ error: '스킬을 찾을 수 없습니다' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const plugin = skill.plugin_id ? (store.getPlugin(skill.plugin_id) ?? null) : null;
|
|
71
|
+
const plan = planInstall(skill, plugin, plugin ? (store.getMarketplace(plugin.marketplace) ?? null) : null);
|
|
72
|
+
if (plan.method === 'manual-only' || plan.disabled) {
|
|
73
|
+
res.status(400).json({ error: plan.reason ?? '앱에서 설치할 수 없는 스킬입니다' });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!plan.scopes.includes(scope)) {
|
|
77
|
+
res.status(400).json({ error: '이 설치 방식에서 지원하지 않는 범위입니다' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (scope !== 'user' && !isProjectDir(projectPath)) {
|
|
81
|
+
res.status(400).json({ error: '프로젝트 경로는 존재하는 디렉터리의 절대경로여야 합니다' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (plan.method === 'skills-cli' && scope !== 'local') {
|
|
85
|
+
const existing = await existingSkillFolder(store, skill.id, plan.skillName, scope, projectPath);
|
|
86
|
+
if (existing && existing.same !== true && parsed.data.overwrite !== existing.path) {
|
|
87
|
+
res.status(409).json({ error: `${existing.path}에 같은 이름의 다른 스킬이 있습니다. 설치 확인 창에서 덮어쓰기를 확인한 뒤 다시 설치하세요.` });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (installing) {
|
|
92
|
+
res.status(409).json({ error: '다른 설치가 진행 중입니다' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
installing = true;
|
|
96
|
+
const out = ndjson(res);
|
|
97
|
+
try {
|
|
98
|
+
const settings = getSettings(store);
|
|
99
|
+
const env = { ...process.env, ...(settings.telemetryDisabled ? { DISABLE_TELEMETRY: '1', DO_NOT_TRACK: '1' } : {}) };
|
|
100
|
+
const steps = buildSteps(plan, scope, projectPath);
|
|
101
|
+
const result = await executeInstall(plan, steps, (line, stream) => out.send({ type: 'log', line, stream: stream ?? 'stdout' }), env);
|
|
102
|
+
store.logInstall({
|
|
103
|
+
skill_id: skill.id,
|
|
104
|
+
plugin_id: plan.pluginId ?? null,
|
|
105
|
+
method: plan.method,
|
|
106
|
+
scope,
|
|
107
|
+
project_path: projectPath,
|
|
108
|
+
argv: result.argv,
|
|
109
|
+
exit_code: result.exitCode,
|
|
110
|
+
output: result.output,
|
|
111
|
+
ref: plan.pinnedRef ?? skill.ref,
|
|
112
|
+
skill_md_sha256: skill.skill_md_sha256,
|
|
113
|
+
risk_level: skill.risk_level,
|
|
114
|
+
});
|
|
115
|
+
forgetPlugins();
|
|
116
|
+
let message = result.message;
|
|
117
|
+
if (result.ok) {
|
|
118
|
+
store.logEvent('install', skill.id, null);
|
|
119
|
+
// a plugin cannot be pinned: say when Claude Code fetched another commit than the one checked (UI debate 3-3)
|
|
120
|
+
if (plan.method === 'plugin' && plan.pluginId) {
|
|
121
|
+
const info = pluginIndexInfo(store, plan.pluginId, installedPluginCommits().get(pluginCommitKey(plan.pluginId, scope, projectPath)));
|
|
122
|
+
if (info.change === 'changed')
|
|
123
|
+
message += ' 설치된 커밋이 위험 검사를 한 커밋과 다릅니다. 설치됨 화면에서 위험 등급을 다시 확인하세요.';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
out.send({ type: 'result', ok: result.ok, exitCode: result.exitCode, message });
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
out.send({ type: 'result', ok: false, exitCode: -1, message: `실행 실패: ${String(err.message ?? err).slice(0, 200)}` });
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
installing = false;
|
|
133
|
+
out.end();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
// What the app ran: installs and removals with their result and output (review 2026-11-02), to find why one
|
|
137
|
+
// failed without the console. Output masked like the log file and cut to 4,000 characters.
|
|
138
|
+
r.get('/install/log', (_req, res) => {
|
|
139
|
+
res.json({
|
|
140
|
+
entries: store.listInstallLog(50).map((e) => ({
|
|
141
|
+
id: e.id,
|
|
142
|
+
at: e.created_at,
|
|
143
|
+
method: e.method,
|
|
144
|
+
scope: e.scope,
|
|
145
|
+
projectPath: e.project_path,
|
|
146
|
+
target: e.plugin_id ?? e.skill_id,
|
|
147
|
+
ok: e.exit_code === 0,
|
|
148
|
+
output: maskSecrets(e.output ?? '').slice(-4000),
|
|
149
|
+
})),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
// Whether an install or removal is running: the page asks when its result stream ended without a result
|
|
153
|
+
// (project review F4), to say "still installing" or "check the 설치됨 screen".
|
|
154
|
+
r.get('/install/status', (_req, res) => {
|
|
155
|
+
res.json({ installing });
|
|
156
|
+
});
|
|
157
|
+
r.post('/uninstall', async (req, res) => {
|
|
158
|
+
const parsed = UninstallBody.safeParse(req.body);
|
|
159
|
+
if (!parsed.success) {
|
|
160
|
+
res.status(400).json({ error: '요청 형식이 올바르지 않습니다' });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const b = parsed.data;
|
|
164
|
+
const projectPath = b.projectPath?.trim() || null;
|
|
165
|
+
if (b.scope !== 'user' && !isProjectDir(projectPath)) {
|
|
166
|
+
res.status(400).json({ error: '프로젝트 범위는 존재하는 프로젝트 폴더가 필요합니다' });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (installing) {
|
|
170
|
+
res.status(409).json({ error: '다른 설치·삭제가 진행 중입니다' });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
installing = true;
|
|
174
|
+
try {
|
|
175
|
+
let result;
|
|
176
|
+
if (b.kind === 'plugin') {
|
|
177
|
+
result = await uninstallPlugin(b.id, b.scope, projectPath);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
if (b.scope === 'local') {
|
|
181
|
+
res.status(400).json({ error: '스킬에는 로컬 범위가 없습니다' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const settings = getSettings(store);
|
|
185
|
+
const env = { ...process.env, ...(settings.telemetryDisabled ? { DISABLE_TELEMETRY: '1', DO_NOT_TRACK: '1' } : {}) };
|
|
186
|
+
result = await uninstallSkill(b.id, b.scope, projectPath, env);
|
|
187
|
+
}
|
|
188
|
+
store.logInstall({
|
|
189
|
+
skill_id: b.kind === 'skill' ? b.id : null,
|
|
190
|
+
plugin_id: b.kind === 'plugin' ? b.id : null,
|
|
191
|
+
method: `uninstall-${b.kind}`,
|
|
192
|
+
scope: b.scope,
|
|
193
|
+
project_path: projectPath,
|
|
194
|
+
argv: [],
|
|
195
|
+
exit_code: result.ok ? 0 : 1,
|
|
196
|
+
output: result.output,
|
|
197
|
+
});
|
|
198
|
+
forgetPlugins();
|
|
199
|
+
res.status(result.ok ? 200 : 500).json(result.ok ? { ok: true, message: result.message } : { error: result.message });
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
res.status(500).json({ error: `삭제 실패: ${String(err.message ?? err).slice(0, 200)}` });
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
installing = false;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
r.post('/pick-folder', async (req, res) => {
|
|
209
|
+
if (picking) {
|
|
210
|
+
res.status(409).json({ error: '폴더 선택 창이 이미 열려 있습니다' });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
picking = true;
|
|
214
|
+
try {
|
|
215
|
+
const initial = typeof req.body?.initial === 'string' ? req.body.initial : null;
|
|
216
|
+
res.json({ path: await pickFolder(initial) });
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
res.status(500).json({ error: err instanceof FolderPickerUnavailable ? err.message : '폴더 선택 창을 열지 못했습니다' });
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
picking = false;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
// Drag & drop: the browser only knows the dropped folder's name and contents; find its absolute path.
|
|
226
|
+
const DropBody = z.object({ name: z.string().min(1).max(255), entries: z.array(z.string().max(255)).max(200) });
|
|
227
|
+
r.post('/resolve-folder', (req, res) => {
|
|
228
|
+
const parsed = DropBody.safeParse(req.body);
|
|
229
|
+
if (!parsed.success || !isPlainFolderName(parsed.data.name)) {
|
|
230
|
+
res.status(400).json({ error: '폴더 이름이 올바르지 않습니다' });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const hints = [getSettings(ctx.store).defaultProjectPath].filter(Boolean);
|
|
234
|
+
res.json({ matches: resolveDroppedFolder(parsed.data.name, parsed.data.entries, hints) });
|
|
235
|
+
});
|
|
236
|
+
r.get('/installed', async (req, res) => {
|
|
237
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.trim() ? req.query.projectPath.trim() : null;
|
|
238
|
+
if (projectPath && !isProjectDir(projectPath)) {
|
|
239
|
+
res.status(400).json({ error: '프로젝트 경로는 존재하는 디렉터리의 절대경로여야 합니다' });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const listed = await listInstalledPlugins(projectPath ?? homedir(), true);
|
|
243
|
+
const commits = installedPluginCommits();
|
|
244
|
+
const plugins = listed.plugins.map((o) => {
|
|
245
|
+
const { scope, id: pid } = o;
|
|
246
|
+
const ownProject = o.projectPath;
|
|
247
|
+
// `claude plugin list` reports enabled for the folder it runs in; read the settings file that owns this install instead.
|
|
248
|
+
const fromSettings = enabledInSettings(pid, scope, ownProject);
|
|
249
|
+
return {
|
|
250
|
+
id: pid,
|
|
251
|
+
version: o.version,
|
|
252
|
+
scope,
|
|
253
|
+
enabled: fromSettings ?? o.enabled ?? null,
|
|
254
|
+
enabledWhere: scope === 'user' ? '모든 프로젝트' : scope === 'project' || scope === 'local' ? ownProject : null,
|
|
255
|
+
installPath: o.installPath,
|
|
256
|
+
projectPath: ownProject,
|
|
257
|
+
removeCommand: ['user', 'project', 'local'].includes(scope) ? `claude plugin uninstall ${pid} --scope ${scope}` : null,
|
|
258
|
+
...pluginIndexInfo(store, pid, commits.get(pluginCommitKey(pid, scope, ownProject))),
|
|
259
|
+
skills: pluginSkills(store, pid),
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
const skills = [
|
|
263
|
+
...(await scanInstalledSkills(store, 'user', null)),
|
|
264
|
+
...(projectPath ? await scanInstalledSkills(store, 'project', projectPath) : []),
|
|
265
|
+
];
|
|
266
|
+
res.json({ plugins, pluginError: listed.error, skills, projects: store.listInstalledProjects() });
|
|
267
|
+
});
|
|
268
|
+
// What is installed, by index id, for the "설치됨" badges on results (UI debate 3-1). Project skills are those of
|
|
269
|
+
// the default project folder in the settings. Unknown ones are simply absent: no badge, never "not installed".
|
|
270
|
+
r.get('/installed/ids', async (_req, res) => {
|
|
271
|
+
const project = getSettings(store).defaultProjectPath;
|
|
272
|
+
const listed = await listInstalledPlugins();
|
|
273
|
+
const skills = [
|
|
274
|
+
...(await scanInstalledSkills(store, 'user', null)),
|
|
275
|
+
...(isProjectDir(project) ? await scanInstalledSkills(store, 'project', project) : []),
|
|
276
|
+
];
|
|
277
|
+
res.json({
|
|
278
|
+
plugins: listed.plugins.map((p) => ({ id: p.id, scope: p.scope })),
|
|
279
|
+
skills: skills.filter((s) => s.skillId).map((s) => ({ id: s.skillId, scope: s.scope })),
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
return r;
|
|
283
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { installSummary } from "../install/summary.js";
|
|
4
|
+
import { isRepo } from "../install/validate.js";
|
|
5
|
+
import { storeLookup } from "../search/result.js";
|
|
6
|
+
import { withTimeout } from "../util/limiter.js";
|
|
7
|
+
// A team preset is a file a teammate exported (설치됨 → 팀 프리셋 내보내기): skill ids from their index and plugin
|
|
8
|
+
// ids. It is untrusted input, so it only names things to look up here: what gets installed is this PC's indexed
|
|
9
|
+
// skill, risk-checked here, through the usual confirm dialog and /install (user decision 2026-11-02).
|
|
10
|
+
const Item = z.object({
|
|
11
|
+
id: z.string().max(400).optional(),
|
|
12
|
+
plugin: z.string().max(200).optional(),
|
|
13
|
+
name: z.string().max(200).optional(),
|
|
14
|
+
});
|
|
15
|
+
const Body = z.object({ items: z.array(Item).max(200) });
|
|
16
|
+
/** GitHub repos to read before resolving: at most this many, each for at most REPO_WAIT_MS. */
|
|
17
|
+
const MAX_NEW_REPOS = 5;
|
|
18
|
+
const REPO_WAIT_MS = 10_000;
|
|
19
|
+
const repoOfId = (id) => id.match(/^gh:([^:]+):/)?.[1] ?? null;
|
|
20
|
+
export function presetsRouter(ctx) {
|
|
21
|
+
const r = Router();
|
|
22
|
+
const { store } = ctx;
|
|
23
|
+
r.post('/presets/resolve', async (req, res) => {
|
|
24
|
+
const parsed = Body.safeParse(req.body);
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
res.status(400).json({ error: '프리셋 형식이 맞지 않습니다' });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const items = parsed.data.items;
|
|
30
|
+
// skills from GitHub repos this PC has not indexed yet: read a few of them now, as a repo-name search does
|
|
31
|
+
const newRepos = [...new Set(items.flatMap((i) => (i.id && !store.getSkill(i.id) ? [repoOfId(i.id)] : [])))]
|
|
32
|
+
.filter((r) => isRepo(r))
|
|
33
|
+
.slice(0, MAX_NEW_REPOS);
|
|
34
|
+
await Promise.all(newRepos.map((repo) => withTimeout(ctx.indexer.indexRepo(repo), REPO_WAIT_MS).catch(() => null)));
|
|
35
|
+
const lookup = storeLookup(store);
|
|
36
|
+
const skills = [];
|
|
37
|
+
const missing = [];
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (const it of items) {
|
|
40
|
+
const label = it.name ?? it.id ?? it.plugin ?? '?';
|
|
41
|
+
let skill = it.id ? (store.getSkill(it.id) ?? null) : null;
|
|
42
|
+
// a plugin installs whole, through any of its skills
|
|
43
|
+
if (!skill && it.plugin)
|
|
44
|
+
skill = store.listSkillsByPlugin(it.plugin)[0] ?? null;
|
|
45
|
+
if (!skill) {
|
|
46
|
+
missing.push({ name: label, reason: it.plugin ? '이 PC에서 그 플러그인의 스킬을 아직 색인하지 못했습니다' : '이 PC의 색인에 없습니다' });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const plugin = lookup.plugin(skill.plugin_id);
|
|
50
|
+
const key = plugin ? `plugin:${plugin.id}` : skill.id;
|
|
51
|
+
if (seen.has(key))
|
|
52
|
+
continue;
|
|
53
|
+
seen.add(key);
|
|
54
|
+
const row = installSummary(skill, lookup, plugin ? `${plugin.name} (플러그인)` : skill.name);
|
|
55
|
+
if (plugin) {
|
|
56
|
+
// the whole plugin installs: its riskiest skill and every signal of its skills, so the dialog asks for the
|
|
57
|
+
// tick a high-risk skill inside needs (review 2026-11-02)
|
|
58
|
+
row.riskLevel = lookup.pluginTopRisk(plugin.id);
|
|
59
|
+
row.riskFlags = [...new Set(store.listSkillsByPlugin(plugin.id).flatMap((s) => (s.risk_json ? JSON.parse(s.risk_json) : [])))];
|
|
60
|
+
}
|
|
61
|
+
skills.push(row);
|
|
62
|
+
}
|
|
63
|
+
res.json({ skills, missing });
|
|
64
|
+
});
|
|
65
|
+
return r;
|
|
66
|
+
}
|