huhaa-myskills 0.2.0 → 0.2.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 (37) hide show
  1. package/{service/bin → bin}/huhaa-myskills.mjs +24 -44
  2. package/package.json +19 -28
  3. package/packages/scanner/package.json +15 -0
  4. package/packages/scanner/test/scanner.test.mjs +140 -0
  5. package/packages/server/package.json +15 -0
  6. package/{service/packages → packages}/server/src/index.mjs +10 -23
  7. package/packages/server/test/server.test.mjs +135 -0
  8. package/packages/web/index.html +12 -0
  9. package/packages/web/package.json +20 -0
  10. package/packages/web/src/App.vue +407 -0
  11. package/packages/web/src/lib/api.js +43 -0
  12. package/packages/web/src/main.js +6 -0
  13. package/packages/web/src/stores/i18n.js +149 -0
  14. package/packages/web/src/stores/skills.js +276 -0
  15. package/packages/web/src/styles.css +325 -0
  16. package/packages/web/vite.config.js +17 -0
  17. package/{service/scripts → scripts}/build-web.mjs +2 -2
  18. package/scripts/prepare-publish.mjs +3 -0
  19. package/{service/scripts → scripts}/verify.mjs +6 -4
  20. package/install-and-sync.sh +0 -209
  21. package/service/scripts/prepare-publish.mjs +0 -44
  22. package/service/scripts/restore-publish.mjs +0 -17
  23. /package/{service/bin → bin}/lib/paths.mjs +0 -0
  24. /package/{service/bin → bin}/lib/port.mjs +0 -0
  25. /package/{service/config → config}/sources.example.yaml +0 -0
  26. /package/{service/packages → packages}/scanner/src/adapters/file-docs.mjs +0 -0
  27. /package/{service/packages → packages}/scanner/src/adapters/hermes-plugin.mjs +0 -0
  28. /package/{service/packages → packages}/scanner/src/adapters/markdown-skill.mjs +0 -0
  29. /package/{service/packages → packages}/scanner/src/adapters/mcp-config.mjs +0 -0
  30. /package/{service/packages → packages}/scanner/src/index.mjs +0 -0
  31. /package/{service/packages → packages}/scanner/src/types.d.ts +0 -0
  32. /package/{service/packages → packages}/scanner/src/utils.mjs +0 -0
  33. /package/{service/packages → packages}/web/README.md +0 -0
  34. /package/{service/packages → packages}/web/dist/assets/index-CTh5OdBd.js +0 -0
  35. /package/{service/packages → packages}/web/dist/assets/index-CtoiGnQ7.css +0 -0
  36. /package/{service/packages → packages}/web/dist/index.html +0 -0
  37. /package/{service/scripts → scripts}/sync-skills.sh +0 -0
@@ -1,35 +1,26 @@
1
1
  #!/usr/bin/env node
2
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
3
 
13
4
  import fs from 'node:fs';
14
5
  import path from 'node:path';
15
6
  import { fileURLToPath } from 'node:url';
16
7
  import { spawn } from 'node:child_process';
17
8
  import { readFileSync } from 'node:fs';
9
+ import { createRequire } from 'node:module';
18
10
 
19
- import {
20
- homeDir,
21
- configFile,
22
- cacheFile,
23
- stateFile,
24
- ensureHomeDir,
25
- writeJson,
26
- } from './lib/paths.mjs';
27
- import { pickPort } from './lib/port.mjs';
28
-
29
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
- // bin/ -> service/ -> repo root
31
- const REPO_ROOT = path.resolve(__dirname, '..', '..');
32
- const SERVICE_ROOT = path.resolve(__dirname, '..');
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+ const REPO_ROOT = path.resolve(__dirname, '..');
14
+ const PKGS_ROOT = path.join(REPO_ROOT, 'packages');
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ // Load paths helper
19
+ const pathsLib = require('./lib/paths.mjs');
20
+ const { homeDir, configFile, cacheFile, stateFile, ensureHomeDir, writeJson } = pathsLib;
21
+
22
+ // Load port helper
23
+ const { pickPort } = require('./lib/port.mjs');
33
24
 
