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.
Files changed (93) hide show
  1. package/README.md +79 -0
  2. package/bin/huhaa-myskills.mjs +88 -17
  3. package/bin/lib/paths.mjs +5 -0
  4. package/build/pack-smoke.mjs +195 -0
  5. package/build/verify.mjs +24 -3
  6. package/docs/Frontend-Spec.md +11 -0
  7. package/docs/scan_skills_rules.md +381 -0
  8. package/package.json +13 -5
  9. package/packages/scanner/src/adapters/scan-tier.mjs +99 -0
  10. package/packages/scanner/src/adapters/tier1-editor-skills.mjs +240 -0
  11. package/packages/scanner/src/adapters/tier2-user-skills.mjs +192 -0
  12. package/packages/scanner/src/adapters/tier3-other-skills.mjs +190 -0
  13. package/packages/scanner/src/config/editor-tiers.mjs +191 -0
  14. package/packages/scanner/src/hash/path-hash.mjs +86 -0
  15. package/packages/scanner/src/icon/brand-map.mjs +110 -30
  16. package/packages/scanner/src/icon/icon-extractor.mjs +198 -40
  17. package/packages/scanner/src/index.mjs +82 -16
  18. package/packages/scanner/src/types.d.ts +10 -0
  19. package/packages/server/src/index.mjs +359 -53
  20. package/packages/server/src/labels.mjs +1 -1
  21. package/packages/server/src/langdetect.mjs +34 -0
  22. package/packages/server/src/llm-client.mjs +42 -0
  23. package/packages/server/src/translate-cache.mjs +132 -0
  24. package/packages/server/src/translator.mjs +11 -29
  25. package/packages/web/dist/assets/index-DBR3X1hb.js +304 -0
  26. package/packages/web/dist/assets/index-DxHxmeVA.css +1 -0
  27. package/packages/web/dist/favicon.ico +0 -0
  28. package/packages/web/dist/index.html +4 -4
  29. package/packages/web/dist/site.webmanifest +4 -4
  30. package/packages/web/public/favicon.ico +0 -0
  31. package/packages/web/public/site.webmanifest +4 -4
  32. package/scripts/install-and-sync.sh +3 -3
  33. package/docs/scan_rules.md +0 -172
  34. package/packages/scanner/test/icon.test.mjs +0 -61
  35. package/packages/scanner/test/scanner.test.mjs +0 -136
  36. package/packages/scanner/test/skill-adapter.test.mjs +0 -70
  37. package/packages/scanner/tests/directory-skill.test.mjs +0 -93
  38. package/packages/scanner/tests/scanner-integration.test.mjs +0 -139
  39. package/packages/server/test/server.test.mjs +0 -140
  40. package/packages/web/dist/assets/index-BCY2cRaO.js +0 -175
  41. package/packages/web/dist/assets/index-DALKjJPi.css +0 -1
  42. package/packages/web/index.html +0 -37
  43. package/packages/web/package-lock.json +0 -2989
  44. package/packages/web/package.json +0 -33
  45. package/packages/web/postcss.config.js +0 -6
  46. package/packages/web/src/App.test.ts +0 -40
  47. package/packages/web/src/App.tsx +0 -177
  48. package/packages/web/src/components/ComingSoon.tsx +0 -14
  49. package/packages/web/src/components/layout/Sidebar.test.tsx +0 -74
  50. package/packages/web/src/components/layout/Sidebar.tsx +0 -114
  51. package/packages/web/src/components/layout/Topbar.tsx +0 -74
  52. package/packages/web/src/components/ui/ActionButton.tsx +0 -48
  53. package/packages/web/src/components/ui/button.tsx +0 -48
  54. package/packages/web/src/components/ui/card.tsx +0 -45
  55. package/packages/web/src/components/views/DashboardView.tsx +0 -122
  56. package/packages/web/src/components/views/OtherSkillsView.test.tsx +0 -126
  57. package/packages/web/src/components/views/OtherSkillsView.tsx +0 -306
  58. package/packages/web/src/components/views/SettingsView.tsx +0 -95
  59. package/packages/web/src/components/views/SkillDetail.test.tsx +0 -57
  60. package/packages/web/src/components/views/SkillDetail.tsx +0 -81
  61. package/packages/web/src/components/views/SkillsView.test.ts +0 -28
  62. package/packages/web/src/components/views/SkillsView.tsx +0 -226
  63. package/packages/web/src/hooks/QUICK_REFERENCE.md +0 -165
  64. package/packages/web/src/hooks/README.md +0 -378
  65. package/packages/web/src/hooks/__tests__/useOtherSkills.test.ts +0 -422
  66. package/packages/web/src/hooks/__tests__/useSkillIcons.test.ts +0 -272
  67. package/packages/web/src/hooks/useLiveReload.ts +0 -20
  68. package/packages/web/src/hooks/useOtherSkills.ts +0 -387
  69. package/packages/web/src/hooks/useSkillIcons.ts +0 -248
  70. package/packages/web/src/hooks/useTheme.ts +0 -36
  71. package/packages/web/src/index.css +0 -161
  72. package/packages/web/src/lib/api.test.ts +0 -47
  73. package/packages/web/src/lib/api.ts +0 -77
  74. package/packages/web/src/lib/cn.ts +0 -7
  75. package/packages/web/src/lib/editors.test.ts +0 -30
  76. package/packages/web/src/lib/editors.ts +0 -70
  77. package/packages/web/src/lib/markdown.test.ts +0 -27
  78. package/packages/web/src/lib/markdown.ts +0 -14
  79. package/packages/web/src/main.tsx +0 -13
  80. package/packages/web/src/types/index.ts +0 -62
  81. package/packages/web/src/types/other-skill.ts +0 -88
  82. package/packages/web/src/types/skill.ts +0 -136
  83. package/packages/web/src/types.ts +0 -47
  84. package/packages/web/src/vite-env.d.ts +0 -1
  85. package/packages/web/tailwind.config.ts +0 -66
  86. package/packages/web/tsconfig.json +0 -28
  87. package/packages/web/vite.config.ts +0 -36
  88. package/packages/web/vitest.config.ts +0 -21
  89. package/packages/web/vitest.setup.ts +0 -1
  90. package/scripts/activate-data-sources.sh +0 -223
  91. package/scripts/brand-completion.py +0 -586
  92. package/scripts/init-nextjs-project.sh +0 -421
  93. 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/`
@@ -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} — local skill / plugin / MCP aggregation hub
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(`✓ 已停止 HuHaa-MySkills (pid ${state.pid}, port ${state.port ?? '未知'})`);
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('\n[uninstall] 即将卸载 HuHaa-MySkills,将执行以下操作:');
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(`\nHuHaa-MySkills scan stats`);
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('\nHuHaa-MySkills duplicate diagnostics');
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(`✓ HuHaa-MySkills 已在运行: http://localhost:${stateData.port}`);
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(`✓ HuHaa-MySkills 已在运行: http://localhost:${stateData.port}`);
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
- console.log(`✓ HuHaa-MySkills 已在后台运行: http://localhost:${port}`);
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 runServer(port, logFile, homeDirectory) {
476
- writeJson(stateFile(), { port, pid: process.pid, startedAt: new Date().toISOString() });
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 HuHaa-MySkills 运行中: http://localhost:${port}\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:${port}`);
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(tempRoot, 'skills');
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.bySource.hermes, 1);
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
- console.log('\n[verify] PASS build + tests + HTTP/API/static smoke checks');
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);
@@ -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
  # 三、框架说明