ragtime-cli 0.2.2__py3-none-any.whl → 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ragtime-cli might be problematic. Click here for more details.

src/cli.py CHANGED
@@ -166,7 +166,7 @@ def get_remote_branches_with_ragtime(path: Path) -> list[str]:
166
166
 
167
167
 
168
168
  @click.group()
169
- @click.version_option(version="0.2.2")
169
+ @click.version_option(version="0.2.4")
170
170
  def main():
171
171
  """Ragtime - semantic search over code and documentation."""
172
172
  pass
@@ -206,11 +206,45 @@ index/
206
206
  """
207
207
  gitignore_path.write_text(gitignore_content)
208
208
 
209
+ # Create conventions file template
210
+ conventions_file = ragtime_dir / "CONVENTIONS.md"
211
+ if not conventions_file.exists():
212
+ conventions_file.write_text("""# Team Conventions
213
+
214
+ Rules and patterns that code must follow. These are checked by `/create-pr`.
215
+
216
+ ## Code Style
217
+
218
+ - [ ] Example: Use async/await, not .then() chains
219
+ - [ ] Example: All API endpoints must use auth middleware
220
+
221
+ ## Architecture
222
+
223
+ - [ ] Example: Services should not directly access repositories from other domains
224
+
225
+ ## Security
226
+
227
+ - [ ] Example: Never commit .env or credentials files
228
+ - [ ] Example: All user input must be validated
229
+
230
+ ## Testing
231
+
232
+ - [ ] Example: All new features need unit tests
233
+
234
+ ---
235
+
236
+ Add your team's conventions above. Each rule should be:
237
+ - Clear and specific
238
+ - Checkable against code
239
+ - Actionable (what to do, not just what not to do)
240
+ """)
241
+
209
242
  click.echo(f"\nCreated .ragtime/ structure:")
210
- click.echo(f" app/ - Graduated knowledge (tracked)")
211
- click.echo(f" team/ - Team conventions (tracked)")
212
- click.echo(f" branches/ - Active branches (yours tracked, synced gitignored)")
213
- click.echo(f" archive/ - Completed branches (tracked)")
243
+ click.echo(f" app/ - Graduated knowledge (tracked)")
244
+ click.echo(f" team/ - Team conventions (tracked)")
245
+ click.echo(f" branches/ - Active branches (yours tracked, synced gitignored)")
246
+ click.echo(f" archive/ - Completed branches (tracked)")
247
+ click.echo(f" CONVENTIONS.md - Team rules checked by /create-pr")
214
248
 
215
249
  # Check for ghp-cli
216
250
  if check_ghp_installed():
@@ -362,6 +396,9 @@ def config(path: Path):
362
396
  click.echo(f" Paths: {cfg.code.paths}")
363
397
  click.echo(f" Languages: {cfg.code.languages}")
364
398
  click.echo(f" Exclude: {cfg.code.exclude}")
399
+ click.echo("\nConventions:")
400
+ click.echo(f" Files: {cfg.conventions.files}")
401
+ click.echo(f" Also search memories: {cfg.conventions.also_search_memories}")
365
402
 
366
403
 
367
404
  # ============================================================================
@@ -709,21 +746,54 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
709
746
  target_dir.mkdir(parents=True, exist_ok=True)
710
747
  commands_dir = get_commands_dir()
711
748
  installed = 0
749
+ skipped = 0
750
+ namespaced = 0
712
751
 
713
752
  for cmd in to_install:
714
753
  source = commands_dir / f"{cmd}.md"
715
754
  target = target_dir / f"{cmd}.md"
755
+ namespaced_target = target_dir / f"ragtime-{cmd}.md"
716
756
 
717
757
  if target.exists() and not force:
718
- if click.confirm(f" {cmd}.md exists. Overwrite?", default=False):
758
+ # Check if it's our file (contains ragtime marker)
759
+ existing_content = target.read_text()
760
+ is_ragtime_file = "ragtime" in existing_content.lower() and "mcp__ragtime" in existing_content
761
+
762
+ if is_ragtime_file:
763
+ # It's our file, safe to overwrite
719
764
  target.write_text(source.read_text())
765
+ click.echo(f" ✓ {cmd}.md (updated)")
720
766
  installed += 1
767
+ else:
768
+ # Conflict with non-ragtime command
769
+ click.echo(f"\n⚠️ Conflict: {cmd}.md already exists (not a ragtime command)")
770
+ click.echo(f" 1. Overwrite with ragtime's version")
771
+ click.echo(f" 2. Skip (keep existing)")
772
+ click.echo(f" 3. Install as ragtime-{cmd}.md")
773
+
774
+ choice = click.prompt(" Choice", type=click.Choice(["1", "2", "3"]), default="2")
775
+
776
+ if choice == "1":
777
+ target.write_text(source.read_text())
778
+ click.echo(f" ✓ {cmd}.md (overwritten)")
779
+ installed += 1
780
+ elif choice == "2":
781
+ click.echo(f" • {cmd}.md (skipped)")
782
+ skipped += 1
783
+ else:
784
+ namespaced_target.write_text(source.read_text())
785
+ click.echo(f" ✓ ragtime-{cmd}.md")
786
+ namespaced += 1
721
787
  else:
722
788
  target.write_text(source.read_text())
723
789
  click.echo(f" ✓ {cmd}.md")
724
790
  installed += 1
725
791
 
726
792
  click.echo(f"\nInstalled {installed} commands to {target_dir}")
793
+ if namespaced:
794
+ click.echo(f" ({namespaced} installed with ragtime- prefix)")
795
+ if skipped:
796
+ click.echo(f" ({skipped} skipped due to conflicts)")
727
797
 
728
798
 
729
799
  @main.command("setup-ghp")
@@ -1105,6 +1175,586 @@ def daemon_status(path: Path):
1105
1175
  pid_file.unlink()
1106
1176
 
1107
1177
 
1178
+ # ============================================================================
1179
+ # Debug Commands
1180
+ # ============================================================================
1181
+
1182
+
1183
+ @main.group()
1184
+ def debug():
1185
+ """Debug and verify the vector index."""
1186
+ pass
1187
+
1188
+
1189
+ @debug.command("search")
1190
+ @click.argument("query")
1191
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
1192
+ @click.option("--limit", "-l", default=5, help="Max results")
1193
+ @click.option("--show-vectors", is_flag=True, help="Show vector statistics")
1194
+ def debug_search(query: str, path: Path, limit: int, show_vectors: bool):
1195
+ """Debug a search query - show scores and ranking details."""
1196
+ path = Path(path).resolve()
1197
+ db = get_db(path)
1198
+
1199
+ results = db.search(query=query, limit=limit)
1200
+
1201
+ if not results:
1202
+ click.echo("No results found.")
1203
+ return
1204
+
1205
+ click.echo(f"\nQuery: \"{query}\"")
1206
+ click.echo(f"{'─' * 60}")
1207
+
1208
+ for i, result in enumerate(results, 1):
1209
+ meta = result["metadata"]
1210
+ distance = result["distance"]
1211
+ similarity = 1 - distance if distance else None
1212
+
1213
+ click.echo(f"\n[{i}] {meta.get('file', 'unknown')}")
1214
+ click.echo(f" Distance: {distance:.4f}")
1215
+ click.echo(f" Similarity: {similarity:.4f} ({similarity * 100:.1f}%)")
1216
+ click.echo(f" Namespace: {meta.get('namespace', '-')}")
1217
+ click.echo(f" Type: {meta.get('type', '-')}")
1218
+
1219
+ # Show content preview
1220
+ preview = result["content"][:100].replace("\n", " ")
1221
+ click.echo(f" Preview: {preview}...")
1222
+
1223
+ if show_vectors:
1224
+ click.echo(f"\n{'─' * 60}")
1225
+ click.echo("Vector Statistics:")
1226
+ click.echo(f" Total indexed: {db.collection.count()}")
1227
+ click.echo(f" Embedding model: all-MiniLM-L6-v2 (ChromaDB default)")
1228
+ click.echo(f" Vector dimensions: 384")
1229
+ click.echo(f" Distance metric: cosine")
1230
+
1231
+
1232
+ @debug.command("similar")
1233
+ @click.argument("file_path", type=click.Path(exists=True))
1234
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
1235
+ @click.option("--limit", "-l", default=5, help="Max similar docs")
1236
+ def debug_similar(file_path: str, path: Path, limit: int):
1237
+ """Find documents similar to a given file."""
1238
+ path = Path(path).resolve()
1239
+ db = get_db(path)
1240
+
1241
+ # Read the file content
1242
+ try:
1243
+ content = Path(file_path).read_text()
1244
+ except Exception as e:
1245
+ click.echo(f"✗ Could not read file: {e}", err=True)
1246
+ return
1247
+
1248
+ # Use the content as the query
1249
+ results = db.search(query=content, limit=limit + 1) # +1 to exclude self
1250
+
1251
+ click.echo(f"\nDocuments similar to: {file_path}")
1252
+ click.echo(f"{'─' * 60}")
1253
+
1254
+ shown = 0
1255
+ for result in results:
1256
+ # Skip the file itself
1257
+ if result["metadata"].get("file", "").endswith(file_path):
1258
+ continue
1259
+
1260
+ shown += 1
1261
+ if shown > limit:
1262
+ break
1263
+
1264
+ meta = result["metadata"]
1265
+ distance = result["distance"]
1266
+ similarity = 1 - distance if distance else None
1267
+
1268
+ click.echo(f"\n[{shown}] {meta.get('file', 'unknown')}")
1269
+ click.echo(f" Similarity: {similarity:.4f} ({similarity * 100:.1f}%)")
1270
+
1271
+ preview = result["content"][:100].replace("\n", " ")
1272
+ click.echo(f" Preview: {preview}...")
1273
+
1274
+
1275
+ @debug.command("stats")
1276
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
1277
+ @click.option("--by-namespace", is_flag=True, help="Show counts by namespace")
1278
+ @click.option("--by-type", is_flag=True, help="Show counts by type")
1279
+ def debug_stats(path: Path, by_namespace: bool, by_type: bool):
1280
+ """Show detailed index statistics."""
1281
+ path = Path(path).resolve()
1282
+ db = get_db(path)
1283
+
1284
+ total = db.collection.count()
1285
+ click.echo(f"\nIndex Statistics")
1286
+ click.echo(f"{'─' * 40}")
1287
+ click.echo(f"Total documents: {total}")
1288
+
1289
+ if total == 0:
1290
+ click.echo("\nIndex is empty. Run 'ragtime index' first.")
1291
+ return
1292
+
1293
+ # Get all documents for analysis
1294
+ all_docs = db.collection.get()
1295
+
1296
+ if by_namespace or (not by_namespace and not by_type):
1297
+ namespaces = {}
1298
+ for meta in all_docs["metadatas"]:
1299
+ ns = meta.get("namespace", "unknown")
1300
+ namespaces[ns] = namespaces.get(ns, 0) + 1
1301
+
1302
+ click.echo(f"\nBy Namespace:")
1303
+ for ns, count in sorted(namespaces.items(), key=lambda x: -x[1]):
1304
+ pct = count / total * 100
1305
+ click.echo(f" {ns}: {count} ({pct:.1f}%)")
1306
+
1307
+ if by_type or (not by_namespace and not by_type):
1308
+ types = {}
1309
+ for meta in all_docs["metadatas"]:
1310
+ t = meta.get("type", "unknown")
1311
+ types[t] = types.get(t, 0) + 1
1312
+
1313
+ click.echo(f"\nBy Type:")
1314
+ for t, count in sorted(types.items(), key=lambda x: -x[1]):
1315
+ pct = count / total * 100
1316
+ click.echo(f" {t}: {count} ({pct:.1f}%)")
1317
+
1318
+
1319
+ @debug.command("verify")
1320
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
1321
+ def debug_verify(path: Path):
1322
+ """Verify index integrity with test queries."""
1323
+ path = Path(path).resolve()
1324
+ db = get_db(path)
1325
+
1326
+ total = db.collection.count()
1327
+ if total == 0:
1328
+ click.echo("✗ Index is empty. Run 'ragtime index' first.")
1329
+ return
1330
+
1331
+ click.echo(f"\nVerifying index ({total} documents)...")
1332
+ click.echo(f"{'─' * 40}")
1333
+
1334
+ issues = []
1335
+
1336
+ # Test 1: Basic search works
1337
+ click.echo("\n1. Testing basic search...")
1338
+ try:
1339
+ results = db.search("test", limit=1)
1340
+ if results:
1341
+ click.echo(" ✓ Search returns results")
1342
+ else:
1343
+ click.echo(" ⚠ Search returned no results (might be ok if no relevant docs)")
1344
+ except Exception as e:
1345
+ click.echo(f" ✗ Search failed: {e}")
1346
+ issues.append("Basic search failed")
1347
+
1348
+ # Test 2: Check for documents with missing metadata
1349
+ click.echo("\n2. Checking metadata integrity...")
1350
+ all_docs = db.collection.get()
1351
+ missing_namespace = 0
1352
+ missing_type = 0
1353
+
1354
+ for meta in all_docs["metadatas"]:
1355
+ if not meta.get("namespace"):
1356
+ missing_namespace += 1
1357
+ if not meta.get("type"):
1358
+ missing_type += 1
1359
+
1360
+ if missing_namespace:
1361
+ click.echo(f" ⚠ {missing_namespace} docs missing namespace")
1362
+ else:
1363
+ click.echo(" ✓ All docs have namespace")
1364
+
1365
+ if missing_type:
1366
+ click.echo(f" ⚠ {missing_type} docs missing type")
1367
+ else:
1368
+ click.echo(" ✓ All docs have type")
1369
+
1370
+ # Test 3: Check for duplicate IDs
1371
+ click.echo("\n3. Checking for duplicates...")
1372
+ ids = all_docs["ids"]
1373
+ unique_ids = set(ids)
1374
+ if len(ids) != len(unique_ids):
1375
+ dup_count = len(ids) - len(unique_ids)
1376
+ click.echo(f" ✗ {dup_count} duplicate IDs found")
1377
+ issues.append("Duplicate IDs")
1378
+ else:
1379
+ click.echo(" ✓ No duplicate IDs")
1380
+
1381
+ # Test 4: Similarity sanity check
1382
+ click.echo("\n4. Testing similarity consistency...")
1383
+ if total >= 2:
1384
+ # Pick first doc and find similar
1385
+ first_content = all_docs["documents"][0]
1386
+ results = db.search(first_content[:500], limit=2)
1387
+ if results and len(results) >= 1:
1388
+ top_similarity = 1 - results[0]["distance"]
1389
+ if top_similarity > 0.9:
1390
+ click.echo(f" ✓ Self-similarity check passed ({top_similarity:.2f})")
1391
+ else:
1392
+ click.echo(f" ⚠ Self-similarity lower than expected ({top_similarity:.2f})")
1393
+ else:
1394
+ click.echo(" ⚠ Could not perform similarity check")
1395
+ else:
1396
+ click.echo(" - Skipped (need at least 2 docs)")
1397
+
1398
+ # Summary
1399
+ click.echo(f"\n{'─' * 40}")
1400
+ if issues:
1401
+ click.echo(f"⚠ Found {len(issues)} issues:")
1402
+ for issue in issues:
1403
+ click.echo(f" - {issue}")
1404
+ else:
1405
+ click.echo("✓ Index verification passed")
1406
+
1407
+
1408
+ # ============================================================================
1409
+ # Documentation Generation
1410
+ # ============================================================================
1411
+
1412
+
1413
+ @main.command()
1414
+ @click.argument("code_path", type=click.Path(exists=True, path_type=Path))
1415
+ @click.option("--output", "-o", type=click.Path(path_type=Path), default="docs/api",
1416
+ help="Output directory for docs")
1417
+ @click.option("--stubs", is_flag=True, help="Generate stub docs with TODOs (no AI)")
1418
+ @click.option("--language", "-l", multiple=True,
1419
+ help="Languages to document (python, typescript, javascript)")
1420
+ @click.option("--include-private", is_flag=True, help="Include private methods (_name)")
1421
+ def generate(code_path: Path, output: Path, stubs: bool, language: tuple, include_private: bool):
1422
+ """Generate documentation from code.
1423
+
1424
+ Creates markdown documentation from code structure.
1425
+
1426
+ Examples:
1427
+ ragtime generate src/ --stubs # Create stub docs
1428
+ ragtime generate src/ -o docs/api # Specify output
1429
+ ragtime generate src/ -l python # Python only
1430
+ """
1431
+ import ast
1432
+ import re as re_module
1433
+
1434
+ code_path = Path(code_path).resolve()
1435
+ output = Path(output)
1436
+
1437
+ if not stubs:
1438
+ click.echo("Use --stubs for stub generation, or /generate-docs for AI-powered docs")
1439
+ click.echo("\nExample: ragtime generate src/ --stubs")
1440
+ return
1441
+
1442
+ # Determine languages
1443
+ if language:
1444
+ languages = list(language)
1445
+ else:
1446
+ languages = ["python", "typescript", "javascript"]
1447
+
1448
+ # Map extensions to languages
1449
+ ext_map = {
1450
+ "python": [".py"],
1451
+ "typescript": [".ts", ".tsx"],
1452
+ "javascript": [".js", ".jsx"],
1453
+ }
1454
+
1455
+ extensions = []
1456
+ for lang in languages:
1457
+ extensions.extend(ext_map.get(lang, []))
1458
+
1459
+ # Find code files
1460
+ code_files = []
1461
+ for ext in extensions:
1462
+ code_files.extend(code_path.rglob(f"*{ext}"))
1463
+
1464
+ # Filter out common exclusions
1465
+ exclude_patterns = ["__pycache__", "node_modules", ".venv", "venv", "dist", "build"]
1466
+ code_files = [
1467
+ f for f in code_files
1468
+ if not any(ex in str(f) for ex in exclude_patterns)
1469
+ ]
1470
+
1471
+ if not code_files:
1472
+ click.echo(f"No code files found in {code_path}")
1473
+ return
1474
+
1475
+ click.echo(f"Found {len(code_files)} code files")
1476
+ click.echo(f"Output: {output}/")
1477
+ click.echo(f"{'─' * 50}")
1478
+
1479
+ output.mkdir(parents=True, exist_ok=True)
1480
+ generated = 0
1481
+
1482
+ for code_file in code_files:
1483
+ try:
1484
+ content = code_file.read_text()
1485
+ except Exception:
1486
+ continue
1487
+
1488
+ relative = code_file.relative_to(code_path)
1489
+ doc_path = output / relative.with_suffix(".md")
1490
+
1491
+ # Parse based on extension
1492
+ if code_file.suffix == ".py":
1493
+ doc_content = generate_python_stub(code_file, content, include_private)
1494
+ elif code_file.suffix in [".ts", ".tsx", ".js", ".jsx"]:
1495
+ doc_content = generate_typescript_stub(code_file, content, include_private)
1496
+ else:
1497
+ continue
1498
+
1499
+ if doc_content:
1500
+ doc_path.parent.mkdir(parents=True, exist_ok=True)
1501
+ doc_path.write_text(doc_content)
1502
+ try:
1503
+ doc_display = doc_path.relative_to(Path.cwd())
1504
+ except ValueError:
1505
+ doc_display = doc_path
1506
+ click.echo(f" ✓ {relative} → {doc_display}")
1507
+ generated += 1
1508
+
1509
+ click.echo(f"\n{'─' * 50}")
1510
+ click.echo(f"✓ Generated {generated} documentation stubs")
1511
+ click.echo(f"\nNext steps:")
1512
+ click.echo(f" 1. Fill in the TODO placeholders")
1513
+ click.echo(f" 2. Or use /generate-docs for AI-generated content")
1514
+ click.echo(f" 3. Run 'ragtime index' to make searchable")
1515
+
1516
+
1517
+ def generate_python_stub(file_path: Path, content: str, include_private: bool) -> str:
1518
+ """Generate markdown stub from Python code."""
1519
+ import ast
1520
+
1521
+ try:
1522
+ tree = ast.parse(content)
1523
+ except SyntaxError:
1524
+ return ""
1525
+
1526
+ lines = []
1527
+ lines.append(f"# {file_path.stem}")
1528
+ lines.append(f"\n> **File:** `{file_path}`")
1529
+ lines.append("\n## Overview\n")
1530
+ lines.append("TODO: Describe what this module does.\n")
1531
+
1532
+ # Get module docstring
1533
+ if ast.get_docstring(tree):
1534
+ lines.append(f"> {ast.get_docstring(tree)}\n")
1535
+
1536
+ classes = []
1537
+ functions = []
1538
+
1539
+ for node in ast.iter_child_nodes(tree):
1540
+ if isinstance(node, ast.ClassDef):
1541
+ if not include_private and node.name.startswith("_"):
1542
+ continue
1543
+ classes.append(node)
1544
+ elif isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef):
1545
+ if not include_private and node.name.startswith("_"):
1546
+ continue
1547
+ functions.append(node)
1548
+
1549
+ # Document classes
1550
+ if classes:
1551
+ lines.append("---\n")
1552
+ lines.append("## Classes\n")
1553
+
1554
+ for cls in classes:
1555
+ lines.append(f"### `{cls.name}`\n")
1556
+ if ast.get_docstring(cls):
1557
+ lines.append(f"{ast.get_docstring(cls)}\n")
1558
+ else:
1559
+ lines.append("TODO: Describe this class.\n")
1560
+
1561
+ # Find __init__ and methods
1562
+ methods = []
1563
+ init_node = None
1564
+ for item in cls.body:
1565
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
1566
+ if item.name == "__init__":
1567
+ init_node = item
1568
+ elif not item.name.startswith("_") or include_private:
1569
+ methods.append(item)
1570
+
1571
+ # Constructor
1572
+ if init_node:
1573
+ lines.append("#### Constructor\n")
1574
+ sig = get_function_signature(init_node)
1575
+ lines.append(f"```python\n{sig}\n```\n")
1576
+ params = get_function_params(init_node)
1577
+ if params:
1578
+ lines.append("| Parameter | Type | Default | Description |")
1579
+ lines.append("|-----------|------|---------|-------------|")
1580
+ for p in params:
1581
+ lines.append(f"| `{p['name']}` | `{p['type']}` | {p['default']} | TODO |")
1582
+ lines.append("")
1583
+
1584
+ # Methods
1585
+ if methods:
1586
+ lines.append("#### Methods\n")
1587
+ for method in methods:
1588
+ async_prefix = "async " if isinstance(method, ast.AsyncFunctionDef) else ""
1589
+ ret = get_return_annotation(method)
1590
+ lines.append(f"##### `{async_prefix}{method.name}(...) -> {ret}`\n")
1591
+ if ast.get_docstring(method):
1592
+ lines.append(f"{ast.get_docstring(method)}\n")
1593
+ else:
1594
+ lines.append("TODO: Describe this method.\n")
1595
+
1596
+ # Document functions
1597
+ if functions:
1598
+ lines.append("---\n")
1599
+ lines.append("## Functions\n")
1600
+
1601
+ for func in functions:
1602
+ async_prefix = "async " if isinstance(func, ast.AsyncFunctionDef) else ""
1603
+ ret = get_return_annotation(func)
1604
+ lines.append(f"### `{async_prefix}{func.name}(...) -> {ret}`\n")
1605
+ if ast.get_docstring(func):
1606
+ lines.append(f"{ast.get_docstring(func)}\n")
1607
+ else:
1608
+ lines.append("TODO: Describe this function.\n")
1609
+
1610
+ params = get_function_params(func)
1611
+ if params:
1612
+ lines.append("**Parameters:**\n")
1613
+ for p in params:
1614
+ lines.append(f"- `{p['name']}` (`{p['type']}`): TODO")
1615
+ lines.append("")
1616
+
1617
+ lines.append(f"**Returns:** `{ret}` - TODO\n")
1618
+
1619
+ return "\n".join(lines)
1620
+
1621
+
1622
+ def get_function_signature(node) -> str:
1623
+ """Get function signature string."""
1624
+ import ast
1625
+
1626
+ args = []
1627
+ for arg in node.args.args:
1628
+ if arg.arg == "self":
1629
+ continue
1630
+ type_hint = ""
1631
+ if arg.annotation:
1632
+ type_hint = f": {ast.unparse(arg.annotation)}"
1633
+ args.append(f"{arg.arg}{type_hint}")
1634
+
1635
+ return f"def {node.name}({', '.join(args)})"
1636
+
1637
+
1638
+ def get_function_params(node) -> list:
1639
+ """Get function parameters with types and defaults."""
1640
+ import ast
1641
+
1642
+ params = []
1643
+ defaults = node.args.defaults
1644
+ num_defaults = len(defaults)
1645
+ num_args = len(node.args.args)
1646
+
1647
+ for i, arg in enumerate(node.args.args):
1648
+ if arg.arg in ("self", "cls"):
1649
+ continue
1650
+
1651
+ type_hint = "Any"
1652
+ if arg.annotation:
1653
+ try:
1654
+ type_hint = ast.unparse(arg.annotation)
1655
+ except:
1656
+ type_hint = "Any"
1657
+
1658
+ default = "-"
1659
+ default_idx = i - (num_args - num_defaults)
1660
+ if default_idx >= 0 and default_idx < len(defaults):
1661
+ try:
1662
+ default = f"`{ast.unparse(defaults[default_idx])}`"
1663
+ except:
1664
+ default = "..."
1665
+
1666
+ params.append({
1667
+ "name": arg.arg,
1668
+ "type": type_hint,
1669
+ "default": default,
1670
+ })
1671
+
1672
+ return params
1673
+
1674
+
1675
+ def get_return_annotation(node) -> str:
1676
+ """Get return type annotation."""
1677
+ import ast
1678
+
1679
+ if node.returns:
1680
+ try:
1681
+ return ast.unparse(node.returns)
1682
+ except:
1683
+ return "Any"
1684
+ return "None"
1685
+
1686
+
1687
+ def generate_typescript_stub(file_path: Path, content: str, include_private: bool) -> str:
1688
+ """Generate markdown stub from TypeScript/JavaScript code."""
1689
+ import re as re_module
1690
+
1691
+ lines = []
1692
+ lines.append(f"# {file_path.stem}")
1693
+ lines.append(f"\n> **File:** `{file_path}`")
1694
+ lines.append("\n## Overview\n")
1695
+ lines.append("TODO: Describe what this module does.\n")
1696
+
1697
+ # Find exports using regex
1698
+ class_pattern = r'export\s+(?:default\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?'
1699
+ func_pattern = r'export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)(?:\s*:\s*([^\{]+))?'
1700
+ const_pattern = r'export\s+const\s+(\w+)\s*(?::\s*([^=]+))?\s*='
1701
+ interface_pattern = r'export\s+(?:default\s+)?interface\s+(\w+)'
1702
+ type_pattern = r'export\s+type\s+(\w+)'
1703
+
1704
+ classes = re_module.findall(class_pattern, content)
1705
+ functions = re_module.findall(func_pattern, content)
1706
+ consts = re_module.findall(const_pattern, content)
1707
+ interfaces = re_module.findall(interface_pattern, content)
1708
+ types = re_module.findall(type_pattern, content)
1709
+
1710
+ # Interfaces and Types
1711
+ if interfaces or types:
1712
+ lines.append("---\n")
1713
+ lines.append("## Types\n")
1714
+ for iface in interfaces:
1715
+ lines.append(f"### `interface {iface}`\n")
1716
+ lines.append("TODO: Describe this interface.\n")
1717
+ for t in types:
1718
+ lines.append(f"### `type {t}`\n")
1719
+ lines.append("TODO: Describe this type.\n")
1720
+
1721
+ # Classes
1722
+ if classes:
1723
+ lines.append("---\n")
1724
+ lines.append("## Classes\n")
1725
+ for cls_name, extends in classes:
1726
+ lines.append(f"### `{cls_name}`")
1727
+ if extends:
1728
+ lines.append(f" extends `{extends}`")
1729
+ lines.append("\n")
1730
+ lines.append("TODO: Describe this class.\n")
1731
+
1732
+ # Functions
1733
+ if functions:
1734
+ lines.append("---\n")
1735
+ lines.append("## Functions\n")
1736
+ for func_name, params, return_type in functions:
1737
+ ret = return_type.strip() if return_type else "void"
1738
+ lines.append(f"### `{func_name}({params}) => {ret}`\n")
1739
+ lines.append("TODO: Describe this function.\n")
1740
+
1741
+ # Constants
1742
+ if consts:
1743
+ lines.append("---\n")
1744
+ lines.append("## Constants\n")
1745
+ lines.append("| Name | Type | Description |")
1746
+ lines.append("|------|------|-------------|")
1747
+ for const_name, const_type in consts:
1748
+ t = const_type.strip() if const_type else "unknown"
1749
+ lines.append(f"| `{const_name}` | `{t}` | TODO |")
1750
+ lines.append("")
1751
+
1752
+ if len(lines) <= 5: # Only header
1753
+ return ""
1754
+
1755
+ return "\n".join(lines)
1756
+
1757
+
1108
1758
  @main.command()
1109
1759
  @click.argument("docs_path", type=click.Path(exists=True, path_type=Path), default="docs")
1110
1760
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
@@ -1300,7 +1950,7 @@ def update(check: bool):
1300
1950
  from urllib.request import urlopen
1301
1951
  from urllib.error import URLError
1302
1952
 
1303
- current = "0.2.2"
1953
+ current = "0.2.4"
1304
1954
 
1305
1955
  click.echo(f"Current version: {current}")
1306
1956
  click.echo("Checking PyPI for updates...")