34
25
  // Load version from package.json
35
26
  function getVersion() {
@@ -68,8 +59,6 @@ fn().catch(err => {
68
59
  process.exit(1);
69
60
  });
70
61
 
71
- // ---------------- handlers ----------------
72
-
73
62
  // ---------------- commands ----------------
74
63
 
75
64
  async function cmdVersion() {
@@ -116,7 +105,7 @@ async function cmdInit() {
116
105
  console.log('[init] not overwriting. delete it first if you want to reset.');
117
106
  return;
118
107
  }
119
- const tpl = path.join(SERVICE_ROOT, 'config', 'sources.example.yaml');
108
+ const tpl = path.join(REPO_ROOT, 'config', 'sources.example.yaml');
120
109
  fs.copyFileSync(tpl, dst);
121
110
  console.log(`[init] wrote ${dst}`);
122
111
  console.log('[init] edit it to add / remove sources, then run: huhaa-myskills start');
@@ -134,8 +123,7 @@ async function cmdPurge() {
134
123
  }
135
124
 
136
125
  async function cmdScan() {
137
- // P0 placeholder. P1 wires real adapters.
138
- const { scan } = await import('@huhaa/scanner');
126
+ const { scan } = await import(path.join(PKGS_ROOT, 'scanner/src/index.mjs'));
139
127
  await ensureConfigOrInit();
140
128
  const items = await scan();
141
129
  process.stdout.write(JSON.stringify(items, null, 2) + '\n');
@@ -143,7 +131,7 @@ async function cmdScan() {
143
131
  }
144
132
 
145
133
  async function cmdStats() {
146
- const { scan } = await import('@huhaa/scanner');
134
+ const { scan } = await import(path.join(PKGS_ROOT, 'scanner/src/index.mjs'));
147
135
  await ensureConfigOrInit();
148
136
  const items = await scan();
149
137
 
@@ -206,7 +194,6 @@ async function cmdStats() {
206
194
  }
207
195
  }
208
196
 
209
- // deterministic-ish sample: every Nth item so coverage spans both sources
210
197
  const sampleN = Math.min(8, items.length);
211
198
  if (sampleN > 0) {
212
199
  const step = Math.max(1, Math.floor(items.length / sampleN));
@@ -228,7 +215,7 @@ async function cmdStats() {
228
215
  }
229
216
 
230
217
  async function cmdDuplicates() {
231
- const { scan } = await import('@huhaa/scanner');
218
+ const { scan } = await import(path.join(PKGS_ROOT, 'scanner/src/index.mjs'));
232
219
  await ensureConfigOrInit();
233
220
  const items = await scan();
234
221
  const byName = groupBy(items, it => `${it.source}:${it.name}`);
@@ -291,20 +278,17 @@ async function cmdStart() {
291
278
  console.error(`[start] port ${preferred} busy, using ${port} instead.`);
292
279
  }
293
280
 
294
- // Persist actual port for any tooling that wants to know.
295
281
  writeJson(stateFile(), { port, pid: process.pid, startedAt: new Date().toISOString() });
296
282
 
297
- const { startServer } = await import('@huhaa/server');
283
+ const { startServer } = await import(path.join(PKGS_ROOT, 'server/src/index.mjs'));
298
284
  await startServer({ port });
299
285
 
300
286
  console.log(`\n HuHaa-MySkills running: http://localhost:${port}\n`);
301
287
  console.log(` data dir: ${homeDir()}`);
302
288
  console.log(` Ctrl+C to stop.\n`);
303
289
 
304
- // Auto-open browser on macOS / linux / win
305
290
  openBrowser(`http://localhost:${port}`);
306
291
 
307
- // Cleanup state file on exit so a stale port number doesn't linger.
308
292
  const cleanup = () => {
309
293
  try { fs.unlinkSync(stateFile()); } catch {}
310
294
  process.exit(0);
@@ -314,7 +298,6 @@ async function cmdStart() {
314
298
  }
315
299
 
316
300
  async function cmdDev() {
317
- // P3+ wires Vite + nodemon. For now alias to start.
318
301
  console.log('[dev] dev mode comes in P3. running start for now.');
319
302
  await cmdStart();
320
303
  }
@@ -325,11 +308,10 @@ async function cmdSync() {
325
308
  ? syncEnv.split(',').map(e => e.trim().toLowerCase())
326
309
  : null;
327
310
 
328
- const { scan } = await import('@huhaa/scanner');
311
+ const { scan } = await import(path.join(PKGS_ROOT, 'scanner/src/index.mjs'));
329
312
  await ensureConfigOrInit();
330
313
  const items = await scan();
331
314
 
332
- // Get skills with content
333
315
  const skills = items.filter(it => it.raw && it.raw.trim());
334
316
 
335
317
  console.log(`[sync] found ${skills.length} skills to sync`);
@@ -359,22 +341,20 @@ async function syncToEditors(skills, editors) {
359
341
  async function syncToEditor(skills, editor) {
360
342
  console.log(`[sync] syncing ${skills.length} skills to ${editor}...`);
361
343
 
362
- const syncScript = path.join(SERVICE_ROOT, 'scripts', 'sync-skills.sh');
363
- const skillsJson = JSON.stringify(skills);
344
+ const syncScript = path.join(REPO_ROOT, 'scripts', 'sync-skills.sh');
364
345
 
365
- return new Promise((resolve, reject) => {
366
- const child = spawn('bash', [syncScript, '--editor', editor, '--skills', skillsJson], {
346
+ return new Promise((resolve) => {
347
+ const child = spawn('bash', [syncScript, '--editor', editor], {
367
348
  stdio: ['pipe', 'inherit', 'inherit']
368
349
  });
369
350
 
370
351
  child.on('close', (code) => {
371
352
  if (code === 0) {
372
353
  console.log(`[sync] ${editor}: done`);
373
- resolve();
374
354
  } else {
375
355
  console.error(`[sync] ${editor}: failed (exit ${code})`);
376
- resolve();
377
356
  }
357
+ resolve();
378
358
  });
379
359
  });
380
360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huhaa-myskills",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "private": false,
5
5
  "description": "Local skill / plugin / MCP aggregation hub. Browse all your AI agent skills at http://localhost:11520.",
6
6
  "license": "MIT",
@@ -13,51 +13,42 @@
13
13
  "url": "https://github.com/aquarkgn/HuHaa-MySkills/issues"
14
14
  },
15
15
  "type": "module",
16
- "workspaces": [
17
- "service/packages/*"
18
- ],
19
16
  "engines": {
20
17
  "node": ">=20.0.0"
21
18
  },
22
19
  "bin": {
23
- "huhaa-myskills": "service/bin/huhaa-myskills.mjs"
20
+ "huhaa-myskills": "bin/huhaa-myskills.mjs"
24
21
  },
25
22
  "files": [
26
- "service/bin/",
27
- "service/config/",
28
- "service/packages/scanner/src/",
29
- "service/packages/server/src/",
30
- "service/packages/web/dist/",
31
- "service/scripts/",
23
+ "bin/",
24
+ "config/",
25
+ "packages/",
26
+ "scripts/",
32
27
  "docs/",
33
28
  "README.md",
34
- "LICENSE",
35
- "install-and-sync.sh"
29
+ "LICENSE"
36
30
  ],
37
31
  "scripts": {
38
- "start": "node service/bin/huhaa-myskills.mjs start",
39
- "scan": "node service/bin/huhaa-myskills.mjs scan",
40
- "stats": "node service/bin/huhaa-myskills.mjs stats",
41
- "duplicates": "node service/bin/huhaa-myskills.mjs duplicates",
42
- "init": "node service/bin/huhaa-myskills.mjs init",
43
- "purge": "node service/bin/huhaa-myskills.mjs purge",
44
- "dev": "node service/bin/huhaa-myskills.mjs dev",
45
- "sync": "bash service/scripts/sync-skills.sh",
46
- "sync:remote": "curl -fsSL https://raw.githubusercontent.com/aquarkgn/HuHaa-MySkills/main/install-and-sync.sh | bash -s -- --sync-only",
47
- "build:web": "node service/scripts/build-web.mjs",
48
- "test": "node --test service/packages/scanner/test/*.test.mjs service/packages/server/test/*.test.mjs",
49
- "verify": "node service/scripts/verify.mjs"
32
+ "start": "node bin/huhaa-myskills.mjs start",
33
+ "scan": "node bin/huhaa-myskills.mjs scan",
34
+ "stats": "node bin/huhaa-myskills.mjs stats",
35
+ "duplicates": "node bin/huhaa-myskills.mjs duplicates",
36
+ "init": "node bin/huhaa-myskills.mjs init",
37
+ "purge": "node bin/huhaa-myskills.mjs purge",
38
+ "dev": "node bin/huhaa-myskills.mjs dev",
39
+ "sync": "bash scripts/sync-skills.sh",
40
+ "build:web": "node scripts/build-web.mjs",
41
+ "test": "node --test packages/scanner/test/*.test.mjs packages/server/test/*.test.mjs",
42
+ "verify": "node scripts/verify.mjs",
43
+ "release": "npm version patch && git push --follow-tags"
50
44
  },
51
45
  "dependencies": {
52
- "@huhaa/scanner": "file:service/packages/scanner",
53
- "@huhaa/server": "file:service/packages/server",
54
46
  "chokidar": "^4.0.3",
55
47
  "fast-glob": "^3.3.2",
56
48
  "fastify": "^5.0.0",
57
49
  "yaml": "^2.6.0"
58
50
  },
59
51
  "devDependencies": {
60
- "@huhaa/web": "file:service/packages/web",
61
52
  "@vitejs/plugin-vue": "^5.2.1",
62
53
  "fuse.js": "^7.0.0",
63
54
  "markdown-it": "^14.1.0",
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@huhaa/scanner",
3
+ "version": "0.1.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs",
9
+ "./types": "./src/types.d.ts"
10
+ },
11
+ "dependencies": {
12
+ "yaml": "^2.6.0",
13
+ "fast-glob": "^3.3.2"
14
+ }
15
+ }
@@ -0,0 +1,140 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { scan, getWatchTargets } from '../src/index.mjs';
8
+
9
+ function makeTempHome() {
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'huhaa-scanner-test-'));
11
+ const home = path.join(root, 'home');
12
+ fs.mkdirSync(home, { recursive: true });
13
+ process.env.HUHAA_HOME = home;
14
+ return { root, home };
15
+ }
16
+
17
+ function write(file, text) {
18
+ fs.mkdirSync(path.dirname(file), { recursive: true });
19
+ fs.writeFileSync(file, text);
20
+ }
21
+
22
+ function groupBy(items, fn) {
23
+ return items.reduce((acc, item) => {
24
+ const key = fn(item);
25
+ (acc[key] ||= []).push(item);
26
+ return acc;
27
+ }, {});
28
+ }
29
+
30
+ test('scan aggregates enabled sources, strips duplicate semantic skill exports, and redacts MCP secrets', async (t) => {
31
+ const { root, home } = makeTempHome();
32
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
33
+
34
+ const sources = path.join(home, 'sources.yaml');
35
+ const hermesRoot = path.join(root, 'hermes-skills');
36
+ const codexRoot = path.join(root, 'project-a');
37
+ const cursorRoot = path.join(root, 'project-b');
38
+ const mcpFile = path.join(root, 'mcp.json');
39
+
40
+ write(path.join(hermesRoot, 'devops', 'deploy-helper', 'SKILL.md'), `---
41
+ name: deploy-helper
42
+ description: Deploy helper for Docker services
43
+ triggers:
44
+ - deploy service
45
+ ---
46
+ # Deploy helper
47
+ Use this when deploying Docker services.
48
+ `);
49
+ write(path.join(hermesRoot, '.hidden-export', 'deploy-helper', 'SKILL.md'), `---
50
+ name: deploy-helper
51
+ description: Duplicate hidden export
52
+ ---
53
+ # Duplicate hidden export
54
+ `);
55
+ write(path.join(codexRoot, 'AGENTS.md'), '# Codex Instructions\n\nUse Node 20 and run tests.');
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));
70
+
71
+ write(sources, `sources:
72
+ hermes:
73
+ enabled: true
74
+ roots:
75
+ - ${JSON.stringify(hermesRoot)}
76
+ codex:
77
+ enabled: true
78
+ roots:
79
+ - ${JSON.stringify(codexRoot)}
80
+ cursor:
81
+ enabled: true
82
+ roots:
83
+ - ${JSON.stringify(cursorRoot)}
84
+ mcp-config:
85
+ enabled: true
86
+ files:
87
+ - ${JSON.stringify(mcpFile)}
88
+ limits:
89
+ maxFiles: 100
90
+ maxFileBytes: 1048576
91
+ `);
92
+
93
+ const items = await scan();
94
+ const bySource = groupBy(items, it => it.source);
95
+
96
+ assert.equal(bySource.hermes?.length, 1, 'semantic duplicate hermes skill should collapse to one item');
97
+ assert.equal(bySource.codex?.length, 1);
98
+ 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/);
115
+ });
116
+
117
+ test('getWatchTargets includes config file plus configured source files and globs', async (t) => {
118
+ const { root, home } = makeTempHome();
119
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
120
+
121
+ const hermesRoot = path.join(root, 'skills');
122
+ const mcpFile = path.join(root, 'mcp.json');
123
+ fs.mkdirSync(hermesRoot, { recursive: true });
124
+ write(mcpFile, '{}');
125
+ write(path.join(home, 'sources.yaml'), `sources:
126
+ hermes:
127
+ enabled: true
128
+ roots:
129
+ - ${JSON.stringify(hermesRoot)}
130
+ mcp-config:
131
+ enabled: true
132
+ files:
133
+ - ${JSON.stringify(mcpFile)}
134
+ `);
135
+
136
+ const targets = await getWatchTargets();
137
+ assert.ok(targets.includes(path.join(home, 'sources.yaml')));
138
+ assert.ok(targets.includes(mcpFile));
139
+ assert.ok(targets.some(t => t.endsWith('/skills/**/SKILL.md')));
140
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@huhaa/server",
3
+ "version": "0.1.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs"
9
+ },
10
+ "dependencies": {
11
+ "fastify": "^5.0.0",
12
+ "chokidar": "^4.0.3",
13
+ "@huhaa/scanner": "*"
14
+ }
15
+ }
@@ -1,37 +1,22 @@
1
1
  // @huhaa/server — Fastify HTTP API + placeholder UI.
2
- //
3
- // Hard rules (locked from day 1):
4
- // - bind 127.0.0.1 ONLY, never 0.0.0.0
5
- // - server NEVER scans files; it reads in-memory IR populated by scanner
6
- // - copy/open ONLY operate on paths that exist in the loaded IR snapshot
7
- // (whitelist defense: a malicious request can't ask us to pbcopy / open
8
- // arbitrary files because we resolve via skill id, not via raw path)
9
- //
10
- // API surface (P2):
11
- // GET /api/health — liveness + counts
12
- // GET /api/skills — list (no `raw` field — detail-only)
13
- // GET /api/skills/:id — full IR item (with raw)
14
- // GET /api/stats — by source / category / brand aggregates
15
- // POST /api/copy — { id, what: 'path'|'dir'|'rel'|'name'|'raw'|'prompt' } -> pbcopy
16
- // POST /api/open — { id, with?: 'cursor'|'finder'|'default' }
17
- //
18
- // P3 replaces the placeholder HTML with a built Vue SPA mounted at /.
19
-
20
2
  import fs from 'node:fs';
21
3
  import path from 'node:path';
22
4
  import { fileURLToPath } from 'node:url';
23
5
  import { spawn } from 'node:child_process';
24
6
  import Fastify from 'fastify';
25
7
  import chokidar from 'chokidar';
26
- import { scan, getWatchTargets } from '@huhaa/scanner';
27
8
 
28
9
  const PHASE = 'P6';
29
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
- // server/src -> server -> packages -> service
31
- const SERVICE_ROOT = path.resolve(__dirname, '..', '..', '..');
32
- const PACKAGE_JSON = path.resolve(SERVICE_ROOT, '..', 'package.json');
11
+ const PKGS_ROOT = path.resolve(__dirname, '..', '..');
12
+ const PACKAGE_JSON = path.resolve(PKGS_ROOT, '..', 'package.json');
33
13
  const VERSION = readPackageVersion();
34
- const WEB_DIST = path.join(SERVICE_ROOT, 'packages', 'web', 'dist');
14
+ const WEB_DIST = path.join(PKGS_ROOT, 'web', 'dist');
15
+
16
+ async function importScanner() {
17
+ const { scan, getWatchTargets } = await import(path.join(PKGS_ROOT, 'scanner/src/index.mjs'));
18
+ return { scan, getWatchTargets };
19
+ }
35
20
 
36
21
  function readPackageVersion() {
37
22
  try {
@@ -46,6 +31,8 @@ export async function startServer({ port = 11520 } = {}) {
46
31
  logger: { level: process.env.LOG_LEVEL || 'warn' },
47
32
  });
48
33
 
34
+ const { scan, getWatchTargets } = await importScanner();
35
+
49
36
  // In-memory IR. HTTP handlers only read this snapshot; reload/watch swaps it atomically.
50
37
  let irCache = await scan();
51
38
  let irById = new Map(irCache.map(it => [it.id, it]));
@@ -0,0 +1,135 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { startServer } from '../src/index.mjs';
8
+
9
+ function makeTempHome() {
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'huhaa-server-test-'));
11
+ const home = path.join(root, 'home');
12
+ fs.mkdirSync(home, { recursive: true });
13
+ process.env.HUHAA_HOME = home;
14
+ return { root, home };
15
+ }
16
+
17
+ function write(file, text) {
18
+ fs.mkdirSync(path.dirname(file), { recursive: true });
19
+ fs.writeFileSync(file, text);
20
+ }
21
+
22
+ async function bootFixtureServer(t) {
23
+ const { root, home } = makeTempHome();
24
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
25
+
26
+ const skillRoot = path.join(root, 'skills');
27
+ write(path.join(skillRoot, 'ops', 'quote-skill', 'SKILL.md'), `---
28
+ name: quote's skill
29
+ description: Skill used by API tests
30
+ ---
31
+ # API test skill
32
+ `);
33
+ write(path.join(home, 'sources.yaml'), `sources:
34
+ hermes:
35
+ enabled: true
36
+ roots:
37
+ - ${JSON.stringify(skillRoot)}
38
+ limits:
39
+ maxFiles: 100
40
+ maxFileBytes: 1048576
41
+ `);
42
+
43
+ const server = await startServer({ port: 0 });
44
+ t.after(async () => { await server.app.close(); });
45
+ return server;
46
+ }
47
+
48
+ test('server exposes health, list, detail, stats, and reload state without raw in list', async (t) => {
49
+ const { app } = await bootFixtureServer(t);
50
+
51
+ const health = await app.inject({ method: 'GET', url: '/api/health' });
52
+ assert.equal(health.statusCode, 200);
53
+ assert.deepEqual(JSON.parse(health.body), {
54
+ ok: true,
55
+ port: 0,
56
+ items: 1,
57
+ version: '0.2.1',
58
+ phase: 'P6',
59
+ });
60
+
61
+ const list = await app.inject({ method: 'GET', url: '/api/skills' });
62
+ assert.equal(list.statusCode, 200);
63
+ const items = JSON.parse(list.body);
64
+ assert.equal(items.length, 1);
65
+ assert.equal(items[0].name, "quote's skill");
66
+ assert.equal(items[0].raw, undefined, 'list endpoint must not expose raw content');
67
+
68
+ const detail = await app.inject({ method: 'GET', url: `/api/skills/${items[0].id}` });
69
+ assert.equal(detail.statusCode, 200);
70
+ assert.match(JSON.parse(detail.body).raw, /API test skill/);
71
+
72
+ const stats = await app.inject({ method: 'GET', url: '/api/stats' });
73
+ assert.equal(stats.statusCode, 200);
74
+ assert.equal(JSON.parse(stats.body).bySource.hermes, 1);
75
+
76
+ const reloadState = await app.inject({ method: 'GET', url: '/api/reload-state' });
77
+ assert.equal(reloadState.statusCode, 200);
78
+ assert.equal(JSON.parse(reloadState.body).items, 1);
79
+ });
80
+
81
+ test('server copies invocation prompt through pbcopy using a whitelisted item id', async (t) => {
82
+ const { root } = makeTempHome();
83
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
84
+ const bin = path.join(root, 'bin');
85
+ const copiedFile = path.join(root, 'copied.txt');
86
+ fs.mkdirSync(bin, { recursive: true });
87
+ write(path.join(bin, 'pbcopy'), `#!/usr/bin/env node\nimport fs from 'node:fs';\nlet s='';\nprocess.stdin.on('data', d => s += d);\nprocess.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(copiedFile)}, s));\n`);
88
+ fs.chmodSync(path.join(bin, 'pbcopy'), 0o755);
89
+ const oldPath = process.env.PATH;
90
+ process.env.PATH = `${bin}:${oldPath || ''}`;
91
+ t.after(() => { process.env.PATH = oldPath; });
92
+
93
+ const { app } = await bootFixtureServer(t);
94
+ const items = JSON.parse((await app.inject({ method: 'GET', url: '/api/skills' })).body);
95
+ const res = await app.inject({
96
+ method: 'POST',
97
+ url: '/api/copy',
98
+ payload: { id: items[0].id, what: 'prompt' },
99
+ });
100
+
101
+ assert.equal(res.statusCode, 200);
102
+ assert.equal(JSON.parse(res.body).ok, true);
103
+ const copied = fs.readFileSync(copiedFile, 'utf8');
104
+ assert.equal(copied, "Use Hermes skill quote's skill: skill_view(name='quote\\'s skill')");
105
+ });
106
+
107
+ test('server rejects unknown ids and invalid copy/open inputs before any OS side effect', async (t) => {
108
+ const { app } = await bootFixtureServer(t);
109
+ const items = JSON.parse((await app.inject({ method: 'GET', url: '/api/skills' })).body);
110
+ const id = items[0].id;
111
+
112
+ const missing = await app.inject({ method: 'GET', url: '/api/skills/nope' });
113
+ assert.equal(missing.statusCode, 404);
114
+
115
+ const badCopy = await app.inject({
116
+ method: 'POST',
117
+ url: '/api/copy',
118
+ payload: { id, what: '../../etc/passwd' },
119
+ });
120
+ assert.equal(badCopy.statusCode, 400);
121
+ assert.match(badCopy.body, /invalid 'what'/);
122
+
123
+ const missingOpen = await app.inject({
124
+ method: 'POST',
125
+ url: '/api/open',
126
+ payload: { id: 'nope', with: 'finder' },
127
+ });
128
+ assert.equal(missingOpen.statusCode, 404);
129
+ });
130
+
131
+ test('server asset route rejects path traversal', async (t) => {
132
+ const { app } = await bootFixtureServer(t);
133
+ const res = await app.inject({ method: 'GET', url: '/assets/..%2F..%2Fpackage.json' });
134
+ assert.equal(res.statusCode, 404);
135
+ });
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>HuHaa-MySkills</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.js"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@huhaa/web",
3
+ "version": "0.1.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --host 127.0.0.1 --port 11521",
8
+ "build": "vite build",
9
+ "preview": "vite preview --host 127.0.0.1 --port 11522"
10
+ },
11
+ "dependencies": {
12
+ "@vitejs/plugin-vue": "^5.2.1",
13
+ "vite": "^6.0.0",
14
+ "vue": "^3.5.13",
15
+ "pinia": "^2.3.0",
16
+ "fuse.js": "^7.0.0",
17
+ "markdown-it": "^14.1.0"
18
+ },
19
+ "devDependencies": {}
20
+ }