huhaa-myskills 0.2.13 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +9 -375
  2. package/bin/huhaa-myskills.mjs +194 -12
  3. package/{scripts → build}/verify.mjs +3 -1
  4. package/docs/Frontend-Spec.md +1149 -0
  5. package/docs/assets/layout-wireframe.png +0 -0
  6. package/docs/assets/theme-reference.png +0 -0
  7. package/docs/scan_rules.md +172 -0
  8. package/package.json +33 -9
  9. package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
  10. package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
  11. package/packages/scanner/src/icon/brand-map.mjs +84 -0
  12. package/packages/scanner/src/icon/icon-extractor.mjs +246 -0
  13. package/packages/scanner/src/index.mjs +107 -82
  14. package/packages/scanner/src/types.d.ts +18 -0
  15. package/packages/scanner/test/icon.test.mjs +61 -0
  16. package/packages/scanner/test/scanner.test.mjs +37 -41
  17. package/packages/scanner/test/skill-adapter.test.mjs +70 -0
  18. package/packages/scanner/tests/directory-skill.test.mjs +93 -0
  19. package/packages/scanner/tests/scanner-integration.test.mjs +139 -0
  20. package/packages/server/src/index.mjs +164 -12
  21. package/packages/server/src/labels.mjs +62 -0
  22. package/packages/server/test/server.test.mjs +1 -1
  23. package/packages/web/README.md +33 -2
  24. package/packages/web/dist/assets/index-BCY2cRaO.js +175 -0
  25. package/packages/web/dist/assets/index-DALKjJPi.css +1 -0
  26. package/packages/web/dist/favicon-16x16.png +0 -0
  27. package/packages/web/dist/favicon-192x192.png +0 -0
  28. package/packages/web/dist/favicon-32x32.png +0 -0
  29. package/packages/web/dist/favicon-512x512.png +0 -0
  30. package/packages/web/dist/favicon-source.svg +5 -0
  31. package/packages/web/dist/index.html +23 -9
  32. package/packages/web/index.html +22 -8
  33. package/packages/web/package-lock.json +2989 -0
  34. package/packages/web/package.json +21 -8
  35. package/packages/web/postcss.config.js +6 -0
  36. package/packages/web/public/favicon-16x16.png +0 -0
  37. package/packages/web/public/favicon-192x192.png +0 -0
  38. package/packages/web/public/favicon-32x32.png +0 -0
  39. package/packages/web/public/favicon-512x512.png +0 -0
  40. package/packages/web/public/favicon-source.svg +5 -0
  41. package/packages/web/src/App.test.ts +40 -0
  42. package/packages/web/src/App.tsx +177 -0
  43. package/packages/web/src/components/ComingSoon.tsx +14 -0
  44. package/packages/web/src/components/layout/Sidebar.test.tsx +74 -0
  45. package/packages/web/src/components/layout/Sidebar.tsx +114 -0
  46. package/packages/web/src/components/layout/Topbar.tsx +74 -0
  47. package/packages/web/src/components/ui/ActionButton.tsx +48 -0
  48. package/packages/web/src/components/ui/button.tsx +48 -0
  49. package/packages/web/src/components/ui/card.tsx +45 -0
  50. package/packages/web/src/components/views/DashboardView.tsx +122 -0
  51. package/packages/web/src/components/views/OtherSkillsView.test.tsx +126 -0
  52. package/packages/web/src/components/views/OtherSkillsView.tsx +306 -0
  53. package/packages/web/src/components/views/SettingsView.tsx +95 -0
  54. package/packages/web/src/components/views/SkillDetail.test.tsx +57 -0
  55. package/packages/web/src/components/views/SkillDetail.tsx +81 -0
  56. package/packages/web/src/components/views/SkillsView.test.ts +28 -0
  57. package/packages/web/src/components/views/SkillsView.tsx +226 -0
  58. package/packages/web/src/hooks/QUICK_REFERENCE.md +165 -0
  59. package/packages/web/src/hooks/README.md +378 -0
  60. package/packages/web/src/hooks/__tests__/useOtherSkills.test.ts +422 -0
  61. package/packages/web/src/hooks/__tests__/useSkillIcons.test.ts +272 -0
  62. package/packages/web/src/hooks/useLiveReload.ts +20 -0
  63. package/packages/web/src/hooks/useOtherSkills.ts +387 -0
  64. package/packages/web/src/hooks/useSkillIcons.ts +248 -0
  65. package/packages/web/src/hooks/useTheme.ts +36 -0
  66. package/packages/web/src/index.css +161 -0
  67. package/packages/web/src/lib/api.test.ts +47 -0
  68. package/packages/web/src/lib/api.ts +77 -0
  69. package/packages/web/src/lib/cn.ts +7 -0
  70. package/packages/web/src/lib/editors.test.ts +30 -0
  71. package/packages/web/src/lib/editors.ts +70 -0
  72. package/packages/web/src/lib/markdown.test.ts +27 -0
  73. package/packages/web/src/lib/markdown.ts +14 -0
  74. package/packages/web/src/main.tsx +13 -0
  75. package/packages/web/src/types/index.ts +62 -0
  76. package/packages/web/src/types/other-skill.ts +88 -0
  77. package/packages/web/src/types/skill.ts +136 -0
  78. package/packages/web/src/types.ts +47 -0
  79. package/packages/web/src/vite-env.d.ts +1 -0
  80. package/packages/web/tailwind.config.ts +66 -0
  81. package/packages/web/tsconfig.json +28 -0
  82. package/packages/web/vite.config.ts +36 -0
  83. package/packages/web/vitest.config.ts +21 -0
  84. package/packages/web/vitest.setup.ts +1 -0
  85. package/scripts/activate-data-sources.sh +223 -0
  86. package/scripts/brand-completion.py +586 -0
  87. package/scripts/init-nextjs-project.sh +421 -0
  88. package/scripts/install-and-sync.sh +209 -0
  89. package/scripts/start-dev.sh +72 -0
  90. package/docs/GUIDE.md +0 -190
  91. package/docs/PLAN.md +0 -359
  92. package/docs/RULES.md +0 -329
  93. package/docs/RUNBOOK-myskills.md +0 -258
  94. package/docs/SYNC-SKILLS.md +0 -259
  95. package/docs/releases/README.md +0 -47
  96. package/docs/releases/v0.1.0.md +0 -256
  97. package/docs/releases/v0.1.1.md +0 -297
  98. package/docs/releases/v0.1.2.md +0 -207
  99. package/docs/releases/v0.1.3.md +0 -269
  100. package/docs/todo/v0.1.2.md +0 -98
  101. package/docs/todo/v0.1.3.md +0 -40
  102. package/docs/todo/v0.1.4.md +0 -36
  103. package/docs/todo/v0.1.5.md +0 -41
  104. package/docs/todo/v0.1.6.md +0 -43
  105. package/docs/todo/v0.1.7.md +0 -38
  106. package/docs/todo/v0.1.8.md +0 -36
  107. package/docs/todo/v0.1.9.md +0 -54
  108. package/packages/web/dist/assets/index-BPepSb3q.js +0 -36
  109. package/packages/web/dist/assets/index-CIGkgT5w.css +0 -1
  110. package/packages/web/src/App.vue +0 -483
  111. package/packages/web/src/components/AppTree.vue +0 -230
  112. package/packages/web/src/components/DirectoryTree.vue +0 -263
  113. package/packages/web/src/components/SkillTree.vue +0 -208
  114. package/packages/web/src/lib/api.js +0 -43
  115. package/packages/web/src/main.js +0 -6
  116. package/packages/web/src/stores/i18n.js +0 -161
  117. package/packages/web/src/stores/skills.js +0 -287
  118. package/packages/web/src/styles.css +0 -365
  119. package/packages/web/vite.config.js +0 -17
  120. /package/{scripts → build}/build-web.mjs +0 -0
  121. /package/{scripts → build}/prepare-publish.mjs +0 -0
