@nospt/plugin-dev-ai-hub-backend 0.1.0

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 (36) hide show
  1. package/.eslintrc.js +1 -0
  2. package/dev/index.ts +8 -0
  3. package/dist/database/AiAssetStore.cjs.js +224 -0
  4. package/dist/database/AiAssetStore.cjs.js.map +1 -0
  5. package/dist/index.cjs.js +11 -0
  6. package/dist/index.cjs.js.map +1 -0
  7. package/dist/index.d.ts +5 -0
  8. package/dist/plugin.cjs.js +70 -0
  9. package/dist/plugin.cjs.js.map +1 -0
  10. package/dist/router.cjs.js +247 -0
  11. package/dist/router.cjs.js.map +1 -0
  12. package/dist/service/AiAssetSyncService.cjs.js +210 -0
  13. package/dist/service/AiAssetSyncService.cjs.js.map +1 -0
  14. package/dist/service/AssetParser.cjs.js +91 -0
  15. package/dist/service/AssetParser.cjs.js.map +1 -0
  16. package/dist/service/McpServerService.cjs.js +366 -0
  17. package/dist/service/McpServerService.cjs.js.map +1 -0
  18. package/migrations/001_init.js +47 -0
  19. package/migrations/002_install_count.js +17 -0
  20. package/migrations/003_install_paths.js +19 -0
  21. package/migrations/004_label.js +17 -0
  22. package/migrations/005_remove_unused_fields.js +22 -0
  23. package/migrations/006_resources_content.js +16 -0
  24. package/package.json +72 -0
  25. package/package.json-prepack +64 -0
  26. package/src/database/AiAssetStore.test.ts +294 -0
  27. package/src/database/AiAssetStore.ts +256 -0
  28. package/src/index.ts +2 -0
  29. package/src/plugin.ts +84 -0
  30. package/src/router.ts +338 -0
  31. package/src/service/AiAssetSyncService.ts +267 -0
  32. package/src/service/AssetParser.test.ts +261 -0
  33. package/src/service/AssetParser.ts +117 -0
  34. package/src/service/McpServerService.ts +464 -0
  35. package/src/types.ts +31 -0
  36. package/tsconfig.json +8 -0
