wmcp-annotate 1.0.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.

Potentially problematic release.


This version of wmcp-annotate might be problematic. Click here for more details.

Files changed (72) hide show
  1. package/AGENTS.md +108 -0
  2. package/IMPLEMENTATION_PLAN.md +187 -0
  3. package/LAUNCH.md +217 -0
  4. package/PRD.md +199 -0
  5. package/PROMPT.md +62 -0
  6. package/README.md +140 -0
  7. package/dist/commands/generate.d.ts +3 -0
  8. package/dist/commands/generate.d.ts.map +1 -0
  9. package/dist/commands/generate.js +46 -0
  10. package/dist/commands/generate.js.map +1 -0
  11. package/dist/commands/scan.d.ts +3 -0
  12. package/dist/commands/scan.d.ts.map +1 -0
  13. package/dist/commands/scan.js +24 -0
  14. package/dist/commands/scan.js.map +1 -0
  15. package/dist/commands/suggest.d.ts +3 -0
  16. package/dist/commands/suggest.d.ts.map +1 -0
  17. package/dist/commands/suggest.js +36 -0
  18. package/dist/commands/suggest.js.map +1 -0
  19. package/dist/commands/validate.d.ts +3 -0
  20. package/dist/commands/validate.d.ts.map +1 -0
  21. package/dist/commands/validate.js +39 -0
  22. package/dist/commands/validate.js.map +1 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +44 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/lib/analyzer.d.ts +10 -0
  28. package/dist/lib/analyzer.d.ts.map +1 -0
  29. package/dist/lib/analyzer.js +80 -0
  30. package/dist/lib/analyzer.js.map +1 -0
  31. package/dist/lib/generator.d.ts +12 -0
  32. package/dist/lib/generator.d.ts.map +1 -0
  33. package/dist/lib/generator.js +136 -0
  34. package/dist/lib/generator.js.map +1 -0
  35. package/dist/lib/output.d.ts +6 -0
  36. package/dist/lib/output.d.ts.map +1 -0
  37. package/dist/lib/output.js +35 -0
  38. package/dist/lib/output.js.map +1 -0
  39. package/dist/lib/scanner.d.ts +19 -0
  40. package/dist/lib/scanner.d.ts.map +1 -0
  41. package/dist/lib/scanner.js +159 -0
  42. package/dist/lib/scanner.js.map +1 -0
  43. package/dist/lib/validator.d.ts +13 -0
  44. package/dist/lib/validator.d.ts.map +1 -0
  45. package/dist/lib/validator.js +178 -0
  46. package/dist/lib/validator.js.map +1 -0
  47. package/dist/types.d.ts +109 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +3 -0
  50. package/dist/types.js.map +1 -0
  51. package/docs/index.html +563 -0
  52. package/marketing/email-outreach.md +183 -0
  53. package/marketing/landing-page.md +165 -0
  54. package/marketing/social-posts.md +192 -0
  55. package/package.json +58 -0
  56. package/scripts/publish.sh +33 -0
  57. package/specs/generate-command.md +147 -0
  58. package/specs/scan-command.md +92 -0
  59. package/specs/suggest-command.md +120 -0
  60. package/specs/validate-command.md +108 -0
  61. package/src/commands/generate.ts +48 -0
  62. package/src/commands/scan.ts +28 -0
  63. package/src/commands/suggest.ts +39 -0
  64. package/src/commands/validate.ts +44 -0
  65. package/src/index.ts +51 -0
  66. package/src/lib/analyzer.ts +90 -0
  67. package/src/lib/generator.ts +149 -0
  68. package/src/lib/output.ts +40 -0
  69. package/src/lib/scanner.ts +185 -0
  70. package/src/lib/validator.ts +192 -0
  71. package/src/types.ts +124 -0
  72. package/tsconfig.json +20 -0
