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
|
@@ -9,90 +9,115 @@ import fs from 'node:fs';
|
|
|
9
9
|
import YAML from 'yaml';
|
|
10
10
|
import { configFile } from '../../../bin/lib/paths.mjs';
|
|
11
11
|
import { scanMarkdownSkills } from './adapters/markdown-skill.mjs';
|
|
12
|
+
import { scanDirectorySkills } from './adapters/directory-skill.mjs';
|
|
12
13
|
import { scanFileDocs } from './adapters/file-docs.mjs';
|
|
13
14
|
import { scanMcpConfigs } from './adapters/mcp-config.mjs';
|
|
14
15
|
import { scanHermesPlugins } from './adapters/hermes-plugin.mjs';
|
|
15
16
|
import { expandRoots, expandTilde } from './utils.mjs';
|
|
16
17
|
|
|
17
18
|
const ADAPTERS = {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
19
|
+
// Tier 1: Tool adapters (return items with tier='tool', brand set)
|
|
20
|
+
hermes: async (cfg, limits) => {
|
|
21
|
+
const result = await scanMarkdownSkills({
|
|
22
|
+
source: 'hermes',
|
|
23
|
+
roots: cfg.roots || [],
|
|
24
|
+
fileGlob: '**/SKILL.md',
|
|
25
|
+
limits,
|
|
26
|
+
});
|
|
27
|
+
result.items.forEach(it => {
|
|
28
|
+
it.tier = 'tool';
|
|
29
|
+
it.brand = 'hermes';
|
|
30
|
+
});
|
|
31
|
+
return result;
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
'claude-code': async (cfg, limits) => {
|
|
35
|
+
const result = await scanMarkdownSkills({
|
|
36
|
+
source: 'claude-code',
|
|
37
|
+
roots: cfg.roots || [],
|
|
38
|
+
fileGlob: '**/SKILL.md',
|
|
39
|
+
limits,
|
|
40
|
+
});
|
|
41
|
+
result.items.forEach(it => {
|
|
42
|
+
it.tier = 'tool';
|
|
43
|
+
it.brand = 'claude';
|
|
44
|
+
});
|
|
45
|
+
return result;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
codex: async (cfg, limits) => {
|
|
49
|
+
const result = await scanFileDocs({
|
|
50
|
+
source: 'codex',
|
|
51
|
+
editor: 'Codex',
|
|
52
|
+
kind: 'instruction',
|
|
53
|
+
files: cfg.files || [],
|
|
54
|
+
roots: cfg.roots || [],
|
|
55
|
+
globs: normalizeGlobs(cfg, ['AGENTS.md']),
|
|
56
|
+
limits,
|
|
57
|
+
});
|
|
58
|
+
result.items.forEach(it => {
|
|
59
|
+
it.tier = 'tool';
|
|
60
|
+
it.brand = 'codex';
|
|
61
|
+
});
|
|
62
|
+
return result;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
cursor: async (cfg, limits) => {
|
|
66
|
+
const result = await scanFileDocs({
|
|
67
|
+
source: 'cursor',
|
|
68
|
+
editor: 'Cursor',
|
|
69
|
+
kind: 'instruction',
|
|
70
|
+
files: cfg.files || [],
|
|
71
|
+
roots: cfg.roots || [],
|
|
72
|
+
globs: normalizeGlobs(cfg, ['.cursorrules', '.cursor/rules/**/*.{md,mdc}']),
|
|
73
|
+
limits,
|
|
74
|
+
});
|
|
75
|
+
result.items.forEach(it => {
|
|
76
|
+
it.tier = 'tool';
|
|
77
|
+
it.brand = 'cursor';
|
|
78
|
+
});
|
|
79
|
+
return result;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
'hermes-plugin': async (cfg, limits) => {
|
|
83
|
+
const result = await scanHermesPlugins({
|
|
84
|
+
source: 'hermes-plugin',
|
|
85
|
+
editor: 'Hermes Agent',
|
|
86
|
+
roots: cfg.roots || [],
|
|
87
|
+
limits,
|
|
88
|
+
});
|
|
89
|
+
result.items.forEach(it => {
|
|
90
|
+
it.tier = 'tool';
|
|
91
|
+
it.brand = 'hermes';
|
|
92
|
+
});
|
|
93
|
+
return result;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// Tier 2: Directory-based skills (already returns tier='directory', dirName)
|
|
97
|
+
'directory-skill': async (cfg, limits) => scanDirectorySkills({
|
|
98
|
+
paths: cfg.paths || [],
|
|
99
|
+
globs: normalizeGlobs(cfg, ['**/SKILL.md']),
|
|
94
100
|
limits,
|
|
95
101
|
}),
|
|
102
|
+
|
|
103
|
+
// Tier 3: Other sources (assign tier='other')
|
|
104
|
+
'project-runbook': async (cfg, limits) => {
|
|
105
|
+
const result = await scanFileDocs({
|
|
106
|
+
source: 'project-runbook',
|
|
107
|
+
editor: 'Project Docs',
|
|
108
|
+
kind: 'runbook',
|
|
109
|
+
files: cfg.files || [],
|
|
110
|
+
roots: cfg.roots || [],
|
|
111
|
+
globs: normalizeGlobs(cfg, ['docs/RUNBOOK-*.md', 'AGENTS.md', 'CLAUDE.md', '.cursorrules', '.cursor/rules/**/*.{md,mdc}']),
|
|
112
|
+
limits,
|
|
113
|
+
});
|
|
114
|
+
result.items.forEach(it => {
|
|
115
|
+
it.tier = 'other';
|
|
116
|
+
});
|
|
117
|
+
return result;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
// Removed: 'mcp-config', 'mcp', 'skills', 'skill' — no longer scanned
|
|
96
121
|
// Future: obsidian
|
|
97
122
|
};
|
|
98
123
|
|
|
@@ -159,7 +184,11 @@ export async function getWatchTargets() {
|
|
|
159
184
|
if (!src?.enabled) continue;
|
|
160
185
|
for (const f of src.files || []) targets.add(expandTilde(f));
|
|
161
186
|
|
|
162
|
-
|
|
187
|
+
// directory-skill uses 'paths' instead of 'roots'
|
|
188
|
+
const roots = name === 'directory-skill'
|
|
189
|
+
? await expandRoots(src.paths || [])
|
|
190
|
+
: await expandRoots(src.roots || []);
|
|
191
|
+
|
|
163
192
|
const globs = normalizeGlobs(src, defaultWatchGlobs(name));
|
|
164
193
|
for (const root of roots) {
|
|
165
194
|
for (const g of globs) targets.add(`${root}/${g}`);
|
|
@@ -173,12 +202,8 @@ function defaultWatchGlobs(name) {
|
|
|
173
202
|
switch (name) {
|
|
174
203
|
case 'hermes':
|
|
175
204
|
case 'claude-code':
|
|
205
|
+
case 'directory-skill':
|
|
176
206
|
return ['**/SKILL.md'];
|
|
177
|
-
case 'skills':
|
|
178
|
-
case 'skill':
|
|
179
|
-
return ['*.md', '*/SKILL.md', '**/SKILL.md'];
|
|
180
|
-
case 'mcp':
|
|
181
|
-
return ['*.md', '*.json', '*.yaml', '*.yml', '**/manifest.*'];
|
|
182
207
|
case 'hermes-plugin':
|
|
183
208
|
return ['**/{plugin.yaml,plugin.yml,plugin.json,manifest.json,package.json,README.md,readme.md}'];
|
|
184
209
|
case 'project-runbook':
|
|
@@ -30,6 +30,7 @@ export interface SkillItem {
|
|
|
30
30
|
| 'project'
|
|
31
31
|
| 'project-runbook'
|
|
32
32
|
| 'hermes-plugin'
|
|
33
|
+
| 'directory'
|
|
33
34
|
| 'mcp-config';
|
|
34
35
|
|
|
35
36
|
/** machine name (frontmatter.name or file basename) */
|
|
@@ -85,4 +86,21 @@ export interface SkillItem {
|
|
|
85
86
|
|
|
86
87
|
/** present when frontmatter / format parse failed; UI shows red badge */
|
|
87
88
|
parseError?: string;
|
|
89
|
+
|
|
90
|
+
// NEW FIELDS for Skills Tab Redesign v0.3.3
|
|
91
|
+
/** Tier 1/2/3 categorization — controls display logic and icon in UI */
|
|
92
|
+
tier?: 'tool' | 'directory' | 'other';
|
|
93
|
+
|
|
94
|
+
/** Parent directory name (Tier 2 only) — used for display label */
|
|
95
|
+
dirName?: string;
|
|
96
|
+
|
|
97
|
+
/** i18n translation metadata (if present) */
|
|
98
|
+
i18n?: {
|
|
99
|
+
zh?: {
|
|
100
|
+
title?: string;
|
|
101
|
+
description?: string;
|
|
102
|
+
};
|
|
103
|
+
translatedAt?: string;
|
|
104
|
+
translationModel?: string;
|
|
105
|
+
};
|
|
88
106
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Tests for the R6 real-icon logic (brand-map + icon-extractor).
|
|
2
|
+
// Focus on the pure/sync resolution chain — spawn-based extraction is exercised
|
|
3
|
+
// lightly since it depends on which apps are installed on the host.
|
|
4
|
+
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { resolveBrandSpec, emojiForBrand, BRAND_APP_MAP } from '../src/icon/brand-map.mjs';
|
|
8
|
+
import { resolveIconRef, getIconForBrand } from '../src/icon/icon-extractor.mjs';
|
|
9
|
+
|
|
10
|
+
test('resolveBrandSpec resolves known brands and raw bundle ids', () => {
|
|
11
|
+
assert.ok(resolveBrandSpec('cursor'), 'cursor is a known brand');
|
|
12
|
+
assert.equal(resolveBrandSpec('Cursor')?.appNames.includes('Cursor'), true, 'case-insensitive');
|
|
13
|
+
// A dotted string is treated as an explicit bundle id
|
|
14
|
+
const spec = resolveBrandSpec('com.example.MyApp');
|
|
15
|
+
assert.deepEqual(spec?.bundleIds, ['com.example.MyApp']);
|
|
16
|
+
// Unknown, non-dotted value → null
|
|
17
|
+
assert.equal(resolveBrandSpec('totally-unknown-brand'), null);
|
|
18
|
+
assert.equal(resolveBrandSpec(''), null);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('emojiForBrand returns the mapped emoji', () => {
|
|
22
|
+
assert.equal(emojiForBrand('hermes'), BRAND_APP_MAP.hermes.emoji);
|
|
23
|
+
assert.equal(emojiForBrand('CLAUDE-CODE'), BRAND_APP_MAP['claude-code'].emoji);
|
|
24
|
+
assert.equal(emojiForBrand('nope'), undefined);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('resolveIconRef honors explicit frontmatter icon overrides', () => {
|
|
28
|
+
assert.deepEqual(resolveIconRef({ icon: 'emoji:🎯' }), { iconFallback: '🎯' });
|
|
29
|
+
assert.deepEqual(resolveIconRef({ icon: 'url:https://x/icon.png' }), {
|
|
30
|
+
iconUrl: 'https://x/icon.png',
|
|
31
|
+
});
|
|
32
|
+
assert.deepEqual(resolveIconRef({ icon: 'app:com.microsoft.VSCode' }), {
|
|
33
|
+
iconUrl: '/api/icons/com.microsoft.VSCode?size=64',
|
|
34
|
+
});
|
|
35
|
+
// Bare glyph → fallback emoji
|
|
36
|
+
assert.deepEqual(resolveIconRef({ icon: '🔥' }), { iconFallback: '🔥' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('resolveIconRef maps brand/source to a real-icon URL with emoji fallback', () => {
|
|
40
|
+
const ref = resolveIconRef({ brand: 'cursor' }, 64);
|
|
41
|
+
assert.equal(ref.iconUrl, '/api/icons/cursor?size=64');
|
|
42
|
+
assert.equal(ref.iconFallback, BRAND_APP_MAP.cursor.emoji);
|
|
43
|
+
|
|
44
|
+
// Falls through to source when brand is absent/unmapped
|
|
45
|
+
const bySource = resolveIconRef({ source: 'vs-code' }, 32);
|
|
46
|
+
assert.equal(bySource.iconUrl, '/api/icons/vs-code?size=32');
|
|
47
|
+
|
|
48
|
+
// Unknown brand → empty (frontend emoji map handles it)
|
|
49
|
+
assert.deepEqual(resolveIconRef({ brand: 'made-up' }), {});
|
|
50
|
+
assert.deepEqual(resolveIconRef({}), {});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('resolveIconRef clamps invalid sizes to 64', () => {
|
|
54
|
+
const ref = resolveIconRef({ brand: 'cursor' }, 999);
|
|
55
|
+
assert.equal(ref.iconUrl, '/api/icons/cursor?size=64');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('getIconForBrand returns null for an unmapped brand', async () => {
|
|
59
|
+
const result = await getIconForBrand('definitely-not-an-app-xyz', 64);
|
|
60
|
+
assert.equal(result, null);
|
|
61
|
+
});
|
|
@@ -27,7 +27,7 @@ function groupBy(items, fn) {
|
|
|
27
27
|
}, {});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
test('scan aggregates enabled sources
|
|
30
|
+
test('scan aggregates enabled sources and strips duplicate semantic skill exports', async (t) => {
|
|
31
31
|
const { root, home } = makeTempHome();
|
|
32
32
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
33
33
|
|
|
@@ -35,7 +35,7 @@ test('scan aggregates enabled sources, strips duplicate semantic skill exports,
|
|
|
35
35
|
const hermesRoot = path.join(root, 'hermes-skills');
|
|
36
36
|
const codexRoot = path.join(root, 'project-a');
|
|
37
37
|
const cursorRoot = path.join(root, 'project-b');
|
|
38
|
-
const
|
|
38
|
+
const directorySkillsRoot = path.join(root, 'custom-skills');
|
|
39
39
|
|
|
40
40
|
write(path.join(hermesRoot, 'devops', 'deploy-helper', 'SKILL.md'), `---
|
|
41
41
|
name: deploy-helper
|
|
@@ -52,21 +52,14 @@ description: Duplicate hidden export
|
|
|
52
52
|
---
|
|
53
53
|
# Duplicate hidden export
|
|
54
54
|
`);
|
|
55
|
-
write(path.join(codexRoot, 'AGENTS.md'), '# Codex Instructions
|
|
55
|
+
write(path.join(codexRoot, 'AGENTS.md'), '# Codex Instructions\\n\\nUse Node 20 and run tests.');
|
|
56
56
|
write(path.join(cursorRoot, '.cursorrules'), 'Always keep UI concise.');
|
|
57
|
-
write(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
API_TOKEN: 'fake-token-value-for-redaction-test',
|
|
64
|
-
normal: 'visible',
|
|
65
|
-
},
|
|
66
|
-
url: 'https://example.com/mcp?token=secret-value',
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
}, null, 2));
|
|
57
|
+
write(path.join(directorySkillsRoot, 'auth-flow', 'SKILL.md'), `---
|
|
58
|
+
name: auth-flow
|
|
59
|
+
description: Custom auth flow implementation
|
|
60
|
+
---
|
|
61
|
+
# Auth Flow
|
|
62
|
+
`);
|
|
70
63
|
|
|
71
64
|
write(sources, `sources:
|
|
72
65
|
hermes:
|
|
@@ -81,10 +74,10 @@ description: Duplicate hidden export
|
|
|
81
74
|
enabled: true
|
|
82
75
|
roots:
|
|
83
76
|
- ${JSON.stringify(cursorRoot)}
|
|
84
|
-
|
|
77
|
+
directory-skill:
|
|
85
78
|
enabled: true
|
|
86
|
-
|
|
87
|
-
- ${JSON.stringify(
|
|
79
|
+
paths:
|
|
80
|
+
- ${JSON.stringify(directorySkillsRoot)}
|
|
88
81
|
limits:
|
|
89
82
|
maxFiles: 100
|
|
90
83
|
maxFileBytes: 1048576
|
|
@@ -96,22 +89,24 @@ limits:
|
|
|
96
89
|
assert.equal(bySource.hermes?.length, 1, 'semantic duplicate hermes skill should collapse to one item');
|
|
97
90
|
assert.equal(bySource.codex?.length, 1);
|
|
98
91
|
assert.equal(bySource.cursor?.length, 1);
|
|
99
|
-
assert.equal(bySource
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
assert.equal(
|
|
104
|
-
assert.equal(
|
|
105
|
-
assert.
|
|
106
|
-
assert.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
assert.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
assert.
|
|
114
|
-
assert.
|
|
92
|
+
assert.equal(bySource.directory?.length, 1, 'directory-skill should return one item');
|
|
93
|
+
|
|
94
|
+
// Verify Tier 1 tool skills have tier='tool' and brand
|
|
95
|
+
const hermesSkill = bySource.hermes[0];
|
|
96
|
+
assert.equal(hermesSkill.tier, 'tool', 'hermes skill should have tier="tool"');
|
|
97
|
+
assert.equal(hermesSkill.brand, 'hermes', 'hermes skill should have brand="hermes"');
|
|
98
|
+
assert.equal(hermesSkill.name, 'deploy-helper');
|
|
99
|
+
assert.equal(hermesSkill.editor, 'Hermes Agent');
|
|
100
|
+
assert.equal(hermesSkill.category, 'devops');
|
|
101
|
+
assert.deepEqual(hermesSkill.triggers, ['deploy service']);
|
|
102
|
+
assert.ok(!hermesSkill.paths.abs.includes('/.hidden-export/'), 'visible export should outrank hidden duplicate');
|
|
103
|
+
|
|
104
|
+
// Verify Tier 2 directory skills have tier='directory' and dirName
|
|
105
|
+
const dirSkill = bySource.directory[0];
|
|
106
|
+
assert.equal(dirSkill.tier, 'directory', 'directory-skill should have tier="directory"');
|
|
107
|
+
assert.equal(dirSkill.dirName, 'auth-flow', 'directory-skill should have dirName="auth-flow"');
|
|
108
|
+
assert.equal(dirSkill.source, 'directory', 'directory-skill should have source="directory"');
|
|
109
|
+
assert.equal(dirSkill.name, 'auth-flow');
|
|
115
110
|
});
|
|
116
111
|
|
|
117
112
|
test('getWatchTargets includes config file plus configured source files and globs', async (t) => {
|
|
@@ -119,22 +114,23 @@ test('getWatchTargets includes config file plus configured source files and glob
|
|
|
119
114
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
120
115
|
|
|
121
116
|
const hermesRoot = path.join(root, 'skills');
|
|
122
|
-
const
|
|
117
|
+
const directorySkillsRoot = path.join(root, 'custom-skills');
|
|
123
118
|
fs.mkdirSync(hermesRoot, { recursive: true });
|
|
124
|
-
|
|
119
|
+
fs.mkdirSync(directorySkillsRoot, { recursive: true });
|
|
120
|
+
|
|
125
121
|
write(path.join(home, 'sources.yaml'), `sources:
|
|
126
122
|
hermes:
|
|
127
123
|
enabled: true
|
|
128
124
|
roots:
|
|
129
125
|
- ${JSON.stringify(hermesRoot)}
|
|
130
|
-
|
|
126
|
+
directory-skill:
|
|
131
127
|
enabled: true
|
|
132
|
-
|
|
133
|
-
- ${JSON.stringify(
|
|
128
|
+
paths:
|
|
129
|
+
- ${JSON.stringify(directorySkillsRoot)}
|
|
134
130
|
`);
|
|
135
131
|
|
|
136
132
|
const targets = await getWatchTargets();
|
|
137
133
|
assert.ok(targets.includes(path.join(home, 'sources.yaml')));
|
|
138
|
-
assert.ok(targets.includes(mcpFile));
|
|
139
134
|
assert.ok(targets.some(t => t.endsWith('/skills/**/SKILL.md')));
|
|
135
|
+
assert.ok(targets.some(t => t.endsWith('/custom-skills/**/SKILL.md')));
|
|
140
136
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Tests for scanSkills R7 behavior: two-stage (mini/full) + stable ordering.
|
|
2
|
+
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { scanSkills } from '../src/adapters/skill-adapter.mjs';
|
|
9
|
+
|
|
10
|
+
function makeFixture() {
|
|
11
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'huhaa-skills-'));
|
|
12
|
+
const write = (rel, content) => {
|
|
13
|
+
const abs = path.join(dir, rel);
|
|
14
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
15
|
+
fs.writeFileSync(abs, content);
|
|
16
|
+
};
|
|
17
|
+
write('zebra/SKILL.md', '---\nname: zebra\ndescription: last alphabetically\ntags: [z1, z2]\n---\n# Zebra body');
|
|
18
|
+
write('alpha/SKILL.md', '---\nname: alpha\ndescription: first alphabetically\ntags: [a1]\nicon: "emoji:🅰️"\n---\n# Alpha body');
|
|
19
|
+
write('mid/SKILL.md', '---\nname: mid\ndescription: middle\n---\n# Mid body');
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('scanSkills full stage returns rich fields', async () => {
|
|
24
|
+
const dir = makeFixture();
|
|
25
|
+
const { items, stats } = await scanSkills({ roots: [dir], stage: 'full' });
|
|
26
|
+
assert.equal(items.length, 3);
|
|
27
|
+
assert.equal(stats.stage, 'full');
|
|
28
|
+
const alpha = items.find(i => i.name === 'alpha');
|
|
29
|
+
assert.deepEqual(alpha.tags, ['a1']);
|
|
30
|
+
assert.ok(alpha.raw.includes('Alpha body'), 'full stage includes raw');
|
|
31
|
+
assert.ok(alpha.preview.length > 0, 'full stage includes preview');
|
|
32
|
+
assert.equal(alpha.icon, 'emoji:🅰️', 'frontmatter icon passed through');
|
|
33
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('scanSkills mini stage omits heavy fields but keeps display data', async () => {
|
|
37
|
+
const dir = makeFixture();
|
|
38
|
+
const { items, stats } = await scanSkills({ roots: [dir], stage: 'mini' });
|
|
39
|
+
assert.equal(items.length, 3);
|
|
40
|
+
assert.equal(stats.stage, 'mini');
|
|
41
|
+
const alpha = items.find(i => i.name === 'alpha');
|
|
42
|
+
assert.equal(alpha.description, 'first alphabetically');
|
|
43
|
+
assert.equal(alpha.icon, 'emoji:🅰️');
|
|
44
|
+
assert.equal(alpha.tags, undefined, 'mini omits tags');
|
|
45
|
+
assert.equal(alpha.raw, undefined, 'mini omits raw');
|
|
46
|
+
assert.equal(alpha.preview, undefined, 'mini omits preview');
|
|
47
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('scanSkills returns a stable path-sorted order regardless of concurrency', async () => {
|
|
51
|
+
const dir = makeFixture();
|
|
52
|
+
const a = await scanSkills({ roots: [dir], concurrency: 1 });
|
|
53
|
+
const b = await scanSkills({ roots: [dir], concurrency: 8 });
|
|
54
|
+
const names = x => x.items.map(i => i.name);
|
|
55
|
+
assert.deepEqual(names(a), names(b), 'order independent of concurrency');
|
|
56
|
+
// Sorted by absolute path → alpha, mid, zebra
|
|
57
|
+
assert.deepEqual(names(a), ['alpha', 'mid', 'zebra']);
|
|
58
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('scanSkills reports duration and handles empty roots', async () => {
|
|
62
|
+
const empty = await scanSkills({ roots: ['/nonexistent-path-xyz'] });
|
|
63
|
+
assert.equal(empty.items.length, 0);
|
|
64
|
+
assert.equal(empty.stats.available, false);
|
|
65
|
+
|
|
66
|
+
const dir = makeFixture();
|
|
67
|
+
const { stats } = await scanSkills({ roots: [dir] });
|
|
68
|
+
assert.equal(typeof stats.duration, 'number');
|
|
69
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// directory-skill.test.mjs — unit tests for directory-skill adapter
|
|
2
|
+
//
|
|
3
|
+
// Tests cover:
|
|
4
|
+
// 1. Successfully scanning a directory with SKILL.md files
|
|
5
|
+
// 2. Proper tier assignment (tier='directory')
|
|
6
|
+
// 3. dirName field population
|
|
7
|
+
// 4. Empty directory handling
|
|
8
|
+
// 5. File glob filtering
|
|
9
|
+
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { scanDirectorySkills } from '../src/adapters/directory-skill.mjs';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const FIXTURES_DIR = path.join(__dirname, 'fixtures/directory-skill');
|
|
18
|
+
|
|
19
|
+
test('scanDirectorySkills: basic scanning', async () => {
|
|
20
|
+
const result = await scanDirectorySkills({
|
|
21
|
+
paths: [FIXTURES_DIR],
|
|
22
|
+
globs: ['**/SKILL.md'],
|
|
23
|
+
limits: { maxFiles: 100, maxFileBytes: 1024 * 1024 },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
assert.ok(result.items, 'items array should exist');
|
|
27
|
+
assert(Array.isArray(result.items), 'items should be an array');
|
|
28
|
+
assert(result.stats, 'stats should exist');
|
|
29
|
+
assert.equal(result.stats.source, 'directory', 'source should be "directory"');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('scanDirectorySkills: tier assignment', async () => {
|
|
33
|
+
const result = await scanDirectorySkills({
|
|
34
|
+
paths: [FIXTURES_DIR],
|
|
35
|
+
globs: ['**/SKILL.md'],
|
|
36
|
+
limits: { maxFiles: 100, maxFileBytes: 1024 * 1024 },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
for (const item of result.items) {
|
|
40
|
+
assert.equal(item.tier, 'directory', `tier should be "directory" for item ${item.name}`);
|
|
41
|
+
assert.ok(item.dirName, `dirName should be set for item ${item.name}`);
|
|
42
|
+
assert.equal(item.source, 'directory', `source should be "directory" for item ${item.name}`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('scanDirectorySkills: empty directory', async () => {
|
|
47
|
+
const result = await scanDirectorySkills({
|
|
48
|
+
paths: ['/nonexistent/path'],
|
|
49
|
+
globs: ['**/SKILL.md'],
|
|
50
|
+
limits: { maxFiles: 100, maxFileBytes: 1024 * 1024 },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(result.items.length, 0, 'should return empty list for non-existent path');
|
|
54
|
+
assert.equal(result.stats.available, false, 'stats.available should be false');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('scanDirectorySkills: no paths provided', async () => {
|
|
58
|
+
const result = await scanDirectorySkills({
|
|
59
|
+
paths: [],
|
|
60
|
+
globs: ['**/SKILL.md'],
|
|
61
|
+
limits: { maxFiles: 100, maxFileBytes: 1024 * 1024 },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.equal(result.items.length, 0, 'should return empty list when paths is empty');
|
|
65
|
+
assert.equal(result.stats.available, false, 'stats.available should be false');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('scanDirectorySkills: dirName extraction', async () => {
|
|
69
|
+
const result = await scanDirectorySkills({
|
|
70
|
+
paths: [FIXTURES_DIR],
|
|
71
|
+
globs: ['**/SKILL.md'],
|
|
72
|
+
limits: { maxFiles: 100, maxFileBytes: 1024 * 1024 },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
for (const item of result.items) {
|
|
76
|
+
// dirName should be the parent directory of SKILL.md
|
|
77
|
+
assert.ok(item.dirName, `dirName should exist for ${item.name}`);
|
|
78
|
+
assert(item.dirName.length > 0, `dirName should not be empty for ${item.name}`);
|
|
79
|
+
// dirName should match the dir part of the path
|
|
80
|
+
const expectedDirName = path.basename(path.dirname(item.paths.abs));
|
|
81
|
+
assert.equal(item.dirName, expectedDirName, `dirName should match directory name for ${item.name}`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('scanDirectorySkills: file limits respected', async () => {
|
|
86
|
+
const result = await scanDirectorySkills({
|
|
87
|
+
paths: [FIXTURES_DIR],
|
|
88
|
+
globs: ['**/SKILL.md'],
|
|
89
|
+
limits: { maxFiles: 1, maxFileBytes: 1024 * 1024 },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assert(result.items.length <= 1, 'should respect maxFiles limit');
|
|
93
|
+
});
|