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.
@@ -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
- // Valid categories - grouped by type
14
- const CATEGORIES = [
15
- // Data & Parsing
16
- { id: 'data', name: 'Data Processing' },
17
- { id: 'json', name: 'JSON & Objects' },
18
- { id: 'csv', name: 'CSV & Tables' },
19
- { id: 'xml', name: 'XML & Markup' },
20
- { id: 'yaml', name: 'YAML & Config' },
21
- // Text & Strings
22
- { id: 'text', name: 'Text & NLP' },
23
- { id: 'string', name: 'String Utils' },
24
- { id: 'regex', name: 'Regex & Patterns' },
25
- { id: 'format', name: 'Formatting' },
26
- // Web & Network
27
- { id: 'web', name: 'Web & HTTP' },
28
- { id: 'api', name: 'API Tools' },
29
- { id: 'url', name: 'URL & Links' },
30
- { id: 'html', name: 'HTML & DOM' },
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 single line: // tool-name - description
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 block comment: * tool-name - description
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
- // Inside JSDoc: look for description after title
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
- // Skip empty lines, shebang, opening markers, and usage lines
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
- // Stop if we hit non-comment code
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
- // Specific categories first (more specific matches)
194
- if (text.includes('jwt') || text.includes('oauth') || text.includes('token') || text.includes('auth'))
195
- return 'auth';
196
- if (text.includes('base64') || text.includes('hex') || text.includes('encode') || text.includes('decode'))
197
- return 'encode';
198
- if (text.includes('sha256') || text.includes('sha512') || text.includes('md5') || text.includes('hash'))
199
- return 'hash';
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
- // Broader categories
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 (text.includes('encrypt') || text.includes('crypto') || text.includes('secure'))
256
- return 'crypto';
257
- if (text.includes('parse') || text.includes('transform') || text.includes('data'))
258
- return 'data';
259
- if (text.includes('time') || text.includes('date') || text.includes('timestamp'))
260
- return 'time';
261
- if (text.includes('text') || text.includes('string') || text.includes('format'))
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 'util';
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
- const language = LANG_MAP[ext];
337
+ // Read source
338
+ const source = readFileSync(filePath, 'utf-8');
339
+ let language = LANG_MAP[ext];
316
340
  if (!language) {
317
- console.log(`\nāŒ Unsupported file type: ${ext}`);
318
- console.log(` Supported: .ts, .js, .py\n`);
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
- // Read source
322
- const source = readFileSync(filePath, 'utf-8');
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
- // Description is required
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(10)} ${cat.name}`);
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/selection if not provided via CLI
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
- // Show most common/relevant categories
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 ? ' ← detected' : '';
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 (parent tools this tool extends/composes)
482
+ // Parse builds_on
419
483
  const buildsOn = options.buildsOn
420
484
  ? options.buildsOn.split(',').map(d => d.trim()).filter(Boolean)
421
485
  : [];
422
- // Encourage lineage tracking
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`);