skillhelper 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/bin/lib/atomic-write.mjs +72 -0
- package/bin/lib/paths.mjs +65 -0
- package/bin/lib/port.mjs +23 -0
- package/bin/skillhelper.mjs +720 -0
- package/build/build-site.mjs +35 -0
- package/build/build-web.mjs +29 -0
- package/build/pack-smoke.mjs +195 -0
- package/build/prepare-publish.mjs +3 -0
- package/build/verify.mjs +157 -0
- package/config/sources.example.yaml +126 -0
- package/docs/Frontend-Spec.md +1207 -0
- package/docs/scan_skills_rules.md +432 -0
- package/package.json +95 -0
- package/packages/scanner/package.json +15 -0
- package/packages/scanner/src/adapters/claude-agents.mjs +134 -0
- package/packages/scanner/src/adapters/claude-plugin.mjs +260 -0
- package/packages/scanner/src/adapters/codex-plugin.mjs +330 -0
- package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
- package/packages/scanner/src/adapters/file-docs.mjs +158 -0
- package/packages/scanner/src/adapters/hermes-plugin.mjs +149 -0
- package/packages/scanner/src/adapters/markdown-skill.mjs +229 -0
- package/packages/scanner/src/adapters/mcp-config.mjs +190 -0
- package/packages/scanner/src/adapters/scan-tier.mjs +99 -0
- package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
- package/packages/scanner/src/adapters/tier1-editor-skills.mjs +278 -0
- package/packages/scanner/src/adapters/tier2-user-skills.mjs +218 -0
- package/packages/scanner/src/adapters/tier3-other-skills.mjs +225 -0
- package/packages/scanner/src/config/editor-tiers.mjs +191 -0
- package/packages/scanner/src/core/atomic-write.mjs +11 -0
- package/packages/scanner/src/core/descriptor.mjs +143 -0
- package/packages/scanner/src/core/path-hash.mjs +89 -0
- package/packages/scanner/src/core/registry.mjs +128 -0
- package/packages/scanner/src/hash/path-hash.mjs +8 -0
- package/packages/scanner/src/icon/brand-map.mjs +268 -0
- package/packages/scanner/src/icon/icon-extractor.mjs +466 -0
- package/packages/scanner/src/index.mjs +450 -0
- package/packages/scanner/src/types.d.ts +144 -0
- package/packages/scanner/src/utils.mjs +194 -0
- package/packages/server/package.json +15 -0
- package/packages/server/src/index.mjs +1071 -0
- package/packages/server/src/labels.mjs +64 -0
- package/packages/server/src/langdetect.mjs +34 -0
- package/packages/server/src/llm-client.mjs +42 -0
- package/packages/server/src/translate-cache.mjs +132 -0
- package/packages/server/src/translator.mjs +53 -0
- package/packages/web/README.md +36 -0
- package/packages/web/dist/assets/index-CGt04vt0.js +304 -0
- package/packages/web/dist/assets/index-CLSDoa3H.css +1 -0
- package/packages/web/dist/brand-icon.png +0 -0
- package/packages/web/dist/brand-logo.png +0 -0
- package/packages/web/dist/favicon-16x16.png +0 -0
- package/packages/web/dist/favicon-192x192.png +0 -0
- package/packages/web/dist/favicon-32x32.png +0 -0
- package/packages/web/dist/favicon-512x512.png +0 -0
- package/packages/web/dist/favicon-source.svg +5 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/favicon.svg +5 -0
- package/packages/web/dist/icons/hermes-128.png +0 -0
- package/packages/web/dist/icons/hermes-192.png +0 -0
- package/packages/web/dist/icons/hermes-256.png +0 -0
- package/packages/web/dist/icons/hermes-32.png +0 -0
- package/packages/web/dist/icons/hermes-512.png +0 -0
- package/packages/web/dist/icons/hermes-64.png +0 -0
- package/packages/web/dist/icons/hermes.png +0 -0
- package/packages/web/dist/index.html +37 -0
- package/packages/web/dist/robots.txt +3 -0
- package/packages/web/dist/site.webmanifest +24 -0
- package/packages/web/public/brand-icon.png +0 -0
- package/packages/web/public/brand-logo.png +0 -0
- package/packages/web/public/favicon-16x16.png +0 -0
- package/packages/web/public/favicon-192x192.png +0 -0
- package/packages/web/public/favicon-32x32.png +0 -0
- package/packages/web/public/favicon-512x512.png +0 -0
- package/packages/web/public/favicon-source.svg +5 -0
- package/packages/web/public/favicon.ico +0 -0
- package/packages/web/public/favicon.svg +5 -0
- package/packages/web/public/icons/hermes-128.png +0 -0
- package/packages/web/public/icons/hermes-192.png +0 -0
- package/packages/web/public/icons/hermes-256.png +0 -0
- package/packages/web/public/icons/hermes-32.png +0 -0
- package/packages/web/public/icons/hermes-512.png +0 -0
- package/packages/web/public/icons/hermes-64.png +0 -0
- package/packages/web/public/icons/hermes.png +0 -0
- package/packages/web/public/robots.txt +3 -0
- package/packages/web/public/site.webmanifest +24 -0
- package/scripts/install-and-sync.sh +209 -0
- package/scripts/sync-skills.sh +605 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// icon-extractor.mjs — extract official icons for skills/commands.
|
|
2
|
+
//
|
|
3
|
+
// Priority chain:
|
|
4
|
+
// 1. local macOS .app icon (official installed application icon)
|
|
5
|
+
// 2. bundled local icon under packages/web/public/<localIconBase>-{size}.png
|
|
6
|
+
// 3. registered official remote icon URL, downloaded once and cached locally
|
|
7
|
+
// 4. no icon (frontend renders a neutral placeholder; never a fake brand icon)
|
|
8
|
+
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { normalizeBrandKey, resolveBrandSpec } from './brand-map.mjs';
|
|
17
|
+
import { atomicWriteBytes, atomicWriteText } from '../core/atomic-write.mjs';
|
|
18
|
+
|
|
19
|
+
const execFileP = promisify(execFile);
|
|
20
|
+
const IS_MACOS = process.platform === 'darwin';
|
|
21
|
+
const APP_DIRS = [
|
|
22
|
+
'/Applications',
|
|
23
|
+
path.join(os.homedir(), 'Applications'),
|
|
24
|
+
'/System/Applications',
|
|
25
|
+
];
|
|
26
|
+
const VALID_SIZES = new Set([32, 64, 128]);
|
|
27
|
+
const MAX_REMOTE_ICON_BYTES = 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
// In-memory caches (per process). locateApp is stable within a session.
|
|
30
|
+
const appPathCache = new Map(); // brand -> appPath|null
|
|
31
|
+
const iconPathCache = new Map(); // `${brand}:${size}` -> iconPath|null
|
|
32
|
+
|
|
33
|
+
export function cacheDir() {
|
|
34
|
+
const base =
|
|
35
|
+
process.env.SKILLHELPER_HOME?.trim() ||
|
|
36
|
+
path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), '.config'), 'skillhelper');
|
|
37
|
+
return path.join(expandTilde(base), 'icon-cache');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function expandTilde(p) {
|
|
41
|
+
if (!p) return p;
|
|
42
|
+
if (p === '~' || p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function slugify(s) {
|
|
47
|
+
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'icon';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clampSize(size) {
|
|
51
|
+
return VALID_SIZES.has(size) ? size : 64;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function remoteDisabled() {
|
|
55
|
+
return process.env.SKILLHELPER_ICON_REMOTE === '0';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extensionForContentType(contentType) {
|
|
59
|
+
const clean = String(contentType || '').toLowerCase().split(';')[0].trim();
|
|
60
|
+
switch (clean) {
|
|
61
|
+
case 'image/png': return '.png';
|
|
62
|
+
case 'image/svg+xml': return '.svg';
|
|
63
|
+
case 'image/x-icon':
|
|
64
|
+
case 'image/vnd.microsoft.icon': return '.ico';
|
|
65
|
+
case 'image/jpeg': return '.jpg';
|
|
66
|
+
case 'image/webp': return '.webp';
|
|
67
|
+
default: return '';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function extensionForUrl(url) {
|
|
72
|
+
try {
|
|
73
|
+
const ext = path.extname(new URL(url).pathname).toLowerCase();
|
|
74
|
+
if (['.png', '.svg', '.ico', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
|
75
|
+
return ext === '.jpeg' ? '.jpg' : ext;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore invalid URL — caller validates separately
|
|
79
|
+
}
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function contentTypeForIconPath(iconPath) {
|
|
84
|
+
const ext = path.extname(iconPath || '').toLowerCase();
|
|
85
|
+
switch (ext) {
|
|
86
|
+
case '.png': return 'image/png';
|
|
87
|
+
case '.svg': return 'image/svg+xml';
|
|
88
|
+
case '.ico': return 'image/x-icon';
|
|
89
|
+
case '.jpg':
|
|
90
|
+
case '.jpeg': return 'image/jpeg';
|
|
91
|
+
case '.webp': return 'image/webp';
|
|
92
|
+
default: return 'application/octet-stream';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function remoteMetaPath(brand) {
|
|
97
|
+
return path.join(cacheDir(), `${slugify(normalizeBrandKey(brand) || brand)}.remote.json`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readRemoteCache(brand) {
|
|
101
|
+
try {
|
|
102
|
+
const meta = JSON.parse(fs.readFileSync(remoteMetaPath(brand), 'utf8'));
|
|
103
|
+
if (!meta?.fileName || !meta?.contentType) return null;
|
|
104
|
+
const filePath = path.join(cacheDir(), meta.fileName);
|
|
105
|
+
if (!fs.existsSync(filePath)) return null;
|
|
106
|
+
return filePath;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validateOfficialUrl(rawUrl) {
|
|
113
|
+
let url;
|
|
114
|
+
try {
|
|
115
|
+
url = new URL(rawUrl);
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (url.protocol !== 'https:') return null;
|
|
120
|
+
return url;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function readResponseBytes(response) {
|
|
124
|
+
const chunks = [];
|
|
125
|
+
let total = 0;
|
|
126
|
+
for await (const chunk of response.body) {
|
|
127
|
+
const buf = Buffer.from(chunk);
|
|
128
|
+
total += buf.length;
|
|
129
|
+
if (total > MAX_REMOTE_ICON_BYTES) {
|
|
130
|
+
throw new Error('remote icon too large');
|
|
131
|
+
}
|
|
132
|
+
chunks.push(buf);
|
|
133
|
+
}
|
|
134
|
+
return Buffer.concat(chunks);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function downloadOfficialIcon(brand, spec) {
|
|
138
|
+
if (!spec?.officialIconUrls?.length) return null;
|
|
139
|
+
if (spec.remoteIconCache === false) return null;
|
|
140
|
+
if (remoteDisabled()) return null;
|
|
141
|
+
|
|
142
|
+
const dir = cacheDir();
|
|
143
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
144
|
+
const normalized = normalizeBrandKey(brand) || String(brand || '').toLowerCase().trim();
|
|
145
|
+
|
|
146
|
+
for (const rawUrl of spec.officialIconUrls) {
|
|
147
|
+
const url = validateOfficialUrl(rawUrl);
|
|
148
|
+
if (!url) continue;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetch(url, {
|
|
152
|
+
headers: {
|
|
153
|
+
accept: 'image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8',
|
|
154
|
+
'user-agent': 'SkillHelper/official-icon-fetcher',
|
|
155
|
+
},
|
|
156
|
+
redirect: 'follow',
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok || !response.body) continue;
|
|
159
|
+
|
|
160
|
+
const contentType = String(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
161
|
+
if (!contentType.startsWith('image/')) continue;
|
|
162
|
+
|
|
163
|
+
const bytes = await readResponseBytes(response);
|
|
164
|
+
if (!bytes.length) continue;
|
|
165
|
+
|
|
166
|
+
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
167
|
+
const ext = extensionForContentType(contentType) || extensionForUrl(response.url || rawUrl) || '.img';
|
|
168
|
+
const fileName = `${slugify(normalized)}-remote-${sha256.slice(0, 12)}${ext}`;
|
|
169
|
+
const filePath = path.join(dir, fileName);
|
|
170
|
+
// v0.4 原子写:临时文件 + rename,进程中断不残留半截文件
|
|
171
|
+
atomicWriteBytes(filePath, bytes);
|
|
172
|
+
atomicWriteText(remoteMetaPath(normalized), JSON.stringify({
|
|
173
|
+
brand: normalized,
|
|
174
|
+
sourceUrl: rawUrl,
|
|
175
|
+
finalUrl: response.url || rawUrl,
|
|
176
|
+
contentType,
|
|
177
|
+
sha256,
|
|
178
|
+
fetchedAt: new Date().toISOString(),
|
|
179
|
+
fileName,
|
|
180
|
+
}, null, 2));
|
|
181
|
+
return filePath;
|
|
182
|
+
} catch {
|
|
183
|
+
// Try the next registered official URL. The API layer reports fallback.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Locate the .app bundle for a brand/source key (or raw bundle id).
|
|
192
|
+
* @param {string} brand
|
|
193
|
+
* @returns {Promise<string|null>} absolute path to the .app or null
|
|
194
|
+
*/
|
|
195
|
+
export async function locateApp(brand) {
|
|
196
|
+
if (!IS_MACOS || !brand) return null;
|
|
197
|
+
const normalized = normalizeBrandKey(brand) || String(brand).toLowerCase().trim();
|
|
198
|
+
if (appPathCache.has(normalized)) return appPathCache.get(normalized);
|
|
199
|
+
|
|
200
|
+
const spec = resolveBrandSpec(brand);
|
|
201
|
+
if (!spec) {
|
|
202
|
+
appPathCache.set(normalized, null);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let found = null;
|
|
207
|
+
|
|
208
|
+
// 1. Bundle ID via Spotlight — fastest and most accurate.
|
|
209
|
+
for (const bundleId of spec.bundleIds || []) {
|
|
210
|
+
const p = await mdfindBundle(bundleId);
|
|
211
|
+
if (p) {
|
|
212
|
+
found = p;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 2. App-name scan of /Applications (case-insensitive) as fallback.
|
|
218
|
+
if (!found) {
|
|
219
|
+
found = scanAppDirs(spec.appNames || []);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
appPathCache.set(normalized, found);
|
|
223
|
+
return found;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function mdfindBundle(bundleId) {
|
|
227
|
+
try {
|
|
228
|
+
const { stdout } = await execFileP('mdfind', [
|
|
229
|
+
`kMDItemCFBundleIdentifier == '${bundleId}'`,
|
|
230
|
+
]);
|
|
231
|
+
const first = stdout.split('\n').map(s => s.trim()).find(s => s.endsWith('.app') && fs.existsSync(s));
|
|
232
|
+
return first || null;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function scanAppDirs(appNames) {
|
|
239
|
+
if (!appNames?.length) return null;
|
|
240
|
+
const wanted = new Set(appNames.map(n => `${n.toLowerCase()}.app`));
|
|
241
|
+
for (const dir of APP_DIRS) {
|
|
242
|
+
let entries;
|
|
243
|
+
try {
|
|
244
|
+
entries = fs.readdirSync(dir);
|
|
245
|
+
} catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
if (wanted.has(entry.toLowerCase())) {
|
|
250
|
+
return path.join(dir, entry);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Extract a PNG icon from an .app bundle at the given size, with disk cache.
|
|
259
|
+
* Cache is invalidated when the .app's mtime is newer than the cached PNG.
|
|
260
|
+
* @param {string} appPath — absolute path to a .app bundle
|
|
261
|
+
* @param {number} size — 32 | 64 | 128
|
|
262
|
+
* @returns {Promise<string|null>} path to the cached PNG or null
|
|
263
|
+
*/
|
|
264
|
+
export async function extractIconPng(appPath, size) {
|
|
265
|
+
if (!IS_MACOS || !appPath) return null;
|
|
266
|
+
const dir = cacheDir();
|
|
267
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
268
|
+
const pngPath = path.join(dir, `${slugify(path.basename(appPath, '.app'))}-${size}.png`);
|
|
269
|
+
|
|
270
|
+
// Reuse cache if fresh (app not modified since PNG was written).
|
|
271
|
+
try {
|
|
272
|
+
const appStat = fs.statSync(appPath);
|
|
273
|
+
const pngStat = fs.statSync(pngPath);
|
|
274
|
+
if (pngStat.mtimeMs >= appStat.mtimeMs) return pngPath;
|
|
275
|
+
} catch {
|
|
276
|
+
// png missing → fall through to (re)build
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const icnsPath = await findIcnsPath(appPath);
|
|
280
|
+
if (!icnsPath) return null;
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
await execFileP('sips', ['-s', 'format', 'png', '-Z', String(size), icnsPath, '--out', pngPath]);
|
|
284
|
+
return fs.existsSync(pngPath) ? pngPath : null;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function findIcnsPath(appPath) {
|
|
291
|
+
const resourcesDir = path.join(appPath, 'Contents', 'Resources');
|
|
292
|
+
const infoPlist = path.join(appPath, 'Contents', 'Info.plist');
|
|
293
|
+
|
|
294
|
+
// Preferred: CFBundleIconFile from Info.plist
|
|
295
|
+
let iconFile = null;
|
|
296
|
+
try {
|
|
297
|
+
const { stdout } = await execFileP('plutil', ['-extract', 'CFBundleIconFile', 'raw', infoPlist]);
|
|
298
|
+
iconFile = stdout.trim();
|
|
299
|
+
} catch {
|
|
300
|
+
// key absent — fall back to directory scan
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (iconFile) {
|
|
304
|
+
const withExt = iconFile.endsWith('.icns') ? iconFile : `${iconFile}.icns`;
|
|
305
|
+
const candidate = path.join(resourcesDir, withExt);
|
|
306
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Fallback: first .icns in Resources (prefer one containing "icon").
|
|
310
|
+
try {
|
|
311
|
+
const icns = fs.readdirSync(resourcesDir).filter(f => f.toLowerCase().endsWith('.icns'));
|
|
312
|
+
if (!icns.length) return null;
|
|
313
|
+
const preferred = icns.find(f => /icon/i.test(f)) || icns[0];
|
|
314
|
+
return path.join(resourcesDir, preferred);
|
|
315
|
+
} catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Get a cached official icon path for a brand at a given size.
|
|
322
|
+
* Priority:
|
|
323
|
+
* 1. local macOS .app bundle (most authoritative)
|
|
324
|
+
* 2. bundled local icon (web/public/icons/<localIconBase>-{size}.png) — no network
|
|
325
|
+
* 3. registered remote icon URL, downloaded once and cached locally
|
|
326
|
+
* @param {string} brand
|
|
327
|
+
* @param {number} [size=64]
|
|
328
|
+
* @returns {Promise<string|null>}
|
|
329
|
+
*/
|
|
330
|
+
export async function getIconForBrand(brand, size = 64) {
|
|
331
|
+
const sz = clampSize(size);
|
|
332
|
+
const canonical = normalizeBrandKey(brand);
|
|
333
|
+
const lookupKey = canonical || String(brand || '').trim();
|
|
334
|
+
const cacheBrand = canonical || lookupKey.toLowerCase();
|
|
335
|
+
if (!lookupKey) return null;
|
|
336
|
+
|
|
337
|
+
const key = `${cacheBrand}:${sz}`;
|
|
338
|
+
if (iconPathCache.has(key)) {
|
|
339
|
+
const cached = iconPathCache.get(key);
|
|
340
|
+
if (cached && fs.existsSync(cached)) return cached;
|
|
341
|
+
if (cached === null) return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const spec = resolveBrandSpec(lookupKey);
|
|
345
|
+
if (!spec) {
|
|
346
|
+
iconPathCache.set(key, null);
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const appPath = await locateApp(lookupKey);
|
|
351
|
+
if (appPath) {
|
|
352
|
+
const png = await extractIconPng(appPath, sz);
|
|
353
|
+
if (png) {
|
|
354
|
+
iconPathCache.set(key, png);
|
|
355
|
+
return png;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Bundled local fallback (offline-safe, no network).
|
|
360
|
+
const localIcon = resolveLocalIcon(spec, sz);
|
|
361
|
+
if (localIcon) {
|
|
362
|
+
iconPathCache.set(key, localIcon);
|
|
363
|
+
return localIcon;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const remoteCached = readRemoteCache(cacheBrand);
|
|
367
|
+
if (remoteCached) {
|
|
368
|
+
iconPathCache.set(key, remoteCached);
|
|
369
|
+
return remoteCached;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const remote = await downloadOfficialIcon(cacheBrand, spec);
|
|
373
|
+
iconPathCache.set(key, remote);
|
|
374
|
+
return remote;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Locate a bundled local icon for the spec.
|
|
379
|
+
* Convention: <repo>/packages/web/public/<localIconBase>-<size>.<ext>
|
|
380
|
+
* Falls back across 32/64/128 if the requested size is missing.
|
|
381
|
+
* @param {object} spec
|
|
382
|
+
* @param {number} size
|
|
383
|
+
* @returns {string|null}
|
|
384
|
+
*/
|
|
385
|
+
function resolveLocalIcon(spec, size) {
|
|
386
|
+
const base = spec?.localIconBase;
|
|
387
|
+
if (!base) return null;
|
|
388
|
+
const dirCandidates = locateWebPublicDirs();
|
|
389
|
+
const candidates = [size, 128, 64, 32, 192, 512];
|
|
390
|
+
const extensions = ['.png', '.svg', '.ico'];
|
|
391
|
+
for (const dir of dirCandidates) {
|
|
392
|
+
for (const c of candidates) {
|
|
393
|
+
for (const ext of extensions) {
|
|
394
|
+
const abs = path.join(dir, `${base}-${c}${ext}`);
|
|
395
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let _webPublicDirs = null;
|
|
403
|
+
function locateWebPublicDirs() {
|
|
404
|
+
if (_webPublicDirs) return _webPublicDirs;
|
|
405
|
+
const candidates = [];
|
|
406
|
+
const cwd = process.cwd();
|
|
407
|
+
// dev: repo root or packages/web cwd
|
|
408
|
+
for (const p of [
|
|
409
|
+
path.join(cwd, 'packages', 'web', 'public'),
|
|
410
|
+
path.join(cwd, '..', 'packages', 'web', 'public'),
|
|
411
|
+
path.join(cwd, '..', '..', 'packages', 'web', 'public'),
|
|
412
|
+
path.join(cwd, 'public'),
|
|
413
|
+
path.join(cwd, '..', 'public'),
|
|
414
|
+
]) {
|
|
415
|
+
if (fs.existsSync(p) && fs.statSync(p).isDirectory()) candidates.push(p);
|
|
416
|
+
}
|
|
417
|
+
// packaged: server sits in packages/server, icons live in packages/web/public
|
|
418
|
+
try {
|
|
419
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
420
|
+
candidates.push(path.resolve(here, '..', '..', 'web', 'public'));
|
|
421
|
+
} catch {}
|
|
422
|
+
_webPublicDirs = candidates;
|
|
423
|
+
return _webPublicDirs;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Decide the icon reference for a skill WITHOUT touching the filesystem/spawns.
|
|
428
|
+
* Implements the R6.5 priority chain:
|
|
429
|
+
* explicit frontmatter.icon > official icon endpoint (brand/source) > neutral UI placeholder
|
|
430
|
+
*
|
|
431
|
+
* @param {object} skill
|
|
432
|
+
* @param {string} [skill.icon] — explicit frontmatter icon ("emoji:🤖"/"url:.."/"app:bundleId")
|
|
433
|
+
* @param {string} [skill.brand]
|
|
434
|
+
* @param {string} [skill.source]
|
|
435
|
+
* @param {number} [size=64]
|
|
436
|
+
* @returns {{ iconUrl?: string, iconFallback?: string }}
|
|
437
|
+
*/
|
|
438
|
+
export function resolveIconRef(skill, size = 64) {
|
|
439
|
+
const sz = clampSize(size);
|
|
440
|
+
const icon = skill?.icon ? String(skill.icon).trim() : '';
|
|
441
|
+
|
|
442
|
+
// 1. Explicit frontmatter override
|
|
443
|
+
if (icon) {
|
|
444
|
+
if (icon.startsWith('emoji:')) return { iconFallback: icon.slice(6) };
|
|
445
|
+
if (icon.startsWith('url:')) return { iconUrl: icon.slice(4) };
|
|
446
|
+
if (icon.startsWith('app:')) {
|
|
447
|
+
const key = normalizeBrandKey(icon.slice(4)) || icon.slice(4);
|
|
448
|
+
return { iconUrl: `/api/icons/${encodeURIComponent(key)}?size=${sz}` };
|
|
449
|
+
}
|
|
450
|
+
// A bare emoji / single glyph
|
|
451
|
+
return { iconFallback: icon };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 2. Official icon endpoint by brand, then source
|
|
455
|
+
for (const rawKey of [skill?.brand, skill?.source]) {
|
|
456
|
+
const key = normalizeBrandKey(rawKey);
|
|
457
|
+
if (key && resolveBrandSpec(key)) {
|
|
458
|
+
return { iconUrl: `/api/icons/${encodeURIComponent(key)}?size=${sz}` };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 3. No mapping — frontend renders a neutral placeholder
|
|
463
|
+
return {};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export { IS_MACOS };
|