@walwal-harness/cli 7.1.38 → 7.1.40

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.
@@ -0,0 +1,509 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ API Discovery Script for prompt-inspector
4
+
5
+ Scans a project directory to find:
6
+ 1. Frontend API calls (axios, fetch, ky, custom instances, etc.)
7
+ 2. Backend API endpoints (Express, FastAPI, NestJS, Next.js API routes, etc.)
8
+ 3. OpenAPI/Swagger specification files
9
+
10
+ Usage:
11
+ python discover_apis.py <project_path> [--output json|markdown]
12
+ """
13
+
14
+ import os
15
+ import re
16
+ import json
17
+ import argparse
18
+ from pathlib import Path
19
+ from dataclasses import dataclass, asdict
20
+ from typing import Optional, Set
21
+
22
+ @dataclass
23
+ class APICall:
24
+ """Represents a discovered API call or endpoint"""
25
+ type: str # 'frontend', 'backend', or 'spec'
26
+ method: str # GET, POST, PUT, DELETE, PATCH
27
+ path: str # API path/URL
28
+ file: str # Source file
29
+ line: int # Line number
30
+ library: str # axios, fetch, express, openapi, etc.
31
+ instance_name: Optional[str] = None # Custom instance name if applicable
32
+ function_name: Optional[str] = None # Function/handler name if available
33
+
34
+ # HTTP methods to detect
35
+ HTTP_METHODS = {'get', 'post', 'put', 'delete', 'patch', 'head', 'options'}
36
+ HTTP_METHODS_UPPER = {m.upper() for m in HTTP_METHODS}
37
+
38
+ # Known HTTP client libraries and their creation patterns
39
+ HTTP_CLIENT_CREATORS = {
40
+ 'axios': [r'axios\.create\s*\(', r'import\s+axios\s+from'],
41
+ 'ky': [r'ky\.create\s*\(', r'import\s+ky\s+from'],
42
+ 'got': [r'got\.extend\s*\(', r'import\s+got\s+from'],
43
+ 'superagent': [r'import\s+superagent\s+from', r'require\s*\(\s*[\'"]superagent[\'"]\s*\)'],
44
+ 'ofetch': [r'ofetch\.create\s*\(', r'\$fetch', r'import.*from\s*[\'"]ofetch[\'"]'],
45
+ 'wretch': [r'wretch\s*\(', r'import\s+wretch\s+from'],
46
+ 'redaxios': [r'import\s+redaxios\s+from', r'redaxios\.create\s*\('],
47
+ 'apisauce': [r'create\s*\(\s*\{[^}]*baseURL', r'import.*from\s*[\'"]apisauce[\'"]'],
48
+ }
49
+
50
+ # Patterns to find custom HTTP client instance declarations
51
+ INSTANCE_DECLARATION_PATTERNS = [
52
+ # export const apiClient = axios.create(...)
53
+ r'(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:axios|ky|got|superagent|wretch|redaxios)(?:\.create|\.extend)?\s*\(',
54
+ # const api = axios.create(...)
55
+ r'(?:const|let|var)\s+(\w+)\s*=\s*\w+\.create\s*\(\s*\{[^}]*baseURL',
56
+ # export const http = new HttpClient(...)
57
+ r'(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*new\s+\w*(?:Http|Api|Client|Service)\w*\s*\(',
58
+ ]
59
+
60
+ # Common HTTP client instance names (fallback detection)
61
+ COMMON_INSTANCE_NAMES = {
62
+ 'api', 'apiClient', 'client', 'http', 'httpClient',
63
+ 'request', 'requests', 'axios', 'instance', 'service',
64
+ 'fetcher', 'apiService', 'httpService', 'restClient',
65
+ }
66
+
67
+ IGNORED_DIRS = {
68
+ 'node_modules', '.git', '.next', 'dist', 'build',
69
+ '__pycache__', '.venv', 'venv', 'coverage', '.turbo'
70
+ }
71
+
72
+ FRONTEND_EXTENSIONS = {'.js', '.jsx', '.ts', '.tsx', '.mjs'}
73
+ BACKEND_EXTENSIONS = {'.js', '.ts', '.py', '.mjs'}
74
+ SPEC_EXTENSIONS = {'.json', '.yaml', '.yml'}
75
+
76
+
77
+ def should_skip_dir(dir_name: str) -> bool:
78
+ return dir_name in IGNORED_DIRS or dir_name.startswith('.')
79
+
80
+
81
+ def find_http_client_instances(content: str) -> Set[str]:
82
+ """Find custom HTTP client instance names in the file"""
83
+ instances = set()
84
+
85
+ for pattern in INSTANCE_DECLARATION_PATTERNS:
86
+ for match in re.finditer(pattern, content, re.MULTILINE):
87
+ if match.group(1):
88
+ instances.add(match.group(1))
89
+
90
+ return instances
91
+
92
+
93
+ def extract_url_from_match(match_str: str) -> Optional[str]:
94
+ """Extract URL/path from various string formats"""
95
+ # Remove TypeScript generics like <T> or <ResponseType>
96
+ match_str = re.sub(r'<[^>]+>\s*', '', match_str)
97
+
98
+ # Handle template literals: `${baseUrl}/path` -> extract /path part
99
+ template_match = re.search(r'`[^`]*(/[^`\$]+)`', match_str)
100
+ if template_match:
101
+ return template_match.group(1)
102
+
103
+ # Handle simple strings: '/api/users' or "/api/users"
104
+ string_match = re.search(r'[\'"`](/[^\'"`\s]+)[\'"`]', match_str)
105
+ if string_match:
106
+ return string_match.group(1)
107
+
108
+ # Handle full URLs
109
+ url_match = re.search(r'[\'"`](https?://[^\'"`\s]+)[\'"`]', match_str)
110
+ if url_match:
111
+ return url_match.group(1)
112
+
113
+ return None
114
+
115
+
116
+ def find_frontend_apis(file_path: Path, content: str) -> list[APICall]:
117
+ """Find frontend API calls in a file"""
118
+ apis = []
119
+
120
+ # Find custom instances in this file
121
+ custom_instances = find_http_client_instances(content)
122
+
123
+ # Build dynamic pattern for HTTP method calls
124
+ # Matches: instanceName.method<T>('/path', ...) or instanceName.method('/path', ...)
125
+ all_instances = custom_instances | COMMON_INSTANCE_NAMES | {'axios', 'ky', 'got', 'superagent', 'fetch'}
126
+
127
+ # Pattern 1: instance.method<T?>('/path') - covers axios, ky, got, custom instances
128
+ # Handles: apiClient.post<LoginResponse>('/v1/accounts/login/email', data)
129
+ methods_pattern = '|'.join(HTTP_METHODS)
130
+ instance_pattern = '|'.join(re.escape(name) for name in all_instances)
131
+
132
+ # Generic pattern for any potential HTTP client call
133
+ # Match: word.httpMethod<optional generic>( string or template literal
134
+ generic_method_pattern = rf'(\w+)\.({methods_pattern})\s*(?:<[^>]*>)?\s*\(\s*([\'"`][^)]+)'
135
+
136
+ for match in re.finditer(generic_method_pattern, content, re.IGNORECASE):
137
+ instance_name = match.group(1)
138
+ method = match.group(2).upper()
139
+ url_part = match.group(3)
140
+
141
+ # Skip if it doesn't look like an HTTP client call
142
+ if instance_name.lower() in {'console', 'math', 'array', 'object', 'string', 'promise', 'window', 'document'}:
143
+ continue
144
+
145
+ url = extract_url_from_match(url_part)
146
+ if not url:
147
+ continue
148
+
149
+ line_num = content[:match.start()].count('\n') + 1
150
+
151
+ # Determine library
152
+ library = 'unknown'
153
+ if instance_name in custom_instances:
154
+ library = 'custom-instance'
155
+ elif instance_name.lower() in {'axios'}:
156
+ library = 'axios'
157
+ elif instance_name.lower() in {'ky'}:
158
+ library = 'ky'
159
+ elif instance_name.lower() in {'got'}:
160
+ library = 'got'
161
+ elif instance_name in COMMON_INSTANCE_NAMES:
162
+ library = 'http-client'
163
+
164
+ apis.append(APICall(
165
+ type='frontend',
166
+ method=method,
167
+ path=url,
168
+ file=str(file_path),
169
+ line=line_num,
170
+ library=library,
171
+ instance_name=instance_name if instance_name not in {'axios', 'ky', 'got', 'fetch'} else None
172
+ ))
173
+
174
+ # Pattern 2: fetch('/path', { method: 'POST' }) or fetch('/path')
175
+ fetch_pattern = r'fetch\s*\(\s*([\'"`][^\'"`]+[\'"`])\s*(?:,\s*\{[^}]*method:\s*[\'"`](\w+)[\'"`])?'
176
+ for match in re.finditer(fetch_pattern, content, re.IGNORECASE):
177
+ url = extract_url_from_match(match.group(1))
178
+ if not url:
179
+ continue
180
+ method = match.group(2).upper() if match.group(2) else 'GET'
181
+ line_num = content[:match.start()].count('\n') + 1
182
+
183
+ apis.append(APICall(
184
+ type='frontend',
185
+ method=method,
186
+ path=url,
187
+ file=str(file_path),
188
+ line=line_num,
189
+ library='fetch'
190
+ ))
191
+
192
+ # Pattern 3: useSWR('/api/users') or useQuery(['/api/users'])
193
+ swr_pattern = r'useSWR\s*\(\s*[\'"`]([^\'"`]+)[\'"`]'
194
+ for match in re.finditer(swr_pattern, content):
195
+ url = match.group(1)
196
+ if url.startswith('/') or url.startswith('http'):
197
+ line_num = content[:match.start()].count('\n') + 1
198
+ apis.append(APICall(
199
+ type='frontend',
200
+ method='GET',
201
+ path=url,
202
+ file=str(file_path),
203
+ line=line_num,
204
+ library='swr'
205
+ ))
206
+
207
+ # Pattern 4: useQuery with fetch
208
+ query_pattern = r'useQuery\s*\([^)]*fetch\s*\(\s*[\'"`]([^\'"`]+)[\'"`]'
209
+ for match in re.finditer(query_pattern, content):
210
+ url = match.group(1)
211
+ if url.startswith('/') or url.startswith('http'):
212
+ line_num = content[:match.start()].count('\n') + 1
213
+ apis.append(APICall(
214
+ type='frontend',
215
+ method='GET',
216
+ path=url,
217
+ file=str(file_path),
218
+ line=line_num,
219
+ library='react-query'
220
+ ))
221
+
222
+ return apis
223
+
224
+
225
+ def find_backend_apis(file_path: Path, content: str) -> list[APICall]:
226
+ """Find backend API endpoints in a file"""
227
+ apis = []
228
+
229
+ # Detect Next.js API routes from file path
230
+ if '/app/api/' in str(file_path) or '/pages/api/' in str(file_path):
231
+ path_str = str(file_path)
232
+ if '/app/api/' in path_str:
233
+ route = path_str.split('/app/api/')[-1]
234
+ route = '/api/' + route.replace('/route.ts', '').replace('/route.js', '')
235
+ else:
236
+ route = path_str.split('/pages/api/')[-1]
237
+ route = '/api/' + route.replace('.ts', '').replace('.js', '')
238
+
239
+ # Find exported HTTP methods
240
+ for match in re.finditer(r'export\s+(async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)', content):
241
+ method = match.group(2)
242
+ line_num = content[:match.start()].count('\n') + 1
243
+ apis.append(APICall(
244
+ type='backend',
245
+ method=method,
246
+ path=route.replace('[', '{').replace(']', '}'),
247
+ file=str(file_path),
248
+ line=line_num,
249
+ library='nextjs-api'
250
+ ))
251
+
252
+ for match in re.finditer(r'export\s+const\s+(GET|POST|PUT|DELETE|PATCH)\s*=', content):
253
+ method = match.group(1)
254
+ line_num = content[:match.start()].count('\n') + 1
255
+ apis.append(APICall(
256
+ type='backend',
257
+ method=method,
258
+ path=route.replace('[', '{').replace(']', '}'),
259
+ file=str(file_path),
260
+ line=line_num,
261
+ library='nextjs-api'
262
+ ))
263
+
264
+ # Express/Hono style: app.get('/path', ...) or router.post('/path', ...)
265
+ express_pattern = r'(app|router)\.(get|post|put|delete|patch)\s*\(\s*[\'"`]([^\'"`]+)[\'"`]'
266
+ for match in re.finditer(express_pattern, content, re.IGNORECASE):
267
+ method = match.group(2).upper()
268
+ path = match.group(3)
269
+ line_num = content[:match.start()].count('\n') + 1
270
+ apis.append(APICall(
271
+ type='backend',
272
+ method=method,
273
+ path=path,
274
+ file=str(file_path),
275
+ line=line_num,
276
+ library='express'
277
+ ))
278
+
279
+ # FastAPI style: @app.get('/path')
280
+ fastapi_pattern = r'@(?:app|router)\.(get|post|put|delete|patch)\s*\(\s*[\'"`]([^\'"`]+)[\'"`]'
281
+ for match in re.finditer(fastapi_pattern, content, re.IGNORECASE):
282
+ method = match.group(1).upper()
283
+ path = match.group(2)
284
+ line_num = content[:match.start()].count('\n') + 1
285
+ apis.append(APICall(
286
+ type='backend',
287
+ method=method,
288
+ path=path,
289
+ file=str(file_path),
290
+ line=line_num,
291
+ library='fastapi'
292
+ ))
293
+
294
+ # NestJS style: @Get('/path'), @Post(), etc.
295
+ nestjs_pattern = r'@(Get|Post|Put|Delete|Patch)\s*\(\s*[\'"`]?([^\'"`\)]*)[\'"`]?\s*\)'
296
+ for match in re.finditer(nestjs_pattern, content):
297
+ method = match.group(1).upper()
298
+ path = match.group(2) if match.group(2) else '/'
299
+ line_num = content[:match.start()].count('\n') + 1
300
+ apis.append(APICall(
301
+ type='backend',
302
+ method=method,
303
+ path=path,
304
+ file=str(file_path),
305
+ line=line_num,
306
+ library='nestjs'
307
+ ))
308
+
309
+ return apis
310
+
311
+
312
+ def find_openapi_specs(file_path: Path, content: str) -> list[APICall]:
313
+ """Parse OpenAPI/Swagger specification files"""
314
+ apis = []
315
+
316
+ try:
317
+ if file_path.suffix == '.json':
318
+ spec = json.loads(content)
319
+ else:
320
+ # For YAML, try to parse as JSON-like structure
321
+ # In production, you'd use pyyaml
322
+ return apis
323
+
324
+ # Check if this is an OpenAPI/Swagger spec
325
+ if not ('openapi' in spec or 'swagger' in spec):
326
+ return apis
327
+
328
+ # Get base path
329
+ base_path = ''
330
+ if 'basePath' in spec: # Swagger 2.0
331
+ base_path = spec['basePath']
332
+ elif 'servers' in spec and spec['servers']: # OpenAPI 3.x
333
+ server_url = spec['servers'][0].get('url', '')
334
+ # Extract path from URL if present
335
+ if '/' in server_url.replace('://', ''):
336
+ base_path = '/' + server_url.split('/', 3)[-1] if server_url.count('/') > 2 else ''
337
+
338
+ # Parse paths
339
+ paths = spec.get('paths', {})
340
+ for path, methods in paths.items():
341
+ if not isinstance(methods, dict):
342
+ continue
343
+
344
+ full_path = base_path + path
345
+
346
+ for method, details in methods.items():
347
+ if method.upper() not in HTTP_METHODS_UPPER:
348
+ continue
349
+
350
+ summary = ''
351
+ if isinstance(details, dict):
352
+ summary = details.get('summary', details.get('operationId', ''))
353
+
354
+ apis.append(APICall(
355
+ type='spec',
356
+ method=method.upper(),
357
+ path=full_path,
358
+ file=str(file_path),
359
+ line=1,
360
+ library='openapi',
361
+ function_name=summary if summary else None
362
+ ))
363
+
364
+ except (json.JSONDecodeError, KeyError, TypeError):
365
+ pass
366
+
367
+ return apis
368
+
369
+
370
+ def scan_directory(project_path: Path) -> list[APICall]:
371
+ """Scan a directory for all API calls and endpoints"""
372
+ all_apis = []
373
+
374
+ for root, dirs, files in os.walk(project_path):
375
+ # Filter out ignored directories
376
+ dirs[:] = [d for d in dirs if not should_skip_dir(d)]
377
+
378
+ for file in files:
379
+ file_path = Path(root) / file
380
+ suffix = file_path.suffix.lower()
381
+
382
+ if suffix not in FRONTEND_EXTENSIONS | BACKEND_EXTENSIONS | SPEC_EXTENSIONS:
383
+ continue
384
+
385
+ try:
386
+ content = file_path.read_text(encoding='utf-8')
387
+ except (UnicodeDecodeError, PermissionError):
388
+ continue
389
+
390
+ # Check frontend patterns
391
+ if suffix in FRONTEND_EXTENSIONS:
392
+ all_apis.extend(find_frontend_apis(file_path, content))
393
+
394
+ # Check backend patterns
395
+ if suffix in BACKEND_EXTENSIONS:
396
+ all_apis.extend(find_backend_apis(file_path, content))
397
+
398
+ # Check OpenAPI specs
399
+ if suffix in SPEC_EXTENSIONS:
400
+ all_apis.extend(find_openapi_specs(file_path, content))
401
+
402
+ # Deduplicate
403
+ seen = set()
404
+ unique_apis = []
405
+ for api in all_apis:
406
+ key = (api.type, api.method, api.path, api.file, api.line)
407
+ if key not in seen:
408
+ seen.add(key)
409
+ unique_apis.append(api)
410
+
411
+ return unique_apis
412
+
413
+
414
+ def format_markdown(apis: list[APICall], project_path: str) -> str:
415
+ """Format API list as markdown"""
416
+ frontend = [a for a in apis if a.type == 'frontend']
417
+ backend = [a for a in apis if a.type == 'backend']
418
+ specs = [a for a in apis if a.type == 'spec']
419
+
420
+ output = f"# API Discovery Report\n\n"
421
+ output += f"**Project:** `{project_path}`\n"
422
+ output += f"**Total APIs Found:** {len(apis)}\n"
423
+ output += f"- Frontend Calls: {len(frontend)}\n"
424
+ output += f"- Backend Endpoints: {len(backend)}\n"
425
+ output += f"- OpenAPI Specs: {len(specs)}\n\n"
426
+
427
+ if specs:
428
+ output += "## OpenAPI Specifications\n\n"
429
+ output += "| Method | Path | Summary | File |\n"
430
+ output += "|--------|------|---------|------|\n"
431
+ for api in sorted(specs, key=lambda x: (x.file, x.path, x.method)):
432
+ rel_path = os.path.relpath(api.file, project_path)
433
+ summary = api.function_name or '-'
434
+ output += f"| `{api.method}` | `{api.path}` | {summary} | `{rel_path}` |\n"
435
+ output += "\n"
436
+
437
+ if backend:
438
+ output += "## Backend Endpoints\n\n"
439
+ output += "| Method | Path | Library | File | Line |\n"
440
+ output += "|--------|------|---------|------|------|\n"
441
+ for api in sorted(backend, key=lambda x: (x.path, x.method)):
442
+ rel_path = os.path.relpath(api.file, project_path)
443
+ output += f"| `{api.method}` | `{api.path}` | {api.library} | `{rel_path}` | {api.line} |\n"
444
+ output += "\n"
445
+
446
+ if frontend:
447
+ output += "## Frontend API Calls\n\n"
448
+ output += "| Method | Path | Library | Instance | File | Line |\n"
449
+ output += "|--------|------|---------|----------|------|------|\n"
450
+ for api in sorted(frontend, key=lambda x: (x.path, x.method)):
451
+ rel_path = os.path.relpath(api.file, project_path)
452
+ instance = api.instance_name or '-'
453
+ output += f"| `{api.method}` | `{api.path}` | {api.library} | {instance} | `{rel_path}` | {api.line} |\n"
454
+
455
+ return output
456
+
457
+
458
+ def format_json_for_inspector(apis: list[APICall]) -> list[dict]:
459
+ """Format APIs for PromptInspector component consumption"""
460
+ result = []
461
+ seen_paths = set()
462
+
463
+ for api in apis:
464
+ # Create unique ID
465
+ api_id = f"{api.method}_{api.path}".replace('/', '_').replace('{', '').replace('}', '')
466
+
467
+ # Skip duplicates (same method + path)
468
+ key = (api.method, api.path)
469
+ if key in seen_paths:
470
+ continue
471
+ seen_paths.add(key)
472
+
473
+ result.append({
474
+ 'id': api_id,
475
+ 'method': api.method,
476
+ 'path': api.path,
477
+ 'description': api.function_name or f"{api.library} - {os.path.basename(api.file)}"
478
+ })
479
+
480
+ return result
481
+
482
+
483
+ def main():
484
+ parser = argparse.ArgumentParser(description='Discover APIs in a project')
485
+ parser.add_argument('project_path', help='Path to the project directory')
486
+ parser.add_argument('--output', choices=['json', 'markdown', 'inspector'], default='markdown',
487
+ help='Output format (default: markdown, inspector for PromptInspector format)')
488
+ args = parser.parse_args()
489
+
490
+ project_path = Path(args.project_path).resolve()
491
+
492
+ if not project_path.exists():
493
+ print(f"Error: Path '{project_path}' does not exist")
494
+ return 1
495
+
496
+ apis = scan_directory(project_path)
497
+
498
+ if args.output == 'json':
499
+ print(json.dumps([asdict(api) for api in apis], indent=2))
500
+ elif args.output == 'inspector':
501
+ print(json.dumps(format_json_for_inspector(apis), indent=2))
502
+ else:
503
+ print(format_markdown(apis, str(project_path)))
504
+
505
+ return 0
506
+
507
+
508
+ if __name__ == '__main__':
509
+ exit(main())
@@ -0,0 +1,32 @@
1
+ #!/bin/bash
2
+ # Prompt Inspector - One-command installer
3
+ # Usage: ./install.sh <project_path>
4
+
5
+ set -e
6
+
7
+ PROJECT_PATH="${1:-.}"
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ SKILL_DIR="$(dirname "$SCRIPT_DIR")"
10
+
11
+ echo "═══════════════════════════════════════════════════════════════"
12
+ echo " 🔧 Prompt Inspector Installer"
13
+ echo "═══════════════════════════════════════════════════════════════"
14
+ echo ""
15
+
16
+ # Run setup
17
+ echo "📦 Step 1/2: Installing component..."
18
+ python3 "$SCRIPT_DIR/setup.py" "$PROJECT_PATH" --force
19
+
20
+ echo ""
21
+ echo "🔍 Step 2/2: Discovering APIs..."
22
+ python3 "$SCRIPT_DIR/discover_apis.py" "$PROJECT_PATH"
23
+
24
+ echo ""
25
+ echo "═══════════════════════════════════════════════════════════════"
26
+ echo " ✅ Installation Complete!"
27
+ echo ""
28
+ echo " Next steps:"
29
+ echo " 1. Start your dev server (npm run dev)"
30
+ echo " 2. Look for the toolbar at bottom-right"
31
+ echo " 3. Click 'Select Element' to start binding APIs"
32
+ echo "═══════════════════════════════════════════════════════════════"