@matimo/core 0.1.3 → 0.1.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 (39) hide show
  1. package/package.json +3 -2
  2. package/tools/calculator/calculator.js +111 -0
  3. package/tools/calculator/definition.yaml +1 -1
  4. package/tools/edit/definition.yaml +1 -1
  5. package/tools/edit/edit.js +144 -0
  6. package/tools/execute/definition.yaml +1 -1
  7. package/tools/execute/execute.js +157 -0
  8. package/tools/matimo_approve_tool/definition.yaml +1 -1
  9. package/tools/matimo_approve_tool/matimo_approve_tool.js +54 -0
  10. package/tools/matimo_create_skill/definition.yaml +1 -1
  11. package/tools/matimo_create_skill/matimo_create_skill.js +48 -0
  12. package/tools/matimo_create_tool/definition.yaml +1 -1
  13. package/tools/matimo_create_tool/matimo_create_tool.js +89 -0
  14. package/tools/matimo_get_skill/definition.yaml +1 -1
  15. package/tools/matimo_get_skill/matimo_get_skill.js +148 -0
  16. package/tools/matimo_get_tool/definition.yaml +1 -1
  17. package/tools/matimo_get_tool/matimo_get_tool.js +38 -0
  18. package/tools/matimo_get_tool_status/definition.yaml +1 -1
  19. package/tools/matimo_get_tool_status/matimo_get_tool_status.js +68 -0
  20. package/tools/matimo_list_skills/definition.yaml +1 -1
  21. package/tools/matimo_list_skills/matimo_list_skills.js +109 -0
  22. package/tools/matimo_list_user_tools/definition.yaml +1 -1
  23. package/tools/matimo_list_user_tools/matimo_list_user_tools.js +44 -0
  24. package/tools/matimo_reload_tools/definition.yaml +1 -1
  25. package/tools/matimo_reload_tools/matimo_reload_tools.js +21 -0
  26. package/tools/matimo_search_tools/definition.yaml +1 -1
  27. package/tools/matimo_search_tools/matimo_search_tools.js +59 -0
  28. package/tools/matimo_validate_skill/definition.yaml +1 -1
  29. package/tools/matimo_validate_skill/matimo_validate_skill.js +94 -0
  30. package/tools/matimo_validate_tool/definition.yaml +1 -1
  31. package/tools/matimo_validate_tool/matimo_validate_tool.js +134 -0
  32. package/tools/read/definition.yaml +1 -1
  33. package/tools/read/read.js +82 -0
  34. package/tools/search/definition.yaml +1 -1
  35. package/tools/search/search.js +140 -0
  36. package/tools/shared/skill-validation.js +219 -208
  37. package/tools/web/definition.yaml +1 -1
  38. package/tools/web/web.js +90 -0
  39. package/tools/web/web.ts +2 -1
@@ -7,234 +7,245 @@
7
7
  */
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
-
11
- // Name validation
10
+ // ── Name Validation ──────────────────────────────────────────────────────────
11
+ /** Spec: lowercase letters, numbers, hyphens only. */
12
12
  const VALID_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
13
+ /** Consecutive hyphens are not allowed. */
13
14
  const CONSECUTIVE_HYPHENS = /--/;
15
+ /** Max length for skill name. */
14
16
  const MAX_NAME_LENGTH = 64;
17
+ /** Max length for description. */
15
18
  const MAX_DESCRIPTION_LENGTH = 1024;
19
+ /** Max length for compatibility field. */
16
20
  const MAX_COMPATIBILITY_LENGTH = 500;
21
+ /** Path traversal detection — kept for defense-in-depth. */
17
22
  const UNSAFE_NAME_PATTERN = /[/\\]|\.\.|[\x00-\x1f]/;