@@ -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
- 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']),
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
- const roots = await expandRoots(src.roots || []);
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, strips duplicate semantic skill exports, and redacts MCP secrets', async (t) => {
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 mcpFile = path.join(root, 'mcp.json');
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\n\nUse Node 20 and run tests.');
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(mcpFile, JSON.stringify({
58
- mcpServers: {
59
- dangerous: {
60
- command: 'node',
61
- args: ['server.js'],
62
- env: {
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
- mcp-config:
77
+ directory-skill:
85
78
  enabled: true
86
- files:
87
- - ${JSON.stringify(mcpFile)}
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['mcp-config']?.length, 1);
100
-
101
- const skill = bySource.hermes[0];
102
- assert.equal(skill.name, 'deploy-helper');
103
- assert.equal(skill.editor, 'Hermes Agent');
104
- assert.equal(skill.category, 'devops');
105
- assert.deepEqual(skill.triggers, ['deploy service']);
106
- assert.ok(!skill.paths.abs.includes('/.hidden-export/'), 'visible export should outrank hidden duplicate');
107
-
108
- const mcp = bySource['mcp-config'][0];
109
- assert.equal(mcp.kind, 'mcp');
110
- assert.equal(mcp.name, 'dangerous');
111
- assert.match(mcp.raw, /\[REDACTED\]/);
112
- assert.doesNotMatch(mcp.raw, /fake-token-value-for-redaction-test/);
113
- assert.doesNotMatch(mcp.raw, /secret-value/);
114
- assert.match(mcp.raw, /visible/);
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 mcpFile = path.join(root, 'mcp.json');
117
+ const directorySkillsRoot = path.join(root, 'custom-skills');
123
118
  fs.mkdirSync(hermesRoot, { recursive: true });
124
- write(mcpFile, '{}');
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
- mcp-config:
126
+ directory-skill:
131
127
  enabled: true
132
- files:
133
- - ${JSON.stringify(mcpFile)}
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
+ });