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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/bin/lib/atomic-write.mjs +72 -0
  4. package/bin/lib/paths.mjs +65 -0
  5. package/bin/lib/port.mjs +23 -0
  6. package/bin/skillhelper.mjs +720 -0
  7. package/build/build-site.mjs +35 -0
  8. package/build/build-web.mjs +29 -0
  9. package/build/pack-smoke.mjs +195 -0
  10. package/build/prepare-publish.mjs +3 -0
  11. package/build/verify.mjs +157 -0
  12. package/config/sources.example.yaml +126 -0
  13. package/docs/Frontend-Spec.md +1207 -0
  14. package/docs/scan_skills_rules.md +432 -0
  15. package/package.json +95 -0
  16. package/packages/scanner/package.json +15 -0
  17. package/packages/scanner/src/adapters/claude-agents.mjs +134 -0
  18. package/packages/scanner/src/adapters/claude-plugin.mjs +260 -0
  19. package/packages/scanner/src/adapters/codex-plugin.mjs +330 -0
  20. package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
  21. package/packages/scanner/src/adapters/file-docs.mjs +158 -0
  22. package/packages/scanner/src/adapters/hermes-plugin.mjs +149 -0
  23. package/packages/scanner/src/adapters/markdown-skill.mjs +229 -0
  24. package/packages/scanner/src/adapters/mcp-config.mjs +190 -0
  25. package/packages/scanner/src/adapters/scan-tier.mjs +99 -0
  26. package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
  27. package/packages/scanner/src/adapters/tier1-editor-skills.mjs +278 -0
  28. package/packages/scanner/src/adapters/tier2-user-skills.mjs +218 -0
  29. package/packages/scanner/src/adapters/tier3-other-skills.mjs +225 -0
  30. package/packages/scanner/src/config/editor-tiers.mjs +191 -0
  31. package/packages/scanner/src/core/atomic-write.mjs +11 -0
  32. package/packages/scanner/src/core/descriptor.mjs +143 -0
  33. package/packages/scanner/src/core/path-hash.mjs +89 -0
  34. package/packages/scanner/src/core/registry.mjs +128 -0
  35. package/packages/scanner/src/hash/path-hash.mjs +8 -0
  36. package/packages/scanner/src/icon/brand-map.mjs +268 -0
  37. package/packages/scanner/src/icon/icon-extractor.mjs +466 -0
  38. package/packages/scanner/src/index.mjs +450 -0
  39. package/packages/scanner/src/types.d.ts +144 -0
  40. package/packages/scanner/src/utils.mjs +194 -0
  41. package/packages/server/package.json +15 -0
  42. package/packages/server/src/index.mjs +1071 -0
  43. package/packages/server/src/labels.mjs +64 -0
  44. package/packages/server/src/langdetect.mjs +34 -0
  45. package/packages/server/src/llm-client.mjs +42 -0
  46. package/packages/server/src/translate-cache.mjs +132 -0
  47. package/packages/server/src/translator.mjs +53 -0
  48. package/packages/web/README.md +36 -0
  49. package/packages/web/dist/assets/index-CGt04vt0.js +304 -0
  50. package/packages/web/dist/assets/index-CLSDoa3H.css +1 -0
  51. package/packages/web/dist/brand-icon.png +0 -0
  52. package/packages/web/dist/brand-logo.png +0 -0
  53. package/packages/web/dist/favicon-16x16.png +0 -0
  54. package/packages/web/dist/favicon-192x192.png +0 -0
  55. package/packages/web/dist/favicon-32x32.png +0 -0
  56. package/packages/web/dist/favicon-512x512.png +0 -0
  57. package/packages/web/dist/favicon-source.svg +5 -0
  58. package/packages/web/dist/favicon.ico +0 -0
  59. package/packages/web/dist/favicon.svg +5 -0
  60. package/packages/web/dist/icons/hermes-128.png +0 -0
  61. package/packages/web/dist/icons/hermes-192.png +0 -0
  62. package/packages/web/dist/icons/hermes-256.png +0 -0
  63. package/packages/web/dist/icons/hermes-32.png +0 -0
  64. package/packages/web/dist/icons/hermes-512.png +0 -0
  65. package/packages/web/dist/icons/hermes-64.png +0 -0
  66. package/packages/web/dist/icons/hermes.png +0 -0
  67. package/packages/web/dist/index.html +37 -0
  68. package/packages/web/dist/robots.txt +3 -0
  69. package/packages/web/dist/site.webmanifest +24 -0
  70. package/packages/web/public/brand-icon.png +0 -0
  71. package/packages/web/public/brand-logo.png +0 -0
  72. package/packages/web/public/favicon-16x16.png +0 -0
  73. package/packages/web/public/favicon-192x192.png +0 -0
  74. package/packages/web/public/favicon-32x32.png +0 -0
  75. package/packages/web/public/favicon-512x512.png +0 -0
  76. package/packages/web/public/favicon-source.svg +5 -0
  77. package/packages/web/public/favicon.ico +0 -0
  78. package/packages/web/public/favicon.svg +5 -0
  79. package/packages/web/public/icons/hermes-128.png +0 -0
  80. package/packages/web/public/icons/hermes-192.png +0 -0
  81. package/packages/web/public/icons/hermes-256.png +0 -0
  82. package/packages/web/public/icons/hermes-32.png +0 -0
  83. package/packages/web/public/icons/hermes-512.png +0 -0
  84. package/packages/web/public/icons/hermes-64.png +0 -0
  85. package/packages/web/public/icons/hermes.png +0 -0
  86. package/packages/web/public/robots.txt +3 -0
  87. package/packages/web/public/site.webmanifest +24 -0
  88. package/scripts/install-and-sync.sh +209 -0
  89. package/scripts/sync-skills.sh +605 -0