18
-
23
+ /**
24
+ * Validate a skill name against the Agent Skills spec.
25
+ *
26
+ * Rules:
27
+ * - 1–64 characters
28
+ * - Lowercase letters, numbers, hyphens only
29
+ * - Must not start or end with a hyphen
30
+ * - Must not contain consecutive hyphens (--)
31
+ * - Must not contain path traversal characters
32
+ */
19
33
  export function validateSkillName(name) {
20
- if (!name || name.trim().length === 0) {
21
- return { valid: false, error: 'Skill name is required' };
22
- }
23
-
24
- if (UNSAFE_NAME_PATTERN.test(name)) {
25
- return { valid: false, error: 'Skill name contains invalid characters' };
26
- }
27
-
28
- if (name.length > MAX_NAME_LENGTH) {
29
- return {
30
- valid: false,
31
- error: `Skill name must be at most ${MAX_NAME_LENGTH} characters (got ${name.length})`,
32
- };
33
- }
34
-
35
- if (!VALID_NAME_PATTERN.test(name)) {
36
- return {
37
- valid: false,
38
- error:
39
- 'Skill name must contain only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen',
40
- };
41
- }
42
-
43
- if (CONSECUTIVE_HYPHENS.test(name)) {
44
- return { valid: false, error: 'Skill name must not contain consecutive hyphens (--)' };
45
- }
46
-
47
- return { valid: true };
34
+ if (!name || name.trim().length === 0) {
35
+ return { valid: false, error: 'Skill name is required' };
36
+ }
37
+ if (UNSAFE_NAME_PATTERN.test(name)) {
38
+ return { valid: false, error: 'Skill name contains invalid characters' };
39
+ }
40
+ if (name.length > MAX_NAME_LENGTH) {
41
+ return { valid: false, error: `Skill name must be at most ${MAX_NAME_LENGTH} characters (got ${name.length})` };
42
+ }
43
+ if (!VALID_NAME_PATTERN.test(name)) {
44
+ return {
45
+ valid: false,
46
+ error: 'Skill name must contain only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen',
47
+ };
48
+ }
49
+ if (CONSECUTIVE_HYPHENS.test(name)) {
50
+ return { valid: false, error: 'Skill name must not contain consecutive hyphens (--)' };
51
+ }
52
+ return { valid: true };
48
53
  }
