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.
- package/{service/bin → bin}/huhaa-myskills.mjs +24 -44
- package/package.json +19 -28
- package/packages/scanner/package.json +15 -0
- package/packages/scanner/test/scanner.test.mjs +140 -0
- package/packages/server/package.json +15 -0
- package/{service/packages → packages}/server/src/index.mjs +10 -23
- package/packages/server/test/server.test.mjs +135 -0
- package/packages/web/index.html +12 -0
- package/packages/web/package.json +20 -0
- package/packages/web/src/App.vue +407 -0
- package/packages/web/src/lib/api.js +43 -0
- package/packages/web/src/main.js +6 -0
- package/packages/web/src/stores/i18n.js +149 -0
- package/packages/web/src/stores/skills.js +276 -0
- package/packages/web/src/styles.css +325 -0
- package/packages/web/vite.config.js +17 -0
- package/{service/scripts → scripts}/build-web.mjs +2 -2
- package/scripts/prepare-publish.mjs +3 -0
- package/{service/scripts → scripts}/verify.mjs +6 -4
- package/install-and-sync.sh +0 -209
- package/service/scripts/prepare-publish.mjs +0 -44
- package/service/scripts/restore-publish.mjs +0 -17
- /package/{service/bin → bin}/lib/paths.mjs +0 -0
- /package/{service/bin → bin}/lib/port.mjs +0 -0
- /package/{service/config → config}/sources.example.yaml +0 -0
- /package/{service/packages → packages}/scanner/src/adapters/file-docs.mjs +0 -0
- /package/{service/packages → packages}/scanner/src/adapters/hermes-plugin.mjs +0 -0
- /package/{service/packages → packages}/scanner/src/adapters/markdown-skill.mjs +0 -0
- /package/{service/packages → packages}/scanner/src/adapters/mcp-config.mjs +0 -0
- /package/{service/packages → packages}/scanner/src/index.mjs +0 -0
- /package/{service/packages → packages}/scanner/src/types.d.ts +0 -0
- /package/{service/packages → packages}/scanner/src/utils.mjs +0 -0
- /package/{service/packages → packages}/web/README.md +0 -0
- /package/{service/packages → packages}/web/dist/assets/index-CTh5OdBd.js +0 -0
- /package/{service/packages → packages}/web/dist/assets/index-CtoiGnQ7.css +0 -0
- /package/{service/packages → packages}/web/dist/index.html +0 -0
- /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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
const
|
|
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(
|
|
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
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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(
|
|
363
|
-
const skillsJson = JSON.stringify(skills);
|
|
344
|
+
const syncScript = path.join(REPO_ROOT, 'scripts', 'sync-skills.sh');
|
|
364
345
|
|
|
365
|
-
return new Promise((resolve
|
|
366
|
-
const child = spawn('bash', [syncScript, '--editor', editor
|
|
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.
|
|
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": "
|
|
20
|
+
"huhaa-myskills": "bin/huhaa-myskills.mjs"
|
|
24
21
|
},
|
|
25
22
|
"files": [
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
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
|
|
39
|
-
"scan": "node
|
|
40
|
-
"stats": "node
|
|
41
|
-
"duplicates": "node
|
|
42
|
-
"init": "node
|
|
43
|
-
"purge": "node
|
|
44
|
-
"dev": "node
|
|
45
|
-
"sync": "bash
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
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
|
-
|
|
31
|
-
const
|
|
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(
|
|
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
|
+
}
|