@@ -0,0 +1,261 @@
1
+ import { AssetParser } from './AssetParser';
2
+
3
+ const VALID_YAML = `
4
+ name: My Instruction
5
+ description: A test instruction
6
+ type: instruction
7
+ tools:
8
+ - claude-code
9
+ tags:
10
+ - testing
11
+ author: Test Author
12
+ version: 1.2.3
13
+ `;
14
+
15
+ describe('AssetParser.parseYaml', () => {
16
+ it('parses a valid YAML envelope and returns metadata + mdPath', () => {
17
+ const result = AssetParser.parseYaml(VALID_YAML, 'instructions/my-instruction.yaml');
18
+ expect(result).not.toBeNull();
19
+ expect(result!.meta.name).toBe('My Instruction');
20
+ expect(result!.meta.description).toBe('A test instruction');
21
+ expect(result!.meta.type).toBe('instruction');
22
+ expect(result!.meta.tools).toEqual(['claude-code']);
23
+ expect(result!.meta.tags).toEqual(['testing']);
24
+ expect(result!.meta.author).toBe('Test Author');
25
+ expect(result!.meta.version).toBe('1.2.3');
26
+ expect(result!.yamlRaw).toBe(VALID_YAML);
27
+ });
28
+
29
+ it('derives mdPath from the yaml file name by default', () => {
30
+ const result = AssetParser.parseYaml(VALID_YAML, 'instructions/my-instruction.yaml');
31
+ expect(result!.mdPath).toBe('instructions/my-instruction.md');
32
+ });
33
+
34
+ it('uses the content field to resolve mdPath when present', () => {
35
+ const yaml = `${VALID_YAML}content: custom-content.md\n`;
36
+ const result = AssetParser.parseYaml(yaml, 'instructions/my-instruction.yaml');
37
+ expect(result!.mdPath).toBe('instructions/custom-content.md');
38
+ });
39
+
40
+ it('resolves content path relative to the yaml directory', () => {
41
+ const yaml = `${VALID_YAML}content: sub/custom.md\n`;
42
+ const result = AssetParser.parseYaml(yaml, 'agents/my-agent.yaml');
43
+ expect(result!.mdPath).toBe('agents/sub/custom.md');
44
+ });
45
+
46
+ it('returns null for invalid YAML syntax', () => {
47
+ expect(AssetParser.parseYaml(': bad: yaml: :', 'test.yaml')).toBeNull();
48
+ });
49
+
50
+ it('returns null when required field "name" is missing', () => {
51
+ const yaml = `
52
+ description: A description
53
+ type: instruction
54
+ tools:
55
+ - claude-code
56
+ `;
57
+ expect(AssetParser.parseYaml(yaml, 'test.yaml')).toBeNull();
58
+ });
59
+
60
+ it('returns null when required field "type" is missing', () => {
61
+ const yaml = `
62
+ name: Test
63
+ description: A description
64
+ tools:
65
+ - claude-code
66
+ `;
67
+ expect(AssetParser.parseYaml(yaml, 'test.yaml')).toBeNull();
68
+ });
69
+
70
+ it('returns null when type is not a recognised enum value', () => {
71
+ const yaml = `
72
+ name: Test
73
+ description: A description
74
+ type: invalid-type
75
+ tools:
76
+ - claude-code
77
+ `;
78
+ expect(AssetParser.parseYaml(yaml, 'test.yaml')).toBeNull();
79
+ });
80
+
81
+ it('returns null when tools array is empty', () => {
82
+ const yaml = `
83
+ name: Test
84
+ description: A description
85
+ type: instruction
86
+ tools: []
87
+ `;
88
+ expect(AssetParser.parseYaml(yaml, 'test.yaml')).toBeNull();
89
+ });
90
+
91
+ it('applies schema defaults for optional fields', () => {
92
+ const yaml = `
93
+ name: Minimal
94
+ description: Minimal description
95
+ type: agent
96
+ tools:
97
+ - claude-code
98
+ `;
99
+ const result = AssetParser.parseYaml(yaml, 'agents/minimal.yaml');
100
+ expect(result!.meta.author).toBe('Unknown');
101
+ expect(result!.meta.version).toBe('1.0.0');
102
+ expect(result!.meta.tags).toEqual([]);
103
+ });
104
+
105
+ it('passes through unrecognised fields (passthrough schema)', () => {
106
+ const yaml = `${VALID_YAML}unknownField: some-value\n`;
107
+ const result = AssetParser.parseYaml(yaml, 'test.yaml');
108
+ expect(result).not.toBeNull();
109
+ });
110
+ });
111
+
112
+ describe('AssetParser.buildId', () => {
113
+ it('returns a base64url encoded string', () => {
114
+ const id = AssetParser.buildId('provider-1', 'instructions/my-asset.yaml');
115
+ expect(id).toBe(Buffer.from('provider-1:instructions/my-asset.yaml').toString('base64url'));
116
+ });
117
+
118
+ it('normalises backslashes to forward slashes', () => {
119
+ const id1 = AssetParser.buildId('p', 'instructions\\my-asset.yaml');
120
+ const id2 = AssetParser.buildId('p', 'instructions/my-asset.yaml');
121
+ expect(id1).toBe(id2);
122
+ });
123
+
124
+ it('strips a leading slash', () => {
125
+ const id1 = AssetParser.buildId('p', '/instructions/my-asset.yaml');
126
+ const id2 = AssetParser.buildId('p', 'instructions/my-asset.yaml');
127
+ expect(id1).toBe(id2);
128
+ });
129
+
130
+ it('is deterministic for the same inputs', () => {
131
+ const id1 = AssetParser.buildId('provider-1', 'path/to/asset.yaml');
132
+ const id2 = AssetParser.buildId('provider-1', 'path/to/asset.yaml');
133
+ expect(id1).toBe(id2);
134
+ });
135
+
136
+ it('produces different IDs for different providers', () => {
137
+ const id1 = AssetParser.buildId('provider-1', 'path/asset.yaml');
138
+ const id2 = AssetParser.buildId('provider-2', 'path/asset.yaml');
139
+ expect(id1).not.toBe(id2);
140
+ });
141
+
142
+ it('produces different IDs for different paths', () => {
143
+ const id1 = AssetParser.buildId('p', 'instructions/a.yaml');
144
+ const id2 = AssetParser.buildId('p', 'agents/a.yaml');
145
+ expect(id1).not.toBe(id2);
146
+ });
147
+ });
148
+
149
+ describe('AssetParser.isAssetFile', () => {
150
+ it.each([
151
+ 'instructions/my-rule.yaml',
152
+ 'agents/my-agent.yaml',
153
+ 'skills/my-skill.yaml',
154
+ 'workflows/my-workflow.yaml',
155
+ '.github/instructions/my-rule.yaml',
156
+ '.github/agents/my-agent.yaml',
157
+ '.github/skills/my-skill.yaml',
158
+ '.github/workflows/my-workflow.yaml',
159
+ ])('returns true for %s', filePath => {
160
+ expect(AssetParser.isAssetFile(filePath)).toBe(true);
161
+ });
162
+
163
+ it.each([
164
+ 'random/file.yaml',
165
+ 'README.md',
166
+ 'instruction.yaml',
167
+ 'some/instructions/nested.yaml',
168
+ '',
169
+ ])('returns false for %s', filePath => {
170
+ expect(AssetParser.isAssetFile(filePath)).toBe(false);
171
+ });
172
+
173
+ it('normalises backslashes (Windows paths)', () => {
174
+ expect(AssetParser.isAssetFile('instructions\\my-rule.yaml')).toBe(true);
175
+ expect(AssetParser.isAssetFile('agents\\my-agent.yaml')).toBe(true);
176
+ expect(AssetParser.isAssetFile('.github\\instructions\\my-rule.yaml')).toBe(true);
177
+ expect(AssetParser.isAssetFile('.github\\agents\\my-agent.yaml')).toBe(true);
178
+ });
179
+ });
180
+
181
+ describe('AssetParser.buildAsset', () => {
182
+ const parsed = AssetParser.parseYaml(VALID_YAML, 'instructions/my-instruction.yaml')!;
183
+
184
+ it('builds a complete AiAssetInput with all expected fields', () => {
185
+ const asset = AssetParser.buildAsset(
186
+ parsed,
187
+ '# Content',
188
+ 'my-provider',
189
+ 'https://github.com/org/repo',
190
+ 'main',
191
+ 'instructions/my-instruction.yaml',
192
+ );
193
+
194
+ expect(asset.id).toBe(AssetParser.buildId('my-provider', 'instructions/my-instruction.yaml'));
195
+ expect(asset.name).toBe('My Instruction');
196
+ expect(asset.type).toBe('instruction');
197
+ expect(asset.tools).toEqual(['claude-code']);
198
+ expect(asset.tags).toEqual(['testing']);
199
+ expect(asset.content).toBe('# Content');
200
+ expect(asset.yamlRaw).toBe(VALID_YAML);
201
+ expect(asset.providerId).toBe('my-provider');
202
+ expect(asset.repoUrl).toBe('https://github.com/org/repo');
203
+ expect(asset.branch).toBe('main');
204
+ expect(asset.yamlPath).toBe('instructions/my-instruction.yaml');
205
+ expect(asset.mdPath).toBe('instructions/my-instruction.md');
206
+ expect(asset.author).toBe('Test Author');
207
+ expect(asset.version).toBe('1.2.3');
208
+ });
209
+
210
+ it('sets metadata.mcpServers when mcpServers is present in the YAML', () => {
211
+ const yaml = `${VALID_YAML}mcpServers:\n my-server:\n url: http://localhost\n`;
212
+ const parsedWithMcp = AssetParser.parseYaml(yaml, 'agents/agent.yaml')!;
213
+ const asset = AssetParser.buildAsset(parsedWithMcp, '', 'p', 'url', 'main', 'agents/agent.yaml');
214
+ expect(asset.metadata?.mcpServers).toEqual({ 'my-server': { url: 'http://localhost' } });
215
+ });
216
+
217
+ it('sets metadata.steps when steps is present in the YAML', () => {
218
+ const yaml = `${VALID_YAML}steps:\n - name: step1\n action: do-something\n`;
219
+ const parsedWithSteps = AssetParser.parseYaml(yaml, 'workflows/w.yaml')!;
220
+ const asset = AssetParser.buildAsset(parsedWithSteps, '', 'p', 'url', 'main', 'workflows/w.yaml');
221
+ expect(asset.metadata?.steps).toEqual([{ name: 'step1', action: 'do-something' }]);
222
+ });
223
+
224
+ it('leaves metadata undefined when no extra fields are present', () => {
225
+ const asset = AssetParser.buildAsset(
226
+ parsed,
227
+ '',
228
+ 'p',
229
+ 'url',
230
+ 'main',
231
+ 'instructions/my-instruction.yaml',
232
+ );
233
+ expect(asset.metadata).toBeUndefined();
234
+ });
235
+
236
+ it('defaults tags to [] when not specified', () => {
237
+ const yaml = `
238
+ name: No Tags
239
+ description: A description
240
+ type: instruction
241
+ tools:
242
+ - claude-code
243
+ `;
244
+ const parsedNoTags = AssetParser.parseYaml(yaml, 'instructions/no-tags.yaml')!;
245
+ const asset = AssetParser.buildAsset(parsedNoTags, '', 'p', 'url', 'main', 'instructions/no-tags.yaml');
246
+ expect(asset.tags).toEqual([]);
247
+ });
248
+
249
+ it('defaults author to "Unknown" when not specified', () => {
250
+ const yaml = `
251
+ name: No Author
252
+ description: A description
253
+ type: instruction
254
+ tools:
255
+ - claude-code
256
+ `;
257
+ const parsedNoAuthor = AssetParser.parseYaml(yaml, 'instructions/no-author.yaml')!;
258
+ const asset = AssetParser.buildAsset(parsedNoAuthor, '', 'p', 'url', 'main', 'instructions/no-author.yaml');
259
+ expect(asset.author).toBe('Unknown');
260
+ });
261
+ });
@@ -0,0 +1,117 @@
1
+ import yaml from 'js-yaml';
2
+ import path from 'path';
3
+ import { AiAssetFrontmatterSchema } from '@nospt/plugin-dev-ai-hub-common';
4
+ import type { AiAssetFrontmatter } from '@nospt/plugin-dev-ai-hub-common';
5
+ import type { AiAssetInput } from '../types';
6
+
7
+ export interface ParsedAssetMeta {
8
+ meta: AiAssetFrontmatter;
9
+ /** Resolved path of the .md content file within the repo tree */
10
+ mdPath: string;
11
+ yamlRaw: string;
12
+ }
13
+
14
+ export class AssetParser {
15
+ /**
16
+ * Parse the YAML metadata file (the envelope).
17
+ * Returns null when the YAML is invalid or missing required fields.
18
+ */
19
+ static parseYaml(
20
+ yamlContent: string,
21
+ yamlFilePath: string,
22
+ ): ParsedAssetMeta | null {
23
+ let data: unknown;
24
+ try {
25
+ data = yaml.load(yamlContent);
26
+ } catch {
27
+ return null;
28
+ }
29
+
30
+ const result = AiAssetFrontmatterSchema.safeParse(data);
31
+ if (!result.success) {
32
+ return null;
33
+ }
34
+
35
+ const meta = result.data;
36
+
37
+ // Resolve .md path: use the `content` field, or fall back to <same-name>.md
38
+ const mdReference =
39
+ meta.content ?? `${path.basename(yamlFilePath, '.yaml')}.md`;
40
+ const mdPath = path.posix.join(
41
+ path.posix.dirname(yamlFilePath),
42
+ mdReference,
43
+ );
44
+
45
+ return { meta, mdPath, yamlRaw: yamlContent };
46
+ }
47
+
48
+ /**
49
+ * Build the full AiAssetInput from parsed metadata + raw markdown content.
50
+ * The mdContent is stored verbatim — never modified.
51
+ */
52
+ static buildAsset(
53
+ parsed: ParsedAssetMeta,
54
+ mdContent: string,
55
+ providerId: string,
56
+ repoUrl: string,
57
+ branch: string,
58
+ yamlFilePath: string,
59
+ resourcesContent?: Record<string, string>,
60
+ ): AiAssetInput {
61
+ const { meta } = parsed;
62
+
63
+ // Store extra fields in metadata (used for display/reference)
64
+ const metadata: Record<string, unknown> = {};
65
+ if (meta.resources) metadata.resources = meta.resources;
66
+ if ((meta as any).mcpServers) metadata.mcpServers = (meta as any).mcpServers;
67
+ if ((meta as any).steps) metadata.steps = (meta as any).steps;
68
+
69
+ return {
70
+ id: AssetParser.buildId(providerId, yamlFilePath),
71
+ providerId,
72
+ name: meta.name,
73
+ label: meta.label,
74
+ description: meta.description,
75
+ type: meta.type,
76
+ tools: meta.tools,
77
+ tags: meta.tags ?? [],
78
+ author: meta.author ?? 'Unknown',
79
+ icon: meta.icon,
80
+ version: meta.version ?? '1.0.0',
81
+ installPath: meta.installPath,
82
+ installPaths: meta.installPaths,
83
+ content: mdContent,
84
+ yamlRaw: parsed.yamlRaw,
85
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
86
+ resourcesContent,
87
+ yamlPath: yamlFilePath,
88
+ mdPath: parsed.mdPath,
89
+ repoUrl,
90
+ branch,
91
+ };
92
+ }
93
+
94
+ static buildId(providerId: string, yamlPath: string): string {
95
+ // Simple deterministic ID: provider + normalized path
96
+ const normalized = yamlPath.replace(/\\/g, '/').replace(/^\//, '');
97
+ return Buffer.from(`${providerId}:${normalized}`).toString('base64url');
98
+ }
99
+
100
+ /** Known top-level directories that contain assets */
101
+ private static readonly ASSET_DIRS = [
102
+ 'instructions/',
103
+ 'agents/',
104
+ 'skills/',
105
+ 'workflows/',
106
+ ];
107
+
108
+ /** True if the file is in a known asset directory (root or .github/) */
109
+ static isAssetFile(filePath: string): boolean {
110
+ const normalized = filePath.replace(/\\/g, '/');
111
+ return AssetParser.ASSET_DIRS.some(
112
+ dir =>
113
+ normalized.startsWith(dir) ||
114
+ normalized.startsWith(`.github/${dir}`),
115
+ );
116
+ }
117
+ }