49
-
54
+ /**
55
+ * Parse YAML frontmatter from SKILL.md content.
56
+ *
57
+ * Handles the spec's required fields (name, description) and optional fields
58
+ * (license, compatibility, metadata, allowed-tools).
59
+ */
50
60
  export function parseSkillContent(content) {
51
- if (!content || !content.startsWith('---')) {
52
- return { success: false, error: 'Skill content must start with YAML frontmatter (---)' };
53
- }
54
-
55
- const endIndex = content.indexOf('---', 3);
56
- if (endIndex === -1) {
57
- return { success: false, error: 'Skill content must have closing YAML frontmatter (---)' };
58
- }
59
-
60
- const frontmatterBlock = content.substring(3, endIndex).trim();
61
- const body = content.substring(endIndex + 3).trim();
62
-
63
- const fields = {};
64
- let currentMetadata = null;
65
- let currentArray = null;
66
- let currentArrayKey = null;
67
-
68
- for (const line of frontmatterBlock.split('\n')) {
69
- if (currentArray !== null && /^\s*- /.test(line)) {
70
- const item = line.trim().substring(2).trim();
71
- if (item) {
72
- currentArray.push(item.replace(/^["']|["']$/g, ''));
73
- }
74
- continue;
75
- }
76
-
77
- if (currentMetadata !== null && /^\s+\S/.test(line)) {
78
- const colonIndex = line.indexOf(':');
79
- if (colonIndex !== -1) {
61
+ if (!content || !content.startsWith('---')) {
62
+ return { success: false, error: 'Skill content must start with YAML frontmatter (---)' };
63
+ }
64
+ const endIndex = content.indexOf('---', 3);
65
+ if (endIndex === -1) {
66
+ return { success: false, error: 'Skill content must have closing YAML frontmatter (---)' };
67
+ }
68
+ const frontmatterBlock = content.substring(3, endIndex).trim();
69
+ const body = content.substring(endIndex + 3).trim();
70
+ // Parse frontmatter lines
71
+ const fields = {};
72
+ let currentMetadata = null;
73
+ let currentArray = null;
74
+ let currentArrayKey = null;
75
+ for (const line of frontmatterBlock.split('\n')) {
76
+ // Detect array elements (prefixed with "- ")
77
+ if (currentArray !== null && /^\s*- /.test(line)) {
78
+ const item = line.trim().substring(2).trim();
79
+ if (item) {
80
+ currentArray.push(item.replace(/^["']|["']$/g, ''));
81
+ }
82
+ continue;
83
+ }
84
+ // Detect metadata sub-keys (indented with spaces under "metadata:")
85
+ if (currentMetadata !== null && /^\s+\S/.test(line)) {
86
+ const colonIndex = line.indexOf(':');
87
+ if (colonIndex !== -1) {
88
+ const key = line.substring(0, colonIndex).trim();
89
+ const value = line.substring(colonIndex + 1).trim();
90
+ if (key && value) {
91
+ currentMetadata[key] = value.replace(/^["']|["']$/g, '');
92
+ }
93
+ }
94
+ continue;
95
+ }
96
+ // Top-level keys
97
+ currentMetadata = null;
98
+ currentArray = null;
99
+ currentArrayKey = null;
100
+ const colonIndex = line.indexOf(':');
101
+ if (colonIndex === -1)
102
+ continue;
80
103
  const key = line.substring(0, colonIndex).trim();
81
104
  const value = line.substring(colonIndex + 1).trim();
105
+ if (key === 'metadata' && !value) {
106
+ // Start collecting metadata sub-keys
107
+ currentMetadata = {};
108
+ fields['__metadata__'] = 'MAP';
109
+ continue;
110
+ }
111
+ // Check for array start (value is empty, array items follow on next lines)
112
+ if (key === 'allowed-tools' && !value) {
113
+ currentArray = [];
114
+ currentArrayKey = 'allowed-tools';
115
+ continue;
116
+ }
82
117
  if (key && value) {
83
- currentMetadata[key] = value.replace(/^["']|["']$/g, '');
118
+ // Strip surrounding quotes (YAML style)
119
+ fields[key] = value.replace(/^["']|["']$/g, '');
84
120
  }
85
- }
86
- continue;
87
121
  }
88
-
122
+ // Store any pending array
89
123
  if (currentArray !== null && currentArrayKey) {
90
- fields[currentArrayKey] = currentArray;
91
- }
92
- if (currentMetadata !== null) {
93
- fields.metadata = currentMetadata;
94
- }
95
- currentMetadata = null;
96
- currentArray = null;
97
- currentArrayKey = null;
98
- const colonIndex = line.indexOf(':');
99
- if (colonIndex === -1) continue;
100
-
101
- const key = line.substring(0, colonIndex).trim();
102
- const value = line.substring(colonIndex + 1).trim();
103
-
104
- if (key === 'metadata' && !value) {
105
- currentMetadata = {};
106
- fields.metadata = currentMetadata;
107
- continue;
108
- }
109
-
110
- if (key === 'allowed-tools' && !value) {
111
- currentArray = [];
112
- currentArrayKey = 'allowed-tools';
113
- continue;
114
- }
115
-
116
- if (key && value) {
117
- fields[key] = value.replace(/^["']|["']$/g, '');
118
- }
119
- }
120
-
121
- if (currentArray !== null && currentArrayKey) {
122
- fields[currentArrayKey] = currentArray;
123
- }
124
-
125
- const frontmatter = {
126
- name: fields.name || '',
127
- description: fields.description || '',
128
- };
129
-
130
- if (fields.license) frontmatter.license = fields.license;
131
- if (fields.compatibility) frontmatter.compatibility = fields.compatibility;
132
- if (fields['allowed-tools']) {
133
- frontmatter['allowed-tools'] = Array.isArray(fields['allowed-tools'])
134
- ? fields['allowed-tools']
135
- : fields['allowed-tools'];
136
- }
137
- if (currentMetadata && Object.keys(currentMetadata).length > 0) {
138
- frontmatter.metadata = currentMetadata;
139
- }
140
-
141
- return {
142
- success: true,
143
- parsed: { frontmatter, body, raw: content },
144
- };
124
+ fields[currentArrayKey] = currentArray;
125
+ }
126
+ // Build frontmatter object
127
+ const frontmatter = {
128
+ name: fields.name || '',
129
+ description: fields.description || '',
130
+ };
131
+ if (fields.license)
132
+ frontmatter.license = fields.license;
133
+ if (fields.compatibility)
134
+ frontmatter.compatibility = fields.compatibility;
135
+ if (fields['allowed-tools']) {
136
+ frontmatter['allowed-tools'] = Array.isArray(fields['allowed-tools'])
137
+ ? fields['allowed-tools']
138
+ : fields['allowed-tools'];
139
+ }
140
+ if (currentMetadata && Object.keys(currentMetadata).length > 0) {
141
+ frontmatter.metadata = currentMetadata;
142
+ }
143
+ return {
144
+ success: true,
145
+ parsed: { frontmatter, body, raw: content },
146
+ };
145
147
  }
146
-
148
+ /**
149
+ * Validate parsed frontmatter against the Agent Skills spec.
150
+ *
151
+ * Checks: name rules, description rules, optional field constraints,
152
+ * and name/directory consistency (if directoryName provided).
153
+ */
147
154
  export function validateFrontmatter(frontmatter, directoryName) {
148
- const issues = [];
149
-
150
- if (!frontmatter.name) {
151
- issues.push({
152
- field: 'name',
153
- message: 'YAML frontmatter must include a "name" field',
154
- severity: 'error',
155
- });
156
- } else {
157
- const nameResult = validateSkillName(frontmatter.name);
158
- if (!nameResult.valid) {
159
- issues.push({ field: 'name', message: nameResult.error, severity: 'error' });
160
- }
161
- }
162
-
163
- if (!frontmatter.description) {
164
- issues.push({
165
- field: 'description',
166
- message: 'YAML frontmatter must include a "description" field',
167
- severity: 'error',
168
- });
169
- } else if (frontmatter.description.length > MAX_DESCRIPTION_LENGTH) {
170
- issues.push({
171
- field: 'description',
172
- message: `Description must be at most ${MAX_DESCRIPTION_LENGTH} characters (got ${frontmatter.description.length})`,
173
- severity: 'error',
174
- });
175
- }
176
-
177
- if (frontmatter.compatibility && frontmatter.compatibility.length > MAX_COMPATIBILITY_LENGTH) {
178
- issues.push({
179
- field: 'compatibility',
180
- message: `Compatibility must be at most ${MAX_COMPATIBILITY_LENGTH} characters (got ${frontmatter.compatibility.length})`,
181
- severity: 'error',
182
- });
183
- }
184
-
185
- if (directoryName && frontmatter.name && frontmatter.name !== directoryName) {
186
- issues.push({
187
- field: 'name',
188
- message: `Skill name "${frontmatter.name}" must match its directory name "${directoryName}"`,
189
- severity: 'error',
190
- });
191
- }
192
-
193
- return {
194
- valid: issues.filter((i) => i.severity === 'error').length === 0,
195
- issues,
196
- };
155
+ const issues = [];
156
+ // Required: name
157
+ if (!frontmatter.name) {
158
+ issues.push({ field: 'name', message: 'YAML frontmatter must include a "name" field', severity: 'error' });
159
+ }
160
+ else {
161
+ const nameResult = validateSkillName(frontmatter.name);
162
+ if (!nameResult.valid) {
163
+ issues.push({ field: 'name', message: nameResult.error, severity: 'error' });
164
+ }
165
+ }
166
+ // Required: description
167
+ if (!frontmatter.description) {
168
+ issues.push({ field: 'description', message: 'YAML frontmatter must include a "description" field', severity: 'error' });
169
+ }
170
+ else if (frontmatter.description.length > MAX_DESCRIPTION_LENGTH) {
171
+ issues.push({
172
+ field: 'description',
173
+ message: `Description must be at most ${MAX_DESCRIPTION_LENGTH} characters (got ${frontmatter.description.length})`,
174
+ severity: 'error',
175
+ });
176
+ }
177
+ // Optional: compatibility
178
+ if (frontmatter.compatibility && frontmatter.compatibility.length > MAX_COMPATIBILITY_LENGTH) {
179
+ issues.push({
180
+ field: 'compatibility',
181
+ message: `Compatibility must be at most ${MAX_COMPATIBILITY_LENGTH} characters (got ${frontmatter.compatibility.length})`,
182
+ severity: 'error',
183
+ });
184
+ }
185
+ // name must match directory name (if provided)
186
+ if (directoryName && frontmatter.name && frontmatter.name !== directoryName) {
187
+ issues.push({
188
+ field: 'name',
189
+ message: `Skill name "${frontmatter.name}" must match its directory name "${directoryName}"`,
190
+ severity: 'error',
191
+ });
192
+ }
193
+ return {
194
+ valid: issues.filter(i => i.severity === 'error').length === 0,
195
+ issues,
196
+ };
197
197
  }
198
-
198
+ /**
199
+ * List bundled resources in a skill directory.
200
+ *
201
+ * The spec recognizes three standard subdirectories:
202
+ * - scripts/ — executable code
203
+ * - references/ — additional documentation
204
+ * - assets/ — templates, resources
205
+ *
206
+ * Any other files (besides SKILL.md) are listed under "other".
207
+ */
199
208
  export function listBundledResources(skillDir) {
200
- const resources = {
201
- scripts: [],
202
- references: [],
203
- assets: [],
204
- other: [],
205
- };
206
-
207
- if (!fs.existsSync(skillDir)) return resources;
208
-
209
- const KNOWN_DIRS = {
210
- scripts: 'scripts',
211
- references: 'references',
212
- assets: 'assets',
213
- };
214
-
215
- const entries = fs.readdirSync(skillDir, { withFileTypes: true });
216
- for (const entry of entries) {
217
- if (entry.name === 'SKILL.md') continue;
218
-
219
- if (entry.isDirectory()) {
220
- const category = KNOWN_DIRS[entry.name];
221
- if (category) {
222
- const subDir = path.join(skillDir, entry.name);
223
- const subEntries = fs.readdirSync(subDir);
224
- for (const sub of subEntries) {
225
- resources[category].push(`${entry.name}/${sub}`);
209
+ const resources = {
210
+ scripts: [],
211
+ references: [],
212
+ assets: [],
213
+ other: [],
214
+ };
215
+ if (!fs.existsSync(skillDir))
216
+ return resources;
217
+ const KNOWN_DIRS = {
218
+ scripts: 'scripts',
219
+ references: 'references',
220
+ assets: 'assets',
221
+ };
222
+ const entries = fs.readdirSync(skillDir, { withFileTypes: true });
223
+ for (const entry of entries) {
224
+ if (entry.name === 'SKILL.md')
225
+ continue;
226
+ if (entry.isDirectory()) {
227
+ const category = KNOWN_DIRS[entry.name];
228
+ if (category) {
229
+ // List files inside the known subdirectory
230
+ const subDir = path.join(skillDir, entry.name);
231
+ const subEntries = fs.readdirSync(subDir);
232
+ for (const sub of subEntries) {
233
+ resources[category].push(`${entry.name}/${sub}`);
234
+ }
235
+ }
236
+ else {
237
+ // Unknown directory — list contents under "other"
238
+ const subDir = path.join(skillDir, entry.name);
239
+ const subEntries = fs.readdirSync(subDir);
240
+ for (const sub of subEntries) {
241
+ resources.other.push(`${entry.name}/${sub}`);
242
+ }
243
+ }
226
244
  }
227
- } else {
228
- const subDir = path.join(skillDir, entry.name);
229
- const subEntries = fs.readdirSync(subDir);
230
- for (const sub of subEntries) {
231
- resources.other.push(`${entry.name}/${sub}`);
245
+ else {
246
+ // Top-level file (not SKILL.md)
247
+ resources.other.push(entry.name);
232
248
  }
233
- }
234
- } else {
235
- resources.other.push(entry.name);
236
249
  }
237
- }
238
-
239
- return resources;
250
+ return resources;
240
251
  }
@@ -55,7 +55,7 @@ parameters:
55
55
 
56
56
  execution:
57
57
  type: function
58
- code: './web.ts'
58
+ code: './web.js'
59
59
 
60
60
  output_schema:
61
61
  type: object
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Web Tool - Fetch web content from URLs with flexible HTTP methods
4
+ * Function-type tool: Exports default async function
5
+ */
6
+ import axios from 'axios';
7
+ import { MatimoError, ErrorCode } from '@matimo/core/runtime';
8
+ /**
9
+ * Fetch URL and return response with optional JSON parsing
10
+ */
11
+ export default async function webTool(params) {
12
+ const { url, method = 'GET', headers, body, timeout = 30000, followRedirects = true, parseJson = true, maxSize = 10485760, } = params;
13
+ const startTime = Date.now();
14
+ // Validate required parameter
15
+ if (!url) {
16
+ throw new MatimoError('Missing required parameter', ErrorCode.INVALID_PARAMETER, {
17
+ reason: 'url is required',
18
+ });
19
+ }
20
+ // Validate URL
21
+ try {
22
+ new URL(url);
23
+ }
24
+ catch {
25
+ throw new MatimoError('Invalid URL', ErrorCode.INVALID_PARAMETER, {
26
+ url,
27
+ reason: 'URL must be valid http or https',
28
+ });
29
+ }
30
+ const requestConfig = {
31
+ method: method.toUpperCase() || 'GET',
32
+ url,
33
+ timeout,
34
+ maxContentLength: maxSize,
35
+ maxBodyLength: maxSize,
36
+ headers: {
37
+ 'User-Agent': 'Matimo/1.0 (AI Agent Tool SDK)',
38
+ Accept: 'application/json, text/plain, text/html, */*',
39
+ ...(headers || {}),
40
+ },
41
+ validateStatus: () => true, // Don't throw on any status code
42
+ };
43
+ if (body && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
44
+ requestConfig.data = body;
45
+ }
46
+ if (!followRedirects) {
47
+ requestConfig.maxRedirects = 0;
48
+ }
49
+ try {
50
+ const response = await axios.request(requestConfig);
51
+ let content = response.data;
52
+ // Try to parse JSON if requested and content-type suggests JSON
53
+ const contentType = response.headers['content-type'];
54
+ if (parseJson && typeof contentType === 'string' && contentType.includes('application/json')) {
55
+ try {
56
+ if (typeof response.data === 'string') {
57
+ content = JSON.parse(response.data);
58
+ }
59
+ else {
60
+ content = response.data;
61
+ }
62
+ }
63
+ catch {
64
+ // If JSON parsing fails, keep original content
65
+ content = response.data;
66
+ }
67
+ }
68
+ const result = {
69
+ success: response.status >= 200 && response.status < 300,
70
+ url,
71
+ statusCode: response.status,
72
+ statusText: response.statusText || '',
73
+ contentType: response.headers['content-type'] || 'unknown',
74
+ content,
75
+ headers: response.headers,
76
+ size: JSON.stringify(response.data).length,
77
+ duration: Date.now() - startTime,
78
+ };
79
+ return result;
80
+ }
81
+ catch (error) {
82
+ const axiosError = error;
83
+ throw new MatimoError('HTTP request failed', ErrorCode.NETWORK_ERROR, {
84
+ url,
85
+ statusCode: axiosError.response?.status,
86
+ statusText: axiosError.response?.statusText,
87
+ originalError: axiosError.message,
88
+ });
89
+ }
90
+ }
package/tools/web/web.ts CHANGED
@@ -92,7 +92,8 @@ export default async function webTool(params: WebParams): Promise<WebResult> {
92
92
  let content: unknown = response.data;
93
93
 
94
94
  // Try to parse JSON if requested and content-type suggests JSON
95
- if (parseJson && response.headers['content-type']?.includes('application/json')) {
95
+ const contentType = response.headers['content-type'];
96
+ if (parseJson && typeof contentType === 'string' && contentType.includes('application/json')) {
96
97
  try {
97
98
  if (typeof response.data === 'string') {
98
99
  content = JSON.parse(response.data);