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.
- package/LICENSE +21 -0
- package/README.md +384 -0
- package/docs/GUIDE.md +190 -0
- package/docs/PLAN.md +359 -0
- package/docs/RULES.md +329 -0
- package/docs/RUNBOOK-myskills.md +258 -0
- package/docs/SYNC-SKILLS.md +259 -0
- package/docs/releases/README.md +47 -0
- package/docs/releases/v0.1.0.md +256 -0
- package/docs/releases/v0.1.1.md +297 -0
- package/docs/releases/v0.1.2.md +207 -0
- package/docs/releases/v0.1.3.md +269 -0
- package/docs/todo/v0.1.2.md +98 -0
- package/docs/todo/v0.1.3.md +40 -0
- package/docs/todo/v0.1.4.md +36 -0
- package/docs/todo/v0.1.5.md +41 -0
- package/docs/todo/v0.1.6.md +43 -0
- package/docs/todo/v0.1.7.md +38 -0
- package/docs/todo/v0.1.8.md +36 -0
- package/docs/todo/v0.1.9.md +54 -0
- package/install-and-sync.sh +209 -0
- package/package.json +68 -0
- package/service/bin/huhaa-myskills.mjs +314 -0
- package/service/bin/lib/paths.mjs +57 -0
- package/service/bin/lib/port.mjs +23 -0
- package/service/config/sources.example.yaml +121 -0
- package/service/packages/scanner/src/adapters/file-docs.mjs +158 -0
- package/service/packages/scanner/src/adapters/hermes-plugin.mjs +149 -0
- package/service/packages/scanner/src/adapters/markdown-skill.mjs +219 -0
- package/service/packages/scanner/src/adapters/mcp-config.mjs +171 -0
- package/service/packages/scanner/src/index.mjs +234 -0
- package/service/packages/scanner/src/types.d.ts +88 -0
- package/service/packages/scanner/src/utils.mjs +186 -0
- package/service/packages/server/src/index.mjs +549 -0
- package/service/packages/web/README.md +5 -0
- package/service/packages/web/dist/assets/index-CTh5OdBd.js +36 -0
- package/service/packages/web/dist/assets/index-CtoiGnQ7.css +1 -0
- package/service/packages/web/dist/index.html +13 -0
- package/service/scripts/build-web.mjs +29 -0
- package/service/scripts/prepare-publish.mjs +44 -0
- package/service/scripts/restore-publish.mjs +17 -0
- package/service/scripts/sync-skills.sh +427 -0
- package/service/scripts/verify.mjs +132 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @huhaa/scanner — multi-source skill aggregator.
|
|
2
|
+
//
|
|
3
|
+
// P1 wires hermes + claude-code adapters via the shared markdown-skill
|
|
4
|
+
// scanner. Other adapters land in P4. The orchestrator loads sources.yaml
|
|
5
|
+
// from ~/.config/huhaa-myskills/, dispatches to enabled adapters, and
|
|
6
|
+
// returns a flat IR list.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import YAML from 'yaml';
|
|
10
|
+
import { configFile } from '../../../bin/lib/paths.mjs';
|
|
11
|
+
import { scanMarkdownSkills } from './adapters/markdown-skill.mjs';
|
|
12
|
+
import { scanFileDocs } from './adapters/file-docs.mjs';
|
|
13
|
+
import { scanMcpConfigs } from './adapters/mcp-config.mjs';
|
|
14
|
+
import { scanHermesPlugins } from './adapters/hermes-plugin.mjs';
|
|
15
|
+
import { expandRoots, expandTilde } from './utils.mjs';
|
|
16
|
+
|
|
17
|
+
const ADAPTERS = {
|
|
18
|
+
hermes: async (cfg, limits) => scanMarkdownSkills({
|
|
19
|
+
source: 'hermes',
|
|
20
|
+
roots: cfg.roots || [],
|
|
21
|
+
fileGlob: '**/SKILL.md',
|
|
22
|
+
limits,
|
|
23
|
+
}),
|
|
24
|
+
'claude-code': async (cfg, limits) => scanMarkdownSkills({
|
|
25
|
+
source: 'claude-code',
|
|
26
|
+
roots: cfg.roots || [],
|
|
27
|
+
fileGlob: '**/SKILL.md',
|
|
28
|
+
limits,
|
|
29
|
+
}),
|
|
30
|
+
codex: async (cfg, limits) => scanFileDocs({
|
|
31
|
+
source: 'codex',
|
|
32
|
+
editor: 'Codex',
|
|
33
|
+
kind: 'instruction',
|
|
34
|
+
files: cfg.files || [],
|
|
35
|
+
roots: cfg.roots || [],
|
|
36
|
+
globs: normalizeGlobs(cfg, ['AGENTS.md']),
|
|
37
|
+
limits,
|
|
38
|
+
}),
|
|
39
|
+
cursor: async (cfg, limits) => scanFileDocs({
|
|
40
|
+
source: 'cursor',
|
|
41
|
+
editor: 'Cursor',
|
|
42
|
+
kind: 'instruction',
|
|
43
|
+
files: cfg.files || [],
|
|
44
|
+
roots: cfg.roots || [],
|
|
45
|
+
globs: normalizeGlobs(cfg, ['.cursorrules', '.cursor/rules/**/*.{md,mdc}']),
|
|
46
|
+
limits,
|
|
47
|
+
}),
|
|
48
|
+
'mcp-config': async (cfg, limits) => scanMcpConfigs({
|
|
49
|
+
source: 'mcp-config',
|
|
50
|
+
editor: 'MCP',
|
|
51
|
+
files: cfg.files || [],
|
|
52
|
+
limits,
|
|
53
|
+
}),
|
|
54
|
+
'hermes-plugin': async (cfg, limits) => scanHermesPlugins({
|
|
55
|
+
source: 'hermes-plugin',
|
|
56
|
+
editor: 'Hermes Agent',
|
|
57
|
+
roots: cfg.roots || [],
|
|
58
|
+
limits,
|
|
59
|
+
}),
|
|
60
|
+
'project-runbook': async (cfg, limits) => scanFileDocs({
|
|
61
|
+
source: 'project-runbook',
|
|
62
|
+
editor: 'Project Docs',
|
|
63
|
+
kind: 'runbook',
|
|
64
|
+
files: cfg.files || [],
|
|
65
|
+
roots: cfg.roots || [],
|
|
66
|
+
globs: normalizeGlobs(cfg, ['docs/RUNBOOK-*.md', 'AGENTS.md', 'CLAUDE.md', '.cursorrules', '.cursor/rules/**/*.{md,mdc}']),
|
|
67
|
+
limits,
|
|
68
|
+
}),
|
|
69
|
+
'skills': async (cfg, limits) => scanFileDocs({
|
|
70
|
+
source: 'skills',
|
|
71
|
+
editor: 'Skills Hub',
|
|
72
|
+
kind: 'skill',
|
|
73
|
+
files: cfg.files || [],
|
|
74
|
+
roots: cfg.roots || [],
|
|
75
|
+
globs: normalizeGlobs(cfg, ['*.md', '*/SKILL.md', '**/SKILL.md']),
|
|
76
|
+
limits,
|
|
77
|
+
}),
|
|
78
|
+
'mcp': async (cfg, limits) => scanFileDocs({
|
|
79
|
+
source: 'mcp',
|
|
80
|
+
editor: 'MCP Hub',
|
|
81
|
+
kind: 'mcp-tool',
|
|
82
|
+
files: cfg.files || [],
|
|
83
|
+
roots: cfg.roots || [],
|
|
84
|
+
globs: normalizeGlobs(cfg, ['*.md', '*.json', '*.yaml', '*.yml', '**/manifest.*']),
|
|
85
|
+
limits,
|
|
86
|
+
}),
|
|
87
|
+
'skill': async (cfg, limits) => scanFileDocs({
|
|
88
|
+
source: 'skill',
|
|
89
|
+
editor: 'Skill Hub',
|
|
90
|
+
kind: 'skill',
|
|
91
|
+
files: cfg.files || [],
|
|
92
|
+
roots: cfg.roots || [],
|
|
93
|
+
globs: normalizeGlobs(cfg, ['*.md', '*/SKILL.md', '**/SKILL.md']),
|
|
94
|
+
limits,
|
|
95
|
+
}),
|
|
96
|
+
// Future: obsidian
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function normalizeGlobs(cfg, defaults) {
|
|
100
|
+
if (Array.isArray(cfg.globs)) return cfg.globs.filter(Boolean);
|
|
101
|
+
if (cfg.glob) return [cfg.glob].flat().filter(Boolean);
|
|
102
|
+
return defaults;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Run all enabled adapters and return a flat IR list.
|
|
107
|
+
* @returns {Promise<import('./types').SkillItem[]>}
|
|
108
|
+
*/
|
|
109
|
+
export async function scan() {
|
|
110
|
+
const cfg = loadConfig();
|
|
111
|
+
if (!cfg) return [];
|
|
112
|
+
|
|
113
|
+
const limits = {
|
|
114
|
+
maxFiles: cfg.limits?.maxFiles ?? 5000,
|
|
115
|
+
maxFileBytes: cfg.limits?.maxFileBytes ?? 1024 * 1024,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const all = [];
|
|
119
|
+
const stats = [];
|
|
120
|
+
for (const [name, src] of Object.entries(cfg.sources || {})) {
|
|
121
|
+
if (!src?.enabled) continue;
|
|
122
|
+
const fn = ADAPTERS[name];
|
|
123
|
+
if (!fn) continue; // unknown adapter (e.g. mcp-config in P1) — skip silently
|
|
124
|
+
try {
|
|
125
|
+
const { items, stats: s } = await fn(src, limits);
|
|
126
|
+
all.push(...items);
|
|
127
|
+
stats.push(s);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
stats.push({ source: name, available: false, error: e.message });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const out = dedupeSemantic(all);
|
|
134
|
+
|
|
135
|
+
if (process.env.HUHAA_DEBUG) {
|
|
136
|
+
console.error('[scan] stats:', JSON.stringify(stats, null, 2));
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function getWatchTargets() {
|
|
142
|
+
const cfg = loadConfig();
|
|
143
|
+
const targets = new Set([configFile()]);
|
|
144
|
+
if (!cfg) return [...targets];
|
|
145
|
+
|
|
146
|
+
for (const [name, src] of Object.entries(cfg.sources || {})) {
|
|
147
|
+
if (!src?.enabled) continue;
|
|
148
|
+
for (const f of src.files || []) targets.add(expandTilde(f));
|
|
149
|
+
|
|
150
|
+
const roots = await expandRoots(src.roots || []);
|
|
151
|
+
const globs = normalizeGlobs(src, defaultWatchGlobs(name));
|
|
152
|
+
for (const root of roots) {
|
|
153
|
+
for (const g of globs) targets.add(`${root}/${g}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return [...targets];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function defaultWatchGlobs(name) {
|
|
161
|
+
switch (name) {
|
|
162
|
+
case 'hermes':
|
|
163
|
+
case 'claude-code':
|
|
164
|
+
return ['**/SKILL.md'];
|
|
165
|
+
case 'skills':
|
|
166
|
+
case 'skill':
|
|
167
|
+
return ['*.md', '*/SKILL.md', '**/SKILL.md'];
|
|
168
|
+
case 'mcp':
|
|
169
|
+
return ['*.md', '*.json', '*.yaml', '*.yml', '**/manifest.*'];
|
|
170
|
+
case 'hermes-plugin':
|
|
171
|
+
return ['**/{plugin.yaml,plugin.yml,plugin.json,manifest.json,package.json,README.md,readme.md}'];
|
|
172
|
+
case 'project-runbook':
|
|
173
|
+
return ['docs/RUNBOOK-*.md', 'AGENTS.md', 'CLAUDE.md', '.cursorrules', '.cursor/rules/**/*.{md,mdc}'];
|
|
174
|
+
case 'cursor':
|
|
175
|
+
return ['rules/**/*.{md,mdc}', '.cursorrules'];
|
|
176
|
+
case 'codex':
|
|
177
|
+
return ['AGENTS.md'];
|
|
178
|
+
default:
|
|
179
|
+
return ['**/*'];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function dedupeSemantic(items) {
|
|
184
|
+
// First remove exact same id defensively.
|
|
185
|
+
const byId = new Map();
|
|
186
|
+
for (const it of items) if (!byId.has(it.id)) byId.set(it.id, it);
|
|
187
|
+
|
|
188
|
+
// Then collapse repeated exports of the same semantic skill.
|
|
189
|
+
// gstack publishes the same skill into multiple hidden tool namespaces:
|
|
190
|
+
// .agents/.cursor/.factory/.gbrain/.hermes/.kiro/.opencode/.openclaw/...
|
|
191
|
+
// Those are useful as provenance, but noisy as separate entries in this hub.
|
|
192
|
+
const bySemantic = new Map();
|
|
193
|
+
for (const it of byId.values()) {
|
|
194
|
+
const key = `${it.source}:${it.kind}:${it.name}`;
|
|
195
|
+
const current = bySemantic.get(key);
|
|
196
|
+
if (!current || rankItem(it) > rankItem(current)) bySemantic.set(key, it);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return [...bySemantic.values()].sort((a, b) => {
|
|
200
|
+
const sa = `${a.source}\u0000${a.category || ''}\u0000${a.name}`;
|
|
201
|
+
const sb = `${b.source}\u0000${b.category || ''}\u0000${b.name}`;
|
|
202
|
+
return sa.localeCompare(sb);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function rankItem(it) {
|
|
207
|
+
const p = it.paths?.abs || '';
|
|
208
|
+
const parts = p.split('/').filter(Boolean);
|
|
209
|
+
const parent = parts.at(-2) || '';
|
|
210
|
+
const hiddenSegments = parts.filter(x => x.startsWith('.')).length;
|
|
211
|
+
let score = 0;
|
|
212
|
+
if (hiddenSegments === 0) score += 1000;
|
|
213
|
+
if (parent === it.name) score += 400;
|
|
214
|
+
if (it.description) score += 80;
|
|
215
|
+
if (it.triggers?.length) score += 60;
|
|
216
|
+
if (it.raw) score += Math.min(50, Math.floor(it.raw.length / 1000));
|
|
217
|
+
score -= hiddenSegments * 50;
|
|
218
|
+
score -= Math.min(200, p.length / 10);
|
|
219
|
+
return score;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function loadConfig() {
|
|
223
|
+
const f = configFile();
|
|
224
|
+
if (!fs.existsSync(f)) return null;
|
|
225
|
+
try {
|
|
226
|
+
const text = fs.readFileSync(f, 'utf8');
|
|
227
|
+
return YAML.parse(text) || {};
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.error(`[scan] sources.yaml parse error: ${e.message}`);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export { configFile };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Unified IR (Intermediate Representation) — every adapter normalizes its
|
|
2
|
+
// source into this shape. The web UI only knows about SkillItem, never the
|
|
3
|
+
// raw source layout.
|
|
4
|
+
//
|
|
5
|
+
// Type definitions live here for IDE / future TS migration. Runtime is plain
|
|
6
|
+
// JS, no compile step needed.
|
|
7
|
+
|
|
8
|
+
export interface ParamSpec {
|
|
9
|
+
name: string;
|
|
10
|
+
type?: string;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
description?: string;
|
|
13
|
+
default?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SkillItem {
|
|
17
|
+
/** sha1(absPath) — stable across runs, doubles as web-side key */
|
|
18
|
+
id: string;
|
|
19
|
+
|
|
20
|
+
/** broad type — drives icon + filtering */
|
|
21
|
+
kind: 'skill' | 'plugin' | 'mcp' | 'runbook' | 'instruction' | 'config' | 'doc' | 'agent-rule';
|
|
22
|
+
|
|
23
|
+
/** which adapter produced this item */
|
|
24
|
+
source:
|
|
25
|
+
| 'hermes'
|
|
26
|
+
| 'claude-code'
|
|
27
|
+
| 'codex'
|
|
28
|
+
| 'cursor'
|
|
29
|
+
| 'obsidian'
|
|
30
|
+
| 'project'
|
|
31
|
+
| 'project-runbook'
|
|
32
|
+
| 'hermes-plugin'
|
|
33
|
+
| 'mcp-config';
|
|
34
|
+
|
|
35
|
+
/** machine name (frontmatter.name or file basename) */
|
|
36
|
+
name: string;
|
|
37
|
+
|
|
38
|
+
/** human-readable title — falls back to name */
|
|
39
|
+
title?: string;
|
|
40
|
+
|
|
41
|
+
/** one-line description — falls back to first paragraph */
|
|
42
|
+
description?: string;
|
|
43
|
+
|
|
44
|
+
/** category path, e.g. "devops", "mlops/inference" */
|
|
45
|
+
category?: string;
|
|
46
|
+
|
|
47
|
+
/** owning editor/tool surface, e.g. "Hermes Agent", "Claude Code", "Cursor" */
|
|
48
|
+
editor?: string;
|
|
49
|
+
|
|
50
|
+
/** brand inferred from description / path, e.g. "OpenAI", "Cloudflare" */
|
|
51
|
+
brand?: string;
|
|
52
|
+
|
|
53
|
+
/** product name extracted from skill, e.g. "new-api", "frp" */
|
|
54
|
+
product?: string;
|
|
55
|
+
|
|
56
|
+
/** trigger phrases (when_to_use / triggers / aliases) */
|
|
57
|
+
triggers?: string[];
|
|
58
|
+
|
|
59
|
+
/** MCP tool param schema — empty for skills */
|
|
60
|
+
params?: ParamSpec[];
|
|
61
|
+
|
|
62
|
+
/** free-form tags */
|
|
63
|
+
tags?: string[];
|
|
64
|
+
|
|
65
|
+
paths: {
|
|
66
|
+
/** absolute path on disk — copy-button payload */
|
|
67
|
+
abs: string;
|
|
68
|
+
/** path relative to project root if applicable */
|
|
69
|
+
rel?: string;
|
|
70
|
+
/** classification of the root the file lives under */
|
|
71
|
+
rootKind: 'home' | 'project' | 'icloud';
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** truncated markdown body for list-view preview */
|
|
75
|
+
preview: string;
|
|
76
|
+
|
|
77
|
+
/** complete file content — lazy-loaded by detail view via /api/skills/:id */
|
|
78
|
+
raw: string;
|
|
79
|
+
|
|
80
|
+
/** related links (docs URL, source repo) discovered in frontmatter */
|
|
81
|
+
links?: { label: string; url: string }[];
|
|
82
|
+
|
|
83
|
+
/** ISO timestamp — last modified time of the underlying file */
|
|
84
|
+
updatedAt: string;
|
|
85
|
+
|
|
86
|
+
/** present when frontmatter / format parse failed; UI shows red badge */
|
|
87
|
+
parseError?: string;
|
|
88
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Shared scanner utilities — every adapter MUST use these for consistency.
|
|
2
|
+
//
|
|
3
|
+
// parseFrontmatter YAML frontmatter at top of markdown, tolerant
|
|
4
|
+
// sha1Id stable id from absolute path
|
|
5
|
+
// expandRoots glob-aware path resolution (~ / *)
|
|
6
|
+
// inferBrand keyword-match brand from text
|
|
7
|
+
// inferProduct extract product from skill name / category
|
|
8
|
+
// classifyRoot 'home' | 'project' | 'icloud'
|
|
9
|
+
// readFileSafe size-guarded read
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import YAML from 'yaml';
|
|
16
|
+
import fg from 'fast-glob';
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────── path / fs ────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export function expandTilde(p) {
|
|
21
|
+
if (!p) return p;
|
|
22
|
+
if (p === '~' || p.startsWith('~/')) {
|
|
23
|
+
return path.join(os.homedir(), p.slice(2));
|
|
24
|
+
}
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Expand a list of roots (may contain ~ and glob *) into actual existing dirs.
|
|
30
|
+
* Non-existing paths are silently dropped — callers report "unavailable" via
|
|
31
|
+
* stat checks separately.
|
|
32
|
+
*/
|
|
33
|
+
export async function expandRoots(roots) {
|
|
34
|
+
if (!roots || !roots.length) return [];
|
|
35
|
+
const out = new Set();
|
|
36
|
+
for (const r of roots) {
|
|
37
|
+
const expanded = expandTilde(r);
|
|
38
|
+
if (expanded.includes('*')) {
|
|
39
|
+
const matches = await fg(expanded, { onlyDirectories: true, absolute: true });
|
|
40
|
+
matches.forEach(m => out.add(m));
|
|
41
|
+
} else {
|
|
42
|
+
const abs = path.resolve(expanded);
|
|
43
|
+
if (fs.existsSync(abs)) out.add(abs);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [...out];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function classifyRoot(absPath) {
|
|
50
|
+
if (absPath.includes('/Library/Mobile Documents/')) return 'icloud';
|
|
51
|
+
if (absPath.startsWith(os.homedir() + '/Project/')) return 'project';
|
|
52
|
+
if (absPath.startsWith(os.homedir())) return 'home';
|
|
53
|
+
return 'project';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readFileSafe(abs, maxBytes = 1024 * 1024) {
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.statSync(abs);
|
|
59
|
+
if (stat.size > maxBytes) {
|
|
60
|
+
return { text: '', truncated: true, size: stat.size, mtime: stat.mtime };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
text: fs.readFileSync(abs, 'utf8'),
|
|
64
|
+
truncated: false,
|
|
65
|
+
size: stat.size,
|
|
66
|
+
mtime: stat.mtime,
|
|
67
|
+
};
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return { text: '', error: e.message, size: 0, mtime: new Date(0) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─────────────────────────────── id / hash ────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export function sha1Id(absPath) {
|
|
76
|
+
return crypto.createHash('sha1').update(absPath).digest('hex').slice(0, 16);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────── frontmatter ──────────────────────────────
|
|
80
|
+
|
|
81
|
+
const FM_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse YAML frontmatter at the very top of a markdown file.
|
|
85
|
+
* Returns { data, body, parseError? }. `data` is always an object (possibly
|
|
86
|
+
* empty). Body never has the frontmatter block.
|
|
87
|
+
*/
|
|
88
|
+
export function parseFrontmatter(text) {
|
|
89
|
+
if (!text || !text.startsWith('---')) {
|
|
90
|
+
return { data: {}, body: text || '' };
|
|
91
|
+
}
|
|
92
|
+
const m = text.match(FM_RE);
|
|
93
|
+
if (!m) {
|
|
94
|
+
return { data: {}, body: text };
|
|
95
|
+
}
|
|
96
|
+
const [, yamlBlock, body] = m;
|
|
97
|
+
try {
|
|
98
|
+
const data = YAML.parse(yamlBlock) || {};
|
|
99
|
+
return {
|
|
100
|
+
data: typeof data === 'object' && !Array.isArray(data) ? data : {},
|
|
101
|
+
body: body || '',
|
|
102
|
+
};
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { data: {}, body: body || text, parseError: `frontmatter: ${e.message}` };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─────────────────────────────── inference ────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const BRAND_KEYWORDS = [
|
|
111
|
+
// tooling
|
|
112
|
+
['Hermes', /\bhermes\b/i],
|
|
113
|
+
['Claude Code', /\bclaude[\s-]?code\b/i],
|
|
114
|
+
['Anthropic', /\b(anthropic|claude)\b/i],
|
|
115
|
+
['OpenAI', /\b(openai|chatgpt|gpt-?[0-9]|codex)\b/i],
|
|
116
|
+
['Cursor', /\bcursor\b/i],
|
|
117
|
+
['GitHub', /\b(github|\bgh\b|octokit)\b/i],
|
|
118
|
+
['Cloudflare', /\b(cloudflare|cf-|workers|wrangler|r2|d1)\b/i],
|
|
119
|
+
['Google', /\b(gemini|google\s|gws)\b/i],
|
|
120
|
+
['Apple', /\b(apple|imessage|appleнотes|findmy|macos)\b/i],
|
|
121
|
+
['Notion', /\bnotion\b/i],
|
|
122
|
+
['Linear', /\blinear\b/i],
|
|
123
|
+
['Obsidian', /\bobsidian\b/i],
|
|
124
|
+
['Modal', /\bmodal\b/i],
|
|
125
|
+
['HuggingFace', /\b(huggingface|hf-cli|hf\s+hub)\b/i],
|
|
126
|
+
['Discord', /\bdiscord\b/i],
|
|
127
|
+
['Telegram', /\btelegram\b/i],
|
|
128
|
+
['X', /\b(twitter|x\.com|x-cli|xurl)\b/i],
|
|
129
|
+
['Vercel', /\bvercel\b/i],
|
|
130
|
+
['Docker', /\bdocker\b/i],
|
|
131
|
+
['Kubernetes', /\b(kubernetes|kubectl|k8s)\b/i],
|
|
132
|
+
['NVIDIA', /\b(cuda|nvidia)\b/i],
|
|
133
|
+
['Suno', /\bsuno\b/i],
|
|
134
|
+
['Spotify', /\bspotify\b/i],
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
export function inferBrand({ name, description, category, raw }) {
|
|
138
|
+
const hay = [name, description, category].filter(Boolean).join(' ');
|
|
139
|
+
const head = (raw || '').slice(0, 800);
|
|
140
|
+
const text = `${hay} ${head}`;
|
|
141
|
+
for (const [brand, re] of BRAND_KEYWORDS) {
|
|
142
|
+
if (re.test(text)) return brand;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Infer "product" — typically the skill name itself for product-shaped skills
|
|
149
|
+
* (e.g. "new-api-deployment" → "new-api"), or the bare name otherwise.
|
|
150
|
+
*/
|
|
151
|
+
export function inferProduct({ name, category }) {
|
|
152
|
+
if (!name) return undefined;
|
|
153
|
+
// Strip common suffixes that aren't product names
|
|
154
|
+
const cleaned = name
|
|
155
|
+
.replace(/-deployment$/, '')
|
|
156
|
+
.replace(/-workflow$/, '')
|
|
157
|
+
.replace(/-bot-notifications$/, '')
|
|
158
|
+
.replace(/-management$/, '');
|
|
159
|
+
return cleaned;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─────────────────────────────── markdown ─────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Extract a one-line description: prefer frontmatter.description, else first
|
|
166
|
+
* non-empty paragraph (no headings, no blank).
|
|
167
|
+
*/
|
|
168
|
+
export function deriveDescription(fmData, body) {
|
|
169
|
+
if (fmData?.description && typeof fmData.description === 'string') {
|
|
170
|
+
return fmData.description.trim();
|
|
171
|
+
}
|
|
172
|
+
for (const line of body.split(/\r?\n/)) {
|
|
173
|
+
const t = line.trim();
|
|
174
|
+
if (!t) continue;
|
|
175
|
+
if (t.startsWith('#')) continue;
|
|
176
|
+
if (t.startsWith('---')) continue;
|
|
177
|
+
return t.slice(0, 240);
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function makePreview(body, maxChars = 600) {
|
|
183
|
+
const trimmed = (body || '').trim();
|
|
184
|
+
if (trimmed.length <= maxChars) return trimmed;
|
|
185
|
+
return trimmed.slice(0, maxChars).trimEnd() + '…';
|
|
186
|
+
}
|