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,143 @@
1
+ /**
2
+ * @file descriptor.mjs
3
+ * ScannerDescriptor 类型与 registry。
4
+ *
5
+ * 对标 cockpit-tools 报告第一建议:把散落在各 adapter 的检测规则
6
+ * (目录候选、glob 模式、ignore、deep、品牌 key)显式登记成 descriptor,
7
+ * 方便测试、热修、置信度判定。
8
+ *
9
+ * 阶段一渐进迁移:registry 与现有 editor-tiers.mjs 配置并存,
10
+ * adapter 逐个改为读 descriptor。本文件只管"去哪扫、用什么 glob",
11
+ * 不管"怎么解析"(解析逻辑仍由各 adapter 自持,避免行为变化)。
12
+ *
13
+ * 置信度四级(对标报告第二建议):
14
+ * L1 只发现目录 / L2 命中技能文件 / L3 解析出有效技能 / L4 完整元数据
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} DetectSpec
19
+ * @property {string} globalPath - 全局扫描根目录(支持 ~)
20
+ * @property {string} [projectPath] - 项目级扫描相对路径
21
+ * @property {string[]} globPatterns - fast-glob 模式列表
22
+ * @property {string[]} [ignore] - ignore 列表
23
+ * @property {number} [deep] - glob 递归深度
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} ScannerDescriptor
28
+ * @property {string} id - 唯一标识,如 'claude-code' / 'tier2-user' / 'tier3-other:homebrew'
29
+ * @property {'tier-1'|'tier-2'|'tier-3'} tier
30
+ * @property {string} brand - 品牌 key(用于图标映射)
31
+ * @property {string} label - 人类可读名称
32
+ * @property {DetectSpec} detect - 检测规则
33
+ * @property {string} [editorName] - SkillItem.editor 字段值
34
+ * @property {string} [sourceTag] - SkillItem.source 字段值
35
+ */
36
+
37
+ const registry = new Map();
38
+
39
+ /**
40
+ * 校验 descriptor 必填字段。手写校验,不引入 zod(见迭代计划 8.2)。
41
+ * @param {ScannerDescriptor} desc
42
+ * @throws {Error} 字段缺失或类型错误时抛出
43
+ */
44
+ export function validateDescriptor(desc) {
45
+ if (!desc || typeof desc !== 'object') {
46
+ throw new Error('descriptor 必须是对象');
47
+ }
48
+ const required = ['id', 'tier', 'brand', 'label', 'detect'];
49
+ for (const key of required) {
50
+ if (desc[key] === undefined || desc[key] === null) {
51
+ throw new Error(`descriptor 缺失必填字段: ${key}`);
52
+ }
53
+ }
54
+ const validTiers = ['tier-1', 'tier-2', 'tier-3'];
55
+ if (!validTiers.includes(desc.tier)) {
56
+ throw new Error(`descriptor.tier 非法: ${desc.tier},应为 ${validTiers.join('|')}`);
57
+ }
58
+ if (!desc.detect || typeof desc.detect !== 'object') {
59
+ throw new Error('descriptor.detect 必须是对象');
60
+ }
61
+ if (!desc.detect.globalPath || typeof desc.detect.globalPath !== 'string') {
62
+ throw new Error('descriptor.detect.globalPath 必须是非空字符串');
63
+ }
64
+ if (!Array.isArray(desc.detect.globPatterns) || desc.detect.globPatterns.length === 0) {
65
+ throw new Error('descriptor.detect.globPatterns 必须是非空数组');
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 注册一个 descriptor。同 id 覆盖(支持热修)。
71
+ * @param {ScannerDescriptor} desc
72
+ * @returns {ScannerDescriptor}
73
+ */
74
+ export function registerDescriptor(desc) {
75
+ validateDescriptor(desc);
76
+ registry.set(desc.id, desc);
77
+ return desc;
78
+ }
79
+
80
+ /**
81
+ * 批量注册。
82
+ * @param {ScannerDescriptor[]} descs
83
+ */
84
+ export function registerDescriptors(descs) {
85
+ for (const d of descs) registerDescriptor(d);
86
+ }
87
+
88
+ /**
89
+ * 按 id 查询 descriptor。
90
+ * @param {string} id
91
+ * @returns {ScannerDescriptor | null}
92
+ */
93
+ export function getDescriptor(id) {
94
+ return registry.get(id) || null;
95
+ }
96
+
97
+ /**
98
+ * 列出全部 descriptor(按 tier 排序)。
99
+ * @returns {ScannerDescriptor[]}
100
+ */
101
+ export function listDescriptors() {
102
+ const order = { 'tier-1': 0, 'tier-2': 1, 'tier-3': 2 };
103
+ return [...registry.values()].sort((a, b) => (order[a.tier] ?? 99) - (order[b.tier] ?? 99));
104
+ }
105
+
106
+ /**
107
+ * 按 tier 过滤。
108
+ * @param {'tier-1'|'tier-2'|'tier-3'} tier
109
+ * @returns {ScannerDescriptor[]}
110
+ */
111
+ export function descriptorsByTier(tier) {
112
+ return listDescriptors().filter(d => d.tier === tier);
113
+ }
114
+
115
+ /**
116
+ * 清空 registry(仅测试用)。
117
+ */
118
+ export function _resetForTest() {
119
+ registry.clear();
120
+ }
121
+
122
+ /**
123
+ * 置信度级别判定。输入扫描结果,输出该 source 的置信度。
124
+ *
125
+ * L1 只发现目录 - globalPath 存在但未命中技能文件
126
+ * L2 命中技能文件 - glob 匹配到至少 1 个 SKILL.md
127
+ * L3 解析出有效技能 - frontmatter 解析成功,name 非空
128
+ * L4 完整元数据 - name + description 齐全
129
+ *
130
+ * @param {Object} result
131
+ * @param {boolean} result.dirExists - 检测目录是否存在
132
+ * @param {number} result.matchedFiles - glob 命中文件数
133
+ * @param {number} result.parsedValid - 解析成功(name 非空)的技能数
134
+ * @param {number} result.metadataComplete - 元数据完整(name+description)的技能数
135
+ * @returns {'L1'|'L2'|'L3'|'L4'|null} - dirExists 为 false 时返回 null
136
+ */
137
+ export function computeConfidence({ dirExists, matchedFiles, parsedValid, metadataComplete }) {
138
+ if (!dirExists) return null;
139
+ if ((metadataComplete || 0) > 0) return 'L4';
140
+ if ((parsedValid || 0) > 0) return 'L3';
141
+ if ((matchedFiles || 0) > 0) return 'L2';
142
+ return 'L1';
143
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @file path-hash.mjs
3
+ * 路径规范化 + MD5 哈希计算
4
+ * 用于去重和菜单分层显示
5
+ *
6
+ * 2026-07 阶段零:从 hash/path-hash.mjs 迁入 core/。
7
+ * 原 hash/path-hash.mjs 改为 re-export,保持现有 import 路径不变。
8
+ */
9
+
10
+ import crypto from 'node:crypto';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+
14
+ /**
15
+ * 规范化路径,然后计算 MD5 哈希
16
+ * 规范化步骤:
17
+ * 1. 展开 ~ 为 $HOME
18
+ * 2. 转换为绝对路径(resolve 符号链接)
19
+ * 3. 统一路径分隔符(使用 /)
20
+ *
21
+ * @param {string} filePath - 待处理路径
22
+ * @returns {string} MD5 哈希值(32位十六进制)
23
+ */
24
+ export function getPathHash(filePath) {
25
+ // Step 1: 展开 ~
26
+ const expanded = filePath.startsWith('~')
27
+ ? path.join(os.homedir(), filePath.slice(1))
28
+ : filePath;
29
+
30
+ // Step 2: 转换为绝对路径
31
+ const absolute = path.resolve(expanded);
32
+
33
+ // Step 3: 统一分隔符(转为 POSIX 风格)
34
+ const normalized = absolute.split(path.sep).join('/');
35
+
36
+ // Step 4: 计算 MD5
37
+ return crypto.createHash('md5').update(normalized).digest('hex');
38
+ }
39
+
40
+ /**
41
+ * LRU 缓存,避免重复计算哈希
42
+ */
43
+ export class PathHashCache {
44
+ constructor(maxSize = 1000) {
45
+ this.maxSize = maxSize;
46
+ this.cache = new Map(); // path -> hash
47
+ }
48
+
49
+ get(filePath) {
50
+ return this.cache.get(filePath);
51
+ }
52
+
53
+ set(filePath, hash) {
54
+ if (this.cache.size >= this.maxSize) {
55
+ // 移除最旧的条目(第一个)
56
+ const firstKey = this.cache.keys().next().value;
57
+ this.cache.delete(firstKey);
58
+ }
59
+ this.cache.set(filePath, hash);
60
+ }
61
+
62
+ /**
63
+ * 获取或计算哈希
64
+ */
65
+ getOrCompute(filePath) {
66
+ let hash = this.cache.get(filePath);
67
+ if (!hash) {
68
+ hash = getPathHash(filePath);
69
+ this.set(filePath, hash);
70
+ }
71
+ return hash;
72
+ }
73
+
74
+ /**
75
+ * 清空缓存
76
+ */
77
+ clear() {
78
+ this.cache.clear();
79
+ }
80
+
81
+ /**
82
+ * 获取缓存大小
83
+ */
84
+ get size() {
85
+ return this.cache.size;
86
+ }
87
+ }
88
+
89
+ export const pathHashCache = new PathHashCache();
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @file registry.mjs
3
+ * 从 editor-tiers.mjs 现有配置派生 ScannerDescriptor 并注册。
4
+ *
5
+ * 设计原则:配置单一来源仍是 editor-tiers.mjs(现有测试直接引用其导出,
6
+ * 不破坏)。registry 是派生层,adapter 改为从 registry 读 descriptor。
7
+ *
8
+ * 阶段一渐进迁移:本模块在 scanner 入口加载时调用 registerAll(),
9
+ * 之后 tier1/2/3 adapter 用 getDescriptor(id) 替代硬编码。
10
+ */
11
+
12
+ import {
13
+ EDITOR_TIER_1_CONFIGS,
14
+ USER_TIER_2_CONFIG,
15
+ OTHER_TIER_3_CONFIGS,
16
+ } from '../config/editor-tiers.mjs';
17
+ import {
18
+ registerDescriptors,
19
+ listDescriptors,
20
+ getDescriptor,
21
+ descriptorsByTier,
22
+ } from './descriptor.mjs';
23
+
24
+ // 三个 tier 共用的 ignore 列表(从原 adapter 硬编码提取,保持一致)
25
+ const DEFAULT_IGNORE = ['**/node_modules/**', '**/.git/**', '**/dist/**'];
26
+ const DEFAULT_DEEP = 10;
27
+
28
+ // glob 模式以相对形式存储(不含 basePath 前缀),adapter 拼接实际 basePath。
29
+ // 这样 globalPath 和 projectPath 扫描可复用同一组模式。
30
+
31
+ // Tier 1 的三种大小写不敏感 glob 模式(从 tier1-editor-skills.mjs:88-92 提取)
32
+ const TIER1_PATTERNS = [
33
+ `**/[sS][kK][iI][lL][lL][sS]/**/[sS][kK][iI][lL][lL].[mM][dD]`,
34
+ `**/SKILL.md`,
35
+ `**/skill.md`,
36
+ ];
37
+
38
+ // Tier 2 的两种模式(从 tier2-user-skills.mjs:37-40 提取)
39
+ const TIER2_PATTERNS = [
40
+ `**/SKILL.md`,
41
+ `**/skill.md`,
42
+ ];
43
+
44
+ // Tier 3 仅小写 skill.md(从 tier3-other-skills.mjs:44 提取,有意的大小写敏感)
45
+ const TIER3_PATTERNS = [`**/skill.md`];
46
+
47
+ let initialized = false;
48
+
49
+ /**
50
+ * 把 editor-tiers.mjs 三种配置转成统一 descriptor 并注册。
51
+ * 幂等:重复调用不重复注册(同 id 覆盖)。
52
+ */
53
+ export function registerAll() {
54
+ const descs = [];
55
+
56
+ // Tier 1: 22 个编辑器
57
+ for (const editor of EDITOR_TIER_1_CONFIGS) {
58
+ descs.push({
59
+ id: `tier1:${editor.brand}`,
60
+ tier: 'tier-1',
61
+ brand: editor.brand,
62
+ label: editor.name,
63
+ editorName: editor.name,
64
+ detect: {
65
+ globalPath: editor.globalPath,
66
+ projectPath: editor.projectPath,
67
+ globPatterns: TIER1_PATTERNS,
68
+ ignore: DEFAULT_IGNORE,
69
+ deep: DEFAULT_DEEP,
70
+ },
71
+ });
72
+ }
73
+
74
+ // Tier 2: 用户根目录技能库(单个)
75
+ descs.push({
76
+ id: 'tier2:user-skills',
77
+ tier: 'tier-2',
78
+ brand: 'my-skills',
79
+ label: USER_TIER_2_CONFIG.name,
80
+ editorName: 'my-skills',
81
+ sourceTag: 'tier2-user',
82
+ detect: {
83
+ globalPath: USER_TIER_2_CONFIG.globalPath,
84
+ globPatterns: TIER2_PATTERNS,
85
+ ignore: DEFAULT_IGNORE,
86
+ deep: DEFAULT_DEEP,
87
+ },
88
+ });
89
+
90
+ // Tier 3: 其他位置(多个,每个一个 descriptor)
91
+ for (const location of OTHER_TIER_3_CONFIGS) {
92
+ descs.push({
93
+ id: `tier3:${location.name}`,
94
+ tier: 'tier-3',
95
+ brand: 'other-skills',
96
+ label: location.name,
97
+ editorName: 'other-skills',
98
+ sourceTag: 'tier3-other',
99
+ detect: {
100
+ globalPath: location.path,
101
+ globPatterns: TIER3_PATTERNS,
102
+ ignore: DEFAULT_IGNORE,
103
+ deep: DEFAULT_DEEP,
104
+ },
105
+ });
106
+ }
107
+
108
+ registerDescriptors(descs);
109
+ initialized = true;
110
+ }
111
+
112
+ /** 确保 registry 已初始化(懒加载,首次访问时触发) */
113
+ export function ensureRegistered() {
114
+ if (!initialized) registerAll();
115
+ }
116
+
117
+ /** 重新加载(测试用:config 改动后强制重建) */
118
+ export function reload() {
119
+ initialized = false;
120
+ registerAll();
121
+ }
122
+
123
+ /** 重置 registry 与初始化标志(仅测试用,配合 descriptor._resetForTest) */
124
+ export function _resetForTest() {
125
+ initialized = false;
126
+ }
127
+
128
+ export { listDescriptors, getDescriptor, descriptorsByTier };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @file path-hash.mjs
3
+ * 2026-07 阶段零:实现已迁入 core/path-hash.mjs。
4
+ * 本文件保留为 re-export,保持现有 import 路径不变(7 处引用未改动)。
5
+ * 新代码请直接引用 ../core/path-hash.mjs。
6
+ */
7
+
8
+ export { getPathHash, PathHashCache, pathHashCache } from '../core/path-hash.mjs';
@@ -0,0 +1,268 @@
1
+ // brand-map.mjs — maps a skill/command brand to official icon sources.
2
+ //
3
+ // 本文件只登记官方来源:本机 .app 的 bundle/appName,或品牌官方网站上的 icon URL。
4
+ // 无法确认官方来源的品牌保留 entry 但不配置 officialIconUrls,前端会显示中性占位,
5
+ // 避免使用“看起来像官方”的自制 logo。
6
+
7
+ /**
8
+ * @typedef {object} BrandAppSpec
9
+ * @property {string[]} bundleIds — candidate CFBundleIdentifier values
10
+ * @property {string[]} appNames — candidate .app display names (no extension)
11
+ * @property {string[]} [aliases] — alternate brand keys normalized to this entry
12
+ * @property {string[]} [officialIconPages] — human-readable official pages documenting source context
13
+ * @property {string[]} [officialIconUrls] — official HTTPS image URLs eligible for local caching
14
+ * @property {boolean} [remoteIconCache] — whether remote download/cache is allowed
15
+ * @property {string} [emoji] — legacy metadata fallback; UI should prefer neutral placeholder
16
+ */
17
+
18
+ /** @type {Record<string, BrandAppSpec>} */
19
+ export const BRAND_APP_MAP = {
20
+ cursor: {
21
+ bundleIds: ['com.todesktop.230313mzl4w4u92'],
22
+ appNames: ['Cursor'],
23
+ officialIconPages: ['https://cursor.com/'],
24
+ officialIconUrls: [],
25
+ remoteIconCache: true,
26
+ fingerprints: ['cursor.com', 'cursor.sh'],
27
+ emoji: '🖱️',
28
+ },
29
+ vscode: {
30
+ bundleIds: ['com.microsoft.VSCode', 'com.microsoft.VSCodeInsiders'],
31
+ appNames: ['Visual Studio Code', 'Code', 'Visual Studio Code - Insiders'],
32
+ aliases: ['vs-code', 'code'],
33
+ officialIconPages: ['https://code.visualstudio.com/brand'],
34
+ officialIconUrls: ['https://code.visualstudio.com/assets/apple-touch-icon.png'],
35
+ remoteIconCache: true,
36
+ fingerprints: ['code.visualstudio.com', 'visualstudio.com'],
37
+ emoji: '📝',
38
+ },
39
+ claude: {
40
+ bundleIds: ['com.anthropic.claude', 'com.anthropic.claudefordesktop'],
41
+ appNames: ['Claude'],
42
+ aliases: ['claude-code', 'anthropic'],
43
+ officialIconPages: ['https://claude.ai/', 'https://www.anthropic.com/'],
44
+ officialIconUrls: ['https://claude.ai/favicon.ico'],
45
+ remoteIconCache: true,
46
+ fingerprints: ['claude.ai', 'anthropic.com'],
47
+ emoji: '🤖',
48
+ },
49
+ obsidian: {
50
+ bundleIds: ['md.obsidian'],
51
+ appNames: ['Obsidian'],
52
+ officialIconPages: ['https://obsidian.md/'],
53
+ officialIconUrls: [],
54
+ remoteIconCache: true,
55
+ fingerprints: ['obsidian.md'],
56
+ emoji: '🧠',
57
+ },
58
+ docker: {
59
+ bundleIds: ['com.docker.docker'],
60
+ appNames: ['Docker', 'Docker Desktop'],
61
+ officialIconPages: ['https://www.docker.com/products/docker-desktop/'],
62
+ officialIconUrls: [],
63
+ remoteIconCache: true,
64
+ fingerprints: ['docker.com'],
65
+ emoji: '🐳',
66
+ },
67
+ // CLI / agent brands. Codex may be available as a local app; no stable official
68
+ // public icon URL is registered here, so remote fallback intentionally stays empty.
69
+ codex: {
70
+ bundleIds: [],
71
+ appNames: ['Codex'],
72
+ officialIconPages: ['https://openai.com/codex/'],
73
+ officialIconUrls: [],
74
+ remoteIconCache: true,
75
+ fingerprints: ['openai.com', 'codex.com'],
76
+ emoji: '📋',
77
+ },
78
+ hermes: {
79
+ bundleIds: [],
80
+ appNames: ['Hermes'],
81
+ // 官方图标来源:用户提供的 Hermes Agent icon,已烘焙到本地 public/icons/hermes-{size}.png。
82
+ // 远程抓取成本高且容易命中营销 banner,这里禁用远程回退,只走 localIconBase。
83
+ officialIconPages: [],
84
+ officialIconUrls: [],
85
+ remoteIconCache: false,
86
+ // 本地兜底:web/public/icons/hermes-{size}.png,必须在 dist 中存在
87
+ localIconBase: 'icons/hermes',
88
+ fingerprints: ['hermes-agent.nousresearch.com'],
89
+ emoji: '⚡',
90
+ },
91
+ gstack: {
92
+ bundleIds: [],
93
+ appNames: [],
94
+ officialIconPages: [],
95
+ officialIconUrls: [],
96
+ remoteIconCache: false,
97
+ emoji: '⚡',
98
+ },
99
+ mcp: { bundleIds: [], appNames: [], officialIconUrls: [], remoteIconCache: false, emoji: '🔌' },
100
+ google: {
101
+ bundleIds: ['com.google.chrome'],
102
+ appNames: ['Google Chrome', 'Chrome'],
103
+ officialIconPages: ['https://www.google.com/chrome/'],
104
+ officialIconUrls: [],
105
+ remoteIconCache: true,
106
+ fingerprints: ['google.com', 'googleapis.com', 'gemini.google.com'],
107
+ emoji: '🌐',
108
+ },
109
+ github: {
110
+ bundleIds: ['com.github.github', 'com.github.GitHubClient'],
111
+ appNames: ['GitHub', 'GitHub Desktop'],
112
+ officialIconPages: ['https://github.com/'],
113
+ officialIconUrls: [],
114
+ remoteIconCache: true,
115
+ fingerprints: ['github.com', 'githubusercontent.com'],
116
+ emoji: '🐙',
117
+ },
118
+ notion: {
119
+ bundleIds: ['notion.id'],
120
+ appNames: ['Notion'],
121
+ officialIconPages: ['https://www.notion.com/'],
122
+ officialIconUrls: [],
123
+ remoteIconCache: true,
124
+ fingerprints: ['notion.com', 'notion.so'],
125
+ emoji: '📓',
126
+ },
127
+ // Trae 字节跳动 AI IDE。trae-cn 本机装了 Trae CN.app,走本地 .app 图标提取。
128
+ 'trae-cn': {
129
+ bundleIds: ['cn.trae.app'],
130
+ appNames: ['Trae CN'],
131
+ officialIconPages: ['https://www.trae.cn/'],
132
+ officialIconUrls: [],
133
+ remoteIconCache: true,
134
+ fingerprints: ['trae.cn'],
135
+ emoji: '🛸',
136
+ },
137
+ trae: {
138
+ bundleIds: ['cn.trae.app'],
139
+ appNames: ['Trae', 'Trae CN'],
140
+ aliases: ['trae-intl'],
141
+ officialIconPages: ['https://www.trae.com/'],
142
+ officialIconUrls: [],
143
+ remoteIconCache: true,
144
+ fingerprints: ['trae.com', 'trae.cn'],
145
+ emoji: '🛸',
146
+ },
147
+ // 仅保留元数据 fallback(无单一可确认官方图标 URL)
148
+ apple: { bundleIds: [], appNames: [], officialIconUrls: [], remoteIconCache: false, emoji: '🍎' },
149
+ rust: { bundleIds: [], appNames: [], officialIconUrls: [], remoteIconCache: false, emoji: '🦀' },
150
+ python: { bundleIds: [], appNames: [], officialIconUrls: [], remoteIconCache: false, emoji: '🐍' },
151
+ suno: { bundleIds: [], appNames: [], officialIconUrls: [], remoteIconCache: false, emoji: '🎵' },
152
+ };
153
+
154
+ const ALIAS_TO_CANONICAL = new Map();
155
+ for (const [brand, spec] of Object.entries(BRAND_APP_MAP)) {
156
+ ALIAS_TO_CANONICAL.set(brand, brand);
157
+ for (const alias of spec.aliases || []) {
158
+ ALIAS_TO_CANONICAL.set(alias.toLowerCase().trim(), brand);
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Normalize a brand/source key to the canonical manifest key.
164
+ * @param {string} key
165
+ * @returns {string | null}
166
+ */
167
+ export function normalizeBrandKey(key) {
168
+ if (!key) return null;
169
+ const normalized = String(key).toLowerCase().trim();
170
+ return ALIAS_TO_CANONICAL.get(normalized) || (BRAND_APP_MAP[normalized] ? normalized : null);
171
+ }
172
+
173
+ /**
174
+ * Resolve a brand/source key (or a raw bundle id) into a lookup spec.
175
+ * A value containing a dot is treated as an explicit bundle id.
176
+ * @param {string} key
177
+ * @returns {BrandAppSpec | null}
178
+ */
179
+ export function resolveBrandSpec(key) {
180
+ if (!key) return null;
181
+ const canonical = normalizeBrandKey(key);
182
+ if (canonical && BRAND_APP_MAP[canonical]) return BRAND_APP_MAP[canonical];
183
+ // Looks like a bundle identifier (e.g. "com.microsoft.VSCode")
184
+ if (/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(key)) {
185
+ return { bundleIds: [key], appNames: [], officialIconUrls: [], remoteIconCache: false, emoji: undefined };
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * Emoji fallback metadata for a brand/source key.
192
+ * @param {string} key
193
+ * @returns {string | undefined}
194
+ */
195
+ export function emojiForBrand(key) {
196
+ const canonical = normalizeBrandKey(key);
197
+ return canonical ? BRAND_APP_MAP[canonical]?.emoji : undefined;
198
+ }
199
+
200
+ // ───────────────────────── provider fingerprint (v0.4) ──────────────────────
201
+ // 对标 cockpit-tools codexProviderPresets 的 baseUrl 规范化匹配,补一层
202
+ // hostname suffix 匹配,让反代/多 region 场景也能命中正确品牌,而不是
203
+ // 一律回退 custom。本模块保持纯数据/纯函数,不读文件;用户手动覆写由
204
+ // 调用方(icon-extractor)注入 overrides 参数。
205
+
206
+ /**
207
+ * 从 URL 或裸 hostname 提取小写 hostname。
208
+ * @param {string} urlOrHost - URL 或 hostname
209
+ * @returns {string | null}
210
+ */
211
+ export function extractHostname(urlOrHost) {
212
+ if (!urlOrHost) return null;
213
+ const s = String(urlOrHost).trim().toLowerCase();
214
+ if (!s) return null;
215
+ // 带 scheme
216
+ try {
217
+ const u = new URL(s.startsWith('http') ? s : `https://${s}`);
218
+ return u.hostname || null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * 按 hostname suffix 多层匹配品牌。
226
+ *
227
+ * 匹配层级(优先级从高到低):
228
+ * 1. 用户 overrides(hostnameSuffix -> brand,由调用方注入)
229
+ * 2. BRAND_APP_MAP 各项的 fingerprints(hostname suffix 匹配,支持多级子域)
230
+ * 3. 无匹配返回 null(调用方可回退 custom)
231
+ *
232
+ * @param {string} urlOrHost - URL 或 hostname
233
+ * @param {Record<string, string>} [overrides] - 用户手动覆写:{ hostnameSuffix: brandKey }
234
+ * @returns {string | null} canonical brand key
235
+ */
236
+ export function resolveBrandByFingerprint(urlOrHost, overrides) {
237
+ const host = extractHostname(urlOrHost);
238
+ if (!host) return null;
239
+
240
+ // 1. 用户 overrides 优先(精确匹配,再 suffix 匹配)
241
+ if (overrides && typeof overrides === 'object') {
242
+ if (overrides[host]) {
243
+ const canonical = normalizeBrandKey(overrides[host]);
244
+ if (canonical) return canonical;
245
+ }
246
+ for (const [suffix, brand] of Object.entries(overrides)) {
247
+ if (host === suffix || host.endsWith(`.${suffix}`)) {
248
+ const canonical = normalizeBrandKey(brand);
249
+ if (canonical) return canonical;
250
+ }
251
+ }
252
+ }
253
+
254
+ // 2. BRAND_APP_MAP fingerprints:hostname suffix 匹配
255
+ // host === 'claude.ai' 或 host.endsWith('.claude.ai') 均命中 claude
256
+ for (const [brand, spec] of Object.entries(BRAND_APP_MAP)) {
257
+ const fps = spec.fingerprints;
258
+ if (!fps || !fps.length) continue;
259
+ for (const suffix of fps) {
260
+ const s = String(suffix).toLowerCase().trim();
261
+ if (host === s || host.endsWith(`.${s}`)) {
262
+ return brand;
263
+ }
264
+ }
265
+ }
266
+
267
+ return null;
268
+ }