skillhelper 0.4.2
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/LICENSE +21 -0
- package/README.md +105 -0
- package/bin/lib/atomic-write.mjs +72 -0
- package/bin/lib/paths.mjs +65 -0
- package/bin/lib/port.mjs +23 -0
- package/bin/skillhelper.mjs +720 -0
- package/build/build-site.mjs +35 -0
- package/build/build-web.mjs +29 -0
- package/build/pack-smoke.mjs +195 -0
- package/build/prepare-publish.mjs +3 -0
- package/build/verify.mjs +157 -0
- package/config/sources.example.yaml +126 -0
- package/docs/Frontend-Spec.md +1207 -0
- package/docs/scan_skills_rules.md +432 -0
- package/package.json +95 -0
- package/packages/scanner/package.json +15 -0
- package/packages/scanner/src/adapters/claude-agents.mjs +134 -0
- package/packages/scanner/src/adapters/claude-plugin.mjs +260 -0
- package/packages/scanner/src/adapters/codex-plugin.mjs +330 -0
- package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
- package/packages/scanner/src/adapters/file-docs.mjs +158 -0
- package/packages/scanner/src/adapters/hermes-plugin.mjs +149 -0
- package/packages/scanner/src/adapters/markdown-skill.mjs +229 -0
- package/packages/scanner/src/adapters/mcp-config.mjs +190 -0
- package/packages/scanner/src/adapters/scan-tier.mjs +99 -0
- package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
- package/packages/scanner/src/adapters/tier1-editor-skills.mjs +278 -0
- package/packages/scanner/src/adapters/tier2-user-skills.mjs +218 -0
- package/packages/scanner/src/adapters/tier3-other-skills.mjs +225 -0
- package/packages/scanner/src/config/editor-tiers.mjs +191 -0
- package/packages/scanner/src/core/atomic-write.mjs +11 -0
- package/packages/scanner/src/core/descriptor.mjs +143 -0
- package/packages/scanner/src/core/path-hash.mjs +89 -0
- package/packages/scanner/src/core/registry.mjs +128 -0
- package/packages/scanner/src/hash/path-hash.mjs +8 -0
- package/packages/scanner/src/icon/brand-map.mjs +268 -0
- package/packages/scanner/src/icon/icon-extractor.mjs +466 -0
- package/packages/scanner/src/index.mjs +450 -0
- package/packages/scanner/src/types.d.ts +144 -0
- package/packages/scanner/src/utils.mjs +194 -0
- package/packages/server/package.json +15 -0
- package/packages/server/src/index.mjs +1071 -0
- package/packages/server/src/labels.mjs +64 -0
- package/packages/server/src/langdetect.mjs +34 -0
- package/packages/server/src/llm-client.mjs +42 -0
- package/packages/server/src/translate-cache.mjs +132 -0
- package/packages/server/src/translator.mjs +53 -0
- package/packages/web/README.md +36 -0
- package/packages/web/dist/assets/index-CGt04vt0.js +304 -0
- package/packages/web/dist/assets/index-CLSDoa3H.css +1 -0
- package/packages/web/dist/brand-icon.png +0 -0
- package/packages/web/dist/brand-logo.png +0 -0
- package/packages/web/dist/favicon-16x16.png +0 -0
- package/packages/web/dist/favicon-192x192.png +0 -0
- package/packages/web/dist/favicon-32x32.png +0 -0
- package/packages/web/dist/favicon-512x512.png +0 -0
- package/packages/web/dist/favicon-source.svg +5 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/favicon.svg +5 -0
- package/packages/web/dist/icons/hermes-128.png +0 -0
- package/packages/web/dist/icons/hermes-192.png +0 -0
- package/packages/web/dist/icons/hermes-256.png +0 -0
- package/packages/web/dist/icons/hermes-32.png +0 -0
- package/packages/web/dist/icons/hermes-512.png +0 -0
- package/packages/web/dist/icons/hermes-64.png +0 -0
- package/packages/web/dist/icons/hermes.png +0 -0
- package/packages/web/dist/index.html +37 -0
- package/packages/web/dist/robots.txt +3 -0
- package/packages/web/dist/site.webmanifest +24 -0
- package/packages/web/public/brand-icon.png +0 -0
- package/packages/web/public/brand-logo.png +0 -0
- package/packages/web/public/favicon-16x16.png +0 -0
- package/packages/web/public/favicon-192x192.png +0 -0
- package/packages/web/public/favicon-32x32.png +0 -0
- package/packages/web/public/favicon-512x512.png +0 -0
- package/packages/web/public/favicon-source.svg +5 -0
- package/packages/web/public/favicon.ico +0 -0
- package/packages/web/public/favicon.svg +5 -0
- package/packages/web/public/icons/hermes-128.png +0 -0
- package/packages/web/public/icons/hermes-192.png +0 -0
- package/packages/web/public/icons/hermes-256.png +0 -0
- package/packages/web/public/icons/hermes-32.png +0 -0
- package/packages/web/public/icons/hermes-512.png +0 -0
- package/packages/web/public/icons/hermes-64.png +0 -0
- package/packages/web/public/icons/hermes.png +0 -0
- package/packages/web/public/robots.txt +3 -0
- package/packages/web/public/site.webmanifest +24 -0
- package/scripts/install-and-sync.sh +209 -0
- package/scripts/sync-skills.sh +605 -0
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
// @skillhelper/server — Fastify HTTP API + placeholder UI.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import Fastify from 'fastify';
|
|
8
|
+
import chokidar from 'chokidar';
|
|
9
|
+
import { translate } from 'google-translate-api-x';
|
|
10
|
+
import { LABELS } from './labels.mjs';
|
|
11
|
+
import { isChinese, detectLanguage } from './langdetect.mjs';
|
|
12
|
+
import { getCached, setCached, flush as flushTranslateCache } from './translate-cache.mjs';
|
|
13
|
+
import { setGlobalDispatcher, EnvHttpProxyAgent } from 'undici';
|
|
14
|
+
|
|
15
|
+
// 尊重系统代理环境变量(HTTP_PROXY / HTTPS_PROXY / NO_PROXY)。
|
|
16
|
+
// Node 内置 fetch 不读这些变量,需显式配置 dispatcher,否则 Google 翻译在代理环境下连接超时。
|
|
17
|
+
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
18
|
+
|
|
19
|
+
const PHASE = 'P6';
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const PKGS_ROOT = path.resolve(__dirname, '..', '..');
|
|
22
|
+
const PACKAGE_JSON = path.resolve(PKGS_ROOT, '..', 'package.json');
|
|
23
|
+
const VERSION = readPackageVersion();
|
|
24
|
+
const WEB_DIST = path.join(PKGS_ROOT, 'web', 'dist');
|
|
25
|
+
const WEB_PUBLIC = path.join(PKGS_ROOT, 'web', 'public');
|
|
26
|
+
const ROOT_STATIC_FILES = [
|
|
27
|
+
'favicon.ico',
|
|
28
|
+
'favicon.svg',
|
|
29
|
+
'favicon-16x16.png',
|
|
30
|
+
'favicon-32x32.png',
|
|
31
|
+
'favicon-192x192.png',
|
|
32
|
+
'favicon-512x512.png',
|
|
33
|
+
'brand-icon.png',
|
|
34
|
+
'brand-logo.png',
|
|
35
|
+
'icons/hermes-32.png',
|
|
36
|
+
'icons/hermes-128.png',
|
|
37
|
+
'icons/hermes-192.png',
|
|
38
|
+
'icons/hermes-512.png',
|
|
39
|
+
'site.webmanifest',
|
|
40
|
+
'robots.txt',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
async function importScanner() {
|
|
44
|
+
const { scan, getWatchTargets } = await import(path.join(PKGS_ROOT, 'scanner/src/index.mjs'));
|
|
45
|
+
return { scan, getWatchTargets };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readPackageVersion() {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8')).version || '0.0.0';
|
|
51
|
+
} catch {
|
|
52
|
+
return '0.0.0';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 翻译文本 —— 中文短路 + md5 缓存 + Google 翻译,失败降级原文本。
|
|
58
|
+
* @param {string} text - 要翻译的文本
|
|
59
|
+
* @param {string} targetLang - 目标语言 (默认: 'zh-CN')
|
|
60
|
+
* @returns {Promise<string>} 翻译结果
|
|
61
|
+
*/
|
|
62
|
+
async function translateText(text, targetLang = 'zh-CN') {
|
|
63
|
+
if (!text || !text.trim()) return '';
|
|
64
|
+
|
|
65
|
+
// 1. 语言判定:已是中文则直接返回,省一次外网请求
|
|
66
|
+
if (isChinese(text)) return text;
|
|
67
|
+
|
|
68
|
+
// 2. 查缓存(命中即返回,跨技能共享)
|
|
69
|
+
const cached = getCached(text);
|
|
70
|
+
if (cached) return cached.result;
|
|
71
|
+
|
|
72
|
+
// 3. 调 Google 翻译(免费),结果写缓存
|
|
73
|
+
// 加 8s 超时:google-translate-api-x 默认无超时,Google 偶发慢/超时会让请求
|
|
74
|
+
// 长期 pending,占满浏览器连接池(HTTP/1.1 同域名 6 并发),导致后续
|
|
75
|
+
// /api/skills/:id 排队,前端"加载正文…"一直 loading。超时后降级返回原文。
|
|
76
|
+
try {
|
|
77
|
+
const result = await Promise.race([
|
|
78
|
+
translate(text, { to: targetLang }),
|
|
79
|
+
new Promise((_, reject) =>
|
|
80
|
+
setTimeout(() => reject(new Error('translate timeout')), 8000),
|
|
81
|
+
),
|
|
82
|
+
]);
|
|
83
|
+
const translated = result.text || text;
|
|
84
|
+
// google-translate-api-x 偶发不返回 from.language.code(部分文本 Google 无法判定源语言),
|
|
85
|
+
// 用本地 detectLanguage 兜底,避免 from 字段落盘为 'unknown'。
|
|
86
|
+
const from = result.from?.language?.code || detectLanguage(text);
|
|
87
|
+
setCached(text, translated, from);
|
|
88
|
+
return translated;
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.warn(`[translate] error: ${e.message}`);
|
|
91
|
+
return text; // 降级返回原文本
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function startServer({ port = 11520 } = {}) {
|
|
96
|
+
const app = Fastify({
|
|
97
|
+
logger: { level: process.env.LOG_LEVEL || 'warn' },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const { scan, getWatchTargets } = await importScanner();
|
|
101
|
+
const { resolveIconRef } = await import(path.join(PKGS_ROOT, 'scanner/src/icon/icon-extractor.mjs'));
|
|
102
|
+
|
|
103
|
+
// In-memory IR. HTTP handlers only read this snapshot; reload/watch swaps it atomically.
|
|
104
|
+
let irCache = await scan();
|
|
105
|
+
let irById = new Map(irCache.map(it => [it.id, it]));
|
|
106
|
+
let reloadState = {
|
|
107
|
+
scanning: false,
|
|
108
|
+
pending: false,
|
|
109
|
+
lastReloadAt: new Date().toISOString(),
|
|
110
|
+
lastError: '',
|
|
111
|
+
items: irCache.length,
|
|
112
|
+
reason: 'boot',
|
|
113
|
+
};
|
|
114
|
+
let reloadTimer = null;
|
|
115
|
+
let watcher = null;
|
|
116
|
+
|
|
117
|
+
async function runReload(reason = 'manual') {
|
|
118
|
+
if (reloadState.scanning) {
|
|
119
|
+
reloadState.pending = true;
|
|
120
|
+
return { ok: true, queued: true, ...reloadState };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
reloadState = { ...reloadState, scanning: true, pending: false, reason, lastError: '' };
|
|
124
|
+
try {
|
|
125
|
+
const next = await scan();
|
|
126
|
+
const shouldRunAgain = reloadState.pending;
|
|
127
|
+
irCache = next;
|
|
128
|
+
irById = new Map(irCache.map(it => [it.id, it]));
|
|
129
|
+
reloadState = {
|
|
130
|
+
scanning: false,
|
|
131
|
+
pending: shouldRunAgain,
|
|
132
|
+
lastReloadAt: new Date().toISOString(),
|
|
133
|
+
lastError: '',
|
|
134
|
+
items: irCache.length,
|
|
135
|
+
reason,
|
|
136
|
+
};
|
|
137
|
+
} catch (e) {
|
|
138
|
+
const shouldRunAgain = reloadState.pending;
|
|
139
|
+
reloadState = {
|
|
140
|
+
...reloadState,
|
|
141
|
+
scanning: false,
|
|
142
|
+
pending: shouldRunAgain,
|
|
143
|
+
lastReloadAt: new Date().toISOString(),
|
|
144
|
+
lastError: e.message,
|
|
145
|
+
items: irCache.length,
|
|
146
|
+
reason,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (reloadState.pending) setTimeout(() => runReload('pending'), 0);
|
|
151
|
+
return { ok: !reloadState.lastError, ...reloadState };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function scheduleReload(reason) {
|
|
155
|
+
clearTimeout(reloadTimer);
|
|
156
|
+
reloadTimer = setTimeout(() => {
|
|
157
|
+
runReload(reason).catch(e => app.log.warn({ err: e }, 'reload failed'));
|
|
158
|
+
}, 800);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function setupWatcher() {
|
|
162
|
+
const targets = await getWatchTargets();
|
|
163
|
+
if (!targets.length) return;
|
|
164
|
+
watcher = chokidar.watch(targets, {
|
|
165
|
+
ignoreInitial: true,
|
|
166
|
+
persistent: true,
|
|
167
|
+
depth: 8,
|
|
168
|
+
usePolling: true,
|
|
169
|
+
interval: 1500,
|
|
170
|
+
binaryInterval: 3000,
|
|
171
|
+
awaitWriteFinish: {
|
|
172
|
+
stabilityThreshold: 250,
|
|
173
|
+
pollInterval: 100,
|
|
174
|
+
},
|
|
175
|
+
ignored: [
|
|
176
|
+
'**/.git/**',
|
|
177
|
+
'**/node_modules/**',
|
|
178
|
+
'**/dist/**',
|
|
179
|
+
'**/.DS_Store',
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
watcher.on('all', (event, changedPath) => {
|
|
183
|
+
scheduleReload(`${event}:${changedPath}`);
|
|
184
|
+
});
|
|
185
|
+
watcher.on('error', err => {
|
|
186
|
+
reloadState = { ...reloadState, lastError: `watch: ${err.message}`, items: irCache.length };
|
|
187
|
+
app.log.warn({ err }, 'watcher error');
|
|
188
|
+
});
|
|
189
|
+
app.addHook('onClose', async () => {
|
|
190
|
+
clearTimeout(reloadTimer);
|
|
191
|
+
if (watcher) await watcher.close();
|
|
192
|
+
flushTranslateCache(); // 落盘翻译缓存,跨重启复用
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function attachIconRef(base, size = 64) {
|
|
197
|
+
if (safePluginLogoPath(base)) {
|
|
198
|
+
base.iconUrl = `/api/plugin-icons/${encodeURIComponent(base.id)}`;
|
|
199
|
+
return base;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 列表接口也补齐官方图标引用,保证旧侧栏和技能列表都能直接渲染真实应用图标。
|
|
203
|
+
const { iconUrl, iconFallback } = resolveIconRef(
|
|
204
|
+
{
|
|
205
|
+
icon: base.icon,
|
|
206
|
+
brand: base.brand || base.editorBrand,
|
|
207
|
+
source: base.source,
|
|
208
|
+
},
|
|
209
|
+
size,
|
|
210
|
+
);
|
|
211
|
+
if (iconUrl && !base.iconUrl) base.iconUrl = iconUrl;
|
|
212
|
+
if (iconFallback && !base.iconFallback) base.iconFallback = iconFallback;
|
|
213
|
+
return base;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const hasSpa = fs.existsSync(path.join(WEB_DIST, 'index.html'));
|
|
217
|
+
const assetsDir = path.join(WEB_DIST, 'assets');
|
|
218
|
+
|
|
219
|
+
// 请求日志(诊断连接占用/慢请求):记 /api 请求的方法、URL、状态码、耗时。
|
|
220
|
+
// 仅在 LOG_LEVEL=debug 时输出,避免默认运行时刷屏(logger level 由 LOG_LEVEL 控制)。
|
|
221
|
+
app.addHook('onRequest', async (req) => {
|
|
222
|
+
req.__t0 = Date.now();
|
|
223
|
+
});
|
|
224
|
+
app.addHook('onResponse', async (req, reply) => {
|
|
225
|
+
if (!req.url || !req.url.startsWith('/api')) return;
|
|
226
|
+
const ms = Date.now() - (req.__t0 || Date.now());
|
|
227
|
+
app.log.debug({ method: req.method, url: req.url, status: reply.statusCode, ms }, 'api request');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ─────────────────────────── routes ─────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
app.get('/api/health', async () => ({
|
|
233
|
+
ok: true,
|
|
234
|
+
port,
|
|
235
|
+
items: irCache.length,
|
|
236
|
+
version: VERSION,
|
|
237
|
+
phase: PHASE,
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
app.get('/api/skills', async () => {
|
|
241
|
+
// strip `raw` — detail view fetches it separately
|
|
242
|
+
// v4.0: 补上 tier 和 editorBrand(如果不存在)
|
|
243
|
+
|
|
244
|
+
return irCache.map(item => {
|
|
245
|
+
const base = stripRaw(item);
|
|
246
|
+
|
|
247
|
+
// 确保有 tier 字段
|
|
248
|
+
if (!base.tier || base.tier === 'tool') {
|
|
249
|
+
base.tier = 'tier-1'; // 默认 tier-1
|
|
250
|
+
} else if (base.tier === 'directory') {
|
|
251
|
+
base.tier = 'tier-2';
|
|
252
|
+
} else if (base.tier === 'other') {
|
|
253
|
+
base.tier = 'tier-3';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 确保有 editorBrand 字段
|
|
257
|
+
if (!base.editorBrand && base.source) {
|
|
258
|
+
base.editorBrand = base.source;
|
|
259
|
+
if (base.editorBrand === 'claude-code') base.editorBrand = 'claude';
|
|
260
|
+
if (base.editorBrand === 'hermes-plugin') base.editorBrand = 'hermes';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
attachIconRef(base, 64);
|
|
264
|
+
|
|
265
|
+
// 确保有 pathHash 字段
|
|
266
|
+
if (!base.pathHash && base.paths?.abs) {
|
|
267
|
+
const normalized = path.resolve(base.paths.abs);
|
|
268
|
+
base.pathHash = crypto.createHash('md5').update(normalized).digest('hex');
|
|
269
|
+
} else if (!base.pathHash && base.id) {
|
|
270
|
+
base.pathHash = base.id;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 填充缓存命中的 i18n(无网络,毫秒级)。未命中的留空,详情页按需翻译。
|
|
274
|
+
// description 和 name 都可能命中(用户在详情页触发翻译后写缓存,下次列表命中)。
|
|
275
|
+
const zh = {};
|
|
276
|
+
if (base.description) {
|
|
277
|
+
const cached = getCached(base.description);
|
|
278
|
+
if (cached) zh.description = cached.result;
|
|
279
|
+
}
|
|
280
|
+
if (base.name) {
|
|
281
|
+
const cached = getCached(base.name);
|
|
282
|
+
if (cached && cached.result !== base.name) zh.name = cached.result;
|
|
283
|
+
}
|
|
284
|
+
if (Object.keys(zh).length) {
|
|
285
|
+
base.i18n = { zh, translatedAt: new Date().toISOString() };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return base;
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
app.get('/api/skills/:id', async (req, reply) => {
|
|
293
|
+
const item = irById.get(req.params.id);
|
|
294
|
+
if (!item) {
|
|
295
|
+
reply.code(404);
|
|
296
|
+
return { error: 'not found' };
|
|
297
|
+
}
|
|
298
|
+
// 返回副本,避免下面的 i18n 填充污染 irById 缓存
|
|
299
|
+
const result = { ...item };
|
|
300
|
+
// 填充缓存命中的 i18n(无网络,毫秒级)——与 /api/skills 列表口径一致
|
|
301
|
+
const zh = {};
|
|
302
|
+
if (result.description) {
|
|
303
|
+
const cached = getCached(result.description);
|
|
304
|
+
if (cached) zh.description = cached.result;
|
|
305
|
+
}
|
|
306
|
+
if (result.name) {
|
|
307
|
+
const cached = getCached(result.name);
|
|
308
|
+
if (cached && cached.result !== result.name) zh.name = cached.result;
|
|
309
|
+
}
|
|
310
|
+
// SKILLHELPER_TRANSLATE=1 时主动翻译 description(缓存未命中)
|
|
311
|
+
if (
|
|
312
|
+
process.env.SKILLHELPER_TRANSLATE === '1' &&
|
|
313
|
+
result.description &&
|
|
314
|
+
!zh.description &&
|
|
315
|
+
!isChinese(result.description)
|
|
316
|
+
) {
|
|
317
|
+
const translated = await translateText(result.description, 'zh-CN');
|
|
318
|
+
if (translated && translated !== result.description) zh.description = translated;
|
|
319
|
+
}
|
|
320
|
+
if (Object.keys(zh).length) {
|
|
321
|
+
result.i18n = { zh, translatedAt: new Date().toISOString() };
|
|
322
|
+
}
|
|
323
|
+
attachIconRef(result, 64);
|
|
324
|
+
return result;
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
app.get('/api/stats', async () => buildStats(irCache));
|
|
328
|
+
|
|
329
|
+
app.get('/api/other-skills', async (req, reply) => {
|
|
330
|
+
try {
|
|
331
|
+
const { scanSkills } = await import('../../../packages/scanner/src/adapters/skill-adapter.mjs');
|
|
332
|
+
const { resolveIconRef } = await import('../../../packages/scanner/src/icon/icon-extractor.mjs');
|
|
333
|
+
|
|
334
|
+
// Parse query parameters
|
|
335
|
+
const roots = req.query.roots
|
|
336
|
+
? req.query.roots.split(',').map(s => s.trim()).filter(Boolean)
|
|
337
|
+
: [];
|
|
338
|
+
const fileGlob = req.query.fileGlob || '**/SKILL.md';
|
|
339
|
+
const source = req.query.source || 'other-skills';
|
|
340
|
+
const stage = req.query.stage === 'mini' ? 'mini' : 'full';
|
|
341
|
+
const useSpotlight = req.query.useSpotlight === '1' || req.query.useSpotlight === 'true';
|
|
342
|
+
const maxFiles = Math.min(parseInt(req.query.maxFiles || '100', 10), 5000);
|
|
343
|
+
const maxFileBytes = Math.min(parseInt(req.query.maxFileBytes || '1048576', 10), 10 * 1024 * 1024);
|
|
344
|
+
|
|
345
|
+
// Validate roots
|
|
346
|
+
if (!roots.length) {
|
|
347
|
+
reply.code(400);
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
error: 'missing query parameter: roots (comma-separated paths)',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Scan skills
|
|
355
|
+
const { items, stats } = await scanSkills({
|
|
356
|
+
source,
|
|
357
|
+
roots,
|
|
358
|
+
fileGlob,
|
|
359
|
+
stage,
|
|
360
|
+
useSpotlight,
|
|
361
|
+
limits: { maxFiles, maxFileBytes },
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Transform items to the required format. 'mini' stage returns lightweight
|
|
365
|
+
// rows (fast first paint, R7.1); 'full' includes the frontmatter block.
|
|
366
|
+
const skills = items.map(item => {
|
|
367
|
+
const { iconUrl, iconFallback } = resolveIconRef(
|
|
368
|
+
{ icon: item.icon, brand: item.brand, source: item.source },
|
|
369
|
+
64,
|
|
370
|
+
);
|
|
371
|
+
const base = {
|
|
372
|
+
id: item.id,
|
|
373
|
+
name: item.name,
|
|
374
|
+
description: item.description,
|
|
375
|
+
category: item.category,
|
|
376
|
+
kind: item.kind,
|
|
377
|
+
brand: item.brand,
|
|
378
|
+
path: item.paths?.abs,
|
|
379
|
+
iconUrl,
|
|
380
|
+
iconFallback,
|
|
381
|
+
};
|
|
382
|
+
if (stage === 'mini') return base;
|
|
383
|
+
return {
|
|
384
|
+
...base,
|
|
385
|
+
tags: item.tags,
|
|
386
|
+
frontmatter: {
|
|
387
|
+
title: item.title,
|
|
388
|
+
triggers: item.triggers,
|
|
389
|
+
links: item.links,
|
|
390
|
+
product: item.product,
|
|
391
|
+
editor: item.editor,
|
|
392
|
+
source: item.source,
|
|
393
|
+
updatedAt: item.updatedAt,
|
|
394
|
+
parseError: item.parseError,
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
ok: true,
|
|
401
|
+
skills,
|
|
402
|
+
stats: {
|
|
403
|
+
...stats,
|
|
404
|
+
scanned: items.length,
|
|
405
|
+
source,
|
|
406
|
+
fileGlob,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
} catch (e) {
|
|
410
|
+
reply.code(500);
|
|
411
|
+
return {
|
|
412
|
+
ok: false,
|
|
413
|
+
error: e.message,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// 官方品牌图标:优先读取本机 .app 图标;无本机图标时按 manifest
|
|
419
|
+
// 中登记的官方 HTTPS URL 下载并缓存;仍无图标时返回 404,让前端显示中性占位。
|
|
420
|
+
// 404 结果在进程内缓存(noIconBrands),避免列表重复触发 mdfind/plutil/sips
|
|
421
|
+
// 或失败的远程探测;缓存仅在进程生命周期内有效,重启后重新探测。
|
|
422
|
+
const noIconBrands = new Set();
|
|
423
|
+
app.get('/api/icons/:brand', async (req, reply) => {
|
|
424
|
+
try {
|
|
425
|
+
const brand = decodeURIComponent(req.params.brand || '');
|
|
426
|
+
const size = Math.min(Math.max(parseInt(req.query.size || '64', 10) || 64, 32), 128);
|
|
427
|
+
const cacheKey = `${brand}:${size}`;
|
|
428
|
+
if (noIconBrands.has(cacheKey)) {
|
|
429
|
+
reply.code(404);
|
|
430
|
+
return {
|
|
431
|
+
ok: false,
|
|
432
|
+
fallback: true,
|
|
433
|
+
brand,
|
|
434
|
+
reason: 'official icon not found in local apps or registered remote cache',
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
const { getIconForBrand, contentTypeForIconPath } = await import('../../../packages/scanner/src/icon/icon-extractor.mjs');
|
|
438
|
+
const iconPath = await getIconForBrand(brand, size);
|
|
439
|
+
if (!iconPath || !fs.existsSync(iconPath)) {
|
|
440
|
+
noIconBrands.add(cacheKey);
|
|
441
|
+
reply.code(404);
|
|
442
|
+
return {
|
|
443
|
+
ok: false,
|
|
444
|
+
fallback: true,
|
|
445
|
+
brand,
|
|
446
|
+
reason: 'official icon not found in local apps or registered remote cache',
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
reply.type(contentTypeForIconPath(iconPath));
|
|
450
|
+
reply.header('cache-control', 'public, max-age=300, must-revalidate');
|
|
451
|
+
return fs.createReadStream(iconPath);
|
|
452
|
+
} catch (e) {
|
|
453
|
+
reply.code(500);
|
|
454
|
+
return { ok: false, error: e.message };
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
app.get('/api/plugin-icons/:id', async (req, reply) => {
|
|
459
|
+
const id = decodeURIComponent(req.params.id || '');
|
|
460
|
+
const item = irById.get(id);
|
|
461
|
+
const iconPath = safePluginLogoPath(item);
|
|
462
|
+
if (!iconPath) {
|
|
463
|
+
reply.code(404);
|
|
464
|
+
return { ok: false, error: 'plugin icon not found' };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
reply.type(contentTypeFor(iconPath));
|
|
468
|
+
reply.header('cache-control', 'public, max-age=300, must-revalidate');
|
|
469
|
+
return fs.createReadStream(iconPath);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
app.get('/api/reload-state', async () => ({
|
|
473
|
+
ok: !reloadState.lastError,
|
|
474
|
+
...reloadState,
|
|
475
|
+
}));
|
|
476
|
+
|
|
477
|
+
app.post('/api/reload', async () => runReload('manual'));
|
|
478
|
+
|
|
479
|
+
app.post('/api/copy', async (req, reply) => {
|
|
480
|
+
const { id, what } = req.body || {};
|
|
481
|
+
const item = irById.get(id);
|
|
482
|
+
if (!item) {
|
|
483
|
+
reply.code(404);
|
|
484
|
+
return { ok: false, error: 'not found' };
|
|
485
|
+
}
|
|
486
|
+
const text = pickCopyText(item, what);
|
|
487
|
+
if (text == null) {
|
|
488
|
+
reply.code(400);
|
|
489
|
+
return { ok: false, error: `invalid 'what': ${what}` };
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
await pbcopy(text);
|
|
493
|
+
return { ok: true, bytes: Buffer.byteLength(text), what };
|
|
494
|
+
} catch (e) {
|
|
495
|
+
reply.code(500);
|
|
496
|
+
return { ok: false, error: e.message };
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
app.post('/api/open', async (req, reply) => {
|
|
501
|
+
const { id, with: openWith = 'default' } = req.body || {};
|
|
502
|
+
const item = irById.get(id);
|
|
503
|
+
if (!item) {
|
|
504
|
+
reply.code(404);
|
|
505
|
+
return { ok: false, error: 'not found' };
|
|
506
|
+
}
|
|
507
|
+
const abs = item.paths?.abs;
|
|
508
|
+
if (!abs) {
|
|
509
|
+
reply.code(400);
|
|
510
|
+
return { ok: false, error: 'item has no path' };
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
await openPath(abs, openWith);
|
|
514
|
+
return { ok: true, opened: abs, with: openWith };
|
|
515
|
+
} catch (e) {
|
|
516
|
+
reply.code(500);
|
|
517
|
+
return { ok: false, error: e.message };
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
app.post('/api/translate', async (req, reply) => {
|
|
522
|
+
const { text, targetLang = 'zh-CN', id } = req.body || {};
|
|
523
|
+
|
|
524
|
+
// 新模式:直接翻译文本(前端使用)
|
|
525
|
+
if (text) {
|
|
526
|
+
try {
|
|
527
|
+
const translated = await translateText(text, targetLang);
|
|
528
|
+
return { ok: true, result: translated, targetLang };
|
|
529
|
+
} catch (e) {
|
|
530
|
+
reply.code(500);
|
|
531
|
+
return { ok: false, error: e.message };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 旧模式:翻译技能对象(保持向后兼容)
|
|
536
|
+
if (id) {
|
|
537
|
+
const item = irById.get(id);
|
|
538
|
+
if (!item) {
|
|
539
|
+
reply.code(404);
|
|
540
|
+
return { error: 'not found' };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const { translateSkill } = await import('./translator.mjs');
|
|
544
|
+
try {
|
|
545
|
+
const translated = await translateSkill(item, 'zh-CN');
|
|
546
|
+
return translated;
|
|
547
|
+
} catch (e) {
|
|
548
|
+
reply.code(500);
|
|
549
|
+
return { error: e.message };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
reply.code(400);
|
|
554
|
+
return { ok: false, error: 'missing text or id parameter' };
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// 正文段落级翻译:切分 raw 为段落 + 代码块,代码块整体跳过,
|
|
558
|
+
// 文本段走 translateText(中文短路 + md5 缓存 + Google)。
|
|
559
|
+
// 并发翻译(并发 5),避免长正文串行翻译耗时过久。
|
|
560
|
+
// 前端用于「中文/原文」tab 切换时拉取译文。
|
|
561
|
+
// 流式 NDJSON 响应:每翻译完一段就发一行,前端实时显示 X/Y 段进度,
|
|
562
|
+
// 避免长正文翻译时长时间无反馈。整体超时 12s,超时未翻译的段填充原文。
|
|
563
|
+
app.post('/api/translate-raw', async (req, reply) => {
|
|
564
|
+
const { id } = req.body || {};
|
|
565
|
+
const item = irById.get(id);
|
|
566
|
+
if (!item) {
|
|
567
|
+
reply.code(404);
|
|
568
|
+
return { ok: false, error: 'not found' };
|
|
569
|
+
}
|
|
570
|
+
const raw = item.raw || '';
|
|
571
|
+
if (!raw.trim()) return { ok: true, segments: [] };
|
|
572
|
+
|
|
573
|
+
const segments = splitSegments(raw);
|
|
574
|
+
const CONCURRENCY = 16; // 并发 16(原 8),提升长正文翻译速度
|
|
575
|
+
const results = new Array(segments.length);
|
|
576
|
+
let cursor = 0;
|
|
577
|
+
let done = 0;
|
|
578
|
+
const deadline = Date.now() + 12000;
|
|
579
|
+
|
|
580
|
+
// hijack 后手动管理响应:NDJSON 流(每行一个 JSON 消息)
|
|
581
|
+
reply.hijack();
|
|
582
|
+
reply.raw.writeHead(200, {
|
|
583
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
584
|
+
'cache-control': 'no-cache, no-transform',
|
|
585
|
+
'x-accel-buffering': 'no',
|
|
586
|
+
});
|
|
587
|
+
reply.raw.write(JSON.stringify({ type: 'start', total: segments.length }) + '\n');
|
|
588
|
+
|
|
589
|
+
// 监听客户端断开(前端 AbortController.abort):切换技能时前端取消请求,
|
|
590
|
+
// 后端 worker 应及时停止翻译,避免浪费 Google 翻译配额。
|
|
591
|
+
// 用 reply.raw 的 'close' 事件 + writableEnded 判断:'close' 在正常结束时
|
|
592
|
+
// 也触发,但此时 writableEnded=true,不算 abort。只有响应未结束就关闭
|
|
593
|
+
// (客户端断开)才标记 aborted。req.raw 的 'close' 在 POST body 读完时
|
|
594
|
+
// 就触发,不能用于判断客户端断开。
|
|
595
|
+
let aborted = false;
|
|
596
|
+
reply.raw.on('close', () => {
|
|
597
|
+
if (!reply.raw.writableEnded) aborted = true;
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const writeSegment = (idx) => {
|
|
601
|
+
done++;
|
|
602
|
+
if (aborted || reply.raw.writableEnded) return;
|
|
603
|
+
reply.raw.write(JSON.stringify({
|
|
604
|
+
type: 'segment',
|
|
605
|
+
index: idx,
|
|
606
|
+
done,
|
|
607
|
+
total: segments.length,
|
|
608
|
+
segment: results[idx],
|
|
609
|
+
}) + '\n');
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
const worker = async () => {
|
|
614
|
+
while (cursor < segments.length) {
|
|
615
|
+
if (aborted || Date.now() > deadline) break;
|
|
616
|
+
const idx = cursor++;
|
|
617
|
+
const seg = segments[idx];
|
|
618
|
+
const key = crypto.createHash('md5').update(seg.text, 'utf8').digest('hex');
|
|
619
|
+
if (seg.type === 'code') {
|
|
620
|
+
results[idx] = { md5: key, text: seg.text, translated: seg.text, skipped: 'code' };
|
|
621
|
+
} else {
|
|
622
|
+
const translated = await translateText(seg.text, 'zh-CN');
|
|
623
|
+
results[idx] = { md5: key, text: seg.text, translated };
|
|
624
|
+
}
|
|
625
|
+
writeSegment(idx);
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
await Promise.all(new Array(CONCURRENCY).fill(0).map(() => worker()));
|
|
629
|
+
// 客户端断开时不再写后续消息(连接已不可用),但仍落盘已翻译缓存
|
|
630
|
+
if (!aborted) {
|
|
631
|
+
// 超时未翻译的段,填充原文
|
|
632
|
+
for (let i = 0; i < segments.length; i++) {
|
|
633
|
+
if (!results[i]) {
|
|
634
|
+
const seg = segments[i];
|
|
635
|
+
const key = crypto.createHash('md5').update(seg.text, 'utf8').digest('hex');
|
|
636
|
+
results[i] = { md5: key, text: seg.text, translated: seg.text, skipped: 'timeout' };
|
|
637
|
+
writeSegment(i);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (!reply.raw.writableEnded) {
|
|
641
|
+
reply.raw.write(JSON.stringify({ type: 'done', ok: true, segments: results }) + '\n');
|
|
642
|
+
reply.raw.end();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
flushTranslateCache();
|
|
646
|
+
} catch (e) {
|
|
647
|
+
if (!reply.raw.writableEnded) {
|
|
648
|
+
reply.raw.write(JSON.stringify({ type: 'error', error: e.message }) + '\n');
|
|
649
|
+
reply.raw.end();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
app.get('/assets/*', async (req, reply) => {
|
|
655
|
+
if (!hasSpa) {
|
|
656
|
+
reply.code(404);
|
|
657
|
+
return { error: 'web assets not built' };
|
|
658
|
+
}
|
|
659
|
+
const rel = decodeURIComponent(req.params['*'] || '');
|
|
660
|
+
const abs = path.resolve(assetsDir, rel);
|
|
661
|
+
if (!abs.startsWith(assetsDir + path.sep) || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
|
|
662
|
+
reply.code(404);
|
|
663
|
+
return { error: 'asset not found' };
|
|
664
|
+
}
|
|
665
|
+
reply.type(contentTypeFor(abs));
|
|
666
|
+
return fs.createReadStream(abs);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// 根路径静态资源必须在 SPA fallback 前注册,否则浏览器会拿到 HTML 而不是图标。
|
|
670
|
+
for (const file of ROOT_STATIC_FILES) {
|
|
671
|
+
app.get(`/${file}`, async (req, reply) => serveRootStatic(file, reply));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Serve built Vue SPA if present; fallback to P2 placeholder when the user
|
|
675
|
+
// has not run `npm run build:web` yet.
|
|
676
|
+
app.get('/', async (req, reply) => {
|
|
677
|
+
if (hasSpa) {
|
|
678
|
+
reply.type('text/html; charset=utf-8');
|
|
679
|
+
reply.header('cache-control', 'no-cache, no-store, must-revalidate');
|
|
680
|
+
return fs.readFileSync(path.join(WEB_DIST, 'index.html'), 'utf8');
|
|
681
|
+
}
|
|
682
|
+
reply.type('text/html; charset=utf-8');
|
|
683
|
+
return placeholderHtml({ port, items: irCache });
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// SPA fallback for deep links (P3+). Keep /api/* above this route.
|
|
687
|
+
app.get('/*', async (req, reply) => {
|
|
688
|
+
if (hasSpa) {
|
|
689
|
+
reply.type('text/html; charset=utf-8');
|
|
690
|
+
reply.header('cache-control', 'no-cache, no-store, must-revalidate');
|
|
691
|
+
return fs.readFileSync(path.join(WEB_DIST, 'index.html'), 'utf8');
|
|
692
|
+
}
|
|
693
|
+
reply.code(404);
|
|
694
|
+
return { error: 'not found' };
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
await setupWatcher();
|
|
698
|
+
|
|
699
|
+
await app.listen({ port, host: '127.0.0.1' });
|
|
700
|
+
// port=0 时系统分配端口,需从 server.address() 取实际端口(测试用)
|
|
701
|
+
const actualPort = app.server.address().port;
|
|
702
|
+
return { app, port: actualPort };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ─────────────────────────────── helpers ─────────────────────────────────
|
|
706
|
+
|
|
707
|
+
function findRootStaticFile(file) {
|
|
708
|
+
for (const base of [WEB_DIST, WEB_PUBLIC]) {
|
|
709
|
+
const abs = path.join(base, file);
|
|
710
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
|
|
711
|
+
}
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function serveRootStatic(file, reply) {
|
|
716
|
+
const abs = findRootStaticFile(file);
|
|
717
|
+
if (!abs) {
|
|
718
|
+
reply.code(404);
|
|
719
|
+
return { error: 'static file not found' };
|
|
720
|
+
}
|
|
721
|
+
reply.type(contentTypeFor(abs));
|
|
722
|
+
if (file.endsWith('.webmanifest') || file === 'robots.txt') {
|
|
723
|
+
reply.header('cache-control', 'no-cache, no-store, must-revalidate');
|
|
724
|
+
} else if (/^favicon|\.png$|\.svg$|\.ico$|\.webp$|\.jpg$/.test(file)) {
|
|
725
|
+
// 图标/品牌资源:5 分钟缓存 + must-revalidate,避免 24h 缓存导致换图标不生效
|
|
726
|
+
reply.header('cache-control', 'public, max-age=300, must-revalidate');
|
|
727
|
+
} else {
|
|
728
|
+
reply.header('cache-control', 'public, max-age=86400');
|
|
729
|
+
}
|
|
730
|
+
return fs.createReadStream(abs);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function stripRaw({ raw, ...rest }) {
|
|
734
|
+
return rest;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// 把 markdown 正文切分为段落 + 代码块序列。
|
|
738
|
+
// - 围栏代码块(``` / ~~~)整体保留为 'code' 类型,不翻译。
|
|
739
|
+
// - 文本部分按双换行切分为段落,每段独立翻译、独立 md5 缓存。
|
|
740
|
+
// 设计目标:改一段只重译那段;代码 / 命令 / 路径不被误译。
|
|
741
|
+
// export 便于单元测试(translate-raw.test.mjs)。
|
|
742
|
+
export function splitSegments(text) {
|
|
743
|
+
const segments = [];
|
|
744
|
+
const lines = text.split('\n');
|
|
745
|
+
let textBuf = [];
|
|
746
|
+
let codeBuf = [];
|
|
747
|
+
let inCode = false;
|
|
748
|
+
|
|
749
|
+
const flushText = () => {
|
|
750
|
+
if (!textBuf.length) return;
|
|
751
|
+
const block = textBuf.join('\n');
|
|
752
|
+
for (const p of block.split(/\n\n+/)) {
|
|
753
|
+
if (p.trim()) segments.push({ type: 'text', text: p });
|
|
754
|
+
}
|
|
755
|
+
textBuf = [];
|
|
756
|
+
};
|
|
757
|
+
const flushCode = () => {
|
|
758
|
+
if (!codeBuf.length) return;
|
|
759
|
+
segments.push({ type: 'code', text: codeBuf.join('\n') });
|
|
760
|
+
codeBuf = [];
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
for (const line of lines) {
|
|
764
|
+
if (/^\s*(```+|~~~+)/.test(line)) {
|
|
765
|
+
if (inCode) {
|
|
766
|
+
codeBuf.push(line);
|
|
767
|
+
flushCode();
|
|
768
|
+
inCode = false;
|
|
769
|
+
} else {
|
|
770
|
+
flushText();
|
|
771
|
+
inCode = true;
|
|
772
|
+
codeBuf.push(line);
|
|
773
|
+
}
|
|
774
|
+
} else if (inCode) {
|
|
775
|
+
codeBuf.push(line);
|
|
776
|
+
} else {
|
|
777
|
+
textBuf.push(line);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (inCode) flushCode(); else flushText();
|
|
781
|
+
return segments;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function contentTypeFor(abs) {
|
|
785
|
+
const lower = abs.toLowerCase();
|
|
786
|
+
if (lower.endsWith('.html')) return 'text/html; charset=utf-8';
|
|
787
|
+
if (lower.endsWith('.js')) return 'text/javascript; charset=utf-8';
|
|
788
|
+
if (lower.endsWith('.css')) return 'text/css; charset=utf-8';
|
|
789
|
+
if (lower.endsWith('.json')) return 'application/json; charset=utf-8';
|
|
790
|
+
if (lower.endsWith('.webmanifest')) return 'application/manifest+json; charset=utf-8';
|
|
791
|
+
if (lower.endsWith('.txt')) return 'text/plain; charset=utf-8';
|
|
792
|
+
if (lower.endsWith('.svg')) return 'image/svg+xml';
|
|
793
|
+
if (lower.endsWith('.png')) return 'image/png';
|
|
794
|
+
if (lower.endsWith('.ico')) return 'image/x-icon';
|
|
795
|
+
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
|
796
|
+
if (lower.endsWith('.gif')) return 'image/gif';
|
|
797
|
+
if (lower.endsWith('.webp')) return 'image/webp';
|
|
798
|
+
if (lower.endsWith('.woff2')) return 'font/woff2';
|
|
799
|
+
return 'application/octet-stream';
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function safePluginLogoPath(item) {
|
|
803
|
+
const logoPath = item?.plugin?.logoPath;
|
|
804
|
+
const pluginDir = item?.paths?.abs;
|
|
805
|
+
if (!item?.id || !logoPath || !pluginDir) return null;
|
|
806
|
+
|
|
807
|
+
const resolvedLogo = path.resolve(logoPath);
|
|
808
|
+
const resolvedPluginDir = path.resolve(pluginDir);
|
|
809
|
+
if (!resolvedLogo.startsWith(resolvedPluginDir + path.sep)) return null;
|
|
810
|
+
if (!/\.(svg|png|ico|jpe?g|gif|webp)$/i.test(resolvedLogo)) return null;
|
|
811
|
+
|
|
812
|
+
try {
|
|
813
|
+
const logoReal = fs.realpathSync(resolvedLogo);
|
|
814
|
+
const pluginReal = fs.realpathSync(resolvedPluginDir);
|
|
815
|
+
if (!logoReal.startsWith(pluginReal + path.sep)) return null;
|
|
816
|
+
const stat = fs.statSync(logoReal);
|
|
817
|
+
if (!stat.isFile()) return null;
|
|
818
|
+
return logoReal;
|
|
819
|
+
} catch {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function pickCopyText(item, what) {
|
|
825
|
+
switch (what) {
|
|
826
|
+
case 'path': return item.paths?.abs ?? null;
|
|
827
|
+
case 'dir': return item.paths?.abs ? path.dirname(item.paths.abs) : null;
|
|
828
|
+
case 'rel': return item.paths?.rel ?? item.paths?.abs ?? null;
|
|
829
|
+
case 'name': return item.name ?? null;
|
|
830
|
+
case 'raw': return item.raw ?? '';
|
|
831
|
+
case 'prompt': return buildInvocationPrompt(item);
|
|
832
|
+
default: return null;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function buildInvocationPrompt(item) {
|
|
837
|
+
const name = item.name || item.title || 'unknown';
|
|
838
|
+
const abs = item.paths?.abs || '';
|
|
839
|
+
// v4.0 tier 扫描器把来源标签放在 item.brand(source 现在是内部扫描来源如 'tier1-editor'),
|
|
840
|
+
// 因此优先按 brand 判断,回退到 source 以兼容旧 IR。
|
|
841
|
+
const brand = item.brand || '';
|
|
842
|
+
const source = item.source || '';
|
|
843
|
+
if (brand === 'hermes' || source === 'hermes') {
|
|
844
|
+
return `Use Hermes skill ${name}: skill_view(name='${escapeSingle(name)}')`;
|
|
845
|
+
}
|
|
846
|
+
if (brand === 'claude' || source === 'claude-code') {
|
|
847
|
+
return `Use Claude Code skill ${name}. Local path: ${abs}`;
|
|
848
|
+
}
|
|
849
|
+
if (brand === 'codex' || source === 'codex') {
|
|
850
|
+
return `Use Codex instructions from ${abs}`;
|
|
851
|
+
}
|
|
852
|
+
if (brand === 'cursor' || source === 'cursor') {
|
|
853
|
+
return `Use Cursor rule ${name}. Local path: ${abs}`;
|
|
854
|
+
}
|
|
855
|
+
if (item.kind === 'mcp') {
|
|
856
|
+
return `Use MCP server/tool ${name}. Config source: ${abs}`;
|
|
857
|
+
}
|
|
858
|
+
if (item.kind === 'runbook') {
|
|
859
|
+
return `Use runbook ${name}. Local path: ${abs}`;
|
|
860
|
+
}
|
|
861
|
+
return `Use ${item.kind || 'skill'} ${name}. Local path: ${abs}`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function escapeSingle(s) {
|
|
865
|
+
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function pbcopy(text) {
|
|
869
|
+
return new Promise((resolve, reject) => {
|
|
870
|
+
const platform = process.platform;
|
|
871
|
+
let cmd, args;
|
|
872
|
+
if (platform === 'darwin') {
|
|
873
|
+
cmd = 'pbcopy'; args = [];
|
|
874
|
+
} else if (platform === 'linux') {
|
|
875
|
+
// best-effort; user installs xclip/wl-copy themselves
|
|
876
|
+
cmd = 'xclip'; args = ['-selection', 'clipboard'];
|
|
877
|
+
} else if (platform === 'win32') {
|
|
878
|
+
cmd = 'clip'; args = [];
|
|
879
|
+
} else {
|
|
880
|
+
return reject(new Error(`clipboard not supported on ${platform}`));
|
|
881
|
+
}
|
|
882
|
+
const child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
883
|
+
let stderr = '';
|
|
884
|
+
child.stderr.on('data', d => { stderr += d.toString(); });
|
|
885
|
+
child.on('error', reject);
|
|
886
|
+
child.on('close', code => {
|
|
887
|
+
if (code === 0) resolve();
|
|
888
|
+
else reject(new Error(`${cmd} exit ${code}: ${stderr.trim()}`));
|
|
889
|
+
});
|
|
890
|
+
child.stdin.end(text);
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function openPath(absPath, openWith) {
|
|
895
|
+
return new Promise((resolve, reject) => {
|
|
896
|
+
let cmd, args;
|
|
897
|
+
if (openWith === 'cursor') {
|
|
898
|
+
cmd = 'cursor'; args = [absPath];
|
|
899
|
+
} else if (openWith === 'finder') {
|
|
900
|
+
// macOS only — reveal in Finder
|
|
901
|
+
cmd = 'open'; args = ['-R', absPath];
|
|
902
|
+
} else {
|
|
903
|
+
// 'default' — let OS pick
|
|
904
|
+
const p = process.platform;
|
|
905
|
+
if (p === 'darwin') { cmd = 'open'; args = [absPath]; }
|
|
906
|
+
else if (p === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '', absPath]; }
|
|
907
|
+
else { cmd = 'xdg-open'; args = [absPath]; }
|
|
908
|
+
}
|
|
909
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
910
|
+
child.on('error', reject);
|
|
911
|
+
// detach + don't wait — opening apps shouldn't block the request
|
|
912
|
+
child.unref();
|
|
913
|
+
// resolve next tick — if the binary doesn't exist, 'error' fires before that
|
|
914
|
+
setTimeout(resolve, 50);
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function buildStats(items) {
|
|
919
|
+
const bySource = {};
|
|
920
|
+
const byEditor = {};
|
|
921
|
+
const byCategory = {};
|
|
922
|
+
const byBrand = {};
|
|
923
|
+
const byKind = {};
|
|
924
|
+
for (const it of items) {
|
|
925
|
+
bySource[it.source] = (bySource[it.source] || 0) + 1;
|
|
926
|
+
const editor = it.editor || it.source || '(none)';
|
|
927
|
+
byEditor[editor] = (byEditor[editor] || 0) + 1;
|
|
928
|
+
byKind[it.kind] = (byKind[it.kind] || 0) + 1;
|
|
929
|
+
const cat = it.category || '(none)';
|
|
930
|
+
byCategory[cat] = (byCategory[cat] || 0) + 1;
|
|
931
|
+
const brand = it.brand || '(none)';
|
|
932
|
+
byBrand[brand] = (byBrand[brand] || 0) + 1;
|
|
933
|
+
}
|
|
934
|
+
return {
|
|
935
|
+
total: items.length,
|
|
936
|
+
bySource,
|
|
937
|
+
byEditor,
|
|
938
|
+
byKind,
|
|
939
|
+
byCategory,
|
|
940
|
+
byBrand,
|
|
941
|
+
labels: LABELS,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ─────────────────────────────── placeholder UI ──────────────────────────
|
|
946
|
+
//
|
|
947
|
+
// P2 placeholder is more than a sign — it lists the first 50 skills and
|
|
948
|
+
// wires the copy / open buttons. Goal: even before P3 (Vue), we want to
|
|
949
|
+
// click around and confirm the API actually works against real skills.
|
|
950
|
+
|
|
951
|
+
function placeholderHtml({ port, items }) {
|
|
952
|
+
const sample = items.slice(0, 50);
|
|
953
|
+
const total = items.length;
|
|
954
|
+
const rows = sample.map(it => {
|
|
955
|
+
const desc = (it.description || '').replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c])).slice(0, 140);
|
|
956
|
+
const brand = it.brand ? `<span class="brand">${escape(it.brand)}</span>` : '';
|
|
957
|
+
const cat = it.category ? `<span class="cat">${escape(it.category)}</span>` : '';
|
|
958
|
+
return `
|
|
959
|
+
<div class="row" data-id="${it.id}">
|
|
960
|
+
<div class="head">
|
|
961
|
+
<span class="src src-${it.source}">${it.source}</span>
|
|
962
|
+
${cat}
|
|
963
|
+
${brand}
|
|
964
|
+
<strong>${escape(it.name)}</strong>
|
|
965
|
+
</div>
|
|
966
|
+
<div class="desc">${desc || '<em class="muted">(无描述)</em>'}</div>
|
|
967
|
+
<div class="path"><code>${escape(it.paths?.abs || '')}</code></div>
|
|
968
|
+
<div class="actions">
|
|
969
|
+
<button data-act="copy-path">Copy path</button>
|
|
970
|
+
<button data-act="copy-name">Copy name</button>
|
|
971
|
+
<button data-act="copy-raw">Copy raw</button>
|
|
972
|
+
<button data-act="open-cursor">Open in Cursor</button>
|
|
973
|
+
<button data-act="open-finder">Reveal</button>
|
|
974
|
+
</div>
|
|
975
|
+
</div>`;
|
|
976
|
+
}).join('\n');
|
|
977
|
+
|
|
978
|
+
return `<!doctype html>
|
|
979
|
+
<html lang="zh-CN">
|
|
980
|
+
<head>
|
|
981
|
+
<meta charset="utf-8">
|
|
982
|
+
<title>呼哈哈-技能助手 · P2</title>
|
|
983
|
+
<style>
|
|
984
|
+
:root { color-scheme: light dark; }
|
|
985
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
986
|
+
max-width: 1080px; margin: 32px auto; padding: 0 24px; color: #222;
|
|
987
|
+
line-height: 1.55; }
|
|
988
|
+
h1 { margin: 0 0 4px; }
|
|
989
|
+
.meta { color: #888; font-size: 13px; margin-bottom: 24px; }
|
|
990
|
+
.meta a { color: #06f; }
|
|
991
|
+
.row { padding: 14px 16px; border: 1px solid #e5e5e5; border-radius: 8px;
|
|
992
|
+
margin-bottom: 10px; background: #fafafa; }
|
|
993
|
+
.head { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 4px; }
|
|
994
|
+
.head strong { font-size: 15px; }
|
|
995
|
+
.src { font-size: 11px; padding: 1px 8px; border-radius: 10px; color: #fff;
|
|
996
|
+
font-weight: 600; letter-spacing: .3px; }
|
|
997
|
+
.src-hermes { background: #4a5fc7; }
|
|
998
|
+
.src-claude-code { background: #c98a3f; }
|
|
999
|
+
.cat { font-size: 11px; padding: 1px 6px; background: #eef0ff; color: #335; border-radius: 4px; }
|
|
1000
|
+
.brand { font-size: 11px; padding: 1px 6px; background: #efe; color: #353; border-radius: 4px; }
|
|
1001
|
+
.desc { font-size: 13px; color: #444; }
|
|
1002
|
+
.path code { font-size: 11px; color: #888; background: transparent; padding: 0; }
|
|
1003
|
+
.actions { margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap; }
|
|
1004
|
+
.actions button { font-size: 12px; padding: 4px 10px; border: 1px solid #ddd;
|
|
1005
|
+
background: #fff; border-radius: 5px; cursor: pointer; }
|
|
1006
|
+
.actions button:hover { background: #eef; border-color: #99c; }
|
|
1007
|
+
.actions button.flash { background: #cfc; border-color: #393; }
|
|
1008
|
+
.actions button.err { background: #fcc; border-color: #c33; }
|
|
1009
|
+
.muted { color: #aaa; }
|
|
1010
|
+
</style>
|
|
1011
|
+
</head>
|
|
1012
|
+
<body>
|
|
1013
|
+
<h1>呼哈哈-技能助手 <span style="font-size:13px;color:#888">P2</span></h1>
|
|
1014
|
+
<div class="meta">
|
|
1015
|
+
本地聚合中枢 · ${total} 条 skill 已加载 · 显示前 ${sample.length} 条 ·
|
|
1016
|
+
<a href="/api/skills">/api/skills</a> ·
|
|
1017
|
+
<a href="/api/stats">/api/stats</a> ·
|
|
1018
|
+
<a href="/api/health">/api/health</a><br>
|
|
1019
|
+
<span class="muted">P3 接 Vue SPA。当前页是占位 — 但复制/打开按钮已经能用。</span>
|
|
1020
|
+
</div>
|
|
1021
|
+
<div id="list">${rows}</div>
|
|
1022
|
+
|
|
1023
|
+
<script>
|
|
1024
|
+
async function api(path, body) {
|
|
1025
|
+
const res = await fetch(path, {
|
|
1026
|
+
method: 'POST',
|
|
1027
|
+
headers: { 'content-type': 'application/json' },
|
|
1028
|
+
body: JSON.stringify(body),
|
|
1029
|
+
});
|
|
1030
|
+
return res.json();
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
document.getElementById('list').addEventListener('click', async ev => {
|
|
1034
|
+
const btn = ev.target.closest('button[data-act]');
|
|
1035
|
+
if (!btn) return;
|
|
1036
|
+
const row = btn.closest('.row');
|
|
1037
|
+
const id = row.dataset.id;
|
|
1038
|
+
const act = btn.dataset.act;
|
|
1039
|
+
const orig = btn.textContent;
|
|
1040
|
+
btn.disabled = true;
|
|
1041
|
+
try {
|
|
1042
|
+
let r;
|
|
1043
|
+
if (act.startsWith('copy-')) {
|
|
1044
|
+
r = await api('/api/copy', { id, what: act.slice(5) });
|
|
1045
|
+
} else if (act === 'open-cursor') {
|
|
1046
|
+
r = await api('/api/open', { id, with: 'cursor' });
|
|
1047
|
+
} else if (act === 'open-finder') {
|
|
1048
|
+
r = await api('/api/open', { id, with: 'finder' });
|
|
1049
|
+
}
|
|
1050
|
+
btn.classList.add(r && r.ok ? 'flash' : 'err');
|
|
1051
|
+
btn.textContent = r && r.ok ? '✓ ' + orig : '✗ ' + (r && r.error || 'fail');
|
|
1052
|
+
} catch (e) {
|
|
1053
|
+
btn.classList.add('err');
|
|
1054
|
+
btn.textContent = '✗ ' + e.message;
|
|
1055
|
+
}
|
|
1056
|
+
setTimeout(() => {
|
|
1057
|
+
btn.classList.remove('flash', 'err');
|
|
1058
|
+
btn.textContent = orig;
|
|
1059
|
+
btn.disabled = false;
|
|
1060
|
+
}, 1200);
|
|
1061
|
+
});
|
|
1062
|
+
</script>
|
|
1063
|
+
</body>
|
|
1064
|
+
</html>`;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function escape(s) {
|
|
1068
|
+
return String(s == null ? '' : s).replace(/[<>&"']/g, c => ({
|
|
1069
|
+
'<': '<', '>': '>', '&': '&', '"': '"', "'": ''',
|
|
1070
|
+
}[c]));
|
|
1071
|
+
}
|