@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.
- package/.eslintrc.js +1 -0
- package/dev/index.ts +8 -0
- package/dist/database/AiAssetStore.cjs.js +224 -0
- package/dist/database/AiAssetStore.cjs.js.map +1 -0
- package/dist/index.cjs.js +11 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/plugin.cjs.js +70 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/router.cjs.js +247 -0
- package/dist/router.cjs.js.map +1 -0
- package/dist/service/AiAssetSyncService.cjs.js +210 -0
- package/dist/service/AiAssetSyncService.cjs.js.map +1 -0
- package/dist/service/AssetParser.cjs.js +91 -0
- package/dist/service/AssetParser.cjs.js.map +1 -0
- package/dist/service/McpServerService.cjs.js +366 -0
- package/dist/service/McpServerService.cjs.js.map +1 -0
- package/migrations/001_init.js +47 -0
- package/migrations/002_install_count.js +17 -0
- package/migrations/003_install_paths.js +19 -0
- package/migrations/004_label.js +17 -0
- package/migrations/005_remove_unused_fields.js +22 -0
- package/migrations/006_resources_content.js +16 -0
- package/package.json +72 -0
- package/package.json-prepack +64 -0
- package/src/database/AiAssetStore.test.ts +294 -0
- package/src/database/AiAssetStore.ts +256 -0
- package/src/index.ts +2 -0
- package/src/plugin.ts +84 -0
- package/src/router.ts +338 -0
- package/src/service/AiAssetSyncService.ts +267 -0
- package/src/service/AssetParser.test.ts +261 -0
- package/src/service/AssetParser.ts +117 -0
- package/src/service/McpServerService.ts +464 -0
- package/src/types.ts +31 -0
- 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
|
+
}
|