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.
- package/README.md +9 -375
- package/bin/huhaa-myskills.mjs +194 -12
- package/{scripts → build}/verify.mjs +3 -1
- package/docs/Frontend-Spec.md +1149 -0
- package/docs/assets/layout-wireframe.png +0 -0
- package/docs/assets/theme-reference.png +0 -0
- package/docs/scan_rules.md +172 -0
- package/package.json +33 -9
- package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
- package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
- package/packages/scanner/src/icon/brand-map.mjs +84 -0
- package/packages/scanner/src/icon/icon-extractor.mjs +246 -0
- package/packages/scanner/src/index.mjs +107 -82
- package/packages/scanner/src/types.d.ts +18 -0
- package/packages/scanner/test/icon.test.mjs +61 -0
- package/packages/scanner/test/scanner.test.mjs +37 -41
- package/packages/scanner/test/skill-adapter.test.mjs +70 -0
- package/packages/scanner/tests/directory-skill.test.mjs +93 -0
- package/packages/scanner/tests/scanner-integration.test.mjs +139 -0
- package/packages/server/src/index.mjs +164 -12
- package/packages/server/src/labels.mjs +62 -0
- package/packages/server/test/server.test.mjs +1 -1
- package/packages/web/README.md +33 -2
- package/packages/web/dist/assets/index-BCY2cRaO.js +175 -0
- package/packages/web/dist/assets/index-DALKjJPi.css +1 -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/index.html +23 -9
- package/packages/web/index.html +22 -8
- package/packages/web/package-lock.json +2989 -0
- package/packages/web/package.json +21 -8
- package/packages/web/postcss.config.js +6 -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/src/App.test.ts +40 -0
- package/packages/web/src/App.tsx +177 -0
- package/packages/web/src/components/ComingSoon.tsx +14 -0
- package/packages/web/src/components/layout/Sidebar.test.tsx +74 -0
- package/packages/web/src/components/layout/Sidebar.tsx +114 -0
- package/packages/web/src/components/layout/Topbar.tsx +74 -0
- package/packages/web/src/components/ui/ActionButton.tsx +48 -0
- package/packages/web/src/components/ui/button.tsx +48 -0
- package/packages/web/src/components/ui/card.tsx +45 -0
- package/packages/web/src/components/views/DashboardView.tsx +122 -0
- package/packages/web/src/components/views/OtherSkillsView.test.tsx +126 -0
- package/packages/web/src/components/views/OtherSkillsView.tsx +306 -0
- package/packages/web/src/components/views/SettingsView.tsx +95 -0
- package/packages/web/src/components/views/SkillDetail.test.tsx +57 -0
- package/packages/web/src/components/views/SkillDetail.tsx +81 -0
- package/packages/web/src/components/views/SkillsView.test.ts +28 -0
- package/packages/web/src/components/views/SkillsView.tsx +226 -0
- package/packages/web/src/hooks/QUICK_REFERENCE.md +165 -0
- package/packages/web/src/hooks/README.md +378 -0
- package/packages/web/src/hooks/__tests__/useOtherSkills.test.ts +422 -0
- package/packages/web/src/hooks/__tests__/useSkillIcons.test.ts +272 -0
- package/packages/web/src/hooks/useLiveReload.ts +20 -0
- package/packages/web/src/hooks/useOtherSkills.ts +387 -0
- package/packages/web/src/hooks/useSkillIcons.ts +248 -0
- package/packages/web/src/hooks/useTheme.ts +36 -0
- package/packages/web/src/index.css +161 -0
- package/packages/web/src/lib/api.test.ts +47 -0
- package/packages/web/src/lib/api.ts +77 -0
- package/packages/web/src/lib/cn.ts +7 -0
- package/packages/web/src/lib/editors.test.ts +30 -0
- package/packages/web/src/lib/editors.ts +70 -0
- package/packages/web/src/lib/markdown.test.ts +27 -0
- package/packages/web/src/lib/markdown.ts +14 -0
- package/packages/web/src/main.tsx +13 -0
- package/packages/web/src/types/index.ts +62 -0
- package/packages/web/src/types/other-skill.ts +88 -0
- package/packages/web/src/types/skill.ts +136 -0
- package/packages/web/src/types.ts +47 -0
- package/packages/web/src/vite-env.d.ts +1 -0
- package/packages/web/tailwind.config.ts +66 -0
- package/packages/web/tsconfig.json +28 -0
- package/packages/web/vite.config.ts +36 -0
- package/packages/web/vitest.config.ts +21 -0
- package/packages/web/vitest.setup.ts +1 -0
- package/scripts/activate-data-sources.sh +223 -0
- package/scripts/brand-completion.py +586 -0
- package/scripts/init-nextjs-project.sh +421 -0
- package/scripts/install-and-sync.sh +209 -0
- package/scripts/start-dev.sh +72 -0
- package/docs/GUIDE.md +0 -190
- package/docs/PLAN.md +0 -359
- package/docs/RULES.md +0 -329
- package/docs/RUNBOOK-myskills.md +0 -258
- package/docs/SYNC-SKILLS.md +0 -259
- package/docs/releases/README.md +0 -47
- package/docs/releases/v0.1.0.md +0 -256
- package/docs/releases/v0.1.1.md +0 -297
- package/docs/releases/v0.1.2.md +0 -207
- package/docs/releases/v0.1.3.md +0 -269
- package/docs/todo/v0.1.2.md +0 -98
- package/docs/todo/v0.1.3.md +0 -40
- package/docs/todo/v0.1.4.md +0 -36
- package/docs/todo/v0.1.5.md +0 -41
- package/docs/todo/v0.1.6.md +0 -43
- package/docs/todo/v0.1.7.md +0 -38
- package/docs/todo/v0.1.8.md +0 -36
- package/docs/todo/v0.1.9.md +0 -54
- package/packages/web/dist/assets/index-BPepSb3q.js +0 -36
- package/packages/web/dist/assets/index-CIGkgT5w.css +0 -1
- package/packages/web/src/App.vue +0 -483
- package/packages/web/src/components/AppTree.vue +0 -230
- package/packages/web/src/components/DirectoryTree.vue +0 -263
- package/packages/web/src/components/SkillTree.vue +0 -208
- package/packages/web/src/lib/api.js +0 -43
- package/packages/web/src/main.js +0 -6
- package/packages/web/src/stores/i18n.js +0 -161
- package/packages/web/src/stores/skills.js +0 -287
- package/packages/web/src/styles.css +0 -365
- package/packages/web/vite.config.js +0 -17
- /package/{scripts → build}/build-web.mjs +0 -0
- /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 };
|