huhaa-myskills 0.2.13 → 0.3.4

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 (121) hide show
  1. package/README.md +9 -375
  2. package/bin/huhaa-myskills.mjs +194 -12
  3. package/{scripts → build}/verify.mjs +3 -1
  4. package/docs/Frontend-Spec.md +1149 -0
  5. package/docs/assets/layout-wireframe.png +0 -0
  6. package/docs/assets/theme-reference.png +0 -0
  7. package/docs/scan_rules.md +172 -0
  8. package/package.json +33 -9
  9. package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
  10. package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
  11. package/packages/scanner/src/icon/brand-map.mjs +84 -0
  12. package/packages/scanner/src/icon/icon-extractor.mjs +246 -0
  13. package/packages/scanner/src/index.mjs +107 -82
  14. package/packages/scanner/src/types.d.ts +18 -0
  15. package/packages/scanner/test/icon.test.mjs +61 -0
  16. package/packages/scanner/test/scanner.test.mjs +37 -41
  17. package/packages/scanner/test/skill-adapter.test.mjs +70 -0
  18. package/packages/scanner/tests/directory-skill.test.mjs +93 -0
  19. package/packages/scanner/tests/scanner-integration.test.mjs +139 -0
  20. package/packages/server/src/index.mjs +164 -12
  21. package/packages/server/src/labels.mjs +62 -0
  22. package/packages/server/test/server.test.mjs +1 -1
  23. package/packages/web/README.md +33 -2
  24. package/packages/web/dist/assets/index-BCY2cRaO.js +175 -0
  25. package/packages/web/dist/assets/index-DALKjJPi.css +1 -0
  26. package/packages/web/dist/favicon-16x16.png +0 -0
  27. package/packages/web/dist/favicon-192x192.png +0 -0
  28. package/packages/web/dist/favicon-32x32.png +0 -0
  29. package/packages/web/dist/favicon-512x512.png +0 -0
  30. package/packages/web/dist/favicon-source.svg +5 -0
  31. package/packages/web/dist/index.html +23 -9
  32. package/packages/web/index.html +22 -8
  33. package/packages/web/package-lock.json +2989 -0
  34. package/packages/web/package.json +21 -8
  35. package/packages/web/postcss.config.js +6 -0
  36. package/packages/web/public/favicon-16x16.png +0 -0
  37. package/packages/web/public/favicon-192x192.png +0 -0
  38. package/packages/web/public/favicon-32x32.png +0 -0
  39. package/packages/web/public/favicon-512x512.png +0 -0
  40. package/packages/web/public/favicon-source.svg +5 -0
  41. package/packages/web/src/App.test.ts +40 -0
  42. package/packages/web/src/App.tsx +177 -0
  43. package/packages/web/src/components/ComingSoon.tsx +14 -0
  44. package/packages/web/src/components/layout/Sidebar.test.tsx +74 -0
  45. package/packages/web/src/components/layout/Sidebar.tsx +114 -0
  46. package/packages/web/src/components/layout/Topbar.tsx +74 -0
  47. package/packages/web/src/components/ui/ActionButton.tsx +48 -0
  48. package/packages/web/src/components/ui/button.tsx +48 -0
  49. package/packages/web/src/components/ui/card.tsx +45 -0
  50. package/packages/web/src/components/views/DashboardView.tsx +122 -0
  51. package/packages/web/src/components/views/OtherSkillsView.test.tsx +126 -0
  52. package/packages/web/src/components/views/OtherSkillsView.tsx +306 -0
  53. package/packages/web/src/components/views/SettingsView.tsx +95 -0
  54. package/packages/web/src/components/views/SkillDetail.test.tsx +57 -0
  55. package/packages/web/src/components/views/SkillDetail.tsx +81 -0
  56. package/packages/web/src/components/views/SkillsView.test.ts +28 -0
  57. package/packages/web/src/components/views/SkillsView.tsx +226 -0
  58. package/packages/web/src/hooks/QUICK_REFERENCE.md +165 -0
  59. package/packages/web/src/hooks/README.md +378 -0
  60. package/packages/web/src/hooks/__tests__/useOtherSkills.test.ts +422 -0
  61. package/packages/web/src/hooks/__tests__/useSkillIcons.test.ts +272 -0
  62. package/packages/web/src/hooks/useLiveReload.ts +20 -0
  63. package/packages/web/src/hooks/useOtherSkills.ts +387 -0
  64. package/packages/web/src/hooks/useSkillIcons.ts +248 -0
  65. package/packages/web/src/hooks/useTheme.ts +36 -0
  66. package/packages/web/src/index.css +161 -0
  67. package/packages/web/src/lib/api.test.ts +47 -0
  68. package/packages/web/src/lib/api.ts +77 -0
  69. package/packages/web/src/lib/cn.ts +7 -0
  70. package/packages/web/src/lib/editors.test.ts +30 -0
  71. package/packages/web/src/lib/editors.ts +70 -0
  72. package/packages/web/src/lib/markdown.test.ts +27 -0
  73. package/packages/web/src/lib/markdown.ts +14 -0
  74. package/packages/web/src/main.tsx +13 -0
  75. package/packages/web/src/types/index.ts +62 -0
  76. package/packages/web/src/types/other-skill.ts +88 -0
  77. package/packages/web/src/types/skill.ts +136 -0
  78. package/packages/web/src/types.ts +47 -0
  79. package/packages/web/src/vite-env.d.ts +1 -0
  80. package/packages/web/tailwind.config.ts +66 -0
  81. package/packages/web/tsconfig.json +28 -0
  82. package/packages/web/vite.config.ts +36 -0
  83. package/packages/web/vitest.config.ts +21 -0
  84. package/packages/web/vitest.setup.ts +1 -0
  85. package/scripts/activate-data-sources.sh +223 -0
  86. package/scripts/brand-completion.py +586 -0
  87. package/scripts/init-nextjs-project.sh +421 -0
  88. package/scripts/install-and-sync.sh +209 -0
  89. package/scripts/start-dev.sh +72 -0
  90. package/docs/GUIDE.md +0 -190
  91. package/docs/PLAN.md +0 -359
  92. package/docs/RULES.md +0 -329
  93. package/docs/RUNBOOK-myskills.md +0 -258
  94. package/docs/SYNC-SKILLS.md +0 -259
  95. package/docs/releases/README.md +0 -47
  96. package/docs/releases/v0.1.0.md +0 -256
  97. package/docs/releases/v0.1.1.md +0 -297
  98. package/docs/releases/v0.1.2.md +0 -207
  99. package/docs/releases/v0.1.3.md +0 -269
  100. package/docs/todo/v0.1.2.md +0 -98
  101. package/docs/todo/v0.1.3.md +0 -40
  102. package/docs/todo/v0.1.4.md +0 -36
  103. package/docs/todo/v0.1.5.md +0 -41
  104. package/docs/todo/v0.1.6.md +0 -43
  105. package/docs/todo/v0.1.7.md +0 -38
  106. package/docs/todo/v0.1.8.md +0 -36
  107. package/docs/todo/v0.1.9.md +0 -54
  108. package/packages/web/dist/assets/index-BPepSb3q.js +0 -36
  109. package/packages/web/dist/assets/index-CIGkgT5w.css +0 -1
  110. package/packages/web/src/App.vue +0 -483
  111. package/packages/web/src/components/AppTree.vue +0 -230
  112. package/packages/web/src/components/DirectoryTree.vue +0 -263
  113. package/packages/web/src/components/SkillTree.vue +0 -208
  114. package/packages/web/src/lib/api.js +0 -43
  115. package/packages/web/src/main.js +0 -6
  116. package/packages/web/src/stores/i18n.js +0 -161
  117. package/packages/web/src/stores/skills.js +0 -287
  118. package/packages/web/src/styles.css +0 -365
  119. package/packages/web/vite.config.js +0 -17
  120. /package/{scripts → build}/build-web.mjs +0 -0
  121. /package/{scripts → build}/prepare-publish.mjs +0 -0
