huhaa-myskills 0.3.5 → 0.3.7
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/README.md +79 -0
- package/bin/huhaa-myskills.mjs +88 -17
- package/bin/lib/paths.mjs +5 -0
- package/build/pack-smoke.mjs +195 -0
- package/build/verify.mjs +24 -3
- package/docs/Frontend-Spec.md +11 -0
- package/docs/scan_skills_rules.md +381 -0
- package/package.json +13 -5
- package/packages/scanner/src/adapters/scan-tier.mjs +99 -0
- package/packages/scanner/src/adapters/tier1-editor-skills.mjs +240 -0
- package/packages/scanner/src/adapters/tier2-user-skills.mjs +192 -0
- package/packages/scanner/src/adapters/tier3-other-skills.mjs +190 -0
- package/packages/scanner/src/config/editor-tiers.mjs +191 -0
- package/packages/scanner/src/hash/path-hash.mjs +86 -0
- package/packages/scanner/src/icon/brand-map.mjs +110 -30
- package/packages/scanner/src/icon/icon-extractor.mjs +198 -40
- package/packages/scanner/src/index.mjs +82 -16
- package/packages/scanner/src/types.d.ts +10 -0
- package/packages/server/src/index.mjs +359 -53
- package/packages/server/src/labels.mjs +1 -1
- 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 +11 -29
- package/packages/web/dist/assets/index-DBR3X1hb.js +304 -0
- package/packages/web/dist/assets/index-DxHxmeVA.css +1 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/index.html +4 -4
- package/packages/web/dist/site.webmanifest +4 -4
- package/packages/web/public/favicon.ico +0 -0
- package/packages/web/public/site.webmanifest +4 -4
- package/scripts/install-and-sync.sh +3 -3
- package/docs/scan_rules.md +0 -172
- package/packages/scanner/test/icon.test.mjs +0 -61
- package/packages/scanner/test/scanner.test.mjs +0 -136
- package/packages/scanner/test/skill-adapter.test.mjs +0 -70
- package/packages/scanner/tests/directory-skill.test.mjs +0 -93
- package/packages/scanner/tests/scanner-integration.test.mjs +0 -139
- package/packages/server/test/server.test.mjs +0 -140
- package/packages/web/dist/assets/index-BCY2cRaO.js +0 -175
- package/packages/web/dist/assets/index-DALKjJPi.css +0 -1
- package/packages/web/index.html +0 -37
- package/packages/web/package-lock.json +0 -2989
- package/packages/web/package.json +0 -33
- package/packages/web/postcss.config.js +0 -6
- package/packages/web/src/App.test.ts +0 -40
- package/packages/web/src/App.tsx +0 -177
- package/packages/web/src/components/ComingSoon.tsx +0 -14
- package/packages/web/src/components/layout/Sidebar.test.tsx +0 -74
- package/packages/web/src/components/layout/Sidebar.tsx +0 -114
- package/packages/web/src/components/layout/Topbar.tsx +0 -74
- package/packages/web/src/components/ui/ActionButton.tsx +0 -48
- package/packages/web/src/components/ui/button.tsx +0 -48
- package/packages/web/src/components/ui/card.tsx +0 -45
- package/packages/web/src/components/views/DashboardView.tsx +0 -122
- package/packages/web/src/components/views/OtherSkillsView.test.tsx +0 -126
- package/packages/web/src/components/views/OtherSkillsView.tsx +0 -306
- package/packages/web/src/components/views/SettingsView.tsx +0 -95
- package/packages/web/src/components/views/SkillDetail.test.tsx +0 -57
- package/packages/web/src/components/views/SkillDetail.tsx +0 -81
- package/packages/web/src/components/views/SkillsView.test.ts +0 -28
- package/packages/web/src/components/views/SkillsView.tsx +0 -226
- package/packages/web/src/hooks/QUICK_REFERENCE.md +0 -165
- package/packages/web/src/hooks/README.md +0 -378
- package/packages/web/src/hooks/__tests__/useOtherSkills.test.ts +0 -422
- package/packages/web/src/hooks/__tests__/useSkillIcons.test.ts +0 -272
- package/packages/web/src/hooks/useLiveReload.ts +0 -20
- package/packages/web/src/hooks/useOtherSkills.ts +0 -387
- package/packages/web/src/hooks/useSkillIcons.ts +0 -248
- package/packages/web/src/hooks/useTheme.ts +0 -36
- package/packages/web/src/index.css +0 -161
- package/packages/web/src/lib/api.test.ts +0 -47
- package/packages/web/src/lib/api.ts +0 -77
- package/packages/web/src/lib/cn.ts +0 -7
- package/packages/web/src/lib/editors.test.ts +0 -30
- package/packages/web/src/lib/editors.ts +0 -70
- package/packages/web/src/lib/markdown.test.ts +0 -27
- package/packages/web/src/lib/markdown.ts +0 -14
- package/packages/web/src/main.tsx +0 -13
- package/packages/web/src/types/index.ts +0 -62
- package/packages/web/src/types/other-skill.ts +0 -88
- package/packages/web/src/types/skill.ts +0 -136
- package/packages/web/src/types.ts +0 -47
- package/packages/web/src/vite-env.d.ts +0 -1
- package/packages/web/tailwind.config.ts +0 -66
- package/packages/web/tsconfig.json +0 -28
- package/packages/web/vite.config.ts +0 -36
- package/packages/web/vitest.config.ts +0 -21
- package/packages/web/vitest.setup.ts +0 -1
- package/scripts/activate-data-sources.sh +0 -223
- package/scripts/brand-completion.py +0 -586
- package/scripts/init-nextjs-project.sh +0 -421
- package/scripts/start-dev.sh +0 -72
package/README.md
CHANGED
|
@@ -16,3 +16,82 @@
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
**快速开始**:见 docs/Frontend-Spec.md §VI 「快速启动指南」
|
|
19
|
+
|
|
20
|
+
## 🔑 关键技术决策
|
|
21
|
+
|
|
22
|
+
### 后端设计
|
|
23
|
+
- **API 端点**: `/api/other-skills`(列表)、`/api/icons/:brand`(真实图标)
|
|
24
|
+
- **扫描器**: 通用 `skill-adapter.mjs`,支持任意 SKILL.md 位置
|
|
25
|
+
- **文件匹配**: `**/SKILL.md` glob 递归扫描,目录名大小写不敏感、文件名大小写敏感
|
|
26
|
+
- **图标**: macOS 内置命令(`mdfind`/`plutil`/`sips`)提取,无 Swift 依赖;非 macOS 自动降级 emoji
|
|
27
|
+
|
|
28
|
+
### 前端设计
|
|
29
|
+
- **数据获取**: Hook 模式(`useOtherSkills`),支持搜索/排序/分组
|
|
30
|
+
- **图标展示**: `SkillIcon` 组件,真实图标优先、emoji 兜底
|
|
31
|
+
- **UI 布局**: 响应式双列(list + detail)
|
|
32
|
+
- **错误处理**: 自定义 `OtherSkillsError` 类
|
|
33
|
+
|
|
34
|
+
### 数据流
|
|
35
|
+
```
|
|
36
|
+
SKILL.md 文件 (160+)
|
|
37
|
+
↓
|
|
38
|
+
skill-adapter.mjs (扫描 + frontmatter 解析 + icon 字段)
|
|
39
|
+
↓
|
|
40
|
+
/api/other-skills (注入 iconUrl/iconFallback) ──► /api/icons/:brand (按需提取真实图标 PNG + 缓存)
|
|
41
|
+
↓
|
|
42
|
+
useOtherSkills Hook (搜索、过滤、排序、分组)
|
|
43
|
+
↓
|
|
44
|
+
OtherSkillsView + SkillIcon (真实图标 / emoji 降级)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 🚀 快速开始
|
|
50
|
+
|
|
51
|
+
### 开发模式
|
|
52
|
+
```bash
|
|
53
|
+
cd /Users/mac/Project/HuHaa-MySkills
|
|
54
|
+
|
|
55
|
+
# 启动后端服务(默认 11520,占用则自动切换)
|
|
56
|
+
npm start
|
|
57
|
+
|
|
58
|
+
# 前端开发(Vite)
|
|
59
|
+
npm run dev
|
|
60
|
+
|
|
61
|
+
# 点击菜单:其它技能 → 查看真实技能 + 真实工具图标
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 验证
|
|
65
|
+
```bash
|
|
66
|
+
# 列表数据(含 iconUrl 字段)
|
|
67
|
+
curl -s "http://127.0.0.1:11520/api/other-skills?roots=~/.hermes/skills&fileGlob=**/SKILL.md" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).skills.length))"
|
|
68
|
+
|
|
69
|
+
# 真实图标(Cursor / VS Code)
|
|
70
|
+
curl -s -o /tmp/i.png "http://127.0.0.1:11520/api/icons/cursor?size=64" && file /tmp/i.png
|
|
71
|
+
|
|
72
|
+
# 后端测试
|
|
73
|
+
npm test # 12/12
|
|
74
|
+
npm run typecheck:web
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 📊 版本历史
|
|
80
|
+
|
|
81
|
+
| 版本 | 日期 | 主要功能 | 状态 |
|
|
82
|
+
|------|------|--------|------|
|
|
83
|
+
| v0.3.2 | - | 基础架构 | ✅ |
|
|
84
|
+
| v0.3.3 | 2026-06-30 | Dashboard + Skills Sources | ✅ |
|
|
85
|
+
| v0.3.4 | 2026-07-01 | Other Skills(真实数据) | ✅ |
|
|
86
|
+
| v0.3.5 | 2026-07-01 | 真实图标(R6) + 扫描性能(R7) · 规则 v3.0 | ✅ 已实现,待发布 |
|
|
87
|
+
| 待定 | - | 桌面模式 / 功能扩展 | 📋 规划中 |
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 🔍 项目规范
|
|
92
|
+
|
|
93
|
+
所有开发须遵循:
|
|
94
|
+
- **CLAUDE.md** — AI 协作规则(全中文、strict mode)
|
|
95
|
+
- **docs/Frontend-Engineering.md** — 技术栈和编码规范
|
|
96
|
+
- **根目录整洁** — 仅保留 README.md、CLAUDE.md、package.json 等核心文件
|
|
97
|
+
- **计划文档** — 统一放 `.hermes/plans/`
|
package/bin/huhaa-myskills.mjs
CHANGED
|
@@ -12,6 +12,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
12
12
|
const __dirname = path.dirname(__filename);
|
|
13
13
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
14
14
|
const PKGS_ROOT = path.join(REPO_ROOT, 'packages');
|
|
15
|
+
const DISPLAY_NAME = 'HuHaa AI 助手';
|
|
15
16
|
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
18
|
|
|
@@ -69,7 +70,7 @@ async function cmdVersion() {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
async function cmdHelp() {
|
|
72
|
-
console.log(`huhaa-myskills v${VERSION} —
|
|
73
|
+
console.log(`huhaa-myskills v${VERSION} — ${DISPLAY_NAME}:本地技能 / 插件 / MCP 聚合中心
|
|
73
74
|
|
|
74
75
|
Usage:
|
|
75
76
|
huhaa-myskills <command> [options]
|
|
@@ -96,6 +97,7 @@ Env:
|
|
|
96
97
|
HUHAA_HOME override user data dir (default: ~/.config/huhaa-myskills)
|
|
97
98
|
PORT override preferred port (default: 11520, falls back +10)
|
|
98
99
|
HUHAA_SYNC comma-separated list of editors to sync (e.g. "cursor,vscode")
|
|
100
|
+
HUHAA_NO_OPEN set to 1/true/yes to skip opening the browser
|
|
99
101
|
|
|
100
102
|
Paths:
|
|
101
103
|
config ${configFile()}
|
|
@@ -143,7 +145,7 @@ async function cmdStop() {
|
|
|
143
145
|
}
|
|
144
146
|
try {
|
|
145
147
|
process.kill(state.pid, 'SIGTERM');
|
|
146
|
-
console.log(`✓ 已停止
|
|
148
|
+
console.log(`✓ 已停止 ${DISPLAY_NAME} (pid ${state.pid}, port ${state.port ?? '未知'})`);
|
|
147
149
|
} catch (err) {
|
|
148
150
|
console.error(`[stop] 停止失败: ${err.message}`);
|
|
149
151
|
return false;
|
|
@@ -191,7 +193,7 @@ async function cmdUninstall() {
|
|
|
191
193
|
const question = (q) => new Promise(resolve => rl.question(q, resolve));
|
|
192
194
|
|
|
193
195
|
const dir = homeDir();
|
|
194
|
-
console.log(
|
|
196
|
+
console.log(`\n[uninstall] 即将卸载 ${DISPLAY_NAME},将执行以下操作:`);
|
|
195
197
|
console.log(' 1. 停止运行中的后台服务');
|
|
196
198
|
console.log(` 2. 删除用户数据目录: ${dir}`);
|
|
197
199
|
console.log(' 3. 卸载全局 npm 包: huhaa-myskills');
|
|
@@ -275,7 +277,7 @@ async function cmdStats() {
|
|
|
275
277
|
|
|
276
278
|
const sortDesc = (obj) => Object.entries(obj).sort((a, b) => b[1] - a[1]);
|
|
277
279
|
|
|
278
|
-
console.log(`\
|
|
280
|
+
console.log(`\n${DISPLAY_NAME} scan stats`);
|
|
279
281
|
console.log(`==========================`);
|
|
280
282
|
console.log(`total items: ${items.length}`);
|
|
281
283
|
console.log(`parse errors: ${errors.length}`);
|
|
@@ -341,7 +343,7 @@ async function cmdDuplicates() {
|
|
|
341
343
|
const contentDups = [...byContent.entries()].filter(([k, arr]) => !k.endsWith(':00000000') && arr.length > 1);
|
|
342
344
|
const pathDups = [...byAbs.entries()].filter(([k, arr]) => k && arr.length > 1);
|
|
343
345
|
|
|
344
|
-
console.log(
|
|
346
|
+
console.log(`\n${DISPLAY_NAME} duplicate diagnostics`);
|
|
345
347
|
console.log('====================================');
|
|
346
348
|
console.log(`total items: ${items.length}`);
|
|
347
349
|
console.log(`duplicate names: ${nameDups.length} groups`);
|
|
@@ -393,7 +395,7 @@ async function cmdStart() {
|
|
|
393
395
|
const stateData = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
|
|
394
396
|
if (stateData.pid && isProcessRunning(stateData.pid)) {
|
|
395
397
|
const logFile = path.join(homeDir(), 'huhaa.log');
|
|
396
|
-
console.log(`✓
|
|
398
|
+
console.log(`✓ ${DISPLAY_NAME} 已在运行: http://localhost:${stateData.port}`);
|
|
397
399
|
console.log(`📝 日志: ${logFile}`);
|
|
398
400
|
return;
|
|
399
401
|
}
|
|
@@ -428,7 +430,7 @@ async function startBackgroundServer(port, logFile, homeDirectory) {
|
|
|
428
430
|
try {
|
|
429
431
|
const stateData = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
|
|
430
432
|
if (stateData.pid && isProcessRunning(stateData.pid)) {
|
|
431
|
-
console.log(`✓
|
|
433
|
+
console.log(`✓ ${DISPLAY_NAME} 已在运行: http://localhost:${stateData.port}`);
|
|
432
434
|
console.log(`📝 日志: ${logFile}`);
|
|
433
435
|
return;
|
|
434
436
|
}
|
|
@@ -449,15 +451,39 @@ async function startBackgroundServer(port, logFile, homeDirectory) {
|
|
|
449
451
|
}
|
|
450
452
|
});
|
|
451
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
|
+
|
|
452
465
|
child.unref();
|
|
453
466
|
|
|
454
|
-
|
|
455
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
467
|
+
const healthy = await waitForHealth(port, 10000, () => childExited);
|
|
456
468
|
|
|
457
|
-
|
|
458
|
-
fs.closeSync(logFd);
|
|
469
|
+
try { fs.closeSync(logFd); } catch {}
|
|
459
470
|
|
|
460
|
-
|
|
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}`);
|
|
461
487
|
console.log(`📝 日志: ${logFile}`);
|
|
462
488
|
console.log(`💡 查看日志: tail -f ${logFile}`);
|
|
463
489
|
console.log(`💡 停止服务: huhaa-myskills stop`);
|
|
@@ -472,18 +498,56 @@ function isProcessRunning(pid) {
|
|
|
472
498
|
}
|
|
473
499
|
}
|
|
474
500
|
|
|
475
|
-
async function
|
|
476
|
-
|
|
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
|
+
}
|
|
477
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) {
|
|
478
540
|
const { startServer } = await import(path.join(PKGS_ROOT, 'server/src/index.mjs'));
|
|
479
|
-
await startServer({ port });
|
|
541
|
+
const server = await startServer({ port });
|
|
542
|
+
const actualPort = server.port || port;
|
|
543
|
+
writeJson(stateFile(), { port: actualPort, pid: process.pid, startedAt: new Date().toISOString() });
|
|
480
544
|
|
|
481
|
-
console.log(`\n
|
|
545
|
+
console.log(`\n ${DISPLAY_NAME} 运行中: http://localhost:${actualPort}\n`);
|
|
482
546
|
console.log(` 数据目录: ${homeDirectory}`);
|
|
483
547
|
console.log(` 日志: ${logFile}`);
|
|
484
548
|
console.log(` 按 Ctrl+C 停止。\n`);
|
|
485
549
|
|
|
486
|
-
openBrowser(`http://localhost:${
|
|
550
|
+
openBrowser(`http://localhost:${actualPort}`);
|
|
487
551
|
|
|
488
552
|
const cleanup = () => {
|
|
489
553
|
try { fs.unlinkSync(stateFile()); } catch {}
|
|
@@ -636,6 +700,8 @@ async function ensureConfigOrInit() {
|
|
|
636
700
|
}
|
|
637
701
|
|
|
638
702
|
function openBrowser(url) {
|
|
703
|
+
if (shouldSkipBrowserOpen()) return;
|
|
704
|
+
|
|
639
705
|
const platform = process.platform;
|
|
640
706
|
let cmd, args;
|
|
641
707
|
if (platform === 'darwin') { cmd = 'open'; args = [url]; }
|
|
@@ -647,3 +713,8 @@ function openBrowser(url) {
|
|
|
647
713
|
// best-effort; user can copy-paste the URL
|
|
648
714
|
}
|
|
649
715
|
}
|
|
716
|
+
|
|
717
|
+
function shouldSkipBrowserOpen() {
|
|
718
|
+
const value = process.env.HUHAA_NO_OPEN;
|
|
719
|
+
return value === '1' || value?.toLowerCase() === 'true' || value?.toLowerCase() === 'yes';
|
|
720
|
+
}
|
package/bin/lib/paths.mjs
CHANGED
|
@@ -25,6 +25,11 @@ export function cacheFile() {
|
|
|
25
25
|
return path.join(homeDir(), 'cache.json');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// 翻译结果持久化缓存(md5 key → 译文)。purge 删除整个 homeDir 时随之清理。
|
|
29
|
+
export function translateCacheFile() {
|
|
30
|
+
return path.join(homeDir(), 'translate-cache.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
export function stateFile() {
|
|
29
34
|
return path.join(homeDir(), 'state.json');
|
|
30
35
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const repoRoot = path.resolve(__dirname, '..');
|
|
12
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'huhaa-pack-smoke-'));
|
|
13
|
+
const packDir = path.join(tempRoot, 'pack');
|
|
14
|
+
const installRoot = path.join(tempRoot, 'install');
|
|
15
|
+
const home = path.join(tempRoot, 'home');
|
|
16
|
+
const childOutput = [];
|
|
17
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
18
|
+
|
|
19
|
+
fs.mkdirSync(packDir, { recursive: true });
|
|
20
|
+
fs.mkdirSync(installRoot, { recursive: true });
|
|
21
|
+
fs.mkdirSync(home, { recursive: true });
|
|
22
|
+
|
|
23
|
+
function run(name, args, options = {}) {
|
|
24
|
+
console.log(`\n[pack-smoke] $ ${name} ${args.join(' ')}`);
|
|
25
|
+
const res = spawnSync(name, args, {
|
|
26
|
+
cwd: repoRoot,
|
|
27
|
+
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
28
|
+
env: options.env || process.env,
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
});
|
|
31
|
+
if (res.status !== 0) {
|
|
32
|
+
if (options.capture) {
|
|
33
|
+
process.stdout.write(res.stdout || '');
|
|
34
|
+
process.stderr.write(res.stderr || '');
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`${name} ${args.join(' ')} failed with exit ${res.status}`);
|
|
37
|
+
}
|
|
38
|
+
return res;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function write(file, text) {
|
|
42
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
43
|
+
fs.writeFileSync(file, text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function getFreePort() {
|
|
47
|
+
return await new Promise((resolve, reject) => {
|
|
48
|
+
const server = net.createServer();
|
|
49
|
+
server.once('error', reject);
|
|
50
|
+
server.listen(0, '127.0.0.1', () => {
|
|
51
|
+
const address = server.address();
|
|
52
|
+
server.close(() => resolve(address.port));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function waitForHealth(base, timeoutMs) {
|
|
58
|
+
const deadline = Date.now() + timeoutMs;
|
|
59
|
+
let lastError = '';
|
|
60
|
+
while (Date.now() < deadline) {
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(`${base}/api/health`);
|
|
63
|
+
const text = await res.text();
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
const body = JSON.parse(text);
|
|
66
|
+
if (body.ok === true) return body;
|
|
67
|
+
}
|
|
68
|
+
lastError = `${res.status}: ${text}`;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
lastError = err.message;
|
|
71
|
+
}
|
|
72
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`health check timed out: ${lastError}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function waitForChildClose(child) {
|
|
78
|
+
if (child.exitCode != null || child.signalCode != null) return;
|
|
79
|
+
await new Promise(resolve => {
|
|
80
|
+
const timer = setTimeout(resolve, 2000);
|
|
81
|
+
child.once('close', () => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
resolve();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assertTarballEntries(tarball) {
|
|
89
|
+
const res = run('tar', ['-tf', tarball], { capture: true });
|
|
90
|
+
const entries = res.stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
91
|
+
assert.ok(entries.length > 0, 'tarball should not be empty');
|
|
92
|
+
|
|
93
|
+
const forbidden = entries.filter(entry =>
|
|
94
|
+
entry.includes('/node_modules/') ||
|
|
95
|
+
entry.includes('/.vite/') ||
|
|
96
|
+
entry.endsWith('/node_modules') ||
|
|
97
|
+
entry.endsWith('/.vite')
|
|
98
|
+
);
|
|
99
|
+
assert.deepEqual(forbidden, [], 'tarball must not contain node_modules or .vite cache');
|
|
100
|
+
|
|
101
|
+
for (const required of [
|
|
102
|
+
'package/packages/web/dist/index.html',
|
|
103
|
+
'package/packages/web/dist/favicon.ico',
|
|
104
|
+
'package/packages/web/dist/site.webmanifest',
|
|
105
|
+
'package/packages/web/public/favicon.ico',
|
|
106
|
+
'package/packages/server/src/index.mjs',
|
|
107
|
+
'package/packages/scanner/src/index.mjs',
|
|
108
|
+
]) {
|
|
109
|
+
assert.ok(entries.includes(required), `tarball missing ${required}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function assertInstalledCli(tarball) {
|
|
114
|
+
run('npm', ['install', '--prefix', installRoot, '--silent', '--omit=dev', tarball]);
|
|
115
|
+
|
|
116
|
+
const bin = path.join(installRoot, 'node_modules', '.bin', 'huhaa-myskills');
|
|
117
|
+
const version = run(bin, ['--version'], { capture: true });
|
|
118
|
+
assert.match(version.stdout, new RegExp(`^huhaa-myskills v${escapeRegExp(pkg.version)}`, 'm'));
|
|
119
|
+
|
|
120
|
+
write(path.join(home, '.hermes', 'skills', 'pack-smoke', 'SKILL.md'), `---
|
|
121
|
+
name: pack-smoke
|
|
122
|
+
description: Pack smoke skill
|
|
123
|
+
---
|
|
124
|
+
# Pack smoke skill
|
|
125
|
+
`);
|
|
126
|
+
write(path.join(home, 'sources.yaml'), `limits:
|
|
127
|
+
maxFiles: 100
|
|
128
|
+
maxFileBytes: 1048576
|
|
129
|
+
`);
|
|
130
|
+
|
|
131
|
+
const port = await getFreePort();
|
|
132
|
+
const base = `http://127.0.0.1:${port}`;
|
|
133
|
+
const env = {
|
|
134
|
+
...process.env,
|
|
135
|
+
HOME: home,
|
|
136
|
+
HUHAA_HOME: home,
|
|
137
|
+
HUHAA_NO_OPEN: '1',
|
|
138
|
+
LOG_LEVEL: 'error',
|
|
139
|
+
PORT: String(port),
|
|
140
|
+
};
|
|
141
|
+
const child = spawn(bin, ['start', '--foreground'], {
|
|
142
|
+
cwd: installRoot,
|
|
143
|
+
env,
|
|
144
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
145
|
+
});
|
|
146
|
+
child.stdout.on('data', data => childOutput.push(data.toString()));
|
|
147
|
+
child.stderr.on('data', data => childOutput.push(data.toString()));
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const health = await waitForHealth(base, 15000);
|
|
151
|
+
assert.equal(health.ok, true);
|
|
152
|
+
assert.equal(health.version, pkg.version);
|
|
153
|
+
|
|
154
|
+
const icon = await fetch(`${base}/favicon.ico`);
|
|
155
|
+
const bytes = Buffer.from(await icon.arrayBuffer());
|
|
156
|
+
assert.equal(icon.ok, true);
|
|
157
|
+
assert.equal((icon.headers.get('content-type') || '').split(';')[0], 'image/x-icon');
|
|
158
|
+
assert.deepEqual([...bytes.subarray(0, 4)], [0x00, 0x00, 0x01, 0x00]);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const tail = childOutput.join('').trim().split(/\r?\n/).slice(-60).join('\n');
|
|
161
|
+
throw new Error(`${err.message}\n\nCLI output:\n${tail}`);
|
|
162
|
+
} finally {
|
|
163
|
+
if (child.exitCode == null && child.signalCode == null) {
|
|
164
|
+
child.kill('SIGTERM');
|
|
165
|
+
}
|
|
166
|
+
await waitForChildClose(child);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function main() {
|
|
171
|
+
try {
|
|
172
|
+
run('npm', ['pack', '--pack-destination', packDir]);
|
|
173
|
+
const tarballs = fs.readdirSync(packDir)
|
|
174
|
+
.filter(name => name.endsWith('.tgz'))
|
|
175
|
+
.map(name => path.join(packDir, name));
|
|
176
|
+
assert.equal(tarballs.length, 1, 'npm pack should create exactly one tarball');
|
|
177
|
+
const tarball = tarballs[0];
|
|
178
|
+
|
|
179
|
+
assertTarballEntries(tarball);
|
|
180
|
+
await assertInstalledCli(tarball);
|
|
181
|
+
console.log('\n[pack-smoke] PASS tarball contents + installed CLI smoke');
|
|
182
|
+
} finally {
|
|
183
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function escapeRegExp(text) {
|
|
188
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
main().catch(err => {
|
|
192
|
+
console.error('\n[pack-smoke] FAIL');
|
|
193
|
+
console.error(err && err.stack || err);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
});
|
package/build/verify.mjs
CHANGED
|
@@ -10,7 +10,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
10
10
|
const repoRoot = path.resolve(__dirname, '..');
|
|
11
11
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'huhaa-verify-'));
|
|
12
12
|
const oldHome = process.env.HUHAA_HOME;
|
|
13
|
+
const oldUserHome = process.env.HOME;
|
|
13
14
|
process.env.HUHAA_HOME = path.join(tempRoot, 'home');
|
|
15
|
+
process.env.HOME = process.env.HUHAA_HOME;
|
|
14
16
|
|
|
15
17
|
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
16
18
|
const testFiles = [
|
|
@@ -40,7 +42,7 @@ async function main() {
|
|
|
40
42
|
run('npm', ['run', 'test:web']);
|
|
41
43
|
run('node', ['--test', ...testFiles]);
|
|
42
44
|
|
|
43
|
-
const skillRoot = path.join(
|
|
45
|
+
const skillRoot = path.join(process.env.HOME, '.hermes', 'skills');
|
|
44
46
|
write(path.join(skillRoot, 'demo', 'verify-skill', 'SKILL.md'), `---
|
|
45
47
|
name: verify-skill
|
|
46
48
|
description: Verify smoke skill
|
|
@@ -83,7 +85,7 @@ limits:
|
|
|
83
85
|
|
|
84
86
|
const stats = await fetchJson(`${base}/api/stats`);
|
|
85
87
|
assert.equal(stats.total, 1);
|
|
86
|
-
assert.equal(stats.
|
|
88
|
+
assert.equal(stats.byBrand.hermes, 1);
|
|
87
89
|
|
|
88
90
|
const reload = await fetchJson(`${base}/api/reload`, { method: 'POST' });
|
|
89
91
|
assert.equal(reload.ok, true);
|
|
@@ -104,13 +106,25 @@ limits:
|
|
|
104
106
|
assert.equal(css.contentType.split(';')[0], 'text/css');
|
|
105
107
|
assert.ok(css.body.includes('topbar') || css.body.includes('sidebar') || css.body.includes('detail'));
|
|
106
108
|
|
|
107
|
-
|
|
109
|
+
const manifest = await fetchJson(`${base}/site.webmanifest`);
|
|
110
|
+
assert.equal(manifest.name, 'HuHaa AI 助手');
|
|
111
|
+
assert.ok(manifest.icons.some(icon => icon.sizes === '192x192'));
|
|
112
|
+
assert.ok(manifest.icons.some(icon => icon.sizes === '512x512'));
|
|
113
|
+
|
|
114
|
+
const favicon = await fetchBuffer(`${base}/favicon.ico`);
|
|
115
|
+
assert.equal(favicon.contentType.split(';')[0], 'image/x-icon');
|
|
116
|
+
assert.deepEqual([...favicon.body.subarray(0, 4)], [0x00, 0x00, 0x01, 0x00]);
|
|
108
117
|
} finally {
|
|
109
118
|
await app.close();
|
|
110
119
|
}
|
|
120
|
+
|
|
121
|
+
run('node', ['build/pack-smoke.mjs']);
|
|
122
|
+
console.log('\n[verify] PASS build + tests + HTTP/API/static/package smoke checks');
|
|
111
123
|
} finally {
|
|
112
124
|
if (oldHome == null) delete process.env.HUHAA_HOME;
|
|
113
125
|
else process.env.HUHAA_HOME = oldHome;
|
|
126
|
+
if (oldUserHome == null) delete process.env.HOME;
|
|
127
|
+
else process.env.HOME = oldUserHome;
|
|
114
128
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
115
129
|
}
|
|
116
130
|
}
|
|
@@ -129,6 +143,13 @@ async function fetchText(url, init) {
|
|
|
129
143
|
return { body, contentType: res.headers.get('content-type') || '' };
|
|
130
144
|
}
|
|
131
145
|
|
|
146
|
+
async function fetchBuffer(url, init) {
|
|
147
|
+
const res = await fetch(url, init);
|
|
148
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
149
|
+
assert.equal(res.ok, true, `${url} returned ${res.status}: ${body.toString('utf8', 0, 200)}`);
|
|
150
|
+
return { body, contentType: res.headers.get('content-type') || '' };
|
|
151
|
+
}
|
|
152
|
+
|
|
132
153
|
main().catch(err => {
|
|
133
154
|
console.error('\n[verify] FAIL');
|
|
134
155
|
console.error(err && err.stack || err);
|
package/docs/Frontend-Spec.md
CHANGED
|
@@ -602,8 +602,19 @@ localStorage.setItem('theme', next)
|
|
|
602
602
|
| 三区布局类(sidebar/topbar/detail/main-pane) | `packages/web/src/index.css`(`@layer components`) |
|
|
603
603
|
| 主题切换 | `packages/web/src/hooks/useTheme.ts` |
|
|
604
604
|
| editor 色映射 | `packages/web/src/lib/editors.ts` |
|
|
605
|
+
| 官方品牌图标(Skill + Command) | `packages/scanner/src/icon/icon-extractor.mjs` + `packages/scanner/src/icon/brand-map.mjs`(后端 `/api/icons/:brand`) |
|
|
606
|
+
| 前端官方图标组件 | `packages/web/src/components/ui/OfficialBrandIcon.tsx` |
|
|
605
607
|
| verify 断言约束 | `build/verify.mjs`(`#app`;CSS 子串 OR 含 `topbar`/`sidebar`/`detail` 任一) |
|
|
606
608
|
|
|
609
|
+
### 官方品牌 icon 资源策略
|
|
610
|
+
|
|
611
|
+
Skill 与 Command 模块统一通过 `/api/icons/:brand` 读取官方 icon,前端不再打包自制品牌 SVG,也不使用 emoji / lucide 品牌图标伪装官方 icon:
|
|
612
|
+
|
|
613
|
+
1. **本机官方 `.app` 图标优先**:后端按 `brand-map.mjs` 中的 Bundle ID / App 名称扫描并提取系统应用图标。
|
|
614
|
+
2. **官方远程 URL 兜底**:本机无图标时,仅从 `brand-map.mjs` 登记的 HTTPS 官方 URL 下载;结果缓存到 `~/.config/huhaa-myskills/icon-cache/`。
|
|
615
|
+
3. **中性占位**:没有本机图标且没有可确认官方远程 URL(如 `hermes` / `gstack`)时,前端显示中性占位。
|
|
616
|
+
4. **禁用联网**:`HUHAA_ICON_REMOTE=0` 时不触发远程下载,但仍可读取已有本地缓存。
|
|
617
|
+
|
|
607
618
|
---
|
|
608
619
|
|
|
609
620
|
# 三、框架说明
|