@@ -0,0 +1,720 @@
1
+ #!/usr/bin/env node
2
+ // skillhelper CLI
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { spawn } from 'node:child_process';
8
+ import { readFileSync } from 'node:fs';
9
+ import { createRequire } from 'node:module';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+ const REPO_ROOT = path.resolve(__dirname, '..');
14
+ const PKGS_ROOT = path.join(REPO_ROOT, 'packages');
15
+ const DISPLAY_NAME = '呼哈哈-技能助手';
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ // Load paths helper
20
+ const pathsLib = require('./lib/paths.mjs');
21
+ const { homeDir, configFile, cacheFile, stateFile, ensureHomeDir, writeJson, readJson } = pathsLib;
22
+
23
+ // Load port helper
24
+ const { pickPort } = require('./lib/port.mjs');
25
+
26
+ // Load version from package.json
27
+ function getVersion() {
28
+ try {
29
+ const pkgPath = path.join(REPO_ROOT, 'package.json');
30
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
31
+ return pkg.version;
32
+ } catch {
33
+ return 'unknown';
34
+ }
35
+ }
36
+
37
+ const VERSION = getVersion();
38
+ const cmd = process.argv[2] || 'start';
39
+
40
+ const handlers = {
41
+ start: cmdStart,
42
+ stop: cmdStop,
43
+ scan: cmdScan,
44
+ stats: cmdStats,
45
+ duplicates: cmdDuplicates,
46
+ init: cmdInit,
47
+ purge: cmdPurge,
48
+ uninstall: cmdUninstall,
49
+ restart: cmdRestart,
50
+ dev: cmdDev,
51
+ sync: cmdSync,
52
+ help: cmdHelp,
53
+ '--help': cmdHelp,
54
+ '-h': cmdHelp,
55
+ '--version': cmdVersion,
56
+ '-v': cmdVersion,
57
+ version: cmdVersion,
58
+ };
59
+
60
+ const fn = handlers[cmd] || cmdHelp;
61
+ fn().catch(err => {
62
+ console.error('[skillhelper] error:', err && err.stack || err);
63
+ process.exit(1);
64
+ });
65
+
66
+ // ---------------- commands ----------------
67
+
68
+ async function cmdVersion() {
69
+ console.log(`skillhelper v${VERSION}`);
70
+ }
71
+
72
+ async function cmdHelp() {
73
+ console.log(`skillhelper v${VERSION} — ${DISPLAY_NAME}:本地技能 / 插件 / MCP 聚合中心
74
+
75
+ Usage:
76
+ skillhelper <command> [options]
77
+
78
+ Commands:
79
+ start Scan + start server + open browser (runs in background by default)
80
+ stop Stop the background server
81
+ restart Restart the background server (kill + restart)
82
+ scan Scan only, dump IR JSON to stdout
83
+ stats Scan + print human-readable summary (counts / brands / errors / samples)
84
+ duplicates Scan + print duplicate diagnostics by name/content/path
85
+ init Write default sources.yaml to ~/.config/skillhelper/
86
+ purge Remove all user data under ~/.config/skillhelper/
87
+ uninstall Stop service + uninstall global npm package (prompts before deleting user data)
88
+ sync Sync current skills to selected editors
89
+ dev Dev mode: Vite frontend hot reload (connect to backend separately)
90
+
91
+ Options:
92
+ -v, --version Show version
93
+ -h, --help Show this message
94
+ -f, --foreground Keep running in foreground (for debugging)
95
+
96
+ Env:
97
+ SKILLHELPER_HOME override user data dir (default: ~/.config/skillhelper)
98
+ PORT override preferred port (default: 11520, falls back +10)
99
+ SKILLHELPER_SYNC comma-separated list of editors to sync (e.g. "cursor,vscode")
100
+ SKILLHELPER_NO_OPEN set to 1/true/yes to skip opening the browser
101
+
102
+ Paths:
103
+ config ${configFile()}
104
+ cache ${cacheFile()}
105
+ state ${stateFile()}
106
+ logs ${path.join(homeDir(), 'skillhelper.log')}
107
+ `);
108
+ }
109
+
110
+ async function cmdInit() {
111
+ ensureHomeDir();
112
+ const dst = configFile();
113
+ if (fs.existsSync(dst)) {
114
+ console.log(`[init] sources.yaml already exists: ${dst}`);
115
+ console.log('[init] not overwriting. delete it first if you want to reset.');
116
+ return;
117
+ }
118
+ const tpl = path.join(REPO_ROOT, 'config', 'sources.example.yaml');
119
+ fs.copyFileSync(tpl, dst);
120
+ console.log(`[init] wrote ${dst}`);
121
+ console.log('[init] edit it to add / remove sources, then run: skillhelper start');
122
+ }
123
+
124
+ async function cmdPurge() {
125
+ const dir = homeDir();
126
+ if (!fs.existsSync(dir)) {
127
+ console.log(`[purge] nothing to remove — ${dir} does not exist`);
128
+ return;
129
+ }
130
+ fs.rmSync(dir, { recursive: true, force: true });
131
+ console.log(`[purge] removed ${dir}`);
132
+ console.log('[purge] code dir is untouched. delete the repo manually if needed.');
133
+ }
134
+
135
+ async function cmdStop() {
136
+ const state = readJson(stateFile());
137
+ if (!state || !state.pid) {
138
+ console.log('[stop] 未发现运行中的实例(无 state.json 记录)');
139
+ return false;
140
+ }
141
+ if (!isProcessRunning(state.pid)) {
142
+ console.log('[stop] 实例未运行,清理残留状态文件');
143
+ try { fs.unlinkSync(stateFile()); } catch {}
144
+ return false;
145
+ }
146
+ try {
147
+ process.kill(state.pid, 'SIGTERM');
148
+ console.log(`✓ 已停止 ${DISPLAY_NAME} (pid ${state.pid}, port ${state.port ?? '未知'})`);
149
+ } catch (err) {
150
+ console.error(`[stop] 停止失败: ${err.message}`);
151
+ return false;
152
+ }
153
+ try { fs.unlinkSync(stateFile()); } catch {}
154
+ return true;
155
+ }
156
+
157
+ async function cmdRestart() {
158
+ const isForeground = process.argv.includes('--foreground') || process.argv.includes('-f');
159
+ const state = readJson(stateFile());
160
+
161
+ if (!state || !state.pid) {
162
+ console.log('[restart] 未发现运行中的实例,启动新服务...');
163
+ await cmdStart();
164
+ return;
165
+ }
166
+
167
+ if (!isProcessRunning(state.pid)) {
168
+ console.log('[restart] 旧实例未运行,清理状态并启动新服务...');
169
+ try { fs.unlinkSync(stateFile()); } catch {}
170
+ await cmdStart();
171
+ return;
172
+ }
173
+
174
+ // Kill old process
175
+ try {
176
+ process.kill(state.pid, 'SIGTERM');
177
+ console.log(`[restart] 已停止旧实例 (PID ${state.pid})`);
178
+ } catch (err) {
179
+ console.error(`[restart] 停止失败: ${err.message}`);
180
+ }
181
+
182
+ try { fs.unlinkSync(stateFile()); } catch {}
183
+
184
+ // Wait and restart
185
+ await new Promise(resolve => setTimeout(resolve, 500));
186
+ console.log('[restart] 启动新实例...');
187
+ await cmdStart();
188
+ }
189
+
190
+ async function cmdUninstall() {
191
+ const readline = await import('node:readline');
192
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
193
+ const question = (q) => new Promise(resolve => rl.question(q, resolve));
194
+
195
+ const dir = homeDir();
196
+ console.log(`\n[uninstall] 即将卸载 ${DISPLAY_NAME},将执行以下操作:`);
197
+ console.log(' 1. 停止运行中的后台服务');
198
+ console.log(` 2. 删除用户数据目录: ${dir}`);
199
+ console.log(' 3. 卸载全局 npm 包: skillhelper');
200
+ console.log('\n⚠️ 此操作不可恢复,代码仓库本身不会被删除。');
201
+
202
+ const ans = await question('\n是否全部卸载?输入 y 确认,其它键取消: ');
203
+ rl.close();
204
+
205
+ if (ans.trim().toLowerCase() !== 'y') {
206
+ console.log('[uninstall] 已取消,未做任何更改。');
207
+ return;
208
+ }
209
+
210
+ // 1. 停止后台服务
211
+ console.log('\n[1/3] 停止后台服务...');
212
+ await cmdStop();
213
+
214
+ // 2. 删除用户数据目录
215
+ console.log('\n[2/3] 删除用户数据目录...');
216
+ if (fs.existsSync(dir)) {
217
+ fs.rmSync(dir, { recursive: true, force: true });
218
+ console.log(`✓ 已删除 ${dir}`);
219
+ } else {
220
+ console.log(`[uninstall] 目录不存在,跳过: ${dir}`);
221
+ }
222
+
223
+ // 3. 卸载全局 npm 包
224
+ console.log('\n[3/3] 卸载全局 npm 包...');
225
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
226
+ await new Promise((resolve) => {
227
+ const child = spawn(npmCmd, ['uninstall', '-g', 'skillhelper'], { stdio: 'inherit' });
228
+ child.on('close', (code) => {
229
+ if (code === 0) console.log('✓ 已卸载全局 npm 包 skillhelper');
230
+ else console.warn(`[uninstall] npm 退出码 ${code}(若未全局安装可忽略)`);
231
+ resolve();
232
+ });
233
+ child.on('error', () => {
234
+ console.warn('[uninstall] 未找到 npm 命令,跳过全局包卸载');
235
+ resolve();
236
+ });
237
+ });
238
+
239
+ console.log('\n[uninstall] 完成 ✓');
240
+ }
241
+
242
+ async function cmdScan() {
243
+ const { scan } = await import(path.join(PKGS_ROOT, 'scanner/src/index.mjs'));
244
+ await ensureConfigOrInit();
245
+ const items = await scan();
246
+ process.stdout.write(JSON.stringify(items, null, 2) + '\n');
247
+ console.error(`[scan] ${items.length} item(s)`);
248
+ }
249
+
250
+ async function cmdStats() {
251
+ const { scan } = await import(path.join(PKGS_ROOT, 'scanner/src/index.mjs'));
252
+ await ensureConfigOrInit();
253
+ const items = await scan();
254
+
255
+ const bySource = {};
256
+ const byEditor = {};
257
+ const byCategory = {};
258
+ const byBrand = {};
259
+ const byKind = {};
260
+ const errors = [];
261
+ const noDescription = [];
262
+ const noTriggers = [];
263
+
264
+ for (const it of items) {
265
+ bySource[it.source] = (bySource[it.source] || 0) + 1;
266
+ const editor = it.editor || it.source || '(none)';
267
+ byEditor[editor] = (byEditor[editor] || 0) + 1;
268
+ byKind[it.kind] = (byKind[it.kind] || 0) + 1;
269
+ const cat = it.category || '(none)';
270
+ byCategory[cat] = (byCategory[cat] || 0) + 1;
271
+ const brand = it.brand || '(none)';
272
+ byBrand[brand] = (byBrand[brand] || 0) + 1;
273
+ if (it.parseError) errors.push(it);
274
+ if (!it.description) noDescription.push(it);
275
+ if (!it.triggers || !it.triggers.length) noTriggers.push(it);
276
+ }
277
+
278
+ const sortDesc = (obj) => Object.entries(obj).sort((a, b) => b[1] - a[1]);
279
+
280
+ console.log(`\n${DISPLAY_NAME} scan stats`);
281
+ console.log(`==========================`);
282
+ console.log(`total items: ${items.length}`);
283
+ console.log(`parse errors: ${errors.length}`);
284
+ console.log(`missing description: ${noDescription.length}`);
285
+ console.log(`missing triggers: ${noTriggers.length}`);
286
+
287
+ console.log(`\nby source:`);
288
+ for (const [k, v] of sortDesc(bySource)) console.log(` ${k.padEnd(20)} ${v}`);
289
+
290
+ console.log(`\nby editor:`);
291
+ for (const [k, v] of sortDesc(byEditor)) console.log(` ${k.padEnd(20)} ${v}`);
292
+
293
+ console.log(`\nby kind:`);
294
+ for (const [k, v] of sortDesc(byKind)) console.log(` ${k.padEnd(20)} ${v}`);
295
+
296
+ console.log(`\nby category (top 15):`);
297
+ for (const [k, v] of sortDesc(byCategory).slice(0, 15)) {
298
+ console.log(` ${k.padEnd(30)} ${v}`);
299
+ }
300
+
301
+ console.log(`\nby brand (top 15):`);
302
+ for (const [k, v] of sortDesc(byBrand).slice(0, 15)) {
303
+ console.log(` ${k.padEnd(20)} ${v}`);
304
+ }
305
+
306
+ if (errors.length) {
307
+ console.log(`\nparse errors (first 5):`);
308
+ for (const e of errors.slice(0, 5)) {
309
+ console.log(` - [${e.source}] ${e.name}: ${e.parseError}`);
310
+ console.log(` ${e.paths.abs}`);
311
+ }
312
+ }
313
+
314
+ const sampleN = Math.min(8, items.length);
315
+ if (sampleN > 0) {
316
+ const step = Math.max(1, Math.floor(items.length / sampleN));
317
+ console.log(`\nsample (every ${step}-th item, ${sampleN} shown):`);
318
+ for (let i = 0; i < sampleN; i++) {
319
+ const it = items[i * step];
320
+ if (!it) break;
321
+ const desc = (it.description || '(no description)').slice(0, 70);
322
+ const trigCount = (it.triggers || []).length;
323
+ console.log(
324
+ ` [${it.source.padEnd(11)}] ${(it.category || '-').padEnd(20)} ` +
325
+ `${it.name.padEnd(38)} brand=${(it.brand || '-').padEnd(14)} t=${trigCount}`
326
+ );
327
+ console.log(` ${desc}`);
328
+ }
329
+ }
330
+
331
+ console.log('');
332
+ }
333
+
334
+ async function cmdDuplicates() {
335
+ const { scan } = await import(path.join(PKGS_ROOT, 'scanner/src/index.mjs'));
336
+ await ensureConfigOrInit();
337
+ const items = await scan();
338
+ const byName = groupBy(items, it => `${it.source}:${it.name}`);
339
+ const byContent = groupBy(items, it => `${it.source}:${hashShort(it.raw || it.preview || '')}`);
340
+ const byAbs = groupBy(items, it => it.paths?.abs || '');
341
+
342
+ const nameDups = [...byName.entries()].filter(([, arr]) => arr.length > 1);
343
+ const contentDups = [...byContent.entries()].filter(([k, arr]) => !k.endsWith(':00000000') && arr.length > 1);
344
+ const pathDups = [...byAbs.entries()].filter(([k, arr]) => k && arr.length > 1);
345
+
346
+ console.log(`\n${DISPLAY_NAME} duplicate diagnostics`);
347
+ console.log('====================================');
348
+ console.log(`total items: ${items.length}`);
349
+ console.log(`duplicate names: ${nameDups.length} groups`);
350
+ console.log(`duplicate contents: ${contentDups.length} groups`);
351
+ console.log(`duplicate abs paths: ${pathDups.length} groups`);
352
+
353
+ printDupGroup('duplicate names (first 20)', nameDups, 20);
354
+ printDupGroup('duplicate contents (first 20)', contentDups, 20);
355
+ printDupGroup('duplicate paths (first 20)', pathDups, 20);
356
+ console.log('');
357
+ }
358
+
359
+ function groupBy(items, keyFn) {
360
+ const m = new Map();
361
+ for (const it of items) {
362
+ const k = keyFn(it);
363
+ if (!m.has(k)) m.set(k, []);
364
+ m.get(k).push(it);
365
+ }
366
+ return m;
367
+ }
368
+
369
+ function hashShort(text) {
370
+ let h = 0;
371
+ for (let i = 0; i < text.length; i++) h = Math.imul(31, h) + text.charCodeAt(i) | 0;
372
+ return (h >>> 0).toString(16).padStart(8, '0');
373
+ }
374
+
375
+ function printDupGroup(title, groups, limit) {
376
+ if (!groups.length) return;
377
+ console.log(`\n${title}:`);
378
+ for (const [key, arr] of groups.slice(0, limit)) {
379
+ console.log(`- ${key} (${arr.length})`);
380
+ for (const it of arr.slice(0, 6)) {
381
+ console.log(` [${it.source}] ${it.name} :: ${it.paths?.abs}`);
382
+ }
383
+ }
384
+ }
385
+
386
+ async function cmdStart() {
387
+ await ensureConfigOrInit();
388
+ const isForeground = process.argv.includes('--foreground') || process.argv.includes('-f');
389
+ const preferred = parseInt(process.env.PORT || '11520', 10);
390
+
391
+ // 后台模式下先做幂等检查:已有实例在运行则直接返回,
392
+ // 避免 pickPort 误判端口被占用而打印"改用其它端口"的误导信息。
393
+ if (!isForeground) {
394
+ try {
395
+ const stateData = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
396
+ if (stateData.pid && isProcessRunning(stateData.pid)) {
397
+ const logFile = path.join(homeDir(), 'skillhelper.log');
398
+ console.log(`✓ ${DISPLAY_NAME} 已在运行: http://localhost:${stateData.port}`);
399
+ console.log(`📝 日志: ${logFile}`);
400
+ return;
401
+ }
402
+ } catch {
403
+ // 状态文件不存在或无效,继续正常启动流程
404
+ }
405
+ }
406
+
407
+ const port = await pickPort(preferred, 10);
408
+ if (!port) {
409
+ console.error(`[start] ${preferred}..${preferred + 10} 范围内无空闲端口,已中止。`);
410
+ process.exit(1);
411
+ }
412
+ if (port !== preferred) {
413
+ console.warn(`[start] 端口 ${preferred} 被占用,改用 ${port}。`);
414
+ }
415
+
416
+ const homeDirectory = homeDir();
417
+ const logFile = path.join(homeDirectory, 'skillhelper.log');
418
+
419
+ // 后台模式:fork 子进程
420
+ if (!isForeground) {
421
+ return startBackgroundServer(port, logFile, homeDirectory);
422
+ }
423
+
424
+ // 前台模式:直接启动
425
+ await runServer(port, logFile, homeDirectory);
426
+ }
427
+
428
+ async function startBackgroundServer(port, logFile, homeDirectory) {
429
+ // 检查是否已有运行的实例
430
+ try {
431
+ const stateData = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
432
+ if (stateData.pid && isProcessRunning(stateData.pid)) {
433
+ console.log(`✓ ${DISPLAY_NAME} 已在运行: http://localhost:${stateData.port}`);
434
+ console.log(`📝 日志: ${logFile}`);
435
+ return;
436
+ }
437
+ } catch {
438
+ // 状态文件不存在或无效,继续启动
439
+ }
440
+
441
+ // 打开日志文件用于输出重定向
442
+ const logFd = fs.openSync(logFile, 'a');
443
+
444
+ // Spawn 子进程
445
+ const child = spawn(process.execPath, [process.argv[1], 'start', '--foreground'], {
446
+ detached: true,
447
+ stdio: ['ignore', logFd, logFd],
448
+ env: {
449
+ ...process.env,
450
+ PORT: port.toString()
451
+ }
452
+ });
453
+
454
+ let childExited = false;
455
+ let exitInfo = '';
456
+ child.once('exit', (code, signal) => {
457
+ childExited = true;
458
+ exitInfo = signal ? `signal ${signal}` : `exit ${code}`;
459
+ });
460
+ child.once('error', (err) => {
461
+ childExited = true;
462
+ exitInfo = err.message;
463
+ });
464
+
465
+ child.unref();
466
+
467
+ const healthy = await waitForHealth(port, 10000, () => childExited);
468
+
469
+ try { fs.closeSync(logFd); } catch {}
470
+
471
+ if (!healthy) {
472
+ if (!childExited) {
473
+ try { process.kill(child.pid, 'SIGTERM'); } catch {}
474
+ }
475
+ try { fs.unlinkSync(stateFile()); } catch {}
476
+ const tail = readLogTail(logFile, 40);
477
+ console.error(`[start] ${DISPLAY_NAME} 后台启动失败${exitInfo ? `(${exitInfo})` : ''}`);
478
+ console.error(`日志: ${logFile}`);
479
+ if (tail) {
480
+ console.error('\n最近日志:');
481
+ console.error(tail);
482
+ }
483
+ process.exit(1);
484
+ }
485
+
486
+ console.log(`✓ ${DISPLAY_NAME} 已在后台运行: http://localhost:${port}`);
487
+ console.log(`📝 日志: ${logFile}`);
488
+ console.log(`💡 查看日志: tail -f ${logFile}`);
489
+ console.log(`💡 停止服务: skillhelper stop`);
490
+ }
491
+
492
+ function isProcessRunning(pid) {
493
+ try {
494
+ process.kill(pid, 0);
495
+ return true;
496
+ } catch {
497
+ return false;
498
+ }
499
+ }
500
+
501
+ async function waitForHealth(port, timeoutMs, shouldStop) {
502
+ const deadline = Date.now() + timeoutMs;
503
+ while (Date.now() < deadline) {
504
+ if (shouldStop && shouldStop()) return false;
505
+ if (await fetchHealth(port)) return true;
506
+ await delay(250);
507
+ }
508
+ return false;
509
+ }
510
+
511
+ async function fetchHealth(port) {
512
+ const controller = new AbortController();
513
+ const timer = setTimeout(() => controller.abort(), 500);
514
+ try {
515
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: controller.signal });
516
+ if (!res.ok) return false;
517
+ const data = await res.json();
518
+ return data && data.ok === true;
519
+ } catch {
520
+ return false;
521
+ } finally {
522
+ clearTimeout(timer);
523
+ }
524
+ }
525
+
526
+ function readLogTail(file, lines = 40) {
527
+ try {
528
+ const text = fs.readFileSync(file, 'utf8');
529
+ return text.trimEnd().split(/\r?\n/).slice(-lines).join('\n');
530
+ } catch {
531
+ return '';
532
+ }
533
+ }
534
+
535
+ function delay(ms) {
536
+ return new Promise(resolve => setTimeout(resolve, ms));
537
+ }
538
+
539
+ async function runServer(port, logFile, homeDirectory) {
540
+ const { startServer } = await import(path.join(PKGS_ROOT, 'server/src/index.mjs'));
541
+ const server = await startServer({ port });
542
+ const actualPort = server.port || port;
543
+ writeJson(stateFile(), { port: actualPort, pid: process.pid, startedAt: new Date().toISOString() });
544
+
545
+ console.log(`\n ${DISPLAY_NAME} 运行中: http://localhost:${actualPort}\n`);
546
+ console.log(` 数据目录: ${homeDirectory}`);
547
+ console.log(` 日志: ${logFile}`);
548
+ console.log(` 按 Ctrl+C 停止。\n`);
549
+
550
+ openBrowser(`http://localhost:${actualPort}`);
551
+
552
+ const cleanup = () => {
553
+ try { fs.unlinkSync(stateFile()); } catch {}
554
+ process.exit(0);
555
+ };
556
+ process.on('SIGINT', cleanup);
557
+ process.on('SIGTERM', cleanup);
558
+ }
559
+
560
+ async function cmdDev() {
561
+ console.log('[dev] Frontend dev mode delegated to packages/web.');
562
+ console.log('[dev] Run: npm run dev (frontend on 11521)');
563
+ console.log('[dev] In another terminal: npm run start (backend on 11520)');
564
+ console.log('[dev] Or: npm run restart (if backend already running)');
565
+ process.exit(0);
566
+ }
567
+
568
+ async function cmdSync() {
569
+ const syncEnv = process.env.SKILLHELPER_SYNC;
570
+ const editors = syncEnv
571
+ ? syncEnv.split(',').map(e => e.trim().toLowerCase())
572
+ : null;
573
+
574
+ const { scan } = await import(path.join(PKGS_ROOT, 'scanner/src/index.mjs'));
575
+ await ensureConfigOrInit();
576
+ const items = await scan();
577
+
578
+ const skills = items.filter(it => it.raw && it.raw.trim());
579
+
580
+ console.log(`[sync] found ${skills.length} skills to sync`);
581
+
582
+ if (editors) {
583
+ console.log(`[sync] syncing to editors: ${editors.join(', ')}`);
584
+ await syncToEditors(skills, editors);
585
+ } else {
586
+ await interactiveSync(skills);
587
+ }
588
+ }
589
+
590
+ async function syncToEditors(skills, editors) {
591
+ const supported = [
592
+ 'cursor', 'vscode', 'vscode-insiders', 'windsurf', 'zed', 'helix',
593
+ 'neovim', 'vim', 'emacs', 'sublime', 'sublime4', 'textmate',
594
+ 'bbedit', 'atom', 'kate', 'gedit', 'jetbrains', 'openclaw',
595
+ 'herems', 'trae', 'trae-cn', 'codex', 'claude'
596
+ ];
597
+ const validEditors = editors.filter(e => supported.includes(e));
598
+
599
+ if (validEditors.length === 0) {
600
+ console.error(`[sync] no valid editors. Supported: ${supported.join(', ')}`);
601
+ return;
602
+ }
603
+
604
+ for (const editor of validEditors) {
605
+ await syncToEditor(skills, editor);
606
+ }
607
+ }
608
+
609
+ async function syncToEditor(skills, editor) {
610
+ console.log(`[sync] syncing ${skills.length} skills to ${editor}...`);
611
+
612
+ const syncScript = path.join(REPO_ROOT, 'scripts', 'sync-skills.sh');
613
+
614
+ return new Promise((resolve) => {
615
+ const child = spawn('bash', [syncScript, '--editor', editor], {
616
+ stdio: ['pipe', 'inherit', 'inherit']
617
+ });
618
+
619
+ child.on('close', (code) => {
620
+ if (code === 0) {
621
+ console.log(`[sync] ${editor}: done`);
622
+ } else {
623
+ console.error(`[sync] ${editor}: failed (exit ${code})`);
624
+ }
625
+ resolve();
626
+ });
627
+ });
628
+ }
629
+
630
+ async function interactiveSync(skills) {
631
+ const readline = await import('node:readline');
632
+ const rl = readline.createInterface({
633
+ input: process.stdin,
634
+ output: process.stdout
635
+ });
636
+
637
+ const question = (prompt) => new Promise(resolve => rl.question(prompt, resolve));
638
+
639
+ const allEditors = [
640
+ 'cursor', 'vscode', 'vscode-insiders', 'windsurf', 'zed', 'helix',
641
+ 'neovim', 'vim', 'emacs', 'sublime', 'sublime4', 'textmate',
642
+ 'bbedit', 'atom', 'kate', 'gedit', 'jetbrains', 'openclaw',
643
+ 'herems', 'trae', 'trae-cn', 'codex', 'claude'
644
+ ];
645
+
646
+ console.log(`\n[sync] found ${skills.length} skills`);
647
+ console.log('\nSupported editors:');
648
+
649
+ // Display editors in 3 columns
650
+ for (let i = 0; i < allEditors.length; i += 3) {
651
+ const line = [];
652
+ for (let j = 0; j < 3; j++) {
653
+ const idx = i + j + 1;
654
+ const editor = allEditors[i + j];
655
+ if (editor) {
656
+ line.push(`[${idx.toString().padEnd(2)}] ${editor.padEnd(16)}`);
657
+ }
658
+ }
659
+ console.log(' ' + line.join(' '));
660
+ }
661
+
662
+ console.log(' [0] all editors [q] quit\n');
663
+
664
+ const editorMap = {};
665
+ allEditors.forEach((editor, i) => {
666
+ editorMap[(i + 1).toString()] = editor;
667
+ });
668
+
669
+ const input = await question('Select editors (0/1/2/... or q): ');
670
+ rl.close();
671
+
672
+ if (input === 'q' || input === 'Q') {
673
+ console.log('[sync] cancelled');
674
+ return;
675
+ }
676
+
677
+ let editors = [];
678
+ if (input === '0') {
679
+ editors = allEditors;
680
+ } else {
681
+ const selected = input.split(/[,\s]+/).filter(s => s);
682
+ editors = selected.map(s => editorMap[s]).filter(Boolean);
683
+ }
684
+
685
+ if (editors.length === 0) {
686
+ console.error('[sync] no valid editors selected');
687
+ return;
688
+ }
689
+
690
+ await syncToEditors(skills, editors);
691
+ }
692
+
693
+ // ---------------- helpers ----------------
694
+
695
+ async function ensureConfigOrInit() {
696
+ if (!fs.existsSync(configFile())) {
697
+ console.error(`[boot] no config yet, writing defaults to ${configFile()}`);
698
+ await cmdInit();
699
+ }
700
+ }
701
+
702
+ function openBrowser(url) {
703
+ if (shouldSkipBrowserOpen()) return;
704
+
705
+ const platform = process.platform;
706
+ let cmd, args;
707
+ if (platform === 'darwin') { cmd = 'open'; args = [url]; }
708
+ else if (platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '', url]; }
709
+ else { cmd = 'xdg-open'; args = [url]; }
710
+ try {
711
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
712
+ } catch {
713
+ // best-effort; user can copy-paste the URL
714
+ }
715
+ }
716
+
717
+ function shouldSkipBrowserOpen() {
718
+ const value = process.env.SKILLHELPER_NO_OPEN;
719
+ return value === '1' || value?.toLowerCase() === 'true' || value?.toLowerCase() === 'yes';
720
+ }