@@ -0,0 +1,345 @@
1
+ // skill-adapter.mjs — adapter for scanning generic SKILL.md files from other sources
2
+ // Unlike markdown-skill.mjs which targets specific tools (hermes, claude-code),
3
+ // this adapter provides a generic way to scan SKILL.md files from any source
4
+ // and returns a standardized format with frontmatter parsing.
5
+
6
+ import path from 'node:path';
7
+ import fsp from 'node:fs/promises';
8
+ import os from 'node:os';
9
+ import { execFile } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+ import fg from 'fast-glob';
12
+ import {
13
+ expandRoots,
14
+ classifyRoot,
15
+ parseFrontmatter,
16
+ inferBrand,
17
+ inferProduct,
18
+ deriveDescription,
19
+ makePreview,
20
+ sha1Id,
21
+ } from '../utils.mjs';
22
+
23
+ const execFileP = promisify(execFile);
24
+
25
+ /** Default concurrency for file reads — bounded by CPU count (R7.2). */
26
+ const DEFAULT_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus()?.length || 4)));
27
+
28
+ /**
29
+ * Run an async mapper over items with a bounded concurrency pool.
30
+ * Keeps the event loop responsive and caps open file handles during scans.
31
+ * @template T,R
32
+ * @param {T[]} items
33
+ * @param {number} concurrency
34
+ * @param {(item: T, index: number) => Promise<R>} fn
35
+ * @returns {Promise<R[]>}
36
+ */
37
+ async function mapWithConcurrency(items, concurrency, fn) {
38
+ const results = new Array(items.length);
39
+ let cursor = 0;
40
+ const workers = new Array(Math.min(concurrency, items.length || 1)).fill(0).map(async () => {
41
+ while (cursor < items.length) {
42
+ const i = cursor++;
43
+ results[i] = await fn(items[i], i);
44
+ }
45
+ });
46
+ await Promise.all(workers);
47
+ return results;
48
+ }
49
+
50
+ /**
51
+ * Scan SKILL.md files from arbitrary locations/sources
52
+ * @param {object} opts
53
+ * @param {string} opts.source — IR source tag (e.g., 'other-skills', 'custom-skills')
54
+ * @param {string[]} opts.roots — base dirs to scan (with ~ / globs)
55
+ * @param {string} [opts.fileGlob='SKILL.md'] — relative glob pattern
56
+ * @param {object} [opts.limits] — { maxFiles, maxFileBytes }
57
+ * @param {'mini'|'full'} [opts.stage='full'] — 'mini' skips heavy fields for fast first paint (R7.1)
58
+ * @param {number} [opts.concurrency] — parallel file reads (R7.2)
59
+ * @param {boolean} [opts.useSpotlight=false] — use `mdls` for timestamps (R7.3)
60
+ * @returns {Promise<{items:SkillItem[], stats:object}>}
61
+ */
62
+ export async function scanSkills(opts) {
63
+ const {
64
+ source = 'other-skills',
65
+ roots = [],
66
+ fileGlob = '**/SKILL.md',
67
+ limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
68
+ stage = 'full',
69
+ concurrency = DEFAULT_CONCURRENCY,
70
+ useSpotlight = false,
71
+ } = opts;
72
+
73
+ const startedAt = Date.now();
74
+ const expanded = await expandRoots(roots);
75
+ if (!expanded.length) {
76
+ return { items: [], stats: { source, available: false, files: 0, stage } };
77
+ }
78
+
79
+ // Collect file paths
80
+ const files = [];
81
+ for (const root of expanded) {
82
+ const found = await fg(fileGlob, {
83
+ cwd: root,
84
+ absolute: true,
85
+ onlyFiles: true,
86
+ dot: true,
87
+ followSymbolicLinks: true,
88
+ deep: 10,
89
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'],
90
+ });
91
+ for (const f of found) {
92
+ files.push({ abs: f, root });
93
+ if (files.length >= limits.maxFiles) break;
94
+ }
95
+ if (files.length >= limits.maxFiles) break;
96
+ }
97
+
98
+ // Optional Spotlight timestamps (R7.3) — batched, best-effort.
99
+ const spotlightDates = useSpotlight ? await fetchSpotlightDates(files.map(f => f.abs)) : null;
100
+
101
+ // Parse each file concurrently (R7.2)
102
+ const items = await mapWithConcurrency(files, concurrency, ({ abs, root }) =>
103
+ parseSkillFile({ abs, root, source, limits, stage, spotlightDates }),
104
+ );
105
+
106
+ // Stable ordering by path (R3) — concurrency must not change output order.
107
+ items.sort((a, b) => (a.paths?.abs || '').localeCompare(b.paths?.abs || ''));
108
+
109
+ return {
110
+ items,
111
+ stats: {
112
+ source,
113
+ available: true,
114
+ files: items.length,
115
+ roots: expanded,
116
+ stage,
117
+ duration: Date.now() - startedAt,
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Best-effort batched Spotlight timestamps via `mdls` (R7.3, opt-in).
124
+ * Returns a Map<abs, {creationDate?, contentChangeDate?}> or null on failure.
125
+ * @param {string[]} paths
126
+ * @returns {Promise<Map<string, {creationDate?: string, contentChangeDate?: string}>|null>}
127
+ */
128
+ async function fetchSpotlightDates(paths) {
129
+ if (process.platform !== 'darwin' || !paths.length) return null;
130
+ const map = new Map();
131
+ const CHUNK = 50;
132
+ try {
133
+ for (let i = 0; i < paths.length; i += CHUNK) {
134
+ const chunk = paths.slice(i, i + CHUNK);
135
+ const { stdout } = await execFileP('mdls', [
136
+ '-name', 'kMDItemFSCreationDate',
137
+ '-name', 'kMDItemFSContentChangeDate',
138
+ '-raw',
139
+ ...chunk,
140
+ ]);
141
+ // -raw emits values separated by NUL, in order: [creation, change] per file.
142
+ const values = stdout.split('\0');
143
+ for (let j = 0; j < chunk.length; j++) {
144
+ const creation = values[j * 2];
145
+ const change = values[j * 2 + 1];
146
+ const entry = {};
147
+ if (creation && creation !== '(null)') entry.creationDate = toIso(creation);
148
+ if (change && change !== '(null)') entry.contentChangeDate = toIso(change);
149
+ if (entry.creationDate || entry.contentChangeDate) map.set(chunk[j], entry);
150
+ }
151
+ }
152
+ return map;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ function toIso(mdlsDate) {
159
+ // mdls raw date looks like "2026-07-01 10:30:00 +0000"
160
+ const d = new Date(mdlsDate.replace(' +', '+').replace(/ (\d{4})$/, ' +$1'));
161
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
162
+ }
163
+
164
+ /**
165
+ * Read a file safely with async I/O + byte limit (R7.2).
166
+ * @returns {Promise<{text:string, truncated:boolean, mtime:Date, error?:string}>}
167
+ */
168
+ async function readFileSafeAsync(abs, maxBytes) {
169
+ try {
170
+ const stat = await fsp.stat(abs);
171
+ if (stat.size > maxBytes) {
172
+ return { text: '', truncated: true, mtime: stat.mtime };
173
+ }
174
+ return { text: await fsp.readFile(abs, 'utf8'), truncated: false, mtime: stat.mtime };
175
+ } catch (e) {
176
+ return { text: '', truncated: false, mtime: new Date(0), error: e.message };
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Parse a single SKILL.md file
182
+ * @param {object} opts
183
+ * @param {string} opts.abs — absolute path to the SKILL.md file
184
+ * @param {string} opts.root — root directory for relative path calculation
185
+ * @param {string} opts.source — source identifier
186
+ * @param {object} opts.limits — { maxFileBytes }
187
+ * @param {'mini'|'full'} opts.stage — 'mini' skips heavy fields (R7.1)
188
+ * @param {Map|null} opts.spotlightDates — optional Spotlight timestamps (R7.3)
189
+ * @returns {Promise<SkillItem>}
190
+ */
191
+ async function parseSkillFile({ abs, root, source, limits, stage = 'full', spotlightDates = null }) {
192
+ const { text, truncated, mtime, error } = await readFileSafeAsync(abs, limits.maxFileBytes);
193
+ const id = sha1Id(abs);
194
+ const rel = path.relative(root, abs);
195
+
196
+ // Extract category from directory structure
197
+ const relDir = path.dirname(rel);
198
+ const category = relDir && relDir !== '.' ? relDir.split('/')[0] : undefined;
199
+
200
+ // Extract skill name from parent directory
201
+ const dirName = path.basename(path.dirname(abs));
202
+
203
+ const updatedAt =
204
+ spotlightDates?.get(abs)?.contentChangeDate || new Date(mtime).toISOString();
205
+
206
+ // Handle file read errors
207
+ if (error) {
208
+ return createBaseItem({ abs, id, source, dirName, category, updatedAt, raw: '', parseError: error });
209
+ }
210
+
211
+ // Handle truncated files
212
+ if (truncated) {
213
+ return createBaseItem({
214
+ abs,
215
+ id,
216
+ source,
217
+ dirName,
218
+ category,
219
+ updatedAt,
220
+ raw: '',
221
+ parseError: `file > ${limits.maxFileBytes} bytes, skipped`,
222
+ });
223
+ }
224
+
225
+ // Parse frontmatter
226
+ const { data: fm, body, i18n, parseError } = parseFrontmatter(text);
227
+
228
+ const name = (fm.name || dirName).toString().trim();
229
+ const title = (fm.title || fm.name || dirName).toString().trim();
230
+ const description = deriveDescription(fm, body);
231
+
232
+ // Explicit icon override from frontmatter (e.g. "emoji:🤖", "url:...", "app:bundleId")
233
+ const icon = typeof fm.icon === 'string' ? fm.icon.trim() : undefined;
234
+
235
+ // Build the item — 'mini' stage omits heavy fields for a fast first paint (R7.1).
236
+ const item = {
237
+ id,
238
+ kind: 'skill',
239
+ source,
240
+ editor: 'Other Skills',
241
+ name,
242
+ title: title !== name ? title : undefined,
243
+ description,
244
+ category,
245
+ icon,
246
+ paths: {
247
+ abs,
248
+ rel,
249
+ rootKind: classifyRoot(abs),
250
+ },
251
+ updatedAt,
252
+ };
253
+
254
+ // Infer brand (needed by both stages for icon resolution)
255
+ item.brand = inferBrand({ name, description, category, raw: stage === 'mini' ? description : body });
256
+
257
+ if (stage !== 'mini') {
258
+ const triggers = collectTriggers(fm);
259
+ const tags = collectTags(fm);
260
+ const links = collectLinks(fm);
261
+ item.triggers = triggers.length ? triggers : undefined;
262
+ item.tags = tags.length ? tags : undefined;
263
+ item.preview = makePreview(body);
264
+ item.raw = text;
265
+ item.links = links.length ? links : undefined;
266
+ item.product = inferProduct({ name, category });
267
+
268
+ if (i18n) {
269
+ item.i18n = {
270
+ ...i18n,
271
+ translatedAt: new Date().toISOString(),
272
+ translationModel: 'frontmatter',
273
+ };
274
+ }
275
+ }
276
+
277
+ if (parseError) item.parseError = parseError;
278
+ return item;
279
+ }
280
+
281
+ /**
282
+ * Create a minimal skill item for error cases
283
+ */
284
+ function createBaseItem({ abs, id, source, dirName, category, updatedAt, raw, parseError }) {
285
+ return {
286
+ id,
287
+ kind: 'skill',
288
+ source,
289
+ editor: 'Other Skills',
290
+ name: dirName,
291
+ description: undefined,
292
+ category,
293
+ paths: { abs, rootKind: classifyRoot(abs) },
294
+ preview: '',
295
+ raw,
296
+ updatedAt,
297
+ parseError,
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Collect trigger strings from frontmatter
303
+ */
304
+ function collectTriggers(fm) {
305
+ const out = [];
306
+ const candidates = [fm.triggers, fm.aliases, fm.when_to_use];
307
+ for (const c of candidates) {
308
+ if (!c) continue;
309
+ if (Array.isArray(c)) {
310
+ out.push(...c.filter(x => typeof x === 'string').map(x => x.trim()));
311
+ } else if (typeof c === 'string') {
312
+ out.push(c.trim());
313
+ }
314
+ }
315
+ return [...new Set(out)];
316
+ }
317
+
318
+ /**
319
+ * Collect tags from frontmatter
320
+ */
321
+ function collectTags(fm) {
322
+ const v = fm.tags;
323
+ if (!v) return [];
324
+ if (Array.isArray(v)) return v.filter(x => typeof x === 'string');
325
+ if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
326
+ return [];
327
+ }
328
+
329
+ /**
330
+ * Collect links from frontmatter
331
+ */
332
+ function collectLinks(fm) {
333
+ const out = [];
334
+ if (Array.isArray(fm.links)) {
335
+ for (const l of fm.links) {
336
+ if (l && typeof l === 'object' && l.url) {
337
+ out.push({ label: l.label || l.url, url: l.url });
338
+ }
339
+ }
340
+ }
341
+ if (typeof fm.url === 'string') {
342
+ out.push({ label: 'docs', url: fm.url });
343
+ }
344
+ return out;
345
+ }
@@ -0,0 +1,84 @@
1
+ // brand-map.mjs — maps a skill's brand/source to candidate macOS applications.
2
+ //
3
+ // Used by icon-extractor.mjs to locate the real .app bundle for a skill so its
4
+ // genuine editor/tool icon can be shown (mirrors Pearcleaner's app-icon logic).
5
+ //
6
+ // Bundle IDs are the fast/accurate path (mdfind); appNames are the fallback
7
+ // (scan /Applications). Entries with neither map to nothing → emoji fallback.
8
+
9
+ /**
10
+ * @typedef {object} BrandAppSpec
11
+ * @property {string[]} bundleIds — candidate CFBundleIdentifier values
12
+ * @property {string[]} appNames — candidate .app display names (no extension)
13
+ * @property {string} [emoji] — emoji fallback when no app is found
14
+ */
15
+
16
+ /** @type {Record<string, BrandAppSpec>} */
17
+ export const BRAND_APP_MAP = {
18
+ cursor: {
19
+ bundleIds: ['com.todesktop.230313mzl4w4u92'],
20
+ appNames: ['Cursor'],
21
+ emoji: '🖱️',
22
+ },
23
+ 'vs-code': {
24
+ bundleIds: ['com.microsoft.VSCode', 'com.microsoft.VSCodeInsiders'],
25
+ appNames: ['Visual Studio Code', 'Code', 'Visual Studio Code - Insiders'],
26
+ emoji: '📝',
27
+ },
28
+ vscode: {
29
+ bundleIds: ['com.microsoft.VSCode'],
30
+ appNames: ['Visual Studio Code', 'Code'],
31
+ emoji: '📝',
32
+ },
33
+ 'claude-code': {
34
+ bundleIds: ['com.anthropic.claude', 'com.anthropic.claudefordesktop'],
35
+ appNames: ['Claude'],
36
+ emoji: '🤖',
37
+ },
38
+ claude: {
39
+ bundleIds: ['com.anthropic.claude', 'com.anthropic.claudefordesktop'],
40
+ appNames: ['Claude'],
41
+ emoji: '🤖',
42
+ },
43
+ obsidian: {
44
+ bundleIds: ['md.obsidian'],
45
+ appNames: ['Obsidian'],
46
+ emoji: '🧠',
47
+ },
48
+ docker: {
49
+ bundleIds: ['com.docker.docker'],
50
+ appNames: ['Docker', 'Docker Desktop'],
51
+ emoji: '🐳',
52
+ },
53
+ // Tools that usually have no GUI .app on macOS → emoji fallback only.
54
+ codex: { bundleIds: [], appNames: ['Codex'], emoji: '📋' },
55
+ hermes: { bundleIds: [], appNames: ['Hermes'], emoji: '⚡' },
56
+ mcp: { bundleIds: [], appNames: [], emoji: '🔌' },
57
+ };
58
+
59
+ /**
60
+ * Resolve a brand/source key (or a raw bundle id) into a lookup spec.
61
+ * A value containing a dot is treated as an explicit bundle id.
62
+ * @param {string} key
63
+ * @returns {BrandAppSpec | null}
64
+ */
65
+ export function resolveBrandSpec(key) {
66
+ if (!key) return null;
67
+ const normalized = String(key).toLowerCase().trim();
68
+ if (BRAND_APP_MAP[normalized]) return BRAND_APP_MAP[normalized];
69
+ // Looks like a bundle identifier (e.g. "com.microsoft.VSCode")
70
+ if (/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(key)) {
71
+ return { bundleIds: [key], appNames: [], emoji: undefined };
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Emoji fallback for a brand/source key (used when no app is found).
78
+ * @param {string} key
79
+ * @returns {string | undefined}
80
+ */
81
+ export function emojiForBrand(key) {
82
+ const spec = BRAND_APP_MAP[String(key || '').toLowerCase().trim()];
83
+ return spec?.emoji;
84
+ }
@@ -0,0 +1,246 @@
1
+ // icon-extractor.mjs — extract real macOS application icons for skills.
2
+ //
3
+ // Node.js equivalent of Pearcleaner's NSWorkspace.icon(forFile:) using only
4
+ // macOS built-in CLIs (mdfind / plutil / sips). No native bindings.
5
+ //
6
+ // 1. locateApp() — find the .app bundle for a brand (mdfind + /Applications)
7
+ // 2. extractIconPng() — read CFBundleIconFile from Info.plist, sips → PNG
8
+ // 3. getIconForBrand()— combine + disk cache (mtime-invalidated)
9
+ // 4. resolveIconRef() — sync: decide iconUrl / emoji fallback for a skill
10
+ //
11
+ // All extraction is lazy (on /api/icons request) and cached, so scanning stays
12
+ // fast and non-macOS platforms degrade gracefully to emoji.
13
+
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import fs from 'node:fs';
17
+ import { execFile } from 'node:child_process';
18
+ import { promisify } from 'node:util';
19
+ import { resolveBrandSpec, emojiForBrand } from './brand-map.mjs';
20
+
21
+ const execFileP = promisify(execFile);
22
+ const IS_MACOS = process.platform === 'darwin';
23
+ const APP_DIRS = [
24
+ '/Applications',
25
+ path.join(os.homedir(), 'Applications'),
26
+ '/System/Applications',
27
+ ];
28
+ const VALID_SIZES = new Set([32, 64, 128]);
29
+
30
+ // In-memory caches (per process). locateApp is stable within a session.
31
+ const appPathCache = new Map(); // brand -> appPath|null
32
+ const iconPathCache = new Map(); // `${brand}:${size}` -> pngPath|null
33
+
34
+ function cacheDir() {
35
+ const base =
36
+ process.env.HUHAA_HOME?.trim() ||
37
+ path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), '.config'), 'huhaa-myskills');
38
+ return path.join(expandTilde(base), 'icon-cache');
39
+ }
40
+
41
+ function expandTilde(p) {
42
+ if (!p) return p;
43
+ if (p === '~' || p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
44
+ return p;
45
+ }
46
+
47
+ function slugify(s) {
48
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
49
+ }
50
+
51
+ /**
52
+ * Locate the .app bundle for a brand/source key (or raw bundle id).
53
+ * @param {string} brand
54
+ * @returns {Promise<string|null>} absolute path to the .app or null
55
+ */
56
+ export async function locateApp(brand) {
57
+ if (!IS_MACOS || !brand) return null;
58
+ const key = String(brand).toLowerCase().trim();
59
+ if (appPathCache.has(key)) return appPathCache.get(key);
60
+
61
+ const spec = resolveBrandSpec(brand);
62
+ if (!spec) {
63
+ appPathCache.set(key, null);
64
+ return null;
65
+ }
66
+
67
+ let found = null;
68
+
69
+ // 1. Bundle ID via Spotlight — fastest and most accurate.
70
+ for (const bundleId of spec.bundleIds) {
71
+ const p = await mdfindBundle(bundleId);
72
+ if (p) {
73
+ found = p;
74
+ break;
75
+ }
76
+ }
77
+
78
+ // 2. App-name scan of /Applications (case-insensitive) as fallback.
79
+ if (!found) {
80
+ found = scanAppDirs(spec.appNames);
81
+ }
82
+
83
+ appPathCache.set(key, found);
84
+ return found;
85
+ }
86
+
87
+ async function mdfindBundle(bundleId) {
88
+ try {
89
+ const { stdout } = await execFileP('mdfind', [
90
+ `kMDItemCFBundleIdentifier == '${bundleId}'`,
91
+ ]);
92
+ const first = stdout.split('\n').map(s => s.trim()).find(s => s.endsWith('.app') && fs.existsSync(s));
93
+ return first || null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function scanAppDirs(appNames) {
100
+ if (!appNames?.length) return null;
101
+ const wanted = new Set(appNames.map(n => `${n.toLowerCase()}.app`));
102
+ for (const dir of APP_DIRS) {
103
+ let entries;
104
+ try {
105
+ entries = fs.readdirSync(dir);
106
+ } catch {
107
+ continue;
108
+ }
109
+ for (const entry of entries) {
110
+ if (wanted.has(entry.toLowerCase())) {
111
+ return path.join(dir, entry);
112
+ }
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Extract a PNG icon from an .app bundle at the given size, with disk cache.
120
+ * Cache is invalidated when the .app's mtime is newer than the cached PNG.
121
+ * @param {string} appPath — absolute path to a .app bundle
122
+ * @param {number} size — 32 | 64 | 128
123
+ * @returns {Promise<string|null>} path to the cached PNG or null
124
+ */
125
+ export async function extractIconPng(appPath, size) {
126
+ if (!IS_MACOS || !appPath) return null;
127
+ const dir = cacheDir();
128
+ fs.mkdirSync(dir, { recursive: true });
129
+ const pngPath = path.join(dir, `${slugify(path.basename(appPath, '.app'))}-${size}.png`);
130
+
131
+ // Reuse cache if fresh (app not modified since PNG was written).
132
+ try {
133
+ const appStat = fs.statSync(appPath);
134
+ const pngStat = fs.statSync(pngPath);
135
+ if (pngStat.mtimeMs >= appStat.mtimeMs) return pngPath;
136
+ } catch {
137
+ // png missing → fall through to (re)build
138
+ }
139
+
140
+ const icnsPath = await findIcnsPath(appPath);
141
+ if (!icnsPath) return null;
142
+
143
+ try {
144
+ await execFileP('sips', ['-s', 'format', 'png', '-Z', String(size), icnsPath, '--out', pngPath]);
145
+ return fs.existsSync(pngPath) ? pngPath : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ async function findIcnsPath(appPath) {
152
+ const resourcesDir = path.join(appPath, 'Contents', 'Resources');
153
+ const infoPlist = path.join(appPath, 'Contents', 'Info.plist');
154
+
155
+ // Preferred: CFBundleIconFile from Info.plist
156
+ let iconFile = null;
157
+ try {
158
+ const { stdout } = await execFileP('plutil', ['-extract', 'CFBundleIconFile', 'raw', infoPlist]);
159
+ iconFile = stdout.trim();
160
+ } catch {
161
+ // key absent — fall back to directory scan
162
+ }
163
+
164
+ if (iconFile) {
165
+ const withExt = iconFile.endsWith('.icns') ? iconFile : `${iconFile}.icns`;
166
+ const candidate = path.join(resourcesDir, withExt);
167
+ if (fs.existsSync(candidate)) return candidate;
168
+ }
169
+
170
+ // Fallback: first .icns in Resources (prefer one containing "icon").
171
+ try {
172
+ const icns = fs.readdirSync(resourcesDir).filter(f => f.toLowerCase().endsWith('.icns'));
173
+ if (!icns.length) return null;
174
+ const preferred = icns.find(f => /icon/i.test(f)) || icns[0];
175
+ return path.join(resourcesDir, preferred);
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Get a cached PNG icon path for a brand at a given size (locate + extract).
183
+ * @param {string} brand
184
+ * @param {number} [size=64]
185
+ * @returns {Promise<string|null>}
186
+ */
187
+ export async function getIconForBrand(brand, size = 64) {
188
+ if (!IS_MACOS) return null;
189
+ const sz = VALID_SIZES.has(size) ? size : 64;
190
+ const key = `${String(brand).toLowerCase().trim()}:${sz}`;
191
+ if (iconPathCache.has(key)) {
192
+ const cached = iconPathCache.get(key);
193
+ if (cached && fs.existsSync(cached)) return cached;
194
+ }
195
+ const appPath = await locateApp(brand);
196
+ if (!appPath) {
197
+ iconPathCache.set(key, null);
198
+ return null;
199
+ }
200
+ const png = await extractIconPng(appPath, sz);
201
+ iconPathCache.set(key, png);
202
+ return png;
203
+ }
204
+
205
+ /**
206
+ * Decide the icon reference for a skill WITHOUT touching the filesystem/spawns.
207
+ * Implements the R6.5 priority chain:
208
+ * explicit frontmatter.icon > real app icon (brand/source) > emoji fallback
209
+ *
210
+ * @param {object} skill
211
+ * @param {string} [skill.icon] — explicit frontmatter icon ("emoji:🤖"/"url:.."/"app:bundleId")
212
+ * @param {string} [skill.brand]
213
+ * @param {string} [skill.source]
214
+ * @param {number} [size=64]
215
+ * @returns {{ iconUrl?: string, iconFallback?: string }}
216
+ */
217
+ export function resolveIconRef(skill, size = 64) {
218
+ const sz = VALID_SIZES.has(size) ? size : 64;
219
+ const icon = skill?.icon ? String(skill.icon).trim() : '';
220
+
221
+ // 1. Explicit frontmatter override
222
+ if (icon) {
223
+ if (icon.startsWith('emoji:')) return { iconFallback: icon.slice(6) };
224
+ if (icon.startsWith('url:')) return { iconUrl: icon.slice(4) };
225
+ if (icon.startsWith('app:')) {
226
+ return { iconUrl: `/api/icons/${encodeURIComponent(icon.slice(4))}?size=${sz}` };
227
+ }
228
+ // A bare emoji / single glyph
229
+ return { iconFallback: icon };
230
+ }
231
+
232
+ // 2. Real app icon by brand, then source
233
+ for (const key of [skill?.brand, skill?.source]) {
234
+ if (key && resolveBrandSpec(key)) {
235
+ return {
236
+ iconUrl: `/api/icons/${encodeURIComponent(key)}?size=${sz}`,
237
+ iconFallback: emojiForBrand(key),
238
+ };
239
+ }
240
+ }
241
+
242
+ // 3. No mapping — frontend emoji map handles it
243
+ return {};
244
+ }
245
+
246
+ export { IS_MACOS };