cicada-mcp 0.1.7__py3-none-any.whl → 0.2.0__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.
Files changed (53) hide show
  1. cicada/ascii_art.py +60 -0
  2. cicada/clean.py +195 -60
  3. cicada/cli.py +757 -0
  4. cicada/colors.py +27 -0
  5. cicada/command_logger.py +14 -16
  6. cicada/dead_code_analyzer.py +12 -19
  7. cicada/extractors/__init__.py +6 -6
  8. cicada/extractors/base.py +3 -3
  9. cicada/extractors/call.py +11 -15
  10. cicada/extractors/dependency.py +39 -51
  11. cicada/extractors/doc.py +8 -9
  12. cicada/extractors/function.py +12 -24
  13. cicada/extractors/module.py +11 -15
  14. cicada/extractors/spec.py +8 -12
  15. cicada/find_dead_code.py +15 -39
  16. cicada/formatter.py +37 -91
  17. cicada/git_helper.py +22 -34
  18. cicada/indexer.py +122 -107
  19. cicada/interactive_setup.py +490 -0
  20. cicada/keybert_extractor.py +286 -0
  21. cicada/keyword_search.py +22 -30
  22. cicada/keyword_test.py +127 -0
  23. cicada/lightweight_keyword_extractor.py +5 -13
  24. cicada/mcp_entry.py +683 -0
  25. cicada/mcp_server.py +103 -209
  26. cicada/parser.py +9 -9
  27. cicada/pr_finder.py +15 -19
  28. cicada/pr_indexer/__init__.py +3 -3
  29. cicada/pr_indexer/cli.py +4 -9
  30. cicada/pr_indexer/github_api_client.py +22 -37
  31. cicada/pr_indexer/indexer.py +17 -29
  32. cicada/pr_indexer/line_mapper.py +8 -12
  33. cicada/pr_indexer/pr_index_builder.py +22 -34
  34. cicada/setup.py +189 -87
  35. cicada/utils/__init__.py +9 -9
  36. cicada/utils/call_site_formatter.py +4 -6
  37. cicada/utils/function_grouper.py +4 -4
  38. cicada/utils/hash_utils.py +12 -15
  39. cicada/utils/index_utils.py +15 -15
  40. cicada/utils/path_utils.py +24 -29
  41. cicada/utils/signature_builder.py +3 -3
  42. cicada/utils/subprocess_runner.py +17 -19
  43. cicada/utils/text_utils.py +1 -2
  44. cicada/version_check.py +2 -5
  45. {cicada_mcp-0.1.7.dist-info → cicada_mcp-0.2.0.dist-info}/METADATA +144 -55
  46. cicada_mcp-0.2.0.dist-info/RECORD +53 -0
  47. cicada_mcp-0.2.0.dist-info/entry_points.txt +4 -0
  48. cicada/install.py +0 -741
  49. cicada_mcp-0.1.7.dist-info/RECORD +0 -47
  50. cicada_mcp-0.1.7.dist-info/entry_points.txt +0 -9
  51. {cicada_mcp-0.1.7.dist-info → cicada_mcp-0.2.0.dist-info}/WHEEL +0 -0
  52. {cicada_mcp-0.1.7.dist-info → cicada_mcp-0.2.0.dist-info}/licenses/LICENSE +0 -0
  53. {cicada_mcp-0.1.7.dist-info → cicada_mcp-0.2.0.dist-info}/top_level.txt +0 -0
