huhaa-myskills 0.1.5

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +384 -0
  3. package/docs/GUIDE.md +190 -0
  4. package/docs/PLAN.md +359 -0
  5. package/docs/RULES.md +329 -0
  6. package/docs/RUNBOOK-myskills.md +258 -0
  7. package/docs/SYNC-SKILLS.md +259 -0
  8. package/docs/releases/README.md +47 -0
  9. package/docs/releases/v0.1.0.md +256 -0
  10. package/docs/releases/v0.1.1.md +297 -0
  11. package/docs/releases/v0.1.2.md +207 -0
  12. package/docs/releases/v0.1.3.md +269 -0
  13. package/docs/todo/v0.1.2.md +98 -0
  14. package/docs/todo/v0.1.3.md +40 -0
  15. package/docs/todo/v0.1.4.md +36 -0
  16. package/docs/todo/v0.1.5.md +41 -0
  17. package/docs/todo/v0.1.6.md +43 -0
  18. package/docs/todo/v0.1.7.md +38 -0
  19. package/docs/todo/v0.1.8.md +36 -0
  20. package/docs/todo/v0.1.9.md +54 -0
  21. package/install-and-sync.sh +209 -0
  22. package/package.json +68 -0
  23. package/service/bin/huhaa-myskills.mjs +314 -0
  24. package/service/bin/lib/paths.mjs +57 -0
  25. package/service/bin/lib/port.mjs +23 -0
  26. package/service/config/sources.example.yaml +121 -0
  27. package/service/packages/scanner/src/adapters/file-docs.mjs +158 -0
  28. package/service/packages/scanner/src/adapters/hermes-plugin.mjs +149 -0
  29. package/service/packages/scanner/src/adapters/markdown-skill.mjs +219 -0
  30. package/service/packages/scanner/src/adapters/mcp-config.mjs +171 -0
  31. package/service/packages/scanner/src/index.mjs +234 -0
  32. package/service/packages/scanner/src/types.d.ts +88 -0
  33. package/service/packages/scanner/src/utils.mjs +186 -0
  34. package/service/packages/server/src/index.mjs +549 -0
  35. package/service/packages/web/README.md +5 -0
  36. package/service/packages/web/dist/assets/index-CTh5OdBd.js +36 -0
  37. package/service/packages/web/dist/assets/index-CtoiGnQ7.css +1 -0
  38. package/service/packages/web/dist/index.html +13 -0
  39. package/service/scripts/build-web.mjs +29 -0
  40. package/service/scripts/prepare-publish.mjs +44 -0
  41. package/service/scripts/restore-publish.mjs +17 -0
  42. package/service/scripts/sync-skills.sh +427 -0
  43. package/service/scripts/verify.mjs +132 -0
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ // huhaa-myskills CLI
3
+ //
4
+ // Subcommands:
5
+ // start — scan + start server + open browser
6
+ // scan — scan only, dump IR JSON to stdout
7
+ // init — write default sources.yaml to ~/.config/huhaa-myskills/
8
+ // purge — delete ~/.config/huhaa-myskills/ (zero-residue uninstall helper)
9
+ // dev — dev mode (P3+ wires Vite + nodemon)
10
+ //
11
+ // All user-side state lives under HUHAA_HOME (defaults to ~/.config/huhaa-myskills).
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { spawn } from 'node:child_process';
17
+
18
+ import {
19
+ homeDir,
20
+ configFile,
21
+ cacheFile,
22
+ stateFile,
23
+ ensureHomeDir,
24
+ writeJson,
25
+ } from './lib/paths.mjs';
26
+ import { pickPort } from './lib/port.mjs';
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+ // bin/ -> service/ -> repo root
30
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
31
+ const SERVICE_ROOT = path.resolve(__dirname, '..');
32
+
33
+ const cmd = process.argv[2] || 'start';
34
+
35
+ const handlers = {
36
+ start: cmdStart,
37
+ scan: cmdScan,
38
+ stats: cmdStats,
39
+ duplicates: cmdDuplicates,
40
+ init: cmdInit,
41
+ purge: cmdPurge,
42
+ dev: cmdDev,
43
+ help: cmdHelp,
44
+ '--help': cmdHelp,
45
+ '-h': cmdHelp,
46
+ };
47
+
48
+ const fn = handlers[cmd] || cmdHelp;
49
+ fn().catch(err => {
50
+ console.error('[huhaa-myskills] error:', err && err.stack || err);
51
+ process.exit(1);
52
+ });
53
+
54
+ // ---------------- handlers ----------------
55
+
56
+ async function cmdHelp() {
57
+ console.log(`huhaa-myskills — local skill / plugin / MCP aggregation hub
58
+
59
+ Usage:
60
+ huhaa-myskills <command>
61
+
62
+ Commands:
63
+ start Scan + start server + open browser (default)
64
+ scan Scan only, dump IR JSON to stdout
65
+ stats Scan + print human-readable summary (counts / brands / errors / samples)
66
+ duplicates Scan + print duplicate diagnostics by name/content/path
67
+ init Write default sources.yaml to ~/.config/huhaa-myskills/
68
+ purge Remove all user data under ~/.config/huhaa-myskills/
69
+ dev Dev mode (Vite + nodemon)
70
+ help Show this message
71
+
72
+ Env:
73
+ HUHAA_HOME override user data dir (default: ~/.config/huhaa-myskills)
74
+ PORT override preferred port (default: 11520, falls back +10)
75
+
76
+ Paths:
77
+ config ${configFile()}
78
+ cache ${cacheFile()}
79
+ state ${stateFile()}
80
+ `);
81
+ }
82
+
83
+ async function cmdInit() {
84
+ ensureHomeDir();
85
+ const dst = configFile();
86
+ if (fs.existsSync(dst)) {
87
+ console.log(`[init] sources.yaml already exists: ${dst}`);
88
+ console.log('[init] not overwriting. delete it first if you want to reset.');
89
+ return;
90
+ }
91
+ const tpl = path.join(SERVICE_ROOT, 'config', 'sources.example.yaml');
92
+ fs.copyFileSync(tpl, dst);
93
+ console.log(`[init] wrote ${dst}`);
94
+ console.log('[init] edit it to add / remove sources, then run: huhaa-myskills start');
95
+ }
96
+
97
+ async function cmdPurge() {
98
+ const dir = homeDir();
99
+ if (!fs.existsSync(dir)) {
100
+ console.log(`[purge] nothing to remove — ${dir} does not exist`);
101
+ return;
102
+ }
103
+ fs.rmSync(dir, { recursive: true, force: true });
104
+ console.log(`[purge] removed ${dir}`);
105
+ console.log('[purge] code dir is untouched. delete the repo manually if needed.');
106
+ }
107
+
108
+ async function cmdScan() {
109
+ // P0 placeholder. P1 wires real adapters.
110
+ const { scan } = await import('@huhaa/scanner');
111
+ await ensureConfigOrInit();
112
+ const items = await scan();
113
+ process.stdout.write(JSON.stringify(items, null, 2) + '\n');
114
+ console.error(`[scan] ${items.length} item(s)`);
115
+ }
116
+
117
+ async function cmdStats() {
118
+ const { scan } = await import('@huhaa/scanner');
119
+ await ensureConfigOrInit();
120
+ const items = await scan();
121
+
122
+ const bySource = {};
123
+ const byEditor = {};
124
+ const byCategory = {};
125
+ const byBrand = {};
126
+ const byKind = {};
127
+ const errors = [];
128
+ const noDescription = [];
129
+ const noTriggers = [];
130
+
131
+ for (const it of items) {
132
+ bySource[it.source] = (bySource[it.source] || 0) + 1;
133
+ const editor = it.editor || it.source || '(none)';
134
+ byEditor[editor] = (byEditor[editor] || 0) + 1;
135
+ byKind[it.kind] = (byKind[it.kind] || 0) + 1;
136
+ const cat = it.category || '(none)';
137
+ byCategory[cat] = (byCategory[cat] || 0) + 1;
138
+ const brand = it.brand || '(none)';
139
+ byBrand[brand] = (byBrand[brand] || 0) + 1;
140
+ if (it.parseError) errors.push(it);
141
+ if (!it.description) noDescription.push(it);
142
+ if (!it.triggers || !it.triggers.length) noTriggers.push(it);
143
+ }
144
+
145
+ const sortDesc = (obj) => Object.entries(obj).sort((a, b) => b[1] - a[1]);
146
+
147
+ console.log(`\nHuHaa-MySkills scan stats`);
148
+ console.log(`==========================`);
149
+ console.log(`total items: ${items.length}`);
150
+ console.log(`parse errors: ${errors.length}`);
151
+ console.log(`missing description: ${noDescription.length}`);
152
+ console.log(`missing triggers: ${noTriggers.length}`);
153
+
154
+ console.log(`\nby source:`);
155
+ for (const [k, v] of sortDesc(bySource)) console.log(` ${k.padEnd(20)} ${v}`);
156
+
157
+ console.log(`\nby editor:`);
158
+ for (const [k, v] of sortDesc(byEditor)) console.log(` ${k.padEnd(20)} ${v}`);
159
+
160
+ console.log(`\nby kind:`);
161
+ for (const [k, v] of sortDesc(byKind)) console.log(` ${k.padEnd(20)} ${v}`);
162
+
163
+ console.log(`\nby category (top 15):`);
164
+ for (const [k, v] of sortDesc(byCategory).slice(0, 15)) {
165
+ console.log(` ${k.padEnd(30)} ${v}`);
166
+ }
167
+
168
+ console.log(`\nby brand (top 15):`);
169
+ for (const [k, v] of sortDesc(byBrand).slice(0, 15)) {
170
+ console.log(` ${k.padEnd(20)} ${v}`);
171
+ }
172
+
173
+ if (errors.length) {
174
+ console.log(`\nparse errors (first 5):`);
175
+ for (const e of errors.slice(0, 5)) {
176
+ console.log(` - [${e.source}] ${e.name}: ${e.parseError}`);
177
+ console.log(` ${e.paths.abs}`);
178
+ }
179
+ }
180
+
181
+ // deterministic-ish sample: every Nth item so coverage spans both sources
182
+ const sampleN = Math.min(8, items.length);
183
+ if (sampleN > 0) {
184
+ const step = Math.max(1, Math.floor(items.length / sampleN));
185
+ console.log(`\nsample (every ${step}-th item, ${sampleN} shown):`);
186
+ for (let i = 0; i < sampleN; i++) {
187
+ const it = items[i * step];
188
+ if (!it) break;
189
+ const desc = (it.description || '(no description)').slice(0, 70);
190
+ const trigCount = (it.triggers || []).length;
191
+ console.log(
192
+ ` [${it.source.padEnd(11)}] ${(it.category || '-').padEnd(20)} ` +
193
+ `${it.name.padEnd(38)} brand=${(it.brand || '-').padEnd(14)} t=${trigCount}`
194
+ );
195
+ console.log(` ${desc}`);
196
+ }
197
+ }
198
+
199
+ console.log('');
200
+ }
201
+
202
+ async function cmdDuplicates() {
203
+ const { scan } = await import('@huhaa/scanner');
204
+ await ensureConfigOrInit();
205
+ const items = await scan();
206
+ const byName = groupBy(items, it => `${it.source}:${it.name}`);
207
+ const byContent = groupBy(items, it => `${it.source}:${hashShort(it.raw || it.preview || '')}`);
208
+ const byAbs = groupBy(items, it => it.paths?.abs || '');
209
+
210
+ const nameDups = [...byName.entries()].filter(([, arr]) => arr.length > 1);
211
+ const contentDups = [...byContent.entries()].filter(([k, arr]) => !k.endsWith(':00000000') && arr.length > 1);
212
+ const pathDups = [...byAbs.entries()].filter(([k, arr]) => k && arr.length > 1);
213
+
214
+ console.log('\nHuHaa-MySkills duplicate diagnostics');
215
+ console.log('====================================');
216
+ console.log(`total items: ${items.length}`);
217
+ console.log(`duplicate names: ${nameDups.length} groups`);
218
+ console.log(`duplicate contents: ${contentDups.length} groups`);
219
+ console.log(`duplicate abs paths: ${pathDups.length} groups`);
220
+
221
+ printDupGroup('duplicate names (first 20)', nameDups, 20);
222
+ printDupGroup('duplicate contents (first 20)', contentDups, 20);
223
+ printDupGroup('duplicate paths (first 20)', pathDups, 20);
224
+ console.log('');
225
+ }
226
+
227
+ function groupBy(items, keyFn) {
228
+ const m = new Map();
229
+ for (const it of items) {
230
+ const k = keyFn(it);
231
+ if (!m.has(k)) m.set(k, []);
232
+ m.get(k).push(it);
233
+ }
234
+ return m;
235
+ }
236
+
237
+ function hashShort(text) {
238
+ let h = 0;
239
+ for (let i = 0; i < text.length; i++) h = Math.imul(31, h) + text.charCodeAt(i) | 0;
240
+ return (h >>> 0).toString(16).padStart(8, '0');
241
+ }
242
+
243
+ function printDupGroup(title, groups, limit) {
244
+ if (!groups.length) return;
245
+ console.log(`\n${title}:`);
246
+ for (const [key, arr] of groups.slice(0, limit)) {
247
+ console.log(`- ${key} (${arr.length})`);
248
+ for (const it of arr.slice(0, 6)) {
249
+ console.log(` [${it.source}] ${it.name} :: ${it.paths?.abs}`);
250
+ }
251
+ }
252
+ }
253
+
254
+ async function cmdStart() {
255
+ await ensureConfigOrInit();
256
+ const preferred = parseInt(process.env.PORT || '11520', 10);
257
+ const port = await pickPort(preferred, 10);
258
+ if (!port) {
259
+ console.error(`[start] no free port in ${preferred}..${preferred + 10}, abort.`);
260
+ process.exit(1);
261
+ }
262
+ if (port !== preferred) {
263
+ console.error(`[start] port ${preferred} busy, using ${port} instead.`);
264
+ }
265
+
266
+ // Persist actual port for any tooling that wants to know.
267
+ writeJson(stateFile(), { port, pid: process.pid, startedAt: new Date().toISOString() });
268
+
269
+ const { startServer } = await import('@huhaa/server');
270
+ await startServer({ port });
271
+
272
+ console.log(`\n HuHaa-MySkills running: http://localhost:${port}\n`);
273
+ console.log(` data dir: ${homeDir()}`);
274
+ console.log(` Ctrl+C to stop.\n`);
275
+
276
+ // Auto-open browser on macOS / linux / win
277
+ openBrowser(`http://localhost:${port}`);
278
+
279
+ // Cleanup state file on exit so a stale port number doesn't linger.
280
+ const cleanup = () => {
281
+ try { fs.unlinkSync(stateFile()); } catch {}
282
+ process.exit(0);
283
+ };
284
+ process.on('SIGINT', cleanup);
285
+ process.on('SIGTERM', cleanup);
286
+ }
287
+
288
+ async function cmdDev() {
289
+ // P3+ wires Vite + nodemon. For now alias to start.
290
+ console.log('[dev] dev mode comes in P3. running start for now.');
291
+ await cmdStart();
292
+ }
293
+
294
+ // ---------------- helpers ----------------
295
+
296
+ async function ensureConfigOrInit() {
297
+ if (!fs.existsSync(configFile())) {
298
+ console.error(`[boot] no config yet, writing defaults to ${configFile()}`);
299
+ await cmdInit();
300
+ }
301
+ }
302
+
303
+ function openBrowser(url) {
304
+ const platform = process.platform;
305
+ let cmd, args;
306
+ if (platform === 'darwin') { cmd = 'open'; args = [url]; }
307
+ else if (platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '', url]; }
308
+ else { cmd = 'xdg-open'; args = [url]; }
309
+ try {
310
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
311
+ } catch {
312
+ // best-effort; user can copy-paste the URL
313
+ }
314
+ }
@@ -0,0 +1,57 @@
1
+ // XDG-style paths for huhaa-myskills.
2
+ // All user-side state lives under HUHAA_HOME (default: ~/.config/huhaa-myskills).
3
+ // Nothing is written outside this dir, so `purge` cleans everything.
4
+
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import fs from 'node:fs';
8
+
9
+ export function homeDir() {
10
+ if (process.env.HUHAA_HOME && process.env.HUHAA_HOME.trim()) {
11
+ return path.resolve(expandTilde(process.env.HUHAA_HOME));
12
+ }
13
+ // XDG_CONFIG_HOME respected if set, else ~/.config
14
+ const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim()
15
+ ? path.resolve(expandTilde(process.env.XDG_CONFIG_HOME))
16
+ : path.join(os.homedir(), '.config');
17
+ return path.join(xdg, 'huhaa-myskills');
18
+ }
19
+
20
+ export function configFile() {
21
+ return path.join(homeDir(), 'sources.yaml');
22
+ }
23
+
24
+ export function cacheFile() {
25
+ return path.join(homeDir(), 'cache.json');
26
+ }
27
+
28
+ export function stateFile() {
29
+ return path.join(homeDir(), 'state.json');
30
+ }
31
+
32
+ export function ensureHomeDir() {
33
+ const dir = homeDir();
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ return dir;
36
+ }
37
+
38
+ export function expandTilde(p) {
39
+ if (!p) return p;
40
+ if (p === '~' || p.startsWith('~/')) {
41
+ return path.join(os.homedir(), p.slice(2));
42
+ }
43
+ return p;
44
+ }
45
+
46
+ export function writeJson(file, obj) {
47
+ ensureHomeDir();
48
+ fs.writeFileSync(file, JSON.stringify(obj, null, 2));
49
+ }
50
+
51
+ export function readJson(file, fallback = null) {
52
+ try {
53
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
54
+ } catch {
55
+ return fallback;
56
+ }
57
+ }
@@ -0,0 +1,23 @@
1
+ // Pick a free port starting from `preferred`, walking up to `preferred + range`.
2
+ // Returns the port that successfully bound (we then close immediately so the
3
+ // real server can claim it). On any error or full exhaustion, returns null.
4
+
5
+ import net from 'node:net';
6
+
7
+ export async function pickPort(preferred = 11520, range = 10) {
8
+ for (let p = preferred; p <= preferred + range; p++) {
9
+ if (await isFree(p)) return p;
10
+ }
11
+ return null;
12
+ }
13
+
14
+ function isFree(port) {
15
+ return new Promise(resolve => {
16
+ const srv = net.createServer();
17
+ srv.unref();
18
+ srv.once('error', () => resolve(false));
19
+ srv.listen({ port, host: '127.0.0.1' }, () => {
20
+ srv.close(() => resolve(true));
21
+ });
22
+ });
23
+ }
@@ -0,0 +1,121 @@
1
+ # HuHaa-MySkills sources.yaml
2
+ #
3
+ # This file is copied to ~/.config/huhaa-myskills/sources.yaml by `npm run init`.
4
+ # Paths support ~ expansion and simple glob expansion in roots.
5
+ #
6
+ # Environment overrides:
7
+ # - HUHAA_HOME: user data directory
8
+ # - PORT: preferred HTTP port
9
+ #
10
+ # Safety:
11
+ # - adapters only read files; they do not execute tools or commands
12
+ # - MCP configs are redacted before entering raw/preview
13
+ # - copy/open API calls only operate on scanned item ids, not arbitrary paths
14
+
15
+ sources:
16
+ hermes:
17
+ enabled: true
18
+ roots:
19
+ - ~/.hermes/skills
20
+ - ~/.hermes/profiles/*/skills
21
+
22
+ claude-code:
23
+ enabled: true
24
+ roots:
25
+ - ~/.claude/skills
26
+
27
+ codex:
28
+ enabled: true
29
+ files:
30
+ - ~/.codex/AGENTS.md
31
+ roots: []
32
+ globs:
33
+ - AGENTS.md
34
+
35
+ cursor:
36
+ enabled: true
37
+ files: []
38
+ roots:
39
+ - ~/.cursor
40
+ globs:
41
+ - rules/**/*.{md,mdc}
42
+ - .cursorrules
43
+
44
+ mcp-config:
45
+ enabled: true
46
+ files:
47
+ - ~/.cursor/mcp.json
48
+ - ~/.codex/config.toml
49
+ - ~/Library/Application Support/Claude/claude_desktop_config.json
50
+ - ~/.hermes/config.yaml
51
+
52
+ hermes-plugin:
53
+ enabled: true
54
+ roots:
55
+ - ~/.hermes/plugins
56
+ - ~/.hermes/profiles/*/plugins
57
+
58
+ project-runbook:
59
+ enabled: true
60
+ # Add your project roots here. The default scans ~/Project/*.
61
+ # Each root scans docs/RUNBOOK-*.md plus common AI-agent instruction files.
62
+ roots:
63
+ - ~/Project/*
64
+ globs:
65
+ - docs/RUNBOOK-*.md
66
+ - AGENTS.md
67
+ - CLAUDE.md
68
+ - .cursorrules
69
+ - .cursor/rules/**/*.{md,mdc}
70
+
71
+ skills:
72
+ enabled: true
73
+ roots:
74
+ - ~/skills
75
+ - ~/Skills
76
+ globs:
77
+ - '*.md'
78
+ - '*/SKILL.md'
79
+ - '**/SKILL.md'
80
+
81
+ mcp:
82
+ enabled: true
83
+ roots:
84
+ - ~/mcp
85
+ - ~/MCP
86
+ globs:
87
+ - '*.md'
88
+ - '*.json'
89
+ - '*.yaml'
90
+ - '*.yml'
91
+ - '**/manifest.*'
92
+
93
+ skill:
94
+ enabled: true
95
+ roots:
96
+ - ~/skill
97
+ globs:
98
+ - '*.md'
99
+ - '*/SKILL.md'
100
+ - '**/SKILL.md'
101
+
102
+ obsidian:
103
+ enabled: false
104
+ roots: []
105
+ tagFilter: '#skill'
106
+
107
+ ui:
108
+ port: 11520
109
+ autoOpen: true
110
+ defaultEditor: cursor
111
+ groupBy: [editor, category, product]
112
+
113
+ limits:
114
+ maxFiles: 5000
115
+ maxFileBytes: 1048576
116
+
117
+ exclude:
118
+ - '**/node_modules/**'
119
+ - '**/.git/**'
120
+ - '**/dist/**'
121
+ - '**/.venv/**'
@@ -0,0 +1,158 @@
1
+ // Generic Markdown file adapter for Codex AGENTS.md, Cursor rules, and project runbooks.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import fg from 'fast-glob';
6
+ import {
7
+ classifyRoot,
8
+ deriveDescription,
9
+ expandRoots,
10
+ expandTilde,
11
+ inferBrand,
12
+ inferProduct,
13
+ makePreview,
14
+ parseFrontmatter,
15
+ readFileSafe,
16
+ sha1Id,
17
+ } from '../utils.mjs';
18
+
19
+ export async function scanFileDocs(opts) {
20
+ const {
21
+ source,
22
+ editor,
23
+ kind = 'doc',
24
+ files = [],
25
+ roots = [],
26
+ globs = [],
27
+ limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
28
+ } = opts;
29
+
30
+ const discovered = [];
31
+ for (const f of files || []) {
32
+ const abs = path.resolve(expandTilde(f));
33
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) discovered.push({ abs, root: path.dirname(abs) });
34
+ }
35
+
36
+ const expandedRoots = await expandRoots(roots || []);
37
+ const patterns = globs?.length ? globs : [];
38
+ for (const root of expandedRoots) {
39
+ for (const pattern of patterns) {
40
+ const found = await fg(pattern, {
41
+ cwd: root,
42
+ absolute: true,
43
+ onlyFiles: true,
44
+ dot: true,
45
+ followSymbolicLinks: false,
46
+ deep: 8,
47
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/.venv/**', '**/vendor_imports/**'],
48
+ });
49
+ for (const abs of found) {
50
+ discovered.push({ abs, root });
51
+ if (discovered.length >= limits.maxFiles) break;
52
+ }
53
+ if (discovered.length >= limits.maxFiles) break;
54
+ }
55
+ if (discovered.length >= limits.maxFiles) break;
56
+ }
57
+
58
+ const byAbs = new Map();
59
+ for (const x of discovered) if (!byAbs.has(x.abs)) byAbs.set(x.abs, x);
60
+ const items = [...byAbs.values()].map(x => parseDoc({ ...x, source, editor, kind, limits }));
61
+ return { items, stats: { source, available: true, files: items.length, roots: expandedRoots } };
62
+ }
63
+
64
+ function parseDoc({ abs, root, source, editor, kind, limits }) {
65
+ const { text, truncated, mtime, error } = readFileSafe(abs, limits.maxFileBytes);
66
+ const rel = path.relative(root, abs);
67
+ const id = sha1Id(abs);
68
+ const base = path.basename(abs);
69
+ const project = inferProjectName(abs, root);
70
+ const name = inferName(abs, base, project);
71
+ const category = inferCategory(abs, root, base);
72
+
73
+ if (error || truncated) {
74
+ return {
75
+ id,
76
+ kind,
77
+ source,
78
+ editor,
79
+ name,
80
+ description: error || `file > ${limits.maxFileBytes} bytes, skipped`,
81
+ category,
82
+ product: project || inferProduct({ name, category }),
83
+ paths: { abs, rel, rootKind: classifyRoot(abs) },
84
+ preview: '',
85
+ raw: '',
86
+ updatedAt: new Date(mtime).toISOString(),
87
+ parseError: error || 'file too large',
88
+ };
89
+ }
90
+
91
+ const { data: fm, body, parseError } = parseFrontmatter(text);
92
+ const title = (fm.title || fm.name || name).toString().trim();
93
+ const description = deriveDescription(fm, body);
94
+ const item = {
95
+ id,
96
+ kind,
97
+ source,
98
+ editor,
99
+ name: title,
100
+ description,
101
+ category,
102
+ product: project || inferProduct({ name: title, category }),
103
+ tags: collectTags(fm),
104
+ triggers: collectTriggers(fm),
105
+ paths: { abs, rel, rootKind: classifyRoot(abs) },
106
+ preview: makePreview(body || text),
107
+ raw: text,
108
+ updatedAt: new Date(mtime).toISOString(),
109
+ };
110
+ item.brand = inferBrand({ name: item.name, description: item.description, category: item.category, raw: body });
111
+ if (!item.tags?.length) delete item.tags;
112
+ if (!item.triggers?.length) delete item.triggers;
113
+ if (parseError) item.parseError = parseError;
114
+ return item;
115
+ }
116
+
117
+ function inferProjectName(abs, root) {
118
+ const homeProject = `${process.env.HOME}/Project/`;
119
+ if (abs.startsWith(homeProject)) return abs.slice(homeProject.length).split('/')[0];
120
+ const parts = root.split('/').filter(Boolean);
121
+ return parts.at(-1);
122
+ }
123
+
124
+ function inferName(abs, base, project) {
125
+ if (base === 'AGENTS.md') return `${project || 'project'} / AGENTS.md`;
126
+ if (base === 'CLAUDE.md') return `${project || 'project'} / CLAUDE.md`;
127
+ if (base === '.cursorrules') return `${project || 'project'} / .cursorrules`;
128
+ if (base.startsWith('RUNBOOK-')) return base.replace(/\.md$/i, '');
129
+ return base.replace(/\.mdc?$/i, '');
130
+ }
131
+
132
+ function inferCategory(abs, root, base) {
133
+ if (base === 'AGENTS.md') return 'codex';
134
+ if (base === 'CLAUDE.md') return 'claude-code';
135
+ if (base === '.cursorrules' || abs.includes('/.cursor/rules/')) return 'cursor';
136
+ if (base.startsWith('RUNBOOK-')) return 'runbook';
137
+ const relDir = path.dirname(path.relative(root, abs));
138
+ if (!relDir || relDir === '.') return 'project';
139
+ return relDir.split('/')[0];
140
+ }
141
+
142
+ function collectTags(fm) {
143
+ const v = fm.tags;
144
+ if (!v) return [];
145
+ if (Array.isArray(v)) return v.filter(x => typeof x === 'string');
146
+ if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
147
+ return [];
148
+ }
149
+
150
+ function collectTriggers(fm) {
151
+ const out = [];
152
+ for (const v of [fm.triggers, fm.aliases, fm.when_to_use]) {
153
+ if (!v) continue;
154
+ if (Array.isArray(v)) out.push(...v.filter(x => typeof x === 'string').map(x => x.trim()));
155
+ else if (typeof v === 'string') out.push(v.trim());
156
+ }
157
+ return [...new Set(out)];
158
+ }