devtopia 1.9.0 ā 2.0.1
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/README.md +111 -140
- package/dist/commands/categories.js +16 -76
- package/dist/commands/compose.d.ts +5 -0
- package/dist/commands/compose.js +129 -0
- package/dist/commands/create.d.ts +7 -0
- package/dist/commands/create.js +302 -0
- package/dist/commands/docs.js +150 -160
- package/dist/commands/idea.d.ts +7 -0
- package/dist/commands/idea.js +83 -0
- package/dist/commands/run-local.d.ts +8 -0
- package/dist/commands/run-local.js +64 -0
- package/dist/commands/run.d.ts +8 -1
- package/dist/commands/run.js +75 -6
- package/dist/commands/search.d.ts +5 -0
- package/dist/commands/search.js +52 -0
- package/dist/commands/start.d.ts +1 -1
- package/dist/commands/start.js +154 -256
- package/dist/commands/submit.d.ts +3 -0
- package/dist/commands/submit.js +340 -197
- package/dist/executor.d.ts +26 -2
- package/dist/executor.js +537 -72
- package/dist/index.js +72 -10
- package/package.json +3 -3
package/dist/commands/submit.js
CHANGED
|
@@ -3,81 +3,38 @@ import { extname, resolve, dirname, join } from 'path';
|
|
|
3
3
|
import { createInterface } from 'readline';
|
|
4
4
|
import { API_BASE } from '../config.js';
|
|
5
5
|
import { loadIdentity, hasIdentity } from '../identity.js';
|
|
6
|
+
import { validateExecution } from '../executor.js';
|
|
6
7
|
const LANG_MAP = {
|
|
7
8
|
'.ts': 'typescript',
|
|
8
9
|
'.js': 'javascript',
|
|
9
10
|
'.py': 'python',
|
|
10
11
|
'.mjs': 'javascript',
|
|
11
12
|
'.cjs': 'javascript',
|
|
13
|
+
'.sh': 'bash',
|
|
14
|
+
'.bash': 'bash',
|
|
15
|
+
'.rb': 'ruby',
|
|
16
|
+
'.php': 'php',
|
|
12
17
|
};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// Security & Crypto
|
|
32
|
-
{ id: 'crypto', name: 'Crypto & Security' },
|
|
33
|
-
{ id: 'hash', name: 'Hashing' },
|
|
34
|
-
{ id: 'encode', name: 'Encoding' },
|
|
35
|
-
{ id: 'auth', name: 'Authentication' },
|
|
36
|
-
// Math & Numbers
|
|
37
|
-
{ id: 'math', name: 'Math & Numbers' },
|
|
38
|
-
{ id: 'stats', name: 'Statistics' },
|
|
39
|
-
{ id: 'convert', name: 'Conversion' },
|
|
40
|
-
{ id: 'random', name: 'Random & Generate' },
|
|
41
|
-
// Date & Time
|
|
42
|
-
{ id: 'time', name: 'Date & Time' },
|
|
43
|
-
{ id: 'timezone', name: 'Timezones' },
|
|
44
|
-
// Files & System
|
|
45
|
-
{ id: 'file', name: 'File & I/O' },
|
|
46
|
-
{ id: 'path', name: 'Paths' },
|
|
47
|
-
{ id: 'compress', name: 'Compression' },
|
|
48
|
-
// Arrays & Collections
|
|
49
|
-
{ id: 'array', name: 'Arrays & Lists' },
|
|
50
|
-
{ id: 'sort', name: 'Sort & Search' },
|
|
51
|
-
{ id: 'set', name: 'Sets & Maps' },
|
|
52
|
-
// Validation
|
|
53
|
-
{ id: 'validate', name: 'Validation' },
|
|
54
|
-
{ id: 'sanitize', name: 'Sanitization' },
|
|
55
|
-
// Generation
|
|
56
|
-
{ id: 'generate', name: 'Generation' },
|
|
57
|
-
{ id: 'template', name: 'Templates' },
|
|
58
|
-
// AI & ML
|
|
59
|
-
{ id: 'ai', name: 'AI & ML' },
|
|
60
|
-
{ id: 'nlp', name: 'NLP' },
|
|
61
|
-
// Media
|
|
62
|
-
{ id: 'image', name: 'Images' },
|
|
63
|
-
{ id: 'color', name: 'Colors' },
|
|
64
|
-
{ id: 'qr', name: 'QR & Barcodes' },
|
|
65
|
-
// Dev Tools
|
|
66
|
-
{ id: 'cli', name: 'CLI & Terminal' },
|
|
67
|
-
{ id: 'debug', name: 'Debug & Test' },
|
|
68
|
-
{ id: 'diff', name: 'Diff & Compare' },
|
|
69
|
-
// General
|
|
70
|
-
{ id: 'util', name: 'Utilities' },
|
|
71
|
-
{ id: 'other', name: 'Other' },
|
|
72
|
-
];
|
|
18
|
+
/**
|
|
19
|
+
* Fetch categories from the API (single source of truth)
|
|
20
|
+
*/
|
|
21
|
+
async function fetchCategories() {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(`${API_BASE}/api/categories`);
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
return (data.categories || []).map((c) => ({
|
|
26
|
+
id: c.id,
|
|
27
|
+
name: c.name,
|
|
28
|
+
description: c.description,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Fallback: return empty array ā submit will still work, server validates
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
73
36
|
/**
|
|
74
37
|
* Extract description from source code comments
|
|
75
|
-
* Supports multiple formats:
|
|
76
|
-
* - // tool-name - description
|
|
77
|
-
* - # tool-name - description
|
|
78
|
-
* - JSDoc: * tool-name - description
|
|
79
|
-
* - JSDoc: First sentence after title in /** block
|
|
80
|
-
* - JSDoc @description tag
|
|
81
38
|
*/
|
|
82
39
|
function extractDescription(source) {
|
|
83
40
|
const lines = source.split('\n');
|
|
@@ -85,7 +42,6 @@ function extractDescription(source) {
|
|
|
85
42
|
let jsdocTitle = false;
|
|
86
43
|
for (const line of lines) {
|
|
87
44
|
const trimmed = line.trim();
|
|
88
|
-
// Track JSDoc block
|
|
89
45
|
if (trimmed === '/**') {
|
|
90
46
|
inJSDoc = true;
|
|
91
47
|
jsdocTitle = false;
|
|
@@ -95,7 +51,7 @@ function extractDescription(source) {
|
|
|
95
51
|
inJSDoc = false;
|
|
96
52
|
continue;
|
|
97
53
|
}
|
|
98
|
-
// TypeScript/JavaScript
|
|
54
|
+
// TypeScript/JavaScript: // tool-name - description
|
|
99
55
|
const tsMatch = line.match(/^\/\/\s*[\w-]+\s*[-ā:]\s*(.+)$/);
|
|
100
56
|
if (tsMatch)
|
|
101
57
|
return tsMatch[1].trim();
|
|
@@ -103,55 +59,35 @@ function extractDescription(source) {
|
|
|
103
59
|
const pyMatch = line.match(/^#\s*[\w-]+\s*[-ā:]\s*(.+)$/);
|
|
104
60
|
if (pyMatch)
|
|
105
61
|
return pyMatch[1].trim();
|
|
106
|
-
// JSDoc
|
|
107
|
-
const jsdocToolMatch = line.match(/^\s*\*\s*[\w-]+\s*[-ā:]\s*(.+)$/);
|
|
108
|
-
if (jsdocToolMatch)
|
|
109
|
-
return jsdocToolMatch[1].trim();
|
|
110
|
-
// JSDoc @description tag
|
|
62
|
+
// JSDoc: * @description
|
|
111
63
|
const jsdocDescMatch = line.match(/^\s*\*\s*@description\s+(.+)$/);
|
|
112
64
|
if (jsdocDescMatch)
|
|
113
65
|
return jsdocDescMatch[1].trim();
|
|
114
|
-
//
|
|
66
|
+
// JSDoc: first content line after /** that describes the tool
|
|
115
67
|
if (inJSDoc) {
|
|
116
|
-
// Skip @ tags
|
|
117
68
|
if (trimmed.match(/^\*\s*@/))
|
|
118
69
|
continue;
|
|
119
|
-
// Extract content after *
|
|
120
70
|
const jsdocContent = trimmed.match(/^\*\s*(.+)$/);
|
|
121
71
|
if (jsdocContent) {
|
|
122
72
|
const content = jsdocContent[1].trim();
|
|
123
|
-
// Skip if it looks like a title (short, capitalized, no punctuation)
|
|
124
73
|
if (!jsdocTitle && content.length < 50 && !content.includes('.') && /^[A-Z]/.test(content)) {
|
|
125
74
|
jsdocTitle = true;
|
|
126
75
|
continue;
|
|
127
76
|
}
|
|
128
|
-
// After title, next non-empty content line is likely the description
|
|
129
77
|
if (jsdocTitle && content.length > 10) {
|
|
130
|
-
// Return first sentence
|
|
131
78
|
const firstSentence = content.match(/^[^.!?]+[.!?]?/);
|
|
132
79
|
if (firstSentence)
|
|
133
80
|
return firstSentence[0].trim();
|
|
134
81
|
return content;
|
|
135
82
|
}
|
|
136
83
|
}
|
|
137
|
-
// Skip empty JSDoc lines
|
|
138
84
|
if (trimmed === '*')
|
|
139
85
|
continue;
|
|
140
86
|
}
|
|
141
|
-
|
|
142
|
-
if (trimmed === '' ||
|
|
143
|
-
line.startsWith('#!') ||
|
|
144
|
-
trimmed === '/**' ||
|
|
145
|
-
trimmed === '*/' ||
|
|
146
|
-
trimmed === '*' ||
|
|
147
|
-
trimmed.toLowerCase().includes('usage:')) {
|
|
87
|
+
if (trimmed === '' || line.startsWith('#!') || trimmed === '/**' || trimmed === '*/' || trimmed === '*' || trimmed.toLowerCase().includes('usage:')) {
|
|
148
88
|
continue;
|
|
149
89
|
}
|
|
150
|
-
//
|
|
151
|
-
if (!trimmed.startsWith('//') &&
|
|
152
|
-
!trimmed.startsWith('#') &&
|
|
153
|
-
!trimmed.startsWith('*') &&
|
|
154
|
-
!trimmed.startsWith('/*')) {
|
|
90
|
+
if (!trimmed.startsWith('//') && !trimmed.startsWith('#') && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) {
|
|
155
91
|
break;
|
|
156
92
|
}
|
|
157
93
|
}
|
|
@@ -164,17 +100,13 @@ function extractDescriptionFromReadme(readme) {
|
|
|
164
100
|
const lines = readme.split('\n');
|
|
165
101
|
let foundTitle = false;
|
|
166
102
|
for (const line of lines) {
|
|
167
|
-
// Skip title lines (# Title)
|
|
168
103
|
if (line.startsWith('#')) {
|
|
169
104
|
foundTitle = true;
|
|
170
105
|
continue;
|
|
171
106
|
}
|
|
172
|
-
// Skip empty lines after title
|
|
173
107
|
if (foundTitle && line.trim() === '')
|
|
174
108
|
continue;
|
|
175
|
-
// First non-empty line after title is the description
|
|
176
109
|
if (foundTitle && line.trim()) {
|
|
177
|
-
// Take first sentence or up to 100 chars
|
|
178
110
|
const desc = line.trim();
|
|
179
111
|
const firstSentence = desc.match(/^[^.!?]+[.!?]?/);
|
|
180
112
|
if (firstSentence && firstSentence[0].length <= 100) {
|
|
@@ -185,88 +117,135 @@ function extractDescriptionFromReadme(readme) {
|
|
|
185
117
|
}
|
|
186
118
|
return null;
|
|
187
119
|
}
|
|
120
|
+
function detectShebangLanguage(source) {
|
|
121
|
+
const firstLine = source.split('\n')[0].trim();
|
|
122
|
+
if (!firstLine.startsWith('#!'))
|
|
123
|
+
return null;
|
|
124
|
+
const cleaned = firstLine.replace(/^#!\s*/, '');
|
|
125
|
+
const parts = cleaned.split(/\s+/);
|
|
126
|
+
const exe = parts[0] || '';
|
|
127
|
+
const isEnv = exe.endsWith('/env');
|
|
128
|
+
const bin = isEnv ? (parts[1] || '') : exe;
|
|
129
|
+
const raw = bin.split('/').pop() || '';
|
|
130
|
+
const normalized = raw.replace(/[0-9.]+$/g, '');
|
|
131
|
+
if (normalized === 'node' || normalized === 'nodejs')
|
|
132
|
+
return 'javascript';
|
|
133
|
+
if (normalized === 'python')
|
|
134
|
+
return 'python';
|
|
135
|
+
if (normalized === 'bash' || normalized === 'sh')
|
|
136
|
+
return 'bash';
|
|
137
|
+
if (normalized === 'ruby')
|
|
138
|
+
return 'ruby';
|
|
139
|
+
if (normalized === 'php')
|
|
140
|
+
return 'php';
|
|
141
|
+
return 'shebang';
|
|
142
|
+
}
|
|
143
|
+
function extractSectionFromReadme(readme, heading) {
|
|
144
|
+
const lines = readme.split('\n');
|
|
145
|
+
const headingRegex = new RegExp(`^##\\s+${heading}\\s*$`, 'i');
|
|
146
|
+
let inSection = false;
|
|
147
|
+
const collected = [];
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
if (headingRegex.test(line.trim())) {
|
|
150
|
+
inSection = true;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (inSection) {
|
|
154
|
+
if (line.startsWith('#'))
|
|
155
|
+
break;
|
|
156
|
+
if (line.trim() === '' && collected.length > 0)
|
|
157
|
+
break;
|
|
158
|
+
if (line.trim() === '')
|
|
159
|
+
continue;
|
|
160
|
+
collected.push(line.trim());
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (collected.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
return collected.join(' ').trim();
|
|
166
|
+
}
|
|
167
|
+
function extractListSectionFromReadme(readme, heading) {
|
|
168
|
+
const lines = readme.split('\n');
|
|
169
|
+
const headingRegex = new RegExp(`^##\\s+${heading}\\s*$`, 'i');
|
|
170
|
+
let inSection = false;
|
|
171
|
+
const collected = [];
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
if (headingRegex.test(line.trim())) {
|
|
174
|
+
inSection = true;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (inSection) {
|
|
178
|
+
if (line.startsWith('#'))
|
|
179
|
+
break;
|
|
180
|
+
const trimmed = line.trim();
|
|
181
|
+
if (!trimmed) {
|
|
182
|
+
if (collected.length > 0)
|
|
183
|
+
break;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
collected.push(trimmed.replace(/^[-*]\s*/, ''));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return collected;
|
|
190
|
+
}
|
|
191
|
+
function extractTaggedValue(source, label) {
|
|
192
|
+
const singleLine = new RegExp(`${label}\\s*[:\\-]\\s*(.+)`, 'i');
|
|
193
|
+
const multiLine = new RegExp(`${label}\\s*:\\s*\\n\\s*\\*\\s*(.+)`, 'i');
|
|
194
|
+
const singleMatch = source.match(singleLine);
|
|
195
|
+
if (singleMatch)
|
|
196
|
+
return singleMatch[1].trim();
|
|
197
|
+
const multiMatch = source.match(multiLine);
|
|
198
|
+
if (multiMatch)
|
|
199
|
+
return multiMatch[1].trim();
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
function clampText(input, maxLen) {
|
|
203
|
+
if (input.length <= maxLen)
|
|
204
|
+
return input;
|
|
205
|
+
return input.slice(0, maxLen - 1).trimEnd() + 'ā¦';
|
|
206
|
+
}
|
|
207
|
+
function normalizeExternalSystem(input) {
|
|
208
|
+
return input
|
|
209
|
+
.toLowerCase()
|
|
210
|
+
.replace(/\s+/g, '-')
|
|
211
|
+
.replace(/[^a-z0-9._-]/g, '')
|
|
212
|
+
.replace(/^-+|-+$/g, '');
|
|
213
|
+
}
|
|
214
|
+
function parseExternalSystems(raw) {
|
|
215
|
+
if (!raw)
|
|
216
|
+
return [];
|
|
217
|
+
const parts = Array.isArray(raw) ? raw : raw.split(/[,;]+/);
|
|
218
|
+
const normalized = parts
|
|
219
|
+
.map((part) => part.trim())
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.flatMap((part) => part.split(','))
|
|
222
|
+
.map((part) => normalizeExternalSystem(part));
|
|
223
|
+
return Array.from(new Set(normalized.filter(Boolean)));
|
|
224
|
+
}
|
|
188
225
|
/**
|
|
189
226
|
* Auto-detect category from description or source
|
|
190
227
|
*/
|
|
191
228
|
function detectCategory(description, source) {
|
|
192
229
|
const text = `${description || ''} ${source}`.toLowerCase();
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (text.includes('json') && (text.includes('parse') || text.includes('stringify') || text.includes('flatten')))
|
|
201
|
-
return 'json';
|
|
202
|
-
if (text.includes('csv') || text.includes('tsv') || text.includes('tabular'))
|
|
203
|
-
return 'csv';
|
|
204
|
-
if (text.includes('yaml') || text.includes('toml') || text.includes('config'))
|
|
205
|
-
return 'yaml';
|
|
206
|
-
if (text.includes('xml') || text.includes('markup'))
|
|
207
|
-
return 'xml';
|
|
208
|
-
if (text.includes('regex') || text.includes('pattern') || text.includes('match'))
|
|
209
|
-
return 'regex';
|
|
210
|
-
if (text.includes('uuid') || text.includes('generate') || text.includes('random id'))
|
|
211
|
-
return 'generate';
|
|
212
|
-
if (text.includes('template') || text.includes('render') || text.includes('mustache'))
|
|
213
|
-
return 'template';
|
|
214
|
-
if (text.includes('validate') || text.includes('schema') || text.includes('check'))
|
|
215
|
-
return 'validate';
|
|
216
|
-
if (text.includes('sanitize') || text.includes('clean') || text.includes('escape'))
|
|
217
|
-
return 'sanitize';
|
|
218
|
-
if (text.includes('qr') || text.includes('barcode'))
|
|
219
|
-
return 'qr';
|
|
220
|
-
if (text.includes('color') || text.includes('rgb') || text.includes('hex') || text.includes('hsl'))
|
|
221
|
-
return 'color';
|
|
222
|
-
if (text.includes('image') || text.includes('resize') || text.includes('thumbnail'))
|
|
223
|
-
return 'image';
|
|
224
|
-
if (text.includes('diff') || text.includes('compare') || text.includes('levenshtein'))
|
|
225
|
-
return 'diff';
|
|
226
|
-
if (text.includes('sort') || text.includes('search') || text.includes('filter'))
|
|
227
|
-
return 'sort';
|
|
228
|
-
if (text.includes('array') || text.includes('chunk') || text.includes('flatten'))
|
|
229
|
-
return 'array';
|
|
230
|
-
if (text.includes('statistics') || text.includes('mean') || text.includes('median') || text.includes('stddev'))
|
|
231
|
-
return 'stats';
|
|
232
|
-
if (text.includes('convert') || text.includes('unit') || text.includes('celsius') || text.includes('fahrenheit'))
|
|
233
|
-
return 'convert';
|
|
234
|
-
if (text.includes('timezone') || text.includes('utc'))
|
|
235
|
-
return 'timezone';
|
|
236
|
-
if (text.includes('compress') || text.includes('zip') || text.includes('gzip'))
|
|
237
|
-
return 'compress';
|
|
238
|
-
if (text.includes('path') || text.includes('dirname') || text.includes('basename'))
|
|
239
|
-
return 'path';
|
|
240
|
-
if (text.includes('nlp') || text.includes('sentiment') || text.includes('tokenize'))
|
|
241
|
-
return 'nlp';
|
|
242
|
-
if (text.includes('cli') || text.includes('terminal') || text.includes('command'))
|
|
243
|
-
return 'cli';
|
|
244
|
-
if (text.includes('debug') || text.includes('test') || text.includes('log'))
|
|
245
|
-
return 'debug';
|
|
246
|
-
if (text.includes('html') || text.includes('dom') || text.includes('scrape'))
|
|
247
|
-
return 'html';
|
|
248
|
-
if (text.includes('url') || text.includes('link') || text.includes('href'))
|
|
249
|
-
return 'url';
|
|
250
|
-
if (text.includes('api') || text.includes('rest') || text.includes('graphql'))
|
|
230
|
+
if (/(github|repo|pull request|issue|workflow|actions)/.test(text))
|
|
231
|
+
return 'github';
|
|
232
|
+
if (/(email|smtp|imap|mailgun|sendgrid)/.test(text))
|
|
233
|
+
return 'email';
|
|
234
|
+
if (/(discord|slack|twitter|x\.com|reddit|youtube|tiktok|social)/.test(text))
|
|
235
|
+
return 'social';
|
|
236
|
+
if (/(api|sdk|oauth|webhook|rest|graphql|integration)/.test(text))
|
|
251
237
|
return 'api';
|
|
252
|
-
|
|
253
|
-
if (text.includes('fetch') || text.includes('http') || text.includes('request'))
|
|
238
|
+
if (/(url|http|https|fetch|request|scrape|crawl|html|dom|web)/.test(text))
|
|
254
239
|
return 'web';
|
|
255
|
-
if (
|
|
256
|
-
return '
|
|
257
|
-
if (
|
|
258
|
-
return '
|
|
259
|
-
if (
|
|
260
|
-
return '
|
|
261
|
-
if (
|
|
262
|
-
return 'text';
|
|
263
|
-
if (text.includes('math') || text.includes('random') || text.includes('number') || text.includes('calc'))
|
|
264
|
-
return 'math';
|
|
265
|
-
if (text.includes('file') || text.includes('read') || text.includes('write'))
|
|
266
|
-
return 'file';
|
|
267
|
-
if (text.includes('ai') || text.includes('ml') || text.includes('gpt') || text.includes('model'))
|
|
240
|
+
if (/(database|postgres|mysql|sqlite|redis|mongo|sql|vector)/.test(text))
|
|
241
|
+
return 'database';
|
|
242
|
+
if (/(file|path|read|write|fs|directory|folder|bucket|s3|storage|upload|download)/.test(text))
|
|
243
|
+
return 'files';
|
|
244
|
+
if (/(auth|jwt|token|sign|verify|encrypt|decrypt|hmac|security)/.test(text))
|
|
245
|
+
return 'security';
|
|
246
|
+
if (/(ai|ml|gpt|llm|model|embedding|nlp|tokenize|summarize|classify)/.test(text))
|
|
268
247
|
return 'ai';
|
|
269
|
-
return '
|
|
248
|
+
return 'core';
|
|
270
249
|
}
|
|
271
250
|
/**
|
|
272
251
|
* Try to find a README file automatically
|
|
@@ -286,6 +265,49 @@ function findReadme(filePath, toolName) {
|
|
|
286
265
|
}
|
|
287
266
|
return null;
|
|
288
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Validate tool source: check it defines proper I/O
|
|
270
|
+
*/
|
|
271
|
+
function validateToolSource(source, language) {
|
|
272
|
+
const warnings = [];
|
|
273
|
+
if (language === 'javascript' || language === 'typescript') {
|
|
274
|
+
const hasArgvParse = /process\.argv\[2\]/.test(source);
|
|
275
|
+
const definesMain = /\bfunction\s+main\s*\(/.test(source);
|
|
276
|
+
const hasConsoleLog = /console\.log\s*\(/.test(source);
|
|
277
|
+
const hasJSONStringify = /JSON\.stringify/.test(source);
|
|
278
|
+
if (!hasArgvParse && !definesMain) {
|
|
279
|
+
warnings.push('Tool does not read input (process.argv[2]) or define main(). It may not work when run.');
|
|
280
|
+
}
|
|
281
|
+
if (!hasConsoleLog && !definesMain) {
|
|
282
|
+
warnings.push('Tool does not output results (console.log). It may produce no output when run.');
|
|
283
|
+
}
|
|
284
|
+
if (!hasJSONStringify && !definesMain) {
|
|
285
|
+
warnings.push('Tool does not use JSON.stringify. Output may not be valid JSON.');
|
|
286
|
+
}
|
|
287
|
+
if (definesMain && !hasArgvParse) {
|
|
288
|
+
// This will be auto-wrapped by executor ā that's fine
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (language === 'python') {
|
|
292
|
+
const hasSysArgv = /sys\.argv/.test(source);
|
|
293
|
+
const definesMain = /\bdef\s+main\s*\(/.test(source);
|
|
294
|
+
const hasPrint = /\bprint\s*\(/.test(source);
|
|
295
|
+
const hasJsonDumps = /json\.dumps/.test(source);
|
|
296
|
+
if (!hasSysArgv && !definesMain) {
|
|
297
|
+
warnings.push('Tool does not read input (sys.argv) or define main(). It may not work when run.');
|
|
298
|
+
}
|
|
299
|
+
if (!hasPrint && !definesMain) {
|
|
300
|
+
warnings.push('Tool does not print output. It may produce nothing when run.');
|
|
301
|
+
}
|
|
302
|
+
if (!hasJsonDumps && !definesMain) {
|
|
303
|
+
warnings.push('Tool does not use json.dumps. Output may not be valid JSON.');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (language === 'bash' || language === 'ruby' || language === 'php' || language === 'shebang') {
|
|
307
|
+
return { valid: true, warnings };
|
|
308
|
+
}
|
|
309
|
+
return { valid: true, warnings };
|
|
310
|
+
}
|
|
289
311
|
export async function submit(name, file, options) {
|
|
290
312
|
// Check identity
|
|
291
313
|
if (!hasIdentity()) {
|
|
@@ -312,14 +334,30 @@ export async function submit(name, file, options) {
|
|
|
312
334
|
}
|
|
313
335
|
// Detect language
|
|
314
336
|
const ext = extname(filePath);
|
|
315
|
-
|
|
337
|
+
// Read source
|
|
338
|
+
const source = readFileSync(filePath, 'utf-8');
|
|
339
|
+
let language = LANG_MAP[ext];
|
|
316
340
|
if (!language) {
|
|
317
|
-
|
|
318
|
-
|
|
341
|
+
const shebangLang = detectShebangLanguage(source);
|
|
342
|
+
if (shebangLang) {
|
|
343
|
+
language = shebangLang;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (!language) {
|
|
347
|
+
console.log(`\nā Unsupported file type: ${ext || '(no extension)'}`);
|
|
348
|
+
console.log(` Supported: .ts, .js, .py, .sh, .rb, .php`);
|
|
349
|
+
console.log(` Or include a shebang (#! /usr/bin/env <lang>) for script tools.\n`);
|
|
319
350
|
process.exit(1);
|
|
320
351
|
}
|
|
321
|
-
//
|
|
322
|
-
const
|
|
352
|
+
// āā Validate tool source format āā
|
|
353
|
+
const { warnings } = validateToolSource(source, language);
|
|
354
|
+
if (warnings.length > 0) {
|
|
355
|
+
console.log(`\nā ļø Source validation warnings:`);
|
|
356
|
+
for (const w of warnings) {
|
|
357
|
+
console.log(` - ${w}`);
|
|
358
|
+
}
|
|
359
|
+
console.log(` (tool may still work if executor can auto-wrap it)\n`);
|
|
360
|
+
}
|
|
323
361
|
// Find and read README
|
|
324
362
|
let readmePath = options.readme ? resolve(options.readme) : findReadme(filePath, name);
|
|
325
363
|
let readme = null;
|
|
@@ -339,11 +377,39 @@ export async function submit(name, file, options) {
|
|
|
339
377
|
}
|
|
340
378
|
// Extract or use provided description
|
|
341
379
|
let description = options.description || extractDescription(source);
|
|
342
|
-
// Try to get description from README if not found in source
|
|
343
380
|
if (!description && readme) {
|
|
344
381
|
description = extractDescriptionFromReadme(readme);
|
|
345
382
|
}
|
|
346
|
-
|
|
383
|
+
const intent = readme
|
|
384
|
+
? (extractSectionFromReadme(readme, 'Intent') || extractTaggedValue(source, 'Intent'))
|
|
385
|
+
: extractTaggedValue(source, 'Intent');
|
|
386
|
+
const gap = readme
|
|
387
|
+
? (extractSectionFromReadme(readme, 'Gap Justification') || extractTaggedValue(source, 'Gap Justification'))
|
|
388
|
+
: extractTaggedValue(source, 'Gap Justification');
|
|
389
|
+
const metaParts = [
|
|
390
|
+
intent ? `Intent: ${intent}` : null,
|
|
391
|
+
gap ? `Gap: ${gap}` : null,
|
|
392
|
+
].filter(Boolean);
|
|
393
|
+
const meta = metaParts.length > 0 ? metaParts.join(' | ') : null;
|
|
394
|
+
const externalFromReadme = readme
|
|
395
|
+
? parseExternalSystems(extractListSectionFromReadme(readme, 'External Systems'))
|
|
396
|
+
: [];
|
|
397
|
+
const externalFromSource = parseExternalSystems(extractTaggedValue(source, 'External Systems') || extractTaggedValue(source, 'External System'));
|
|
398
|
+
const externalFromOption = parseExternalSystems(options.external);
|
|
399
|
+
const externalSystems = Array.from(new Set([
|
|
400
|
+
...externalFromOption,
|
|
401
|
+
...externalFromReadme,
|
|
402
|
+
...externalFromSource,
|
|
403
|
+
]));
|
|
404
|
+
if (meta) {
|
|
405
|
+
if (!description) {
|
|
406
|
+
description = meta;
|
|
407
|
+
}
|
|
408
|
+
else if (!description.includes('Intent:') && !description.includes('Gap:')) {
|
|
409
|
+
description = `${description} | ${meta}`;
|
|
410
|
+
}
|
|
411
|
+
description = clampText(description, 240);
|
|
412
|
+
}
|
|
347
413
|
if (!description) {
|
|
348
414
|
console.log(`\nā Description required. Please provide a description for your tool.`);
|
|
349
415
|
console.log(`\n Option 1: Use -d "Your description here"`);
|
|
@@ -352,13 +418,15 @@ export async function submit(name, file, options) {
|
|
|
352
418
|
console.log(` Option 3: Add a description after the title in your README\n`);
|
|
353
419
|
process.exit(1);
|
|
354
420
|
}
|
|
421
|
+
// āā Fetch categories from API (single source of truth) āā
|
|
422
|
+
const CATEGORIES = await fetchCategories();
|
|
355
423
|
// Determine category
|
|
356
424
|
let category = options.category;
|
|
357
|
-
if (category && !CATEGORIES.find(c => c.id === category)) {
|
|
425
|
+
if (category && CATEGORIES.length > 0 && !CATEGORIES.find(c => c.id === category)) {
|
|
358
426
|
console.log(`\nā Invalid category: ${category}`);
|
|
359
427
|
console.log(`\n Valid categories:`);
|
|
360
428
|
for (const cat of CATEGORIES) {
|
|
361
|
-
console.log(` ${cat.id.padEnd(
|
|
429
|
+
console.log(` ${cat.id.padEnd(12)} ${cat.name}`);
|
|
362
430
|
}
|
|
363
431
|
console.log();
|
|
364
432
|
process.exit(1);
|
|
@@ -367,35 +435,25 @@ export async function submit(name, file, options) {
|
|
|
367
435
|
if (!category) {
|
|
368
436
|
category = detectCategory(description, source);
|
|
369
437
|
}
|
|
370
|
-
// Prompt for category confirmation
|
|
371
|
-
if (!options.category && process.stdin.isTTY) {
|
|
372
|
-
const rl = createInterface({
|
|
373
|
-
input: process.stdin,
|
|
374
|
-
output: process.stdout
|
|
375
|
-
});
|
|
438
|
+
// Prompt for category confirmation if TTY
|
|
439
|
+
if (!options.category && process.stdin.isTTY && CATEGORIES.length > 0) {
|
|
440
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
376
441
|
const detectedCat = CATEGORIES.find(c => c.id === category);
|
|
377
442
|
console.log(`\nš Category Selection`);
|
|
378
443
|
console.log(` Auto-detected: ${detectedCat?.name || category} (${category})`);
|
|
379
444
|
console.log(`\n Common categories:`);
|
|
380
|
-
|
|
381
|
-
const commonCategories = [
|
|
382
|
-
'api', 'json', 'data', 'text', 'web', 'crypto', 'file',
|
|
383
|
-
'array', 'validate', 'util', 'other'
|
|
384
|
-
];
|
|
445
|
+
const commonCategories = ['core', 'web', 'api', 'ai', 'social', 'github', 'email', 'database', 'files', 'security'];
|
|
385
446
|
for (const catId of commonCategories) {
|
|
386
447
|
const cat = CATEGORIES.find(c => c.id === catId);
|
|
387
448
|
if (cat) {
|
|
388
|
-
const marker = cat.id === category ? '
|
|
449
|
+
const marker = cat.id === category ? ' <- detected' : '';
|
|
389
450
|
console.log(` ${cat.id.padEnd(12)} ${cat.name}${marker}`);
|
|
390
451
|
}
|
|
391
452
|
}
|
|
392
453
|
console.log(`\n (Use 'devtopia categories' to see all categories)`);
|
|
393
454
|
console.log(`\n Press Enter to use detected category, or type a category ID:`);
|
|
394
455
|
const answer = await new Promise((resolve) => {
|
|
395
|
-
rl.question(` Category [${category}]: `, (ans) => {
|
|
396
|
-
rl.close();
|
|
397
|
-
resolve(ans.trim());
|
|
398
|
-
});
|
|
456
|
+
rl.question(` Category [${category}]: `, (ans) => { rl.close(); resolve(ans.trim()); });
|
|
399
457
|
});
|
|
400
458
|
if (answer) {
|
|
401
459
|
const selectedCat = CATEGORIES.find(c => c.id === answer.toLowerCase());
|
|
@@ -411,20 +469,90 @@ export async function submit(name, file, options) {
|
|
|
411
469
|
console.log(`\nš” Tip: Category auto-detected as "${category}"`);
|
|
412
470
|
console.log(` Use -c <category> to specify a different category.\n`);
|
|
413
471
|
}
|
|
472
|
+
if (category !== 'core' && externalSystems.length === 0) {
|
|
473
|
+
console.log(`\nā External Systems required for gravity tools.`);
|
|
474
|
+
console.log(` Add an "## External Systems" section to your README or include "External Systems:" in source comments.`);
|
|
475
|
+
console.log(` Or pass: --external "github,slack,openai"\n`);
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
414
478
|
// Parse dependencies
|
|
415
479
|
const dependencies = options.deps
|
|
416
480
|
? options.deps.split(',').map(d => d.trim()).filter(Boolean)
|
|
417
481
|
: [];
|
|
418
|
-
// Parse builds_on
|
|
482
|
+
// Parse builds_on
|
|
419
483
|
const buildsOn = options.buildsOn
|
|
420
484
|
? options.buildsOn.split(',').map(d => d.trim()).filter(Boolean)
|
|
421
485
|
: [];
|
|
422
|
-
|
|
486
|
+
if (buildsOn.length === 0) {
|
|
487
|
+
if (!intent) {
|
|
488
|
+
console.log(`\nā Intent required for primitive tools.`);
|
|
489
|
+
console.log(` Add an "## Intent" section to your README or include "Intent:" in source comments.`);
|
|
490
|
+
console.log(` Example:\n ## Intent\n Normalize a URL before hashing\n`);
|
|
491
|
+
process.exit(1);
|
|
492
|
+
}
|
|
493
|
+
if (!gap) {
|
|
494
|
+
console.log(`\nā Gap justification required for primitive tools.`);
|
|
495
|
+
console.log(` Add an "## Gap Justification" section to your README or include "Gap Justification:" in source comments.`);
|
|
496
|
+
console.log(` Example:\n ## Gap Justification\n Existing tools do not trim tracking params before hashing.\n`);
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
423
500
|
if (!options.buildsOn && buildsOn.length === 0) {
|
|
424
501
|
console.log(`\nš” Tip: Use --builds-on to show lineage!`);
|
|
425
502
|
console.log(` Example: --builds-on api-request,json-validate`);
|
|
426
503
|
console.log(` This helps others see how tools build on each other.\n`);
|
|
427
504
|
}
|
|
505
|
+
// āā Extract schema from JSDoc @param tags āā
|
|
506
|
+
let inputSchema = null;
|
|
507
|
+
let outputSchema = null;
|
|
508
|
+
if (options.schema) {
|
|
509
|
+
try {
|
|
510
|
+
const schemaContent = JSON.parse(readFileSync(resolve(options.schema), 'utf-8'));
|
|
511
|
+
inputSchema = schemaContent.inputs || null;
|
|
512
|
+
outputSchema = schemaContent.outputs || null;
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
console.log(`\nā ļø Could not parse schema file: ${options.schema}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (!inputSchema) {
|
|
519
|
+
// Auto-extract from JSDoc @param tags
|
|
520
|
+
const paramMatches = source.matchAll(/@param\s+\{(\w+)\}\s+([\w.]+)\s*[-ā]?\s*(.*)/g);
|
|
521
|
+
const extracted = {};
|
|
522
|
+
for (const match of paramMatches) {
|
|
523
|
+
const type = match[1].toLowerCase();
|
|
524
|
+
const paramName = match[2].replace(/^params\./, '');
|
|
525
|
+
if (paramName !== 'params') {
|
|
526
|
+
extracted[paramName] = type;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (Object.keys(extracted).length > 0) {
|
|
530
|
+
inputSchema = extracted;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// āā Pre-submit execution validation āā
|
|
534
|
+
if (options.skipValidation) {
|
|
535
|
+
const allowSkip = ['1', 'true', 'yes'].includes((process.env.DEVTOPIA_ALLOW_SKIP_VALIDATION || '').toLowerCase());
|
|
536
|
+
if (!allowSkip) {
|
|
537
|
+
console.log(`\nā --skip-validation is restricted.`);
|
|
538
|
+
console.log(` Set DEVTOPIA_ALLOW_SKIP_VALIDATION=1 to use it (admin only).\n`);
|
|
539
|
+
process.exit(1);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (!options.skipValidation) {
|
|
543
|
+
console.log(`\n Validating tool execution...`);
|
|
544
|
+
const validation = await validateExecution(source, language);
|
|
545
|
+
if (!validation.valid) {
|
|
546
|
+
const reason = validation.stderr
|
|
547
|
+
? validation.stderr.split('\n')[0]
|
|
548
|
+
: (validation.stdout ? 'Tool produced non-JSON output.' : 'Tool produced no output.');
|
|
549
|
+
console.log(`\nā Tool failed validation: ${reason}`);
|
|
550
|
+
console.log(`\n Your tool must produce JSON output (even error JSON like {"error":"..."}).`);
|
|
551
|
+
console.log(` Use --skip-validation to bypass this check.\n`);
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
console.log(` Validation passed (exit ${validation.exitCode}, ${validation.stdout.length} bytes output)`);
|
|
555
|
+
}
|
|
428
556
|
const catInfo = CATEGORIES.find(c => c.id === category);
|
|
429
557
|
console.log(`\nš¦ Submitting ${name}...`);
|
|
430
558
|
console.log(` File: ${file}`);
|
|
@@ -433,10 +561,14 @@ export async function submit(name, file, options) {
|
|
|
433
561
|
console.log(` README: ${readmePath}`);
|
|
434
562
|
if (description)
|
|
435
563
|
console.log(` Desc: ${description}`);
|
|
564
|
+
if (inputSchema)
|
|
565
|
+
console.log(` Schema: ${Object.keys(inputSchema).join(', ')}`);
|
|
436
566
|
if (dependencies.length)
|
|
437
567
|
console.log(` Deps: ${dependencies.join(', ')}`);
|
|
438
568
|
if (buildsOn.length)
|
|
439
569
|
console.log(` Builds on: ${buildsOn.join(', ')}`);
|
|
570
|
+
if (externalSystems.length)
|
|
571
|
+
console.log(` External: ${externalSystems.join(', ')}`);
|
|
440
572
|
try {
|
|
441
573
|
const res = await fetch(`${API_BASE}/api/submit`, {
|
|
442
574
|
method: 'POST',
|
|
@@ -450,7 +582,10 @@ export async function submit(name, file, options) {
|
|
|
450
582
|
category,
|
|
451
583
|
source,
|
|
452
584
|
dependencies,
|
|
585
|
+
external_systems: externalSystems.length > 0 ? externalSystems : undefined,
|
|
453
586
|
builds_on: buildsOn.length > 0 ? buildsOn : undefined,
|
|
587
|
+
input_schema: inputSchema ? JSON.stringify(inputSchema) : undefined,
|
|
588
|
+
output_schema: outputSchema ? JSON.stringify(outputSchema) : undefined,
|
|
454
589
|
}),
|
|
455
590
|
});
|
|
456
591
|
const data = await res.json();
|
|
@@ -465,6 +600,14 @@ export async function submit(name, file, options) {
|
|
|
465
600
|
if (buildsOn.length > 0) {
|
|
466
601
|
console.log(` Builds on: ${buildsOn.join(', ')}`);
|
|
467
602
|
}
|
|
603
|
+
// Show similar tools warning (duplicate detection)
|
|
604
|
+
if (data.similar_tools && data.similar_tools.length > 0) {
|
|
605
|
+
console.log(`\nā ļø Similar tools already exist:`);
|
|
606
|
+
for (const sim of data.similar_tools) {
|
|
607
|
+
console.log(` - ${sim.name}: "${sim.description || 'No description'}"`);
|
|
608
|
+
}
|
|
609
|
+
console.log(` Consider composing with existing tools instead of creating new ones.`);
|
|
610
|
+
}
|
|
468
611
|
console.log(`\n Others can now:`);
|
|
469
612
|
console.log(` $ devtopia cat ${name}`);
|
|
470
613
|
console.log(` $ devtopia run ${name} '{...}'\n`);
|