@seojin08/skill-finder 0.0.0-stage → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.env.example +3 -0
  2. package/CHANGELOG.md +44 -0
  3. package/LICENSE +21 -0
  4. package/README.md +86 -2
  5. package/bin/skill-finder.mjs +389 -0
  6. package/package.json +39 -4
  7. package/server/src/analyze/frontmatter.js +130 -0
  8. package/server/src/analyze/quality.js +34 -0
  9. package/server/src/analyze/risk.js +123 -0
  10. package/server/src/app.js +57 -0
  11. package/server/src/bootstrap.js +46 -0
  12. package/server/src/claude/cli.js +89 -0
  13. package/server/src/config.js +82 -0
  14. package/server/src/context.js +1 -0
  15. package/server/src/db/index.js +128 -0
  16. package/server/src/db/schema.js +95 -0
  17. package/server/src/db/store.js +353 -0
  18. package/server/src/discovery/classifyRepo.js +81 -0
  19. package/server/src/discovery/genericRules.js +182 -0
  20. package/server/src/discovery/pluginRules.js +59 -0
  21. package/server/src/discovery/tree.js +79 -0
  22. package/server/src/index.js +55 -0
  23. package/server/src/indexer.js +502 -0
  24. package/server/src/install/installed.js +206 -0
  25. package/server/src/install/plan.js +118 -0
  26. package/server/src/install/run.js +97 -0
  27. package/server/src/install/scan.js +70 -0
  28. package/server/src/install/summary.js +14 -0
  29. package/server/src/install/uninstall.js +59 -0
  30. package/server/src/install/validate.js +24 -0
  31. package/server/src/jobs.js +107 -0
  32. package/server/src/recommend/readme.js +508 -0
  33. package/server/src/release.js +41 -0
  34. package/server/src/routes/admin.js +203 -0
  35. package/server/src/routes/favorites.js +61 -0
  36. package/server/src/routes/health.js +27 -0
  37. package/server/src/routes/install.js +283 -0
  38. package/server/src/routes/presets.js +66 -0
  39. package/server/src/routes/recommend.js +36 -0
  40. package/server/src/routes/search.js +53 -0
  41. package/server/src/routes/skills.js +214 -0
  42. package/server/src/search/activity.js +13 -0
  43. package/server/src/search/category.js +109 -0
  44. package/server/src/search/koreanTerms.js +284 -0
  45. package/server/src/search/pipeline.js +977 -0
  46. package/server/src/search/rank.js +138 -0
  47. package/server/src/search/result.js +129 -0
  48. package/server/src/search/searchLog.js +53 -0
  49. package/server/src/security.js +53 -0
  50. package/server/src/settings.js +56 -0
  51. package/server/src/sources/gitTree.js +101 -0
  52. package/server/src/sources/github.js +503 -0
  53. package/server/src/sources/marketplace.js +78 -0
  54. package/server/src/sources/pluginSource.js +98 -0
  55. package/server/src/sources/popularRepos.js +74 -0
  56. package/server/src/sources/skillsSh.js +74 -0
  57. package/server/src/tasks.js +216 -0
  58. package/server/src/update.js +53 -0
  59. package/server/src/util/folderPicker.js +208 -0
  60. package/server/src/util/http.js +41 -0
  61. package/server/src/util/limiter.js +110 -0
  62. package/server/src/util/log.js +94 -0
  63. package/server/src/util/ndjson.js +27 -0
  64. package/server/src/util/proc.js +80 -0
  65. package/server/src/util/resolveFolder.js +78 -0
  66. package/server/src/util/sanitize.js +45 -0
  67. package/server/src/util/sizedCache.js +74 -0
  68. package/server/src/util/textFiles.js +9 -0
  69. package/server/src/util/tokens.js +21 -0
  70. package/server/src/version.js +12 -0
  71. package/web/dist/LICENSE.txt +21 -0
  72. package/web/dist/THIRD-PARTY-LICENSES.txt +2267 -0
  73. package/web/dist/assets/ArasakaGame-B0V_LZLl.css +1 -0
  74. package/web/dist/assets/ArasakaGame-C1Wkmqik.js +36 -0
  75. package/web/dist/assets/index-BgL1OaU4.css +2 -0
  76. package/web/dist/assets/index-Ec2XBK4-.js +66 -0
  77. package/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  78. package/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  79. package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  80. package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  81. package/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  82. package/web/dist/index.html +15 -0