@@ -0,0 +1,90 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import type { ScanResult, SuggestResult, ToolSuggestion, JsonSchema } from '../types.js';
3
+
4
+ class Analyzer {
5
+ private client: Anthropic | null = null;
6
+
7
+ private getClient(): Anthropic {
8
+ if (!this.client) {
9
+ const apiKey = process.env.ANTHROPIC_API_KEY;
10
+ if (!apiKey) {
11
+ throw new Error('ANTHROPIC_API_KEY environment variable is required');
12
+ }
13
+ this.client = new Anthropic({ apiKey });
14
+ }
15
+ return this.client;
16
+ }
17
+
18
+ async suggest(scanResult: ScanResult): Promise<SuggestResult> {
19
+ const tools: ToolSuggestion[] = [];
20
+
21
+ for (const element of scanResult.elements) {
22
+ const suggestion = await this.analyzeElement(element);
23
+ if (suggestion) {
24
+ tools.push(suggestion);
25
+ }
26
+ }
27
+
28
+ return {
29
+ version: '1.0.0',
30
+ url: scanResult.url,
31
+ suggestedAt: new Date().toISOString(),
32
+ tools,
33
+ };
34
+ }
35
+
36
+ private async analyzeElement(element: any): Promise<ToolSuggestion | null> {
37
+ const client = this.getClient();
38
+
39
+ const prompt = `Analyze this website element and generate a WebMCP tool definition.
40
+
41
+ Element:
42
+ - Type: ${element.type}
43
+ - Label: ${element.label}
44
+ - Selector: ${element.selector}
45
+ ${element.inputs ? `- Inputs: ${JSON.stringify(element.inputs)}` : ''}
46
+
47
+ Generate a WebMCP tool definition with:
48
+ 1. name: camelCase, action-oriented verb (e.g., searchProducts, addToCart)
49
+ 2. description: 1-2 sentences explaining what it does
50
+ 3. readOnly: true if it's a search/view action, false for create/update/delete
51
+ 4. inputSchema: JSON Schema for the inputs
52
+
53
+ Respond with valid JSON only:
54
+ {
55
+ "name": "...",
56
+ "description": "...",
57
+ "readOnly": true/false,
58
+ "inputSchema": { ... }
59
+ }`;
60
+
61
+ try {
62
+ const response = await client.messages.create({
63
+ model: 'claude-sonnet-4-20250514',
64
+ max_tokens: 1024,
65
+ messages: [{ role: 'user', content: prompt }],
66
+ });
67
+
68
+ const content = response.content[0];
69
+ if (content.type !== 'text') return null;
70
+
71
+ const json = JSON.parse(content.text);
72
+
73
+ return {
74
+ name: json.name,
75
+ description: json.description,
76
+ readOnly: json.readOnly,
77
+ inputSchema: json.inputSchema,
78
+ sourceElement: {
79
+ type: element.type,
80
+ selector: element.selector,
81
+ },
82
+ };
83
+ } catch (error) {
84
+ console.error('Analysis failed for element:', element.selector, error);
85
+ return null;
86
+ }
87
+ }
88
+ }
89
+
90
+ export const analyzer = new Analyzer();
@@ -0,0 +1,149 @@
1
+ import type { SuggestResult, ToolSuggestion } from '../types.js';
2
+ import Handlebars from 'handlebars';
3
+
4
+ interface GenerateOptions {
5
+ format: 'js' | 'ts' | 'react' | 'vue';
6
+ module: 'esm' | 'cjs';
7
+ }
8
+
9
+ const jsTemplate = Handlebars.compile(`// WebMCP Tool Registration
10
+ // Generated by wmcp-annotate
11
+ // URL: {{url}}
12
+ // Generated: {{generatedAt}}
13
+
14
+ {{#each tools}}
15
+ // Tool: {{name}}
16
+ // {{description}}
17
+ navigator.modelContext.registerTool({
18
+ name: "{{name}}",
19
+ description: "{{description}}",
20
+ readOnly: {{readOnly}},
21
+ inputSchema: {{{schemaJson}}},
22
+ async execute({{#if hasInputs}}{ {{inputNames}} }{{/if}}) {
23
+ // TODO: Implement {{name}}
24
+ // Source element: {{sourceElement.selector}}
25
+
26
+ {{#if isForm}}
27
+ const form = document.querySelector('{{sourceElement.selector}}');
28
+ {{#each inputFields}}
29
+ form.querySelector('[name="{{this}}"]').value = {{this}};
30
+ {{/each}}
31
+ form.submit();
32
+ {{else}}
33
+ const element = document.querySelector('{{sourceElement.selector}}');
34
+ element.click();
35
+ {{/if}}
36
+
37
+ // Return results
38
+ return {
39
+ content: [{
40
+ type: "text",
41
+ text: JSON.stringify({ success: true })
42
+ }]
43
+ };
44
+ }
45
+ });
46
+
47
+ {{/each}}
48
+ `);
49
+
50
+ const tsTemplate = Handlebars.compile(`// WebMCP Tool Registration
51
+ // Generated by wmcp-annotate
52
+ // URL: {{url}}
53
+
54
+ {{#each tools}}
55
+ interface {{pascalName}}Input {
56
+ {{#each inputFields}}
57
+ {{this}}: string;
58
+ {{/each}}
59
+ }
60
+
61
+ {{/each}}
62
+ {{#each tools}}
63
+ navigator.modelContext.registerTool({
64
+ name: "{{name}}",
65
+ description: "{{description}}",
66
+ readOnly: {{readOnly}},
67
+ inputSchema: {{{schemaJson}}},
68
+ async execute(input: {{pascalName}}Input) {
69
+ // TODO: Implement
70
+ return { content: [{ type: "text", text: JSON.stringify({ success: true }) }] };
71
+ }
72
+ });
73
+
74
+ {{/each}}
75
+ `);
76
+
77
+ const reactTemplate = Handlebars.compile(`// WebMCP React Hook
78
+ // Generated by wmcp-annotate
79
+
80
+ import { useEffect } from 'react';
81
+
82
+ export function useWebMCPTools() {
83
+ useEffect(() => {
84
+ if (!navigator.modelContext) {
85
+ console.warn('WebMCP not available');
86
+ return;
87
+ }
88
+
89
+ const cleanups: (() => void)[] = [];
90
+
91
+ {{#each tools}}
92
+ cleanups.push(
93
+ navigator.modelContext.registerTool({
94
+ name: "{{name}}",
95
+ description: "{{description}}",
96
+ readOnly: {{readOnly}},
97
+ inputSchema: {{{schemaJson}}},
98
+ async execute(input) {
99
+ // TODO: Implement
100
+ return { content: [{ type: "text", text: JSON.stringify({ success: true }) }] };
101
+ }
102
+ })
103
+ );
104
+
105
+ {{/each}}
106
+ return () => cleanups.forEach(cleanup => cleanup?.());
107
+ }, []);
108
+ }
109
+ `);
110
+
111
+ class Generator {
112
+ async generate(suggestions: SuggestResult, options: GenerateOptions): Promise<string> {
113
+ const data = {
114
+ url: suggestions.url,
115
+ generatedAt: new Date().toISOString(),
116
+ tools: suggestions.tools.map(tool => this.prepareToolData(tool)),
117
+ };
118
+
119
+ switch (options.format) {
120
+ case 'ts':
121
+ return tsTemplate(data);
122
+ case 'react':
123
+ return reactTemplate(data);
124
+ case 'vue':
125
+ // Vue is similar to React for this use case
126
+ return reactTemplate(data).replace('useWebMCPTools', 'useWebMCPTools');
127
+ default:
128
+ return jsTemplate(data);
129
+ }
130
+ }
131
+
132
+ private prepareToolData(tool: ToolSuggestion) {
133
+ const inputFields = tool.inputSchema.properties
134
+ ? Object.keys(tool.inputSchema.properties)
135
+ : [];
136
+
137
+ return {
138
+ ...tool,
139
+ schemaJson: JSON.stringify(tool.inputSchema, null, 2),
140
+ hasInputs: inputFields.length > 0,
141
+ inputNames: inputFields.join(', '),
142
+ inputFields,
143
+ isForm: tool.sourceElement.type === 'form',
144
+ pascalName: tool.name.charAt(0).toUpperCase() + tool.name.slice(1),
145
+ };
146
+ }
147
+ }
148
+
149
+ export const generator = new Generator();
@@ -0,0 +1,40 @@
1
+ import fs from 'fs/promises';
2
+
3
+ export async function writeOutput(data: unknown, options: { output?: string; format?: string }): Promise<void> {
4
+ const output = options.format === 'yaml'
5
+ ? toYaml(data)
6
+ : JSON.stringify(data, null, 2);
7
+
8
+ if (options.output) {
9
+ await fs.writeFile(options.output, output, 'utf-8');
10
+ } else {
11
+ console.log(output);
12
+ }
13
+ }
14
+
15
+ export async function readInput<T>(path: string): Promise<T> {
16
+ const content = await fs.readFile(path, 'utf-8');
17
+ return JSON.parse(content) as T;
18
+ }
19
+
20
+ function toYaml(data: unknown, indent = 0): string {
21
+ // Simple YAML conversion
22
+ const spaces = ' '.repeat(indent);
23
+
24
+ if (Array.isArray(data)) {
25
+ return data.map(item => `${spaces}- ${toYaml(item, indent + 1).trim()}`).join('\\n');
26
+ }
27
+
28
+ if (typeof data === 'object' && data !== null) {
29
+ return Object.entries(data)
30
+ .map(([key, value]) => {
31
+ if (typeof value === 'object' && value !== null) {
32
+ return `${spaces}${key}:\\n${toYaml(value, indent + 1)}`;
33
+ }
34
+ return `${spaces}${key}: ${JSON.stringify(value)}`;
35
+ })
36
+ .join('\\n');
37
+ }
38
+
39
+ return JSON.stringify(data);
40
+ }
@@ -0,0 +1,185 @@
1
+ import { chromium, type Browser, type Page } from 'playwright';
2
+ import type { ScanResult, ScannedElement, ApiCall, InputField } from '../types.js';
3
+
4
+ interface ScanOptions {
5
+ depth?: number;
6
+ verbose?: boolean;
7
+ }
8
+
9
+ class Scanner {
10
+ private browser: Browser | null = null;
11
+
12
+ async scan(url: string, options: ScanOptions = {}): Promise<ScanResult> {
13
+ const { depth = 1, verbose = false } = options;
14
+
15
+ await this.ensureBrowser();
16
+ const page = await this.browser!.newPage();
17
+
18
+ try {
19
+ // Navigate and wait for load
20
+ await page.goto(url, { waitUntil: 'networkidle' });
21
+
22
+ // Collect API calls
23
+ const apiCalls: ApiCall[] = [];
24
+ page.on('request', (request) => {
25
+ const reqUrl = request.url();
26
+ if (reqUrl.includes('/api/') || request.resourceType() === 'fetch' || request.resourceType() === 'xhr') {
27
+ apiCalls.push({
28
+ method: request.method(),
29
+ url: reqUrl,
30
+ params: Array.from(new URL(reqUrl).searchParams.keys()),
31
+ });
32
+ }
33
+ });
34
+
35
+ // Scan for elements
36
+ const elements = await this.scanElements(page);
37
+
38
+ return {
39
+ url,
40
+ scannedAt: new Date().toISOString(),
41
+ elements,
42
+ apiCalls,
43
+ };
44
+ } finally {
45
+ await page.close();
46
+ }
47
+ }
48
+
49
+ private async scanElements(page: Page): Promise<ScannedElement[]> {
50
+ const elements: ScannedElement[] = [];
51
+
52
+ // Scan forms
53
+ const forms = await page.$$('form');
54
+ for (const form of forms) {
55
+ const id = await form.getAttribute('id');
56
+ const inputs = await this.getFormInputs(form);
57
+ const submitBtn = await form.$('button[type="submit"], input[type="submit"]');
58
+
59
+ elements.push({
60
+ type: 'form',
61
+ id: id || undefined,
62
+ selector: id ? `#${id}` : 'form',
63
+ label: await this.getLabel(form) || 'Form',
64
+ inputs,
65
+ submitButton: submitBtn ? {
66
+ selector: await this.getSelector(submitBtn),
67
+ label: await submitBtn.textContent() || 'Submit',
68
+ } : undefined,
69
+ });
70
+ }
71
+
72
+ // Scan buttons (not in forms)
73
+ const buttons = await page.$$('button:not(form button), [role="button"]');
74
+ for (const button of buttons) {
75
+ const label = await button.textContent();
76
+ if (label?.trim()) {
77
+ elements.push({
78
+ type: 'button',
79
+ selector: await this.getSelector(button),
80
+ label: label.trim(),
81
+ });
82
+ }
83
+ }
84
+
85
+ // Scan links with actions
86
+ const actionLinks = await page.$$('a[href^="#"], a[href^="javascript:"], a[onclick]');
87
+ for (const link of actionLinks) {
88
+ const label = await link.textContent();
89
+ if (label?.trim()) {
90
+ elements.push({
91
+ type: 'link',
92
+ selector: await this.getSelector(link),
93
+ label: label.trim(),
94
+ });
95
+ }
96
+ }
97
+
98
+ return elements;
99
+ }
100
+
101
+ private async getFormInputs(form: any): Promise<InputField[]> {
102
+ const inputs: InputField[] = [];
103
+ const inputElements = await form.$$('input, select, textarea');
104
+
105
+ for (const input of inputElements) {
106
+ const name = await input.getAttribute('name');
107
+ const type = await input.getAttribute('type') || 'text';
108
+ const required = await input.getAttribute('required') !== null;
109
+ const placeholder = await input.getAttribute('placeholder');
110
+ const label = await this.getInputLabel(input);
111
+
112
+ if (name && type !== 'hidden' && type !== 'submit') {
113
+ inputs.push({
114
+ name,
115
+ type,
116
+ label: label || undefined,
117
+ required,
118
+ placeholder: placeholder || undefined,
119
+ });
120
+ }
121
+ }
122
+
123
+ return inputs;
124
+ }
125
+
126
+ private async getInputLabel(input: any): Promise<string | null> {
127
+ try {
128
+ const id = await input.getAttribute('id');
129
+ if (id) {
130
+ // Use evaluate to find the label in the DOM
131
+ const labelText = await input.evaluate((el: HTMLElement) => {
132
+ const id = el.getAttribute('id');
133
+ if (id) {
134
+ const label = document.querySelector(`label[for="${id}"]`);
135
+ return label?.textContent || null;
136
+ }
137
+ return null;
138
+ });
139
+ return labelText;
140
+ }
141
+ } catch {
142
+ // Ignore errors
143
+ }
144
+ return null;
145
+ }
146
+
147
+ private async getLabel(element: any): Promise<string | null> {
148
+ const ariaLabel = await element.getAttribute('aria-label');
149
+ if (ariaLabel) return ariaLabel;
150
+
151
+ const title = await element.getAttribute('title');
152
+ if (title) return title;
153
+
154
+ return null;
155
+ }
156
+
157
+ private async getSelector(element: any): Promise<string> {
158
+ const id = await element.getAttribute('id');
159
+ if (id) return `#${id}`;
160
+
161
+ const className = await element.getAttribute('class');
162
+ if (className) {
163
+ const classes = className.split(' ').filter((c: string) => c && !c.includes(':'));
164
+ if (classes.length > 0) return `.${classes[0]}`;
165
+ }
166
+
167
+ const tagName = await element.evaluate((el: HTMLElement) => el.tagName.toLowerCase());
168
+ return tagName;
169
+ }
170
+
171
+ private async ensureBrowser(): Promise<void> {
172
+ if (!this.browser) {
173
+ this.browser = await chromium.launch({ headless: true });
174
+ }
175
+ }
176
+
177
+ async close(): Promise<void> {
178
+ if (this.browser) {
179
+ await this.browser.close();
180
+ this.browser = null;
181
+ }
182
+ }
183
+ }
184
+
185
+ export const scanner = new Scanner();
@@ -0,0 +1,192 @@
1
+ import { chromium, type Browser } from 'playwright';
2
+ import AjvModule from 'ajv';
3
+ import type { ValidationResult, ToolValidation, ValidationIssue } from '../types.js';
4
+
5
+ const Ajv = AjvModule.default || AjvModule;
6
+ const ajv = new Ajv();
7
+
8
+ class Validator {
9
+ private browser: Browser | null = null;
10
+
11
+ async validate(url: string): Promise<ValidationResult> {
12
+ await this.ensureBrowser();
13
+ const page = await this.browser!.newPage();
14
+
15
+ try {
16
+ // Navigate with WebMCP flag (Chrome 146+)
17
+ await page.goto(url, { waitUntil: 'networkidle' });
18
+
19
+ // Query registered tools
20
+ const tools = await page.evaluate(() => {
21
+ // @ts-ignore - WebMCP API
22
+ if (typeof navigator.modelContext?.getTools === 'function') {
23
+ // @ts-ignore
24
+ return navigator.modelContext.getTools();
25
+ }
26
+ return [];
27
+ });
28
+
29
+ const validations: ToolValidation[] = [];
30
+ let validCount = 0;
31
+ let warningCount = 0;
32
+ let errorCount = 0;
33
+
34
+ for (const tool of tools) {
35
+ const validation = this.validateTool(tool);
36
+ validations.push(validation);
37
+
38
+ if (validation.status === 'valid') validCount++;
39
+ else if (validation.status === 'warning') warningCount++;
40
+ else errorCount++;
41
+ }
42
+
43
+ return {
44
+ url,
45
+ validatedAt: new Date().toISOString(),
46
+ summary: {
47
+ total: tools.length,
48
+ valid: validCount,
49
+ warnings: warningCount,
50
+ errors: errorCount,
51
+ },
52
+ tools: validations,
53
+ };
54
+ } finally {
55
+ await page.close();
56
+ }
57
+ }
58
+
59
+ private validateTool(tool: any): ToolValidation {
60
+ const checks: Record<string, 'pass' | 'fail' | 'warning'> = {};
61
+ const issues: ValidationIssue[] = [];
62
+
63
+ // Check name format (camelCase)
64
+ if (this.isCamelCase(tool.name)) {
65
+ checks.nameConvention = 'pass';
66
+ } else {
67
+ checks.nameConvention = 'fail';
68
+ issues.push({
69
+ level: 'error',
70
+ code: 'NAME_FORMAT',
71
+ message: 'Tool name must be camelCase',
72
+ suggestion: `Rename to: ${this.toCamelCase(tool.name)}`,
73
+ });
74
+ }
75
+
76
+ // Check description
77
+ if (!tool.description) {
78
+ checks.descriptionPresent = 'fail';
79
+ issues.push({
80
+ level: 'error',
81
+ code: 'DESCRIPTION_MISSING',
82
+ message: 'Description is required',
83
+ });
84
+ } else if (tool.description.length < 20) {
85
+ checks.descriptionPresent = 'warning';
86
+ issues.push({
87
+ level: 'warning',
88
+ code: 'DESCRIPTION_TOO_SHORT',
89
+ message: 'Description should be at least 20 characters',
90
+ suggestion: 'Add more detail about what this tool does',
91
+ });
92
+ } else {
93
+ checks.descriptionPresent = 'pass';
94
+ }
95
+
96
+ // Check schema validity
97
+ if (tool.inputSchema) {
98
+ try {
99
+ ajv.compile(tool.inputSchema);
100
+ checks.schemaValid = 'pass';
101
+
102
+ // Check for property descriptions
103
+ const props = tool.inputSchema.properties || {};
104
+ const missingDesc = Object.entries(props).filter(
105
+ ([_, prop]: [string, any]) => !prop.description
106
+ );
107
+
108
+ if (missingDesc.length > 0) {
109
+ checks.schemaDescriptions = 'warning';
110
+ issues.push({
111
+ level: 'warning',
112
+ code: 'SCHEMA_NO_DESCRIPTION',
113
+ message: `Properties missing descriptions: ${missingDesc.map(([k]) => k).join(', ')}`,
114
+ });
115
+ } else {
116
+ checks.schemaDescriptions = 'pass';
117
+ }
118
+ } catch (error) {
119
+ checks.schemaValid = 'fail';
120
+ issues.push({
121
+ level: 'error',
122
+ code: 'SCHEMA_INVALID',
123
+ message: `Invalid JSON Schema: ${error instanceof Error ? error.message : 'Unknown error'}`,
124
+ });
125
+ }
126
+ }
127
+
128
+ // Check handler
129
+ if (typeof tool.execute === 'function') {
130
+ checks.handlerPresent = 'pass';
131
+
132
+ // Check if async
133
+ if (tool.execute.constructor.name === 'AsyncFunction') {
134
+ checks.handlerAsync = 'pass';
135
+ } else {
136
+ checks.handlerAsync = 'fail';
137
+ issues.push({
138
+ level: 'error',
139
+ code: 'HANDLER_NOT_ASYNC',
140
+ message: 'Execute handler must be an async function',
141
+ });
142
+ }
143
+ } else {
144
+ checks.handlerPresent = 'fail';
145
+ issues.push({
146
+ level: 'error',
147
+ code: 'HANDLER_MISSING',
148
+ message: 'Execute handler is required',
149
+ });
150
+ }
151
+
152
+ // Determine overall status
153
+ let status: 'valid' | 'warning' | 'error' = 'valid';
154
+ if (issues.some(i => i.level === 'error')) {
155
+ status = 'error';
156
+ } else if (issues.some(i => i.level === 'warning')) {
157
+ status = 'warning';
158
+ }
159
+
160
+ return {
161
+ name: tool.name,
162
+ status,
163
+ checks,
164
+ issues: issues.length > 0 ? issues : undefined,
165
+ };
166
+ }
167
+
168
+ private isCamelCase(str: string): boolean {
169
+ return /^[a-z][a-zA-Z0-9]*$/.test(str);
170
+ }
171
+
172
+ private toCamelCase(str: string): string {
173
+ return str
174
+ .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ''))
175
+ .replace(/^./, c => c.toLowerCase());
176
+ }
177
+
178
+ private async ensureBrowser(): Promise<void> {
179
+ if (!this.browser) {
180
+ this.browser = await chromium.launch({ headless: true });
181
+ }
182
+ }
183
+
184
+ async close(): Promise<void> {
185
+ if (this.browser) {
186
+ await this.browser.close();
187
+ this.browser = null;
188
+ }
189
+ }
190
+ }
191
+
192
+ export const validator = new Validator();