cicada/cli.py ADDED
@@ -0,0 +1,757 @@
1
+ """
2
+ Unified CLI entry point for Cicada.
3
+
4
+ Provides a single `cicada` command with multiple subcommands:
5
+ - cicada [path] - Setup/install Cicada for a project
6
+ - cicada claude - Setup Cicada for Claude Code editor
7
+ - cicada cursor - Setup Cicada for Cursor editor
8
+ - cicada vs - Setup Cicada for VS Code editor
9
+ - cicada index - Index an Elixir repository
10
+ - cicada index-pr - Index GitHub pull requests
11
+ - cicada find-dead-code - Find potentially unused functions
12
+ - cicada clean - Remove Cicada configuration and indexes
13
+ """
14
+
15
+ import argparse
16
+ import sys
17
+
18
+
19
+ def main():
20
+ """Main entry point for the unified cicada CLI."""
21
+ # Pre-process arguments for backward compatibility
22
+ # If first arg is not a known subcommand and looks like a path, inject "install"
23
+ if len(sys.argv) > 1:
24
+ first_arg = sys.argv[1]
25
+ known_commands = [
26
+ "install",
27
+ "server",
28
+ "claude",
29
+ "cursor",
30
+ "vs",
31
+ "index",
32
+ "index-pr",
33
+ "find-dead-code",
34
+ "clean",
35
+ ]
36
+ # If first arg is not a known command and not a help flag, treat as path for install
37
+ if first_arg not in known_commands and not first_arg.startswith("-"):
38
+ # Insert 'install' as the subcommand
39
+ sys.argv.insert(1, "install")
40
+
41
+ parser = argparse.ArgumentParser(
42
+ prog="cicada",
43
+ description="Cicada - AI-powered Elixir code analysis and search",
44
+ epilog="Run 'cicada <command> --help' for more information on a command.",
45
+ )
46
+
47
+ # Create subparsers for commands
48
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
49
+
50
+ # ========================================================================
51
+ # INSTALL subcommand - Interactive setup
52
+ # ========================================================================
53
+ install_parser = subparsers.add_parser(
54
+ "install",
55
+ help="Interactive setup for Cicada",
56
+ description="Interactive setup with editor and model selection",
57
+ )
58
+ install_parser.add_argument(
59
+ "repo",
60
+ nargs="?",
61
+ default=None,
62
+ help="Path to Elixir repository (default: current directory)",
63
+ )
64
+ install_parser.add_argument(
65
+ "--claude",
66
+ action="store_true",
67
+ help="Skip editor selection, use Claude Code",
68
+ )
69
+ install_parser.add_argument(
70
+ "--cursor",
71
+ action="store_true",
72
+ help="Skip editor selection, use Cursor",
73
+ )
74
+ install_parser.add_argument(
75
+ "--vs",
76
+ action="store_true",
77
+ help="Skip editor selection, use VS Code",
78
+ )
79
+ install_parser.add_argument(
80
+ "--nlp",
81
+ action="store_true",
82
+ help="Skip model selection, use Lemminflect",
83
+ )
84
+ install_parser.add_argument(
85
+ "--rag",
86
+ action="store_true",
87
+ help="Skip model selection, use BERT (default tier)",
88
+ )
89
+ install_parser.add_argument(
90
+ "--fast",
91
+ action="store_true",
92
+ help="Use BERT fast tier (requires --rag)",
93
+ )
94
+ install_parser.add_argument(
95
+ "--max",
96
+ action="store_true",
97
+ help="Use BERT max tier (requires --rag)",
98
+ )
99
+
100
+ # ========================================================================
101
+ # SERVER subcommand - Silent MCP server
102
+ # ========================================================================
103
+ server_parser = subparsers.add_parser(
104
+ "server",
105
+ help="Start MCP server (silent mode with defaults)",
106
+ description="Start MCP server with auto-setup using defaults",
107
+ )
108
+ server_parser.add_argument(
109
+ "repo",
110
+ nargs="?",
111
+ default=None,
112
+ help="Path to Elixir repository (default: current directory)",
113
+ )
114
+ server_parser.add_argument(
115
+ "--claude",
116
+ action="store_true",
117
+ help="Create Claude Code config before starting server",
118
+ )
119
+ server_parser.add_argument(
120
+ "--cursor",
121
+ action="store_true",
122
+ help="Create Cursor config before starting server",
123
+ )
124
+ server_parser.add_argument(
125
+ "--vs",
126
+ action="store_true",
127
+ help="Create VS Code config before starting server",
128
+ )
129
+ server_parser.add_argument(
130
+ "--nlp",
131
+ action="store_true",
132
+ help="Force Lemminflect (if reindexing needed)",
133
+ )
134
+ server_parser.add_argument(
135
+ "--rag",
136
+ action="store_true",
137
+ help="Force BERT (if reindexing needed)",
138
+ )
139
+ server_parser.add_argument(
140
+ "--fast",
141
+ action="store_true",
142
+ help="Force BERT fast tier (requires --rag)",
143
+ )
144
+ server_parser.add_argument(
145
+ "--max",
146
+ action="store_true",
147
+ help="Force BERT max tier (requires --rag)",
148
+ )
149
+
150
+ # ========================================================================
151
+ # CLAUDE subcommand (editor setup)
152
+ # ========================================================================
153
+ claude_parser = subparsers.add_parser(
154
+ "claude",
155
+ help="Setup Cicada for Claude Code editor",
156
+ description="One-command setup for Claude Code with keyword extraction",
157
+ )
158
+ claude_parser.add_argument(
159
+ "--nlp",
160
+ action="store_true",
161
+ help="Use NLP keyword extraction (lemminflect-based)",
162
+ )
163
+ claude_parser.add_argument(
164
+ "--rag",
165
+ action="store_true",
166
+ help="Use RAG-optimized keyword extraction (BERT-based embeddings)",
167
+ )
168
+ claude_parser.add_argument(
169
+ "--fast",
170
+ action="store_true",
171
+ help="Use fast tier model (requires --rag)",
172
+ )
173
+ claude_parser.add_argument(
174
+ "--max",
175
+ action="store_true",
176
+ help="Use maximum quality tier model (requires --rag)",
177
+ )
178
+
179
+ # ========================================================================
180
+ # CURSOR subcommand (editor setup)
181
+ # ========================================================================
182
+ cursor_parser = subparsers.add_parser(
183
+ "cursor",
184
+ help="Setup Cicada for Cursor editor",
185
+ description="One-command setup for Cursor with keyword extraction",
186
+ )
187
+ cursor_parser.add_argument(
188
+ "--nlp",
189
+ action="store_true",
190
+ help="Use NLP keyword extraction (lemminflect-based)",
191
+ )
192
+ cursor_parser.add_argument(
193
+ "--rag",
194
+ action="store_true",
195
+ help="Use RAG-optimized keyword extraction (BERT-based embeddings)",
196
+ )
197
+ cursor_parser.add_argument(
198
+ "--fast",
199
+ action="store_true",
200
+ help="Use fast tier model (requires --rag)",
201
+ )
202
+ cursor_parser.add_argument(
203
+ "--max",
204
+ action="store_true",
205
+ help="Use maximum quality tier model (requires --rag)",
206
+ )
207
+
208
+ # ========================================================================
209
+ # VS subcommand (editor setup)
210
+ # ========================================================================
211
+ vs_parser = subparsers.add_parser(
212
+ "vs",
213
+ help="Setup Cicada for VS Code editor",
214
+ description="One-command setup for VS Code with keyword extraction",
215
+ )
216
+ vs_parser.add_argument(
217
+ "--nlp",
218
+ action="store_true",
219
+ help="Use NLP keyword extraction (lemminflect-based)",
220
+ )
221
+ vs_parser.add_argument(
222
+ "--rag",
223
+ action="store_true",
224
+ help="Use RAG-optimized keyword extraction (BERT-based embeddings)",
225
+ )
226
+ vs_parser.add_argument(
227
+ "--fast",
228
+ action="store_true",
229
+ help="Use fast tier model (requires --rag)",
230
+ )
231
+ vs_parser.add_argument(
232
+ "--max",
233
+ action="store_true",
234
+ help="Use maximum quality tier model (requires --rag)",
235
+ )
236
+
237
+ # ========================================================================
238
+ # INDEX subcommand
239
+ # ========================================================================
240
+ index_parser = subparsers.add_parser(
241
+ "index",
242
+ help="Index an Elixir repository to extract modules and functions",
243
+ description="Index current Elixir repository to extract modules and functions",
244
+ )
245
+ index_parser.add_argument(
246
+ "repo",
247
+ nargs="?",
248
+ default=".",
249
+ help="Path to the Elixir repository to index (default: current directory)",
250
+ )
251
+ index_parser.add_argument(
252
+ "--nlp",
253
+ action="store_true",
254
+ help="Use NLP keyword extraction (lemminflect-based)",
255
+ )
256
+ index_parser.add_argument(
257
+ "--rag",
258
+ action="store_true",
259
+ help="Use RAG-optimized keyword extraction (BERT-based embeddings)",
260
+ )
261
+ index_parser.add_argument(
262
+ "--fast",
263
+ action="store_true",
264
+ help="Use fast tier model (requires --rag)",
265
+ )
266
+ index_parser.add_argument(
267
+ "--max",
268
+ action="store_true",
269
+ help="Use maximum quality tier model (requires --rag)",
270
+ )
271
+ index_parser.add_argument(
272
+ "--test",
273
+ action="store_true",
274
+ help="Start interactive keyword extraction test mode",
275
+ )
276
+
277
+ # ========================================================================
278
+ # INDEX-PR subcommand
279
+ # ========================================================================
280
+ index_pr_parser = subparsers.add_parser(
281
+ "index-pr",
282
+ help="Index GitHub pull requests for fast offline lookup",
283
+ description="Index GitHub pull requests for fast offline lookup",
284
+ )
285
+ index_pr_parser.add_argument(
286
+ "repo",
287
+ nargs="?",
288
+ default=".",
289
+ help="Path to git repository (default: current directory)",
290
+ )
291
+ index_pr_parser.add_argument(
292
+ "--clean",
293
+ action="store_true",
294
+ help="Clean and rebuild the entire index from scratch (default: incremental update)",
295
+ )
296
+
297
+ # ========================================================================
298
+ # FIND-DEAD-CODE subcommand
299
+ # ========================================================================
300
+ dead_code_parser = subparsers.add_parser(
301
+ "find-dead-code",
302
+ help="Find potentially unused public functions in Elixir codebase",
303
+ description="Find potentially unused public functions in Elixir codebase",
304
+ formatter_class=argparse.RawDescriptionHelpFormatter,
305
+ epilog="""
306
+ Confidence Levels:
307
+ high - Zero usage, no dynamic call indicators, no behaviors/uses
308
+ medium - Zero usage, but module has behaviors or uses (possible callbacks)
309
+ low - Zero usage, but module passed as value (possible dynamic calls)
310
+
311
+ Examples:
312
+ cicada find-dead-code # Show high confidence candidates
313
+ cicada find-dead-code --min-confidence low # Show all candidates
314
+ cicada find-dead-code --format json # Output as JSON
315
+ """,
316
+ )
317
+ dead_code_parser.add_argument(
318
+ "--format",
319
+ choices=["markdown", "json"],
320
+ default="markdown",
321
+ help="Output format (default: markdown)",
322
+ )
323
+ dead_code_parser.add_argument(
324
+ "--min-confidence",
325
+ choices=["high", "medium", "low"],
326
+ default="high",
327
+ help="Minimum confidence level to show (default: high)",
328
+ )
329
+
330
+ # ========================================================================
331
+ # CLEAN subcommand
332
+ # ========================================================================
333
+ clean_parser = subparsers.add_parser(
334
+ "clean",
335
+ help="Remove Cicada configuration and indexes",
336
+ description="Remove Cicada configuration and indexes for current repository",
337
+ formatter_class=argparse.RawDescriptionHelpFormatter,
338
+ epilog="""
339
+ Examples:
340
+ cicada clean # Remove everything (interactive with confirmation)
341
+ cicada clean -f # Remove everything (skip confirmation)
342
+ cicada clean --index # Remove main index (index.json, hashes.json)
343
+ cicada clean --pr-index # Remove PR index (pr_index.json)
344
+ cicada clean --all # Remove ALL project storage
345
+ cicada clean --all -f # Remove ALL project storage (skip confirmation)
346
+ """,
347
+ )
348
+ clean_parser.add_argument(
349
+ "-f",
350
+ "--force",
351
+ action="store_true",
352
+ help="Skip confirmation prompt (for full clean or --all)",
353
+ )
354
+ clean_parser.add_argument(
355
+ "--index",
356
+ action="store_true",
357
+ help="Remove only main index files (index.json, hashes.json)",
358
+ )
359
+ clean_parser.add_argument(
360
+ "--pr-index",
361
+ action="store_true",
362
+ help="Remove only PR index file (pr_index.json)",
363
+ )
364
+ clean_parser.add_argument(
365
+ "--all",
366
+ action="store_true",
367
+ help="Remove ALL Cicada storage for all projects (~/.cicada/projects/)",
368
+ )
369
+
370
+ # Parse arguments - now simplified with pre-processing above
371
+ args = parser.parse_args()
372
+
373
+ # Route to appropriate handler
374
+ if args.command == "install":
375
+ handle_install_command(args)
376
+ elif args.command == "server":
377
+ handle_server_command(args)
378
+ elif args.command == "claude":
379
+ handle_editor_setup(args, "claude")
380
+ elif args.command == "cursor":
381
+ handle_editor_setup(args, "cursor")
382
+ elif args.command == "vs":
383
+ handle_editor_setup(args, "vs")
384
+ elif args.command == "index":
385
+ handle_index(args)
386
+ elif args.command == "index-pr":
387
+ handle_index_pr(args)
388
+ elif args.command == "find-dead-code":
389
+ handle_find_dead_code(args)
390
+ elif args.command == "clean":
391
+ handle_clean(args)
392
+ elif args.command is None:
393
+ # No subcommand and no path - show help
394
+ parser.print_help()
395
+ sys.exit(1)
396
+
397
+
398
+ def handle_install_command(args):
399
+ """Handle the explicit install subcommand."""
400
+ from cicada.mcp_entry import handle_install
401
+
402
+ handle_install(args)
403
+
404
+
405
+ def handle_server_command(args):
406
+ """Handle the server subcommand (silent MCP server with optional configs)."""
407
+ from cicada.mcp_entry import handle_server
408
+
409
+ handle_server(args)
410
+
411
+
412
+ def handle_editor_setup(args, editor: str):
413
+ """Handle editor setup subcommands (claude, cursor, vs)."""
414
+ from pathlib import Path
415
+ from typing import cast
416
+
417
+ from cicada.setup import EditorType, setup
418
+
419
+ # Validate that --fast or --max requires --rag
420
+ if (args.fast or args.max) and not args.rag:
421
+ print("Error: --fast or --max requires --rag", file=sys.stderr)
422
+ sys.exit(1)
423
+
424
+ # Both --nlp and --rag cannot be specified
425
+ if args.nlp and args.rag:
426
+ print("Error: Cannot specify both --nlp and --rag", file=sys.stderr)
427
+ sys.exit(1)
428
+
429
+ # Use current directory as repo path
430
+ repo_path = Path.cwd()
431
+
432
+ # Check if it's an Elixir repository
433
+ if not (repo_path / "mix.exs").exists():
434
+ print(f"Error: {repo_path} does not appear to be an Elixir project", file=sys.stderr)
435
+ print("(mix.exs not found)", file=sys.stderr)
436
+ sys.exit(1)
437
+
438
+ # Determine keyword extraction method and tier from flags
439
+ keyword_method = None
440
+ keyword_tier = None
441
+
442
+ if args.nlp:
443
+ keyword_method = "lemminflect"
444
+ keyword_tier = "regular" # Lemminflect only has one tier
445
+ elif args.rag:
446
+ keyword_method = "bert"
447
+ # Determine tier from flags
448
+ if args.fast:
449
+ keyword_tier = "fast"
450
+ elif args.max:
451
+ keyword_tier = "max"
452
+ else:
453
+ keyword_tier = "regular" # Default for bert
454
+
455
+ # If no flags provided, check if index already exists
456
+ index_exists = False
457
+ if keyword_method is None:
458
+ from cicada.utils.storage import get_config_path, get_index_path
459
+
460
+ config_path = get_config_path(repo_path)
461
+ index_path = get_index_path(repo_path)
462
+
463
+ if config_path.exists() and index_path.exists():
464
+ # Index exists - read existing settings and mark index_exists
465
+ import yaml
466
+
467
+ try:
468
+ with open(config_path) as f:
469
+ existing_config = yaml.safe_load(f)
470
+ keyword_method = existing_config.get("keyword_extraction", {}).get(
471
+ "method", "lemminflect"
472
+ )
473
+ keyword_tier = existing_config.get("keyword_extraction", {}).get(
474
+ "tier", "regular"
475
+ )
476
+ index_exists = True
477
+ except Exception:
478
+ # If we can't read config, proceed with defaults
479
+ pass
480
+
481
+ # Run setup
482
+ try:
483
+ setup(
484
+ cast(EditorType, editor),
485
+ repo_path,
486
+ keyword_method=keyword_method,
487
+ keyword_tier=keyword_tier,
488
+ index_exists=index_exists,
489
+ )
490
+ except Exception as e:
491
+ print(f"\nError: Setup failed: {e}", file=sys.stderr)
492
+ sys.exit(1)
493
+
494
+
495
+ def handle_index(args):
496
+ """Handle the index subcommand."""
497
+ from pathlib import Path
498
+
499
+ from cicada.indexer import ElixirIndexer
500
+ from cicada.utils.storage import get_config_path
501
+ from cicada.version_check import check_for_updates
502
+
503
+ # Check for updates (non-blocking, fails silently)
504
+ check_for_updates()
505
+
506
+ # Handle --test mode (interactive keyword extraction testing)
507
+ if args.test:
508
+ # Validate that --fast or --max requires --rag
509
+ if (args.fast or args.max) and not args.rag:
510
+ print("Error: --fast or --max requires --rag", file=sys.stderr)
511
+ sys.exit(1)
512
+
513
+ # Both --nlp and --rag cannot be specified
514
+ if args.nlp and args.rag:
515
+ print("Error: Cannot specify both --nlp and --rag", file=sys.stderr)
516
+ sys.exit(1)
517
+
518
+ # Determine method and tier
519
+ if args.nlp:
520
+ method = "lemminflect"
521
+ tier = "regular"
522
+ elif args.rag:
523
+ method = "bert"
524
+ if args.fast:
525
+ tier = "fast"
526
+ elif args.max:
527
+ tier = "max"
528
+ else:
529
+ tier = "regular"
530
+ else:
531
+ # Default to lemminflect if no method specified
532
+ method = "lemminflect"
533
+ tier = "regular"
534
+
535
+ # Start interactive test mode
536
+ from cicada.keyword_test import run_keywords_interactive
537
+
538
+ run_keywords_interactive(method=method, tier=tier)
539
+ return
540
+
541
+ # Validate that --fast or --max requires --rag
542
+ if (args.fast or args.max) and not args.rag:
543
+ print("Error: --fast or --max requires --rag", file=sys.stderr)
544
+ sys.exit(1)
545
+
546
+ # Both --nlp and --rag cannot be specified
547
+ if args.nlp and args.rag:
548
+ print("Error: Cannot specify both --nlp and --rag", file=sys.stderr)
549
+ sys.exit(1)
550
+
551
+ # Check if config.yaml exists to determine if we need interactive setup
552
+ repo_path_obj = Path(args.repo).resolve()
553
+ config_path = get_config_path(repo_path_obj)
554
+ config_exists = config_path.exists()
555
+
556
+ # Use centralized storage paths
557
+ from cicada.utils.storage import create_storage_dir, get_index_path
558
+
559
+ storage_dir = create_storage_dir(repo_path_obj)
560
+ index_path = get_index_path(repo_path_obj)
561
+
562
+ # Determine keyword extraction method and tier
563
+ keyword_method = None
564
+ keyword_tier = None
565
+
566
+ # If flags provided, update config with new settings
567
+ if args.nlp or args.rag:
568
+ # User explicitly specified extraction method via flags
569
+ from cicada.setup import create_config_yaml
570
+
571
+ # Determine method and tier from flags
572
+ if args.nlp:
573
+ keyword_method = "lemminflect"
574
+ keyword_tier = "regular"
575
+ else: # args.rag
576
+ keyword_method = "bert"
577
+ if args.fast:
578
+ keyword_tier = "fast"
579
+ elif args.max:
580
+ keyword_tier = "max"
581
+ else:
582
+ keyword_tier = "regular"
583
+
584
+ # Warn if changing existing config
585
+ if config_exists:
586
+ import yaml
587
+
588
+ try:
589
+ with open(config_path) as f:
590
+ existing_config = yaml.safe_load(f)
591
+ existing_method = existing_config.get("keyword_extraction", {}).get(
592
+ "method", "lemminflect"
593
+ )
594
+ existing_tier = existing_config.get("keyword_extraction", {}).get(
595
+ "tier", "regular"
596
+ )
597
+
598
+ # Check if either method or tier has changed
599
+ method_changed = existing_method != keyword_method
600
+ tier_changed = existing_tier != keyword_tier
601
+
602
+ if method_changed or tier_changed:
603
+ # Build error message based on what changed
604
+ if method_changed and tier_changed:
605
+ change_desc = f"extraction method from {existing_method} to {keyword_method} and tier from {existing_tier} to {keyword_tier}"
606
+ elif method_changed:
607
+ change_desc = (
608
+ f"extraction method from {existing_method} to {keyword_method}"
609
+ )
610
+ else:
611
+ change_desc = f"tier from {existing_tier} to {keyword_tier}"
612
+
613
+ print(
614
+ f"Error: Cannot change {change_desc}",
615
+ file=sys.stderr,
616
+ )
617
+ print(
618
+ "\nTo reindex with different settings, first run:",
619
+ file=sys.stderr,
620
+ )
621
+ print(" cicada clean", file=sys.stderr)
622
+ print("\nThen run your index command again.", file=sys.stderr)
623
+ sys.exit(1)
624
+ except Exception:
625
+ pass # If we can't read config, just proceed
626
+
627
+ create_config_yaml(repo_path_obj, storage_dir, keyword_method, keyword_tier)
628
+ config_exists = True # Config now exists
629
+ elif not config_exists:
630
+ # No flags provided AND no config exists - print help and exit
631
+ print("Error: No keyword extraction method specified.", file=sys.stderr)
632
+ print("\nYou must specify either --nlp or --rag for keyword extraction:", file=sys.stderr)
633
+ print(" --nlp Use NLP keyword extraction (lemminflect-based)", file=sys.stderr)
634
+ print(" --rag Use RAG-optimized keyword extraction (BERT-based)", file=sys.stderr)
635
+ print("\nRun 'cicada index --help' for more information.", file=sys.stderr)
636
+ sys.exit(2)
637
+
638
+ # If config exists (or was just created), indexer will read it automatically
639
+ indexer = ElixirIndexer(verbose=True)
640
+ indexer.incremental_index_repository(
641
+ str(repo_path_obj),
642
+ str(index_path), # Use centralized storage path
643
+ extract_keywords=True, # Always extract keywords if we have a config
644
+ force_full=False,
645
+ )
646
+
647
+
648
+ def handle_index_pr(args):
649
+ """Handle the index-pr subcommand."""
650
+ from cicada.pr_indexer import PRIndexer
651
+ from cicada.utils import get_pr_index_path
652
+ from cicada.version_check import check_for_updates
653
+
654
+ # Check for updates (non-blocking, fails silently)
655
+ check_for_updates()
656
+
657
+ try:
658
+ # Always use centralized storage
659
+ output_path = str(get_pr_index_path(args.repo))
660
+
661
+ indexer = PRIndexer(repo_path=args.repo)
662
+ # Incremental by default, unless --clean is specified
663
+ indexer.index_repository(output_path=output_path, incremental=not args.clean)
664
+
665
+ print("\n✅ Indexing complete! You can now use the MCP tools for PR history lookups.")
666
+
667
+ except KeyboardInterrupt:
668
+ print("\n\n⚠️ Indexing interrupted by user.")
669
+ print("Partial index may have been saved. Run again to continue (incremental by default).")
670
+ sys.exit(130) # Standard exit code for SIGINT
671
+
672
+ except Exception as e:
673
+ print(f"Error: {e}", file=sys.stderr)
674
+ sys.exit(1)
675
+
676
+
677
+ def handle_find_dead_code(args):
678
+ """Handle the find-dead-code subcommand."""
679
+ from cicada.dead_code_analyzer import DeadCodeAnalyzer
680
+ from cicada.find_dead_code import filter_by_confidence, format_json, format_markdown
681
+ from cicada.utils import get_index_path, load_index
682
+
683
+ # Always use centralized storage
684
+ index_path = get_index_path(".")
685
+
686
+ if not index_path.exists():
687
+ print(f"Error: Index file not found: {index_path}", file=sys.stderr)
688
+ print("\nRun 'cicada index' first to create the index.", file=sys.stderr)
689
+ sys.exit(1)
690
+
691
+ try:
692
+ index = load_index(index_path, raise_on_error=True)
693
+ except Exception as e:
694
+ print(f"Error loading index: {e}", file=sys.stderr)
695
+ sys.exit(1)
696
+
697
+ assert index is not None, "Index should not be None after successful load"
698
+
699
+ # Run analysis
700
+ analyzer = DeadCodeAnalyzer(index)
701
+ results = analyzer.analyze()
702
+
703
+ # Filter by confidence
704
+ results = filter_by_confidence(results, args.min_confidence)
705
+
706
+ # Format output
707
+ output = format_json(results) if args.format == "json" else format_markdown(results)
708
+
709
+ print(output)
710
+
711
+
712
+ def handle_clean(args):
713
+ """Handle the clean subcommand."""
714
+ from pathlib import Path
715
+
716
+ from cicada.clean import (
717
+ clean_all_projects,
718
+ clean_index_only,
719
+ clean_pr_index_only,
720
+ clean_repository,
721
+ )
722
+
723
+ # Handle --all flag
724
+ if args.all:
725
+ try:
726
+ clean_all_projects(force=args.force)
727
+ except Exception as e:
728
+ print(f"\nError: Cleanup failed: {e}", file=sys.stderr)
729
+ sys.exit(1)
730
+ return
731
+
732
+ # Check for conflicting flags
733
+ flag_count = sum([args.index, args.pr_index])
734
+ if flag_count > 1:
735
+ print("Error: Cannot specify multiple clean options.", file=sys.stderr)
736
+ print("Choose only one: --index, --pr-index, or -f/--force", file=sys.stderr)
737
+ sys.exit(1)
738
+
739
+ # Clean current directory
740
+ repo_path = Path.cwd()
741
+
742
+ # Run cleanup based on flags
743
+ try:
744
+ if args.index:
745
+ clean_index_only(repo_path)
746
+ elif args.pr_index:
747
+ clean_pr_index_only(repo_path)
748
+ else:
749
+ # No specific flag - do full clean (with or without confirmation based on --force)
750
+ clean_repository(repo_path, force=args.force)
751
+ except Exception as e:
752
+ print(f"\nError: Cleanup failed: {e}", file=sys.stderr)
753
+ sys.exit(1)
754
+
755
+
756
+ if __name__ == "__main__":
757
+ main()