@@ -0,0 +1,206 @@
1
+ // What is installed on this PC and how it matches the index (UI debate stage 3: shown only, nothing is run).
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { readdir, readFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { join, posix } from 'node:path';
7
+ import { extractSkillMeta } from "../analyze/frontmatter.js";
8
+ import { riskLevel, skillRiskFlags } from "../analyze/risk.js";
9
+ import { runClaude } from "../claude/cli.js";
10
+ import { SKILLS_CLI_VERSION } from "../config.js";
11
+ import { truncate } from "../util/sanitize.js";
12
+ import { skillTokens } from "../util/tokens.js";
13
+ import { parseJsonArray } from "./run.js";
14
+ const sha256 = (s) => createHash('sha256').update(s).digest('hex');
15
+ /** As the indexer hashes SKILL.md (indexer.ts buildSkillRow): line endings would otherwise make every copy differ. */
16
+ const mdHash = (raw) => sha256(raw.replace(/\r\n/g, '\n'));
17
+ /** Paths relative to the skill folder, for the "scripts" signal; at most 500, no dependency folders. */
18
+ async function listFiles(dir, prefix = '', out = []) {
19
+ let entries;
20
+ try {
21
+ entries = await readdir(dir, { withFileTypes: true });
22
+ }
23
+ catch {
24
+ return out;
25
+ }
26
+ for (const e of entries) {
27
+ if (out.length >= 500)
28
+ break;
29
+ if (e.name === 'node_modules' || e.name === '.git')
30
+ continue;
31
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
32
+ if (e.isDirectory())
33
+ await listFiles(join(dir, e.name), rel, out);
34
+ else
35
+ out.push(rel);
36
+ }
37
+ return out;
38
+ }
39
+ /** skills CLI lock files: `skills-lock.json` in a project, `~/.agents/.skill-lock.json` for the user. */
40
+ function readLock(file) {
41
+ const byFolder = new Map();
42
+ try {
43
+ const skills = JSON.parse(readFileSync(file, 'utf8')).skills ?? {};
44
+ for (const [folder, e] of Object.entries(skills)) {
45
+ if (typeof e.source !== 'string' || typeof e.skillPath !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(e.source))
46
+ continue;
47
+ if (e.sourceType !== undefined && e.sourceType !== 'github')
48
+ continue; // gitlab etc. are not gh: rows
49
+ const dir = posix.dirname(e.skillPath.replace(/\\/g, '/'));
50
+ // index ids keep the repo part in lower case (indexer.ts), so an exact lookup finds "ECC" as "ecc"
51
+ byFolder.set(folder.toLowerCase(), `gh:${e.source.toLowerCase()}:${dir === '.' ? '' : dir}`);
52
+ }
53
+ }
54
+ catch {
55
+ // no lock file, or not one we can read
56
+ }
57
+ return byFolder;
58
+ }
59
+ /** The folder the skills CLI installs a skill into (its `sanitizeName`, the same in skills@1.7.4 and 1.7.6). */
60
+ export function skillFolderName(name) {
61
+ return name.toLowerCase().replace(/[^a-z0-9._]+/g, '-').replace(/^[.\-]+|[.\-]+$/g, '').slice(0, 255) || 'unnamed-skill';
62
+ }
63
+ /**
64
+ * Installed skill folders of one scope, each matched to the index by, in order: the skills CLI lock file
65
+ * (source + path), the app's own install log (same name), or an identical SKILL.md (hash). `onlyFolder` limits
66
+ * the scan to that folder.
67
+ */
68
+ export async function scanInstalledSkills(store, scope, projectPath, onlyFolder) {
69
+ const root = scope === 'user' ? homedir() : projectPath;
70
+ const dir = join(root, '.claude', 'skills');
71
+ const lock = readLock(scope === 'user' ? join(homedir(), '.agents', '.skill-lock.json') : join(root, 'skills-lock.json'));
72
+ const logged = new Map();
73
+ for (const l of store.listInstalledSkillLog()) {
74
+ if (l.scope !== scope || (scope === 'project' && l.project_path?.toLowerCase() !== root.toLowerCase()))
75
+ continue;
76
+ const name = store.getSkill(l.skill_id)?.name.toLowerCase();
77
+ if (name && !logged.has(name))
78
+ logged.set(name, { id: l.skill_id, risk: l.risk_level });
79
+ }
80
+ let entries = [];
81
+ try {
82
+ entries = (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => e.name);
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ const out = [];
88
+ for (const folder of entries.sort()) {
89
+ if (onlyFolder && folder.toLowerCase() !== onlyFolder.toLowerCase())
90
+ continue;
91
+ const path = join(dir, folder);
92
+ let raw;
93
+ try {
94
+ raw = await readFile(join(path, 'SKILL.md'), 'utf8');
95
+ }
96
+ catch {
97
+ 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
+ out.push({
107
+ scope,
108
+ path,
109
+ folder,
110
+ name: meta.name,
111
+ description: truncate(meta.description, 200),
112
+ 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
+ });
119
+ }
120
+ return out;
121
+ }
122
+ export async function existingSkillFolder(store, skillId, skillName, scope, projectPath) {
123
+ const folder = skillFolderName(skillName);
124
+ const path = join(scope === 'user' ? homedir() : projectPath, '.claude', 'skills', folder);
125
+ if (!existsSync(path))
126
+ return null;
127
+ const [hit] = await scanInstalledSkills(store, scope, projectPath, folder);
128
+ if (!hit)
129
+ return { path, name: folder, same: null, change: 'unknown' };
130
+ const same = hit.skillId == null ? null : hit.skillId === skillId;
131
+ return { path: hit.path, name: hit.name, same, change: same ? hit.change : 'unknown' };
132
+ }
133
+ /** `claude plugin list --json`, kept for a minute per folder; installs and uninstalls drop it (forgetPlugins). */
134
+ const pluginCache = new Map();
135
+ const PLUGIN_CACHE_MS = 60_000;
136
+ export function forgetPlugins() {
137
+ pluginCache.clear();
138
+ }
139
+ /** `fresh`: ask Claude Code now (the installed page, where a change made in a terminal must show at once). */
140
+ export function listInstalledPlugins(cwd = homedir(), fresh = false) {
141
+ const hit = pluginCache.get(cwd);
142
+ if (!fresh && hit && Date.now() - hit.at < PLUGIN_CACHE_MS)
143
+ return hit.value;
144
+ const value = runClaude(['plugin', 'list', '--json'], { cwd, timeoutMs: 60_000 })
145
+ .then((list) => ({
146
+ plugins: (parseJsonArray(list.stdout) ?? []).map((p) => {
147
+ const o = p;
148
+ return {
149
+ id: String(o.id ?? ''),
150
+ version: o.version ?? null,
151
+ scope: typeof o.scope === 'string' ? o.scope : 'user',
152
+ projectPath: typeof o.projectPath === 'string' ? o.projectPath : null,
153
+ installPath: o.installPath ?? null,
154
+ enabled: o.enabled ?? null,
155
+ };
156
+ }),
157
+ error: list.exitCode !== 0 ? 'claude plugin list 실패' : null,
158
+ }))
159
+ .catch((err) => ({ plugins: [], error: String(err.message ?? err) }));
160
+ pluginCache.set(cwd, { at: Date.now(), value });
161
+ // a failure is not kept: the next call asks again
162
+ void value.then((v) => v.error && pluginCache.delete(cwd));
163
+ return value;
164
+ }
165
+ /** The commit Claude Code installed each plugin from (`~/.claude/plugins/installed_plugins.json`), by id and scope. */
166
+ export function installedPluginCommits() {
167
+ const out = new Map();
168
+ try {
169
+ const file = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
170
+ const plugins = JSON.parse(readFileSync(file, 'utf8')).plugins ?? {};
171
+ for (const [id, installs] of Object.entries(plugins)) {
172
+ for (const i of Array.isArray(installs) ? installs : []) {
173
+ if (typeof i.gitCommitSha === 'string')
174
+ out.set(pluginCommitKey(id, String(i.scope), typeof i.projectPath === 'string' ? i.projectPath : null), i.gitCommitSha);
175
+ }
176
+ }
177
+ }
178
+ catch {
179
+ // Claude Code keeps this file; without it the commit is unknown
180
+ }
181
+ return out;
182
+ }
183
+ /** Windows paths compared as Claude Code writes them: backslashes, no trailing one, any case. */
184
+ const pathKey = (p) => p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase();
185
+ export const pluginCommitKey = (id, scope, projectPath) => `${id}|${scope}|${scope === 'user' ? '' : pathKey(projectPath ?? '')}`;
186
+ /** How a plugin install compares with the index: its skills' top risk and whether it came from the indexed commit. */
187
+ /** The indexed skills a plugin install brought in (its own, or those whose install plan names it), for the 설치됨 screen. */
188
+ export function pluginSkills(store, id) {
189
+ const at = id.lastIndexOf('@');
190
+ const rows = store.getPlugin(id) ? store.listSkillsByPlugin(id) : at > 0 ? store.listSkillsByInstallPlugin(id.slice(at + 1), id.slice(0, at)) : [];
191
+ return rows.map((s) => ({ id: s.id, name: s.name, riskLevel: (s.risk_level ?? 'low') }));
192
+ }
193
+ export function pluginIndexInfo(store, id, commit) {
194
+ const rank = { low: 0, medium: 1, high: 2 };
195
+ const top = (skills) => skills.reduce((t, s) => (rank[s.risk_level] > rank[t] ? s.risk_level : t), 'low');
196
+ const plugin = store.getPlugin(id);
197
+ if (!plugin) {
198
+ // installed from a repo's own marketplace: known through the skills whose install plan points at it
199
+ const at = id.lastIndexOf('@');
200
+ const skills = at > 0 ? store.listSkillsByInstallPlugin(id.slice(at + 1), id.slice(0, at)) : [];
201
+ return skills.length ? { indexed: true, topRisk: top(skills), change: 'unknown' } : { indexed: false, topRisk: null, change: 'unknown' };
202
+ }
203
+ const topRisk = top(store.listSkillsByPlugin(id));
204
+ const indexedAt = plugin.skills_indexed_sha;
205
+ return { indexed: true, topRisk, change: !commit || !indexedAt ? 'unknown' : commit === indexedAt ? 'same' : 'changed' };
206
+ }
@@ -0,0 +1,118 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, sep } from 'node:path';
3
+ import { SKILLS_CLI_PATH, SKILLS_CLI_VERSION } from "../config.js";
4
+ import { formatCommand } from "../claude/cli.js";
5
+ import { skillFolderName } from "./installed.js";
6
+ import { isCommitSha, isPluginName, isRepo, isRepoDir, isSkillName } from "./validate.js";
7
+ const NPX_SKILLS = `npx skills@${SKILLS_CLI_VERSION}`;
8
+ /**
9
+ * `-y` only for the app's own run, after its confirm dialog (D-5). The command shown for running by hand (copied,
10
+ * shared, exported) leaves it out, so the CLI asks there as it normally would (review 2026-11-02).
11
+ */
12
+ function skillsCliArgs(source, name, scope, yes = true) {
13
+ return ['add', source, '--skill', name, '-a', 'claude-code', '--copy', ...(yes ? ['-y'] : []), ...(scope === 'user' ? ['-g'] : [])];
14
+ }
15
+ /**
16
+ * The skills CLI takes `…/tree/<sha>/<folder>` and clones exactly that commit, so the files installed are the ones
17
+ * the risk check read (UI debate 1-3). The plain repo (its latest commit) when the stored commit or folder fails
18
+ * the checks.
19
+ */
20
+ function skillsSource(repo, ref, dir) {
21
+ if (!isCommitSha(ref) || dir == null || (dir !== '' && !isRepoDir(dir)))
22
+ return { source: repo };
23
+ return { source: `https://github.com/${repo}/tree/${ref}${dir ? `/${dir}` : ''}`, pinnedRef: ref };
24
+ }
25
+ function pluginPlan(pluginName, mp, pluginId) {
26
+ return {
27
+ method: 'plugin',
28
+ scopes: ['user', 'project', 'local'],
29
+ command: formatCommand('claude', ['plugin', 'install', `${pluginName}@${mp.name}`, '--scope', 'user']),
30
+ pluginId: pluginId ?? `${pluginName}@${mp.name}`,
31
+ pluginName,
32
+ marketplace: mp,
33
+ };
34
+ }
35
+ const manual = (command, reason, disabled = false) => ({
36
+ method: 'manual-only',
37
+ scopes: [],
38
+ command,
39
+ reason,
40
+ disabled,
41
+ });
42
+ export function planInstall(skill, plugin, marketplace) {
43
+ const flags = skill.risk_json ? JSON.parse(skill.risk_json) : [];
44
+ if (skill.origin === 'wellknown') {
45
+ const domain = JSON.parse(skill.install_json ?? '{}').domain ?? '';
46
+ return manual(formatCommand('npx', [`skills@${SKILLS_CLI_VERSION}`, 'add', `https://${domain}`, '--skill', skill.name]), 'well-known 출처는 미리보기 없이 설치 명령만 제공합니다');
47
+ }
48
+ if (skill.origin === 'plugin' && plugin) {
49
+ const cmd = formatCommand('claude', ['plugin', 'install', `${plugin.name}@${plugin.marketplace}`]);
50
+ if (plugin.source_kind === 'command')
51
+ return manual(cmd, '설치할 때 마켓플레이스가 지정한 명령을 실행하는 command 출처라 앱에서 설치하지 않습니다. 터미널에서 직접 확인 후 설치하세요.', true);
52
+ if (['npm', 'archive', 'unknown'].includes(plugin.source_kind) || !plugin.repo)
53
+ return manual(cmd, `${plugin.source_kind} 출처 또는 GitHub 외 호스트라 앱에서 설치하지 않습니다`);
54
+ if (!marketplace || !isPluginName(plugin.name) || !isPluginName(marketplace.name) || !isRepo(marketplace.repo)) {
55
+ return manual(cmd, '이름 형식이 검증 규칙에 맞지 않아 앱에서 설치하지 않습니다');
56
+ }
57
+ return pluginPlan(plugin.name, { name: marketplace.name, repo: marketplace.repo }, plugin.id);
58
+ }
59
+ const info = (skill.install_json ? JSON.parse(skill.install_json) : null);
60
+ const repo = skill.repo ?? '';
61
+ if (flags.includes('pluginRootDependency')) {
62
+ if (info?.marketplaceName && info.pluginName && isPluginName(info.pluginName) && isPluginName(info.marketplaceName) && isRepo(repo)) {
63
+ return pluginPlan(info.pluginName, { name: info.marketplaceName, repo });
64
+ }
65
+ return manual(formatCommand('claude', ['plugin', 'marketplace', 'add', repo]), '${CLAUDE_PLUGIN_ROOT}에 의존해 플러그인으로만 동작하지만, 이 저장소에 설치할 마켓플레이스가 없습니다');
66
+ }
67
+ if (!isRepo(repo) || !isSkillName(skill.name)) {
68
+ return manual(formatCommand('npx', [`skills@${SKILLS_CLI_VERSION}`, 'add', repo]), '저장소나 스킬 이름이 검증 규칙에 맞지 않아 앱에서 설치하지 않습니다');
69
+ }
70
+ const { source, pinnedRef } = skillsSource(repo, skill.ref, skill.dir_path);
71
+ return {
72
+ method: 'skills-cli',
73
+ scopes: ['user', 'project'],
74
+ command: formatCommand('npx', [`skills@${SKILLS_CLI_VERSION}`, ...skillsCliArgs(source, skill.name, 'user', false)]),
75
+ repo,
76
+ source,
77
+ ...(pinnedRef ? { pinnedRef } : {}),
78
+ skillName: skill.name,
79
+ };
80
+ }
81
+ export function buildSteps(plan, scope, projectPath) {
82
+ const cwd = scope === 'user' ? homedir() : projectPath;
83
+ if (plan.method === 'plugin') {
84
+ const mp = plan.marketplace;
85
+ const installArgs = ['plugin', 'install', `${plan.pluginName}@${mp.name}`, '--scope', scope, '--json'];
86
+ const addArgs = ['plugin', 'marketplace', 'add', mp.repo, '--json'];
87
+ return [
88
+ { kind: 'ensure-marketplace', file: 'claude', args: addArgs, cwd: homedir(), display: formatCommand('claude', addArgs), note: '마켓플레이스 미등록 시에만' },
89
+ { kind: 'plugin-install', file: 'claude', args: installArgs, cwd, display: formatCommand('claude', installArgs) },
90
+ ];
91
+ }
92
+ if (plan.method === 'skills-cli') {
93
+ const source = plan.source ?? plan.repo;
94
+ const args = [SKILLS_CLI_PATH, ...skillsCliArgs(source, plan.skillName, scope)];
95
+ // The app runs its own copy of the CLI, whose path depends on how the app was installed (under npx it was
96
+ // "../skills/bin/cli.mjs"). Shown as the same version through npx and without -y, since it can be copied into
97
+ // any terminal and there the CLI should ask before replacing a folder (D#410d); the note says what the app adds.
98
+ const shown = [`skills@${SKILLS_CLI_VERSION}`, ...skillsCliArgs(source, plan.skillName, scope, false)];
99
+ return [{ kind: 'skills-add', file: 'node', args, cwd, display: formatCommand('npx', shown), note: '앱은 내장된 같은 버전에 -y를 붙여 실행' }];
100
+ }
101
+ return [];
102
+ }
103
+ export function installLocation(plan, scope, projectPath) {
104
+ if (plan.method === 'skills-cli') {
105
+ // the folder the CLI writes (its own name rule), in the system's path style (review 2026-11-02: "\" and "/" mixed)
106
+ const folder = skillFolderName(plan.skillName);
107
+ return scope === 'user' ? `~/.claude/skills/${folder}/` : `${join(projectPath, '.claude', 'skills', folder)}${sep} (+ skills-lock.json)`;
108
+ }
109
+ if (plan.method === 'plugin') {
110
+ if (scope === 'user')
111
+ return '~/.claude/settings.json (enabledPlugins), 플러그인 캐시';
112
+ if (scope === 'project')
113
+ return `${join(projectPath, '.claude', 'settings.json')} (enabledPlugins)`;
114
+ return `${join(projectPath, '.claude', 'settings.local.json')} (enabledPlugins)`;
115
+ }
116
+ return '';
117
+ }
118
+ export { NPX_SKILLS };
@@ -0,0 +1,97 @@
1
+ import { runClaude } from "../claude/cli.js";
2
+ import { run } from "../util/proc.js";
3
+ /** Last stdout line that parses as a JSON object (`claude ... --json` prints its result there). */
4
+ export function lastJsonLine(stdout) {
5
+ const lines = stdout.trim().split('\n').reverse();
6
+ for (const line of lines) {
7
+ const t = line.trim();
8
+ if (!t.startsWith('{'))
9
+ continue;
10
+ try {
11
+ const v = JSON.parse(t);
12
+ if (v && typeof v === 'object' && !Array.isArray(v))
13
+ return v;
14
+ }
15
+ catch {
16
+ // keep looking
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ export function parseJsonArray(stdout) {
22
+ const start = stdout.indexOf('[');
23
+ if (start < 0)
24
+ return null;
25
+ try {
26
+ const v = JSON.parse(stdout.slice(start));
27
+ return Array.isArray(v) ? v : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ const FAILURE_OUTCOMES = /fail|error|refus|denied|reject|not.?found|cancel/i;
34
+ export async function executeInstall(plan, steps, log, env) {
35
+ const argv = [];
36
+ let output = '';
37
+ const onLine = (line, stream) => {
38
+ output += line + '\n';
39
+ log(line, stream);
40
+ };
41
+ const exec = async (step) => {
42
+ argv.push([step.file, ...step.args]);
43
+ log(`$ ${step.display}`, 'info');
44
+ if (step.file === 'claude')
45
+ return runClaude(step.args, { cwd: step.cwd, env, timeoutMs: 5 * 60_000, onLine });
46
+ return run(process.execPath, step.args, { cwd: step.cwd, env, timeoutMs: 5 * 60_000, onLine });
47
+ };
48
+ for (const step of steps) {
49
+ if (step.kind === 'ensure-marketplace') {
50
+ const list = await runClaude(['plugin', 'marketplace', 'list', '--json'], { env, timeoutMs: 60_000 });
51
+ const entries = (parseJsonArray(list.stdout) ?? []);
52
+ const { name, repo } = plan.marketplace;
53
+ const same = entries.find((e) => e.name === name);
54
+ if (same) {
55
+ // The name alone is not enough: another repo registered under it would serve "plugin@name" from a source
56
+ // this app never checked (project review 2026-11-02). Stop instead of installing from it.
57
+ if (typeof same.repo !== 'string' || same.repo.toLowerCase() !== repo.toLowerCase()) {
58
+ const message = `마켓플레이스 이름 ${name}이(가) 이미 다른 저장소(${same.repo ?? '알 수 없음'})로 등록되어 있어 설치하지 않았습니다. 검사한 저장소는 ${repo}입니다. Claude Code에서 그 마켓플레이스를 확인하세요.`;
59
+ log(message, 'stderr');
60
+ return { ok: false, exitCode: -1, message, argv, output };
61
+ }
62
+ log(`마켓플레이스 ${name}은(는) 이미 등록되어 있습니다`, 'info');
63
+ continue;
64
+ }
65
+ const r = await exec(step);
66
+ const res = lastJsonLine(r.stdout);
67
+ if (r.exitCode !== 0) {
68
+ return { ok: false, exitCode: r.exitCode, message: String(res?.message ?? '마켓플레이스 등록에 실패했습니다'), argv, output };
69
+ }
70
+ continue;
71
+ }
72
+ const r = await exec(step);
73
+ if (r.timedOut)
74
+ return { ok: false, exitCode: r.exitCode, message: '시간 초과로 중단했습니다', argv, output };
75
+ if (step.kind === 'plugin-install') {
76
+ const res = lastJsonLine(r.stdout);
77
+ const outcome = String(res?.outcome ?? '');
78
+ const message = String(res?.message ?? (r.exitCode === 0 ? '설치했습니다' : '설치에 실패했습니다'));
79
+ const ok = r.exitCode === 0 && !FAILURE_OUTCOMES.test(outcome);
80
+ return {
81
+ ok,
82
+ exitCode: r.exitCode,
83
+ message: ok ? `${message} — 다음 세션부터 또는 /reload-plugins 실행 후 반영됩니다` : message,
84
+ argv,
85
+ output,
86
+ };
87
+ }
88
+ return {
89
+ ok: r.exitCode === 0,
90
+ exitCode: r.exitCode,
91
+ message: r.exitCode === 0 ? '설치했습니다 — 새 Claude Code 세션에서 사용할 수 있습니다' : 'skills CLI가 오류로 끝났습니다',
92
+ argv,
93
+ output,
94
+ };
95
+ }
96
+ return { ok: false, exitCode: -1, message: '실행할 단계가 없습니다', argv, output };
97
+ }
@@ -0,0 +1,70 @@
1
+ // What an install brings besides SKILL.md (project review 2026-11-02 F1). The risk rules run on SKILL.md when a
2
+ // skill is indexed; the other text files of the folder (scripts, references) are installed unchecked. Before an
3
+ // install the app reads them at the pinned commit and runs the same text rules on each. Remote files are only read.
4
+ import { hasCurlPipeShell, hasRmRf } from "../analyze/risk.js";
5
+ import { looksBinary } from "../util/http.js";
6
+ import { pool } from "../util/limiter.js";
7
+ import { SizedCache } from "../util/sizedCache.js";
8
+ import { isTextPath, MAX_FILE_BYTES } from "../util/textFiles.js";
9
+ /** Text files read per skill: scripts first, then the rest in path order. */
10
+ export const SCAN_MAX_FILES = 50;
11
+ const SCRIPT_EXT = /\.(sh|bash|zsh|fish|py|js|mjs|cjs|ts|rb|pl|ps1|psm1|bat|cmd)$/i;
12
+ /** The text rules that apply outside SKILL.md: download-and-run and rm -rf (the rest read frontmatter). */
13
+ export function textFileFlags(text) {
14
+ const flags = [];
15
+ if (hasCurlPipeShell(text))
16
+ flags.push('curlPipeShell');
17
+ if (hasRmRf(text))
18
+ flags.push('rmRf');
19
+ return flags;
20
+ }
21
+ const cache = new SizedCache({ maxSize: 500, ttlMs: 30 * 60 * 1000, sizeOf: () => 1 });
22
+ /** One skill folder at its indexed commit; kept 30 minutes unless a file could not be downloaded. */
23
+ export async function scanSkillFiles(github, skill) {
24
+ const key = `${skill.repo}@${skill.ref}:${skill.dir_path}`;
25
+ const hit = cache.get(key);
26
+ if (hit)
27
+ return hit;
28
+ const scan = await runScan(github, skill);
29
+ if (!scan.skipped.some((s) => s.reason === 'failed'))
30
+ cache.set(key, Promise.resolve(scan));
31
+ return scan;
32
+ }
33
+ async function runScan(github, skill) {
34
+ const repo = skill.repo;
35
+ const ref = skill.ref;
36
+ const stored = (skill.files_json ? JSON.parse(skill.files_json) : { files: [], total: 0 });
37
+ const files = stored.files.filter((f) => f.path !== 'SKILL.md');
38
+ const skipped = [];
39
+ const text = [];
40
+ for (const f of files) {
41
+ if (!isTextPath(f.path))
42
+ skipped.push({ path: f.path, reason: 'binary' });
43
+ else if (f.size != null && f.size > MAX_FILE_BYTES)
44
+ skipped.push({ path: f.path, reason: 'large' });
45
+ else
46
+ text.push(f);
47
+ }
48
+ text.sort((a, b) => Number(SCRIPT_EXT.test(b.path)) - Number(SCRIPT_EXT.test(a.path)) || a.path.localeCompare(b.path));
49
+ for (const f of text.slice(SCAN_MAX_FILES))
50
+ skipped.push({ path: f.path, reason: 'limit' });
51
+ const findings = [];
52
+ let checked = 0;
53
+ await pool(text.slice(0, SCAN_MAX_FILES), 6, async (f) => {
54
+ const full = skill.dir_path ? `${skill.dir_path}/${f.path}` : f.path;
55
+ const out = await github.getRawText(repo, ref, full, MAX_FILE_BYTES).catch(() => null);
56
+ if (!out || out.text == null)
57
+ return skipped.push({ path: f.path, reason: 'failed' });
58
+ if (out.truncated)
59
+ return skipped.push({ path: f.path, reason: 'large' });
60
+ if (looksBinary(out.text))
61
+ return skipped.push({ path: f.path, reason: 'binary' });
62
+ checked++;
63
+ const flags = textFileFlags(out.text);
64
+ if (flags.length)
65
+ findings.push({ path: f.path, flags });
66
+ });
67
+ findings.sort((a, b) => a.path.localeCompare(b.path));
68
+ skipped.sort((a, b) => a.path.localeCompare(b.path));
69
+ return { commit: ref, checked, findings, skipped, unlisted: Math.max(0, stored.total - stored.files.length) };
70
+ }
@@ -0,0 +1,14 @@
1
+ import { planInstall } from "./plan.js";
2
+ export function installSummary(s, lookup, name = s.name) {
3
+ const plugin = lookup.plugin(s.plugin_id);
4
+ const plan = planInstall(s, plugin, lookup.marketplace(plugin?.marketplace));
5
+ return {
6
+ id: s.id,
7
+ name,
8
+ riskLevel: s.risk_level ?? 'low',
9
+ riskFlags: s.risk_json ? JSON.parse(s.risk_json) : [],
10
+ method: plan.method,
11
+ installable: plan.method !== 'manual-only' && !plan.disabled,
12
+ scopes: plan.scopes,
13
+ };
14
+ }
@@ -0,0 +1,59 @@
1
+ import { existsSync, lstatSync } from 'node:fs';
2
+ import { rm } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { runClaude } from "../claude/cli.js";
6
+ import { SKILLS_CLI_PATH } from "../config.js";
7
+ import { run } from "../util/proc.js";
8
+ import { lastJsonLine } from "./run.js";
9
+ export const PLUGIN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}@[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
+ export const SKILL_FOLDER_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$/;
11
+ export function skillsRoot(scope, projectPath) {
12
+ return scope === 'user' ? join(homedir(), '.claude', 'skills') : join(projectPath, '.claude', 'skills');
13
+ }
14
+ /** `claude plugin uninstall <id> --scope <scope>`; project/local scopes run in the project folder. */
15
+ export async function uninstallPlugin(id, scope, projectPath) {
16
+ if (!PLUGIN_ID_RE.test(id))
17
+ return { ok: false, message: '플러그인 ID 형식이 올바르지 않습니다', output: '' };
18
+ const r = await runClaude(['plugin', 'uninstall', id, '--scope', scope, '--json'], {
19
+ cwd: scope === 'user' ? homedir() : projectPath,
20
+ timeoutMs: 2 * 60_000,
21
+ });
22
+ const res = lastJsonLine(r.stdout);
23
+ const ok = r.exitCode === 0;
24
+ const message = String(res?.message ?? (ok ? '플러그인을 제거했습니다' : '플러그인 제거에 실패했습니다'));
25
+ return { ok, message, output: (r.stdout + r.stderr).slice(-4000) };
26
+ }
27
+ /**
28
+ * Removes one skill folder directly under ~/.claude/skills or <project>/.claude/skills.
29
+ * The skills CLI goes first (it also updates skills-lock.json); if the folder is still there
30
+ * (e.g. it was copied in by hand) it is deleted after checking it is exactly one level below the skills root.
31
+ */
32
+ export async function uninstallSkill(folder, scope, projectPath, env) {
33
+ if (!SKILL_FOLDER_RE.test(folder) || folder.includes('..'))
34
+ return { ok: false, message: '스킬 폴더 이름이 올바르지 않습니다', output: '' };
35
+ const root = resolve(skillsRoot(scope, projectPath));
36
+ const target = resolve(root, folder);
37
+ if (dirname(target) !== root)
38
+ return { ok: false, message: '스킬 폴더 위치가 올바르지 않습니다', output: '' };
39
+ if (!existsSync(target))
40
+ return { ok: false, message: '이미 없는 스킬입니다', output: '' };
41
+ if (!existsSync(join(target, 'SKILL.md')))
42
+ return { ok: false, message: 'SKILL.md가 없는 폴더라 지우지 않았습니다', output: '' };
43
+ const cli = await run(process.execPath, [SKILLS_CLI_PATH, 'remove', folder, '-a', 'claude-code', '-y', ...(scope === 'user' ? ['-g'] : [])], {
44
+ cwd: scope === 'user' ? homedir() : projectPath,
45
+ env,
46
+ timeoutMs: 2 * 60_000,
47
+ }).catch((err) => ({ exitCode: -1, stdout: '', stderr: String(err), timedOut: false }));
48
+ let output = (cli.stdout + cli.stderr).slice(-4000);
49
+ if (existsSync(target)) {
50
+ // A symlinked skill: remove the link, never what it points to.
51
+ if (lstatSync(target).isSymbolicLink())
52
+ await rm(target, { force: true });
53
+ else
54
+ await rm(target, { recursive: true, force: true });
55
+ output += '\n(skills CLI가 지우지 않은 폴더를 직접 삭제했습니다)';
56
+ }
57
+ const gone = !existsSync(target);
58
+ return { ok: gone, message: gone ? '스킬을 삭제했습니다' : '스킬 폴더를 지우지 못했습니다', output };
59
+ }
@@ -0,0 +1,24 @@
1
+ import { statSync } from 'node:fs';
2
+ import { isAbsolute } from 'node:path';
3
+ export const OWNER_REPO_RE = /^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
4
+ export const PLUGIN_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
5
+ export const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/;
6
+ export const COMMIT_SHA_RE = /^[0-9a-f]{40}$/;
7
+ /** A folder inside a repo: plain name segments, never `.` or `..`, no leading or trailing slash. */
8
+ export const REPO_DIR_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
9
+ export const isRepo = (s) => typeof s === 'string' && OWNER_REPO_RE.test(s) && !s.includes('..');
10
+ export const isPluginName = (s) => typeof s === 'string' && PLUGIN_NAME_RE.test(s);
11
+ export const isSkillName = (s) => typeof s === 'string' && SKILL_NAME_RE.test(s) && !/\s$/.test(s);
12
+ export const isCommitSha = (s) => typeof s === 'string' && COMMIT_SHA_RE.test(s);
13
+ export const isRepoDir = (s) => typeof s === 'string' && REPO_DIR_RE.test(s) && !s.split('/').some((seg) => seg === '.' || seg === '..');
14
+ /** Absolute path to an existing directory. */
15
+ export function isProjectDir(p) {
16
+ if (typeof p !== 'string' || !p || !isAbsolute(p) || /[\0\r\n]/.test(p))
17
+ return false;
18
+ try {
19
+ return statSync(p).isDirectory();
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }