cicada-mcp 0.2.0__py3-none-any.whl → 0.3.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 (62) hide show
  1. cicada/_version_hash.py +4 -0
  2. cicada/cli.py +6 -748
  3. cicada/commands.py +1255 -0
  4. cicada/dead_code/__init__.py +1 -0
  5. cicada/{find_dead_code.py → dead_code/finder.py} +2 -1
  6. cicada/dependency_analyzer.py +147 -0
  7. cicada/entry_utils.py +92 -0
  8. cicada/extractors/base.py +9 -9
  9. cicada/extractors/call.py +17 -20
  10. cicada/extractors/common.py +64 -0
  11. cicada/extractors/dependency.py +117 -235
  12. cicada/extractors/doc.py +2 -49
  13. cicada/extractors/function.py +10 -14
  14. cicada/extractors/keybert.py +228 -0
  15. cicada/extractors/keyword.py +191 -0
  16. cicada/extractors/module.py +6 -10
  17. cicada/extractors/spec.py +8 -56
  18. cicada/format/__init__.py +20 -0
  19. cicada/{ascii_art.py → format/ascii_art.py} +1 -1
  20. cicada/format/formatter.py +1145 -0
  21. cicada/git_helper.py +134 -7
  22. cicada/indexer.py +322 -89
  23. cicada/interactive_setup.py +251 -323
  24. cicada/interactive_setup_helpers.py +302 -0
  25. cicada/keyword_expander.py +437 -0
  26. cicada/keyword_search.py +208 -422
  27. cicada/keyword_test.py +383 -16
  28. cicada/mcp/__init__.py +10 -0
  29. cicada/mcp/entry.py +17 -0
  30. cicada/mcp/filter_utils.py +107 -0
  31. cicada/mcp/pattern_utils.py +118 -0
  32. cicada/{mcp_server.py → mcp/server.py} +819 -73
  33. cicada/mcp/tools.py +473 -0
  34. cicada/pr_finder.py +2 -3
  35. cicada/pr_indexer/indexer.py +3 -2
  36. cicada/setup.py +167 -35
  37. cicada/tier.py +225 -0
  38. cicada/utils/__init__.py +9 -2
  39. cicada/utils/fuzzy_match.py +54 -0
  40. cicada/utils/index_utils.py +9 -0
  41. cicada/utils/path_utils.py +18 -0
  42. cicada/utils/text_utils.py +52 -1
  43. cicada/utils/tree_utils.py +47 -0
  44. cicada/version_check.py +99 -0
  45. cicada/watch_manager.py +320 -0
  46. cicada/watcher.py +431 -0
  47. cicada_mcp-0.3.0.dist-info/METADATA +541 -0
  48. cicada_mcp-0.3.0.dist-info/RECORD +70 -0
  49. cicada_mcp-0.3.0.dist-info/entry_points.txt +4 -0
  50. cicada/formatter.py +0 -864
  51. cicada/keybert_extractor.py +0 -286
  52. cicada/lightweight_keyword_extractor.py +0 -290
  53. cicada/mcp_entry.py +0 -683
  54. cicada/mcp_tools.py +0 -291
  55. cicada_mcp-0.2.0.dist-info/METADATA +0 -735
  56. cicada_mcp-0.2.0.dist-info/RECORD +0 -53
  57. cicada_mcp-0.2.0.dist-info/entry_points.txt +0 -4
  58. /cicada/{dead_code_analyzer.py → dead_code/analyzer.py} +0 -0
  59. /cicada/{colors.py → format/colors.py} +0 -0
  60. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/WHEEL +0 -0
  61. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/licenses/LICENSE +0 -0
  62. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/top_level.txt +0 -0
cicada/commands.py ADDED
@@ -0,0 +1,1255 @@
1
+ """
2
+ CLI Command Handlers - Centralizes argparse logic and all CLI command handlers.
3
+
4
+ This module defines the argument parser and individual handler functions for all
5
+ Cicada CLI commands. It aims to consolidate command-line interface logic,
6
+ making `cli.py` a thin entry point and `mcp_entry.py` focused solely on MCP server startup.
7
+ """
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ # Import tier resolution functions from centralized module
14
+ from cicada.tier import (
15
+ determine_tier,
16
+ get_extraction_expansion_methods,
17
+ tier_flag_specified,
18
+ validate_tier_flags,
19
+ )
20
+
21
+ # Default debounce interval for watch mode (in seconds)
22
+ DEFAULT_WATCH_DEBOUNCE = 2.0
23
+
24
+ KNOWN_SUBCOMMANDS: tuple[str, ...] = (
25
+ "install",
26
+ "server",
27
+ "claude",
28
+ "cursor",
29
+ "vs",
30
+ "gemini",
31
+ "codex",
32
+ "watch",
33
+ "index",
34
+ "index-pr",
35
+ "find-dead-code",
36
+ "clean",
37
+ "dir",
38
+ )
39
+ KNOWN_SUBCOMMANDS_SET = frozenset(KNOWN_SUBCOMMANDS)
40
+
41
+
42
+ def _setup_and_start_watcher(args, repo_path_str: str) -> None:
43
+ """Shared logic for starting file watcher.
44
+
45
+ Args:
46
+ args: Parsed command-line arguments
47
+ repo_path_str: Path to the repository as a string
48
+
49
+ Raises:
50
+ SystemExit: If configuration is invalid or watcher fails to start
51
+ """
52
+ from cicada.utils.storage import get_config_path
53
+ from cicada.watcher import FileWatcher
54
+
55
+ # Validate tier flags
56
+ validate_tier_flags(args, require_force=True)
57
+
58
+ # Resolve repository path
59
+ repo_path = Path(repo_path_str).resolve()
60
+ config_path = get_config_path(repo_path)
61
+
62
+ # Determine tier using helper
63
+ tier = determine_tier(args, repo_path)
64
+
65
+ # Check if config exists when no tier is specified
66
+ tier_specified = tier_flag_specified(args)
67
+ if not tier_specified and not config_path.exists():
68
+ _print_tier_requirement_error()
69
+ print("\nRun 'cicada watch --help' for more information.", file=sys.stderr)
70
+ sys.exit(2)
71
+
72
+ # Create and start watcher
73
+ try:
74
+ watcher = FileWatcher(
75
+ repo_path=str(repo_path),
76
+ debounce_seconds=getattr(args, "debounce", DEFAULT_WATCH_DEBOUNCE),
77
+ verbose=True,
78
+ tier=tier,
79
+ )
80
+ watcher.start_watching()
81
+ except KeyboardInterrupt:
82
+ print("\nWatch mode stopped by user.")
83
+ sys.exit(0)
84
+ except Exception as e:
85
+ print(f"Error: {e}", file=sys.stderr)
86
+ sys.exit(1)
87
+
88
+
89
+ def get_argument_parser():
90
+ parser = argparse.ArgumentParser(
91
+ prog="cicada",
92
+ description="Cicada - AI-powered Elixir code analysis and search",
93
+ epilog="Run 'cicada <command> --help' for more information on a command.",
94
+ )
95
+ parser.add_argument(
96
+ "-v",
97
+ "--version",
98
+ action="version",
99
+ version="%(prog)s version from subcommand",
100
+ help="Show version and commit hash",
101
+ )
102
+
103
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
104
+
105
+ install_parser = subparsers.add_parser(
106
+ "install",
107
+ help="Interactive setup for Cicada",
108
+ description="Interactive setup with editor and model selection",
109
+ )
110
+ install_parser.add_argument(
111
+ "repo",
112
+ nargs="?",
113
+ default=None,
114
+ help="Path to Elixir repository (default: current directory)",
115
+ )
116
+ install_parser.add_argument(
117
+ "--claude",
118
+ action="store_true",
119
+ help="Skip editor selection, use Claude Code",
120
+ )
121
+ install_parser.add_argument(
122
+ "--cursor",
123
+ action="store_true",
124
+ help="Skip editor selection, use Cursor",
125
+ )
126
+ install_parser.add_argument(
127
+ "--vs",
128
+ action="store_true",
129
+ help="Skip editor selection, use VS Code",
130
+ )
131
+ install_parser.add_argument(
132
+ "--gemini",
133
+ action="store_true",
134
+ help="Skip editor selection, use Gemini CLI",
135
+ )
136
+ install_parser.add_argument(
137
+ "--codex",
138
+ action="store_true",
139
+ help="Skip editor selection, use Codex",
140
+ )
141
+ install_parser.add_argument(
142
+ "--fast",
143
+ action="store_true",
144
+ help="Fast tier: Regular extraction + lemmi expansion (no downloads)",
145
+ )
146
+ install_parser.add_argument(
147
+ "--regular",
148
+ action="store_true",
149
+ help="Regular tier: KeyBERT small + GloVe expansion (128MB, default)",
150
+ )
151
+ install_parser.add_argument(
152
+ "--max",
153
+ action="store_true",
154
+ help="Max tier: KeyBERT large + FastText expansion (958MB+)",
155
+ )
156
+
157
+ server_parser = subparsers.add_parser(
158
+ "server",
159
+ help="Start MCP server (silent mode with defaults)",
160
+ description="Start MCP server with auto-setup using defaults",
161
+ )
162
+ server_parser.add_argument(
163
+ "repo",
164
+ nargs="?",
165
+ default=None,
166
+ help="Path to Elixir repository (default: current directory)",
167
+ )
168
+ server_parser.add_argument(
169
+ "--claude",
170
+ action="store_true",
171
+ help="Create Claude Code config before starting server",
172
+ )
173
+ server_parser.add_argument(
174
+ "--cursor",
175
+ action="store_true",
176
+ help="Create Cursor config before starting server",
177
+ )
178
+ server_parser.add_argument(
179
+ "--vs",
180
+ action="store_true",
181
+ help="Create VS Code config before starting server",
182
+ )
183
+ server_parser.add_argument(
184
+ "--gemini",
185
+ action="store_true",
186
+ help="Create Gemini CLI config before starting server",
187
+ )
188
+ server_parser.add_argument(
189
+ "--codex",
190
+ action="store_true",
191
+ help="Create Codex config before starting server",
192
+ )
193
+ server_parser.add_argument(
194
+ "--fast",
195
+ action="store_true",
196
+ help="Fast tier: Regular extraction + lemmi expansion (if reindexing needed)",
197
+ )
198
+ server_parser.add_argument(
199
+ "--regular",
200
+ action="store_true",
201
+ help="Regular tier: KeyBERT small + GloVe expansion (if reindexing needed)",
202
+ )
203
+ server_parser.add_argument(
204
+ "--max",
205
+ action="store_true",
206
+ help="Max tier: KeyBERT large + FastText expansion (if reindexing needed)",
207
+ )
208
+ server_parser.add_argument(
209
+ "--watch",
210
+ action="store_true",
211
+ help="Start file watcher in a linked process for automatic reindexing",
212
+ )
213
+
214
+ claude_parser = subparsers.add_parser(
215
+ "claude",
216
+ help="Setup Cicada for Claude Code editor",
217
+ description="One-command setup for Claude Code with keyword extraction",
218
+ )
219
+ claude_parser.add_argument(
220
+ "--fast",
221
+ action="store_true",
222
+ help="Fast tier: Regular extraction + lemmi expansion",
223
+ )
224
+ claude_parser.add_argument(
225
+ "--regular",
226
+ action="store_true",
227
+ help="Regular tier: KeyBERT small + GloVe expansion (default)",
228
+ )
229
+ claude_parser.add_argument(
230
+ "--max",
231
+ action="store_true",
232
+ help="Max tier: KeyBERT large + FastText expansion",
233
+ )
234
+
235
+ cursor_parser = subparsers.add_parser(
236
+ "cursor",
237
+ help="Setup Cicada for Cursor editor",
238
+ description="One-command setup for Cursor with keyword extraction",
239
+ )
240
+ cursor_parser.add_argument(
241
+ "--fast",
242
+ action="store_true",
243
+ help="Fast tier: Regular extraction + lemmi expansion",
244
+ )
245
+ cursor_parser.add_argument(
246
+ "--regular",
247
+ action="store_true",
248
+ help="Regular tier: KeyBERT small + GloVe expansion (default)",
249
+ )
250
+ cursor_parser.add_argument(
251
+ "--max",
252
+ action="store_true",
253
+ help="Max tier: KeyBERT large + FastText expansion",
254
+ )
255
+
256
+ vs_parser = subparsers.add_parser(
257
+ "vs",
258
+ help="Setup Cicada for VS Code editor",
259
+ description="One-command setup for VS Code with keyword extraction",
260
+ )
261
+ vs_parser.add_argument(
262
+ "--fast",
263
+ action="store_true",
264
+ help="Fast tier: Regular extraction + lemmi expansion",
265
+ )
266
+ vs_parser.add_argument(
267
+ "--regular",
268
+ action="store_true",
269
+ help="Regular tier: KeyBERT small + GloVe expansion (default)",
270
+ )
271
+ vs_parser.add_argument(
272
+ "--max",
273
+ action="store_true",
274
+ help="Max tier: KeyBERT large + FastText expansion",
275
+ )
276
+
277
+ gemini_parser = subparsers.add_parser(
278
+ "gemini",
279
+ help="Setup Cicada for Gemini CLI",
280
+ description="One-command setup for Gemini CLI with keyword extraction",
281
+ )
282
+ gemini_parser.add_argument(
283
+ "--fast",
284
+ action="store_true",
285
+ help="Fast tier: Regular extraction + lemmi expansion",
286
+ )
287
+ gemini_parser.add_argument(
288
+ "--regular",
289
+ action="store_true",
290
+ help="Regular tier: KeyBERT small + GloVe expansion (default)",
291
+ )
292
+ gemini_parser.add_argument(
293
+ "--max",
294
+ action="store_true",
295
+ help="Max tier: KeyBERT large + FastText expansion",
296
+ )
297
+
298
+ codex_parser = subparsers.add_parser(
299
+ "codex",
300
+ help="Setup Cicada for Codex editor",
301
+ description="One-command setup for Codex with keyword extraction",
302
+ )
303
+ codex_parser.add_argument(
304
+ "--fast",
305
+ action="store_true",
306
+ help="Fast tier: Regular extraction + lemmi expansion",
307
+ )
308
+ codex_parser.add_argument(
309
+ "--regular",
310
+ action="store_true",
311
+ help="Regular tier: KeyBERT small + GloVe expansion (default)",
312
+ )
313
+ codex_parser.add_argument(
314
+ "--max",
315
+ action="store_true",
316
+ help="Max tier: KeyBERT large + FastText expansion",
317
+ )
318
+
319
+ watch_parser = subparsers.add_parser(
320
+ "watch",
321
+ help="Watch for file changes and automatically reindex",
322
+ description="Watch Elixir source files for changes and trigger automatic incremental reindexing",
323
+ )
324
+ watch_parser.add_argument(
325
+ "repo",
326
+ nargs="?",
327
+ default=".",
328
+ help="Path to the Elixir repository to watch (default: current directory)",
329
+ )
330
+ watch_parser.add_argument(
331
+ "--debounce",
332
+ type=float,
333
+ default=2.0,
334
+ metavar="SECONDS",
335
+ help="Debounce interval in seconds to wait after file changes before reindexing (default: 2.0)",
336
+ )
337
+ watch_parser.add_argument(
338
+ "--fast",
339
+ action="store_true",
340
+ help="Fast tier: Regular extraction + lemmi expansion",
341
+ )
342
+ watch_parser.add_argument(
343
+ "--regular",
344
+ action="store_true",
345
+ help="Regular tier: KeyBERT small + GloVe expansion (default)",
346
+ )
347
+ watch_parser.add_argument(
348
+ "--max",
349
+ action="store_true",
350
+ help="Max tier: KeyBERT large + FastText expansion",
351
+ )
352
+
353
+ index_parser = subparsers.add_parser(
354
+ "index",
355
+ help="Index an Elixir repository to extract modules and functions",
356
+ description="Index current Elixir repository to extract modules and functions",
357
+ )
358
+ index_parser.add_argument(
359
+ "repo",
360
+ nargs="?",
361
+ default=".",
362
+ help="Path to the Elixir repository to index (default: current directory)",
363
+ )
364
+ index_parser.add_argument(
365
+ "--fast",
366
+ action="store_true",
367
+ help="Fast tier: Regular extraction + lemmi expansion",
368
+ )
369
+ index_parser.add_argument(
370
+ "--regular",
371
+ action="store_true",
372
+ help="Regular tier: KeyBERT small + GloVe expansion (default)",
373
+ )
374
+ index_parser.add_argument(
375
+ "--max",
376
+ action="store_true",
377
+ help="Max tier: KeyBERT large + FastText expansion",
378
+ )
379
+ index_parser.add_argument(
380
+ "-f",
381
+ "--force",
382
+ action="store_true",
383
+ help="Override configured tier (requires --fast, --regular, or --max)",
384
+ )
385
+ index_parser.add_argument(
386
+ "--test",
387
+ action="store_true",
388
+ help="Start interactive keyword extraction test mode",
389
+ )
390
+ index_parser.add_argument(
391
+ "--test-expansion",
392
+ action="store_true",
393
+ help="Start interactive keyword expansion test mode",
394
+ )
395
+ index_parser.add_argument(
396
+ "--extraction-threshold",
397
+ type=float,
398
+ default=0.3,
399
+ metavar="SCORE",
400
+ help="Minimum score for keyword extraction (0.0-1.0). For KeyBERT: semantic similarity threshold. Default: 0.3",
401
+ )
402
+ index_parser.add_argument(
403
+ "--min-score",
404
+ type=float,
405
+ default=0.5,
406
+ metavar="SCORE",
407
+ help="Minimum score threshold for keywords (filters out low-scoring terms). Default: 0.5",
408
+ )
409
+ index_parser.add_argument(
410
+ "--expansion-threshold",
411
+ type=float,
412
+ default=0.2,
413
+ metavar="SCORE",
414
+ help="Minimum similarity score for keyword expansion (0.0-1.0, default: 0.2)",
415
+ )
416
+ index_parser.add_argument(
417
+ "--watch",
418
+ action="store_true",
419
+ help="Watch for file changes and automatically reindex (runs initial index first)",
420
+ )
421
+ index_parser.add_argument(
422
+ "--debounce",
423
+ type=float,
424
+ default=2.0,
425
+ metavar="SECONDS",
426
+ help="Debounce interval in seconds when using --watch (default: 2.0)",
427
+ )
428
+
429
+ index_pr_parser = subparsers.add_parser(
430
+ "index-pr",
431
+ help="Index GitHub pull requests for fast offline lookup",
432
+ description="Index GitHub pull requests for fast offline lookup",
433
+ )
434
+ index_pr_parser.add_argument(
435
+ "repo",
436
+ nargs="?",
437
+ default=".",
438
+ help="Path to git repository (default: current directory)",
439
+ )
440
+ index_pr_parser.add_argument(
441
+ "--clean",
442
+ action="store_true",
443
+ help="Clean and rebuild the entire index from scratch (default: incremental update)",
444
+ )
445
+
446
+ dead_code_parser = subparsers.add_parser(
447
+ "find-dead-code",
448
+ help="Find potentially unused public functions in Elixir codebase",
449
+ description="Find potentially unused public functions in Elixir codebase",
450
+ formatter_class=argparse.RawDescriptionHelpFormatter,
451
+ epilog="""
452
+ Confidence Levels:
453
+ high - Zero usage, no dynamic call indicators, no behaviors/uses
454
+ medium - Zero usage, but module has behaviors or uses (possible callbacks)
455
+ low - Zero usage, but module passed as value (possible dynamic calls)
456
+
457
+ Examples:
458
+ cicada find-dead-code # Show high confidence candidates
459
+ cicada find-dead-code --min-confidence low # Show all candidates
460
+ cicada find-dead-code --format json # Output as JSON
461
+ """,
462
+ )
463
+ dead_code_parser.add_argument(
464
+ "--format",
465
+ choices=["markdown", "json"],
466
+ default="markdown",
467
+ help="Output format (default: markdown)",
468
+ )
469
+ dead_code_parser.add_argument(
470
+ "--min-confidence",
471
+ choices=["high", "medium", "low"],
472
+ default="high",
473
+ help="Minimum confidence level to show (default: high)",
474
+ )
475
+
476
+ clean_parser = subparsers.add_parser(
477
+ "clean",
478
+ help="Remove Cicada configuration and indexes",
479
+ description="Remove Cicada configuration and indexes for current repository",
480
+ formatter_class=argparse.RawDescriptionHelpFormatter,
481
+ epilog="""
482
+ Examples:
483
+ cicada clean # Remove everything (interactive with confirmation)
484
+ cicada clean -f # Remove everything (skip confirmation)
485
+ cicada clean --index # Remove main index (index.json, hashes.json)
486
+ cicada clean --pr-index # Remove PR index (pr_index.json)
487
+ cicada clean --all # Remove ALL project storage
488
+ cicada clean --all -f # Remove ALL project storage (skip confirmation)
489
+ """,
490
+ )
491
+ clean_parser.add_argument(
492
+ "-f",
493
+ "--force",
494
+ action="store_true",
495
+ help="Skip confirmation prompt (for full clean or --all)",
496
+ )
497
+ clean_parser.add_argument(
498
+ "--index",
499
+ action="store_true",
500
+ help="Remove only main index files (index.json, hashes.json)",
501
+ )
502
+ clean_parser.add_argument(
503
+ "--pr-index",
504
+ action="store_true",
505
+ help="Remove only PR index file (pr_index.json)",
506
+ )
507
+ clean_parser.add_argument(
508
+ "--all",
509
+ action="store_true",
510
+ help="Remove ALL Cicada storage for all projects (~/.cicada/projects/)",
511
+ )
512
+
513
+ dir_parser = subparsers.add_parser(
514
+ "dir",
515
+ help="Show the absolute path to the Cicada storage directory",
516
+ description="Display the absolute path to where Cicada stores configuration and indexes",
517
+ )
518
+ dir_parser.add_argument(
519
+ "repo",
520
+ nargs="?",
521
+ default=".",
522
+ help="Path to the repository (default: current directory)",
523
+ )
524
+
525
+ return parser
526
+
527
+
528
+ def handle_command(args) -> bool:
529
+ """Route command to appropriate handler.
530
+
531
+ Args:
532
+ args: Parsed command-line arguments
533
+
534
+ Returns:
535
+ True if a command was handled, False if no command specified
536
+ """
537
+ command_handlers = {
538
+ "install": handle_install,
539
+ "server": handle_server,
540
+ "claude": lambda args: handle_editor_setup(args, "claude"),
541
+ "cursor": lambda args: handle_editor_setup(args, "cursor"),
542
+ "vs": lambda args: handle_editor_setup(args, "vs"),
543
+ "gemini": lambda args: handle_editor_setup(args, "gemini"),
544
+ "codex": lambda args: handle_editor_setup(args, "codex"),
545
+ "watch": handle_watch,
546
+ "index": handle_index,
547
+ "index-pr": handle_index_pr,
548
+ "find-dead-code": handle_find_dead_code,
549
+ "clean": handle_clean,
550
+ "dir": handle_dir,
551
+ }
552
+
553
+ if args.command is None:
554
+ return False
555
+
556
+ handler = command_handlers.get(args.command)
557
+ if handler:
558
+ handler(args)
559
+ return True
560
+
561
+ return False
562
+
563
+
564
+ def handle_editor_setup(args, editor: str) -> None:
565
+ """Handle setup for a specific editor.
566
+
567
+ Args:
568
+ args: Parsed command-line arguments
569
+ editor: Editor type ('claude', 'cursor', or 'vs')
570
+ """
571
+ from typing import cast
572
+
573
+ from cicada.setup import EditorType, setup
574
+ from cicada.utils.storage import get_config_path, get_index_path
575
+
576
+ # Validate tier flags
577
+ validate_tier_flags(args)
578
+
579
+ repo_path = Path.cwd()
580
+
581
+ # Verify it's an Elixir project
582
+ if not (repo_path / "mix.exs").exists():
583
+ print(f"Error: {repo_path} does not appear to be an Elixir project", file=sys.stderr)
584
+ print("(mix.exs not found)", file=sys.stderr)
585
+ sys.exit(1)
586
+
587
+ config_path = get_config_path(repo_path)
588
+ index_path = get_index_path(repo_path)
589
+ index_exists = config_path.exists() and index_path.exists()
590
+
591
+ extraction_method, expansion_method = get_extraction_expansion_methods(args)
592
+
593
+ # Load existing config if no tier specified but index exists
594
+ if extraction_method is None and index_exists:
595
+ extraction_method, expansion_method = _load_existing_config(config_path)
596
+
597
+ try:
598
+ assert editor is not None
599
+ setup(
600
+ cast(EditorType, editor),
601
+ repo_path,
602
+ extraction_method=extraction_method,
603
+ expansion_method=expansion_method,
604
+ index_exists=index_exists,
605
+ )
606
+ except Exception as e:
607
+ print(f"\nError: Setup failed: {e}", file=sys.stderr)
608
+ sys.exit(1)
609
+
610
+
611
+ def _load_existing_config(config_path: Path) -> tuple[str, str]:
612
+ """Load extraction and expansion methods from existing config.
613
+
614
+ Args:
615
+ config_path: Path to config.yaml
616
+
617
+ Returns:
618
+ Tuple of (extraction_method, expansion_method)
619
+ """
620
+ import yaml
621
+
622
+ try:
623
+ with open(config_path) as f:
624
+ existing_config = yaml.safe_load(f)
625
+ extraction_method = existing_config.get("keyword_extraction", {}).get(
626
+ "method", "regular"
627
+ )
628
+ expansion_method = existing_config.get("keyword_expansion", {}).get("method", "lemmi")
629
+ return extraction_method, expansion_method
630
+ except Exception as e:
631
+ print(f"Warning: Could not load existing config: {e}", file=sys.stderr)
632
+ return "regular", "lemmi"
633
+
634
+
635
+ def handle_index_test_mode(args):
636
+ """Handle interactive keyword extraction test mode."""
637
+ from cicada.keyword_test import run_keywords_interactive
638
+ from cicada.tier import determine_tier, tier_to_methods
639
+
640
+ # Validate tier flags
641
+ validate_tier_flags(args)
642
+
643
+ # Get tier (includes fallback to 'regular' if not specified)
644
+ tier_name = determine_tier(args)
645
+
646
+ # Convert tier to extraction method
647
+ extraction_method, _ = tier_to_methods(tier_name)
648
+
649
+ extraction_threshold = getattr(args, "extraction_threshold", None)
650
+ run_keywords_interactive(
651
+ method=extraction_method, tier=tier_name, extraction_threshold=extraction_threshold
652
+ )
653
+
654
+
655
+ def handle_index_test_expansion_mode(args):
656
+ """Handle interactive keyword expansion test mode."""
657
+ from cicada.keyword_test import run_expansion_interactive
658
+ from cicada.tier import determine_tier, tier_to_methods
659
+
660
+ # Validate tier flags
661
+ validate_tier_flags(args)
662
+
663
+ # Get tier (includes fallback to 'regular' if not specified)
664
+ tier_name = determine_tier(args)
665
+
666
+ # Convert tier to extraction method and expansion type
667
+ extraction_method, expansion_type = tier_to_methods(tier_name)
668
+
669
+ extraction_threshold = getattr(args, "extraction_threshold", 0.3)
670
+ expansion_threshold = getattr(args, "expansion_threshold", 0.2)
671
+ min_score = getattr(args, "min_score", 0.5)
672
+ run_expansion_interactive(
673
+ expansion_type=expansion_type,
674
+ extraction_method=extraction_method,
675
+ extraction_tier=tier_name,
676
+ extraction_threshold=extraction_threshold,
677
+ expansion_threshold=expansion_threshold,
678
+ min_score=min_score,
679
+ )
680
+
681
+
682
+ def handle_index_main(args) -> None:
683
+ """Handle main repository indexing."""
684
+ from cicada.indexer import ElixirIndexer
685
+ from cicada.utils.storage import create_storage_dir, get_config_path, get_index_path
686
+
687
+ # Validate tier flags
688
+ validate_tier_flags(args, require_force=True)
689
+
690
+ repo_path = Path(args.repo).resolve()
691
+ config_path = get_config_path(repo_path)
692
+ storage_dir = create_storage_dir(repo_path)
693
+ index_path = get_index_path(repo_path)
694
+
695
+ force_enabled = getattr(args, "force", False) is True
696
+ extraction_method: str | None = None
697
+ expansion_method: str | None = None
698
+
699
+ if force_enabled:
700
+ extraction_method, expansion_method = get_extraction_expansion_methods(args)
701
+ assert extraction_method is not None
702
+ assert expansion_method is not None
703
+ _handle_index_config_update(
704
+ config_path, storage_dir, repo_path, extraction_method, expansion_method
705
+ )
706
+ elif not config_path.exists():
707
+ _print_tier_requirement_error()
708
+ sys.exit(2)
709
+
710
+ # Perform indexing
711
+ indexer = ElixirIndexer(verbose=True)
712
+ indexer.incremental_index_repository(
713
+ str(repo_path),
714
+ str(index_path),
715
+ extract_keywords=True,
716
+ force_full=False,
717
+ )
718
+
719
+
720
+ def _handle_index_config_update(
721
+ config_path: Path,
722
+ storage_dir: Path,
723
+ repo_path: Path,
724
+ extraction_method: str,
725
+ expansion_method: str,
726
+ ) -> None:
727
+ """Handle config creation or validation during indexing.
728
+
729
+ Args:
730
+ config_path: Path to config.yaml
731
+ storage_dir: Storage directory path
732
+ repo_path: Repository path
733
+ extraction_method: Extraction method to use
734
+ expansion_method: Expansion method to use
735
+ """
736
+ from cicada.setup import create_config_yaml
737
+
738
+ if config_path.exists():
739
+ existing_extraction, existing_expansion = _load_existing_config(config_path)
740
+
741
+ extraction_changed = existing_extraction != extraction_method
742
+ expansion_changed = existing_expansion != expansion_method
743
+
744
+ if extraction_changed or expansion_changed:
745
+ _print_config_change_error(
746
+ existing_extraction,
747
+ existing_expansion,
748
+ extraction_method,
749
+ expansion_method,
750
+ extraction_changed,
751
+ expansion_changed,
752
+ )
753
+ sys.exit(1)
754
+
755
+ create_config_yaml(repo_path, storage_dir, extraction_method, expansion_method)
756
+
757
+
758
+ def _print_config_change_error(
759
+ existing_extraction: str,
760
+ existing_expansion: str,
761
+ extraction_method: str,
762
+ expansion_method: str,
763
+ extraction_changed: bool,
764
+ expansion_changed: bool,
765
+ ) -> None:
766
+ """Print error message for config changes."""
767
+ change_desc = _describe_config_change(
768
+ existing_extraction,
769
+ existing_expansion,
770
+ extraction_method,
771
+ expansion_method,
772
+ extraction_changed,
773
+ expansion_changed,
774
+ )
775
+
776
+ print(f"Error: Cannot change {change_desc}", file=sys.stderr)
777
+ print("\nTo reindex with different settings, first run:", file=sys.stderr)
778
+ print(" cicada clean", file=sys.stderr)
779
+ print("\nThen run your index command again.", file=sys.stderr)
780
+
781
+
782
+ def _describe_config_change(
783
+ existing_extraction: str,
784
+ existing_expansion: str,
785
+ extraction_method: str,
786
+ expansion_method: str,
787
+ extraction_changed: bool,
788
+ expansion_changed: bool,
789
+ ) -> str:
790
+ """Generate description of config change."""
791
+ if extraction_changed and expansion_changed:
792
+ return f"extraction from {existing_extraction} to {extraction_method} and expansion from {existing_expansion} to {expansion_method}"
793
+ if extraction_changed:
794
+ return f"extraction from {existing_extraction} to {extraction_method}"
795
+ return f"expansion from {existing_expansion} to {expansion_method}"
796
+
797
+
798
+ def _print_tier_requirement_error() -> None:
799
+ """Print error message when no tier is specified."""
800
+ print("Error: No tier configured.", file=sys.stderr)
801
+ print(
802
+ "\nUse '--force' with a tier flag to select keyword extraction settings:", file=sys.stderr
803
+ )
804
+ print(
805
+ " cicada index --force --fast Fast tier: Regular extraction + lemmi expansion",
806
+ file=sys.stderr,
807
+ )
808
+ print(
809
+ " cicada index --force --regular Regular tier: KeyBERT small + GloVe expansion (default)",
810
+ file=sys.stderr,
811
+ )
812
+ print(
813
+ " cicada index --force --max Max tier: KeyBERT large + FastText expansion",
814
+ file=sys.stderr,
815
+ )
816
+ print("\nRun 'cicada index --help' for more information.", file=sys.stderr)
817
+
818
+
819
+ def handle_index(args):
820
+ """Route index command to appropriate handler based on mode."""
821
+ from cicada.version_check import check_for_updates
822
+
823
+ check_for_updates()
824
+
825
+ if getattr(args, "test", False):
826
+ handle_index_test_mode(args)
827
+ return
828
+
829
+ if getattr(args, "test_expansion", False):
830
+ handle_index_test_expansion_mode(args)
831
+ return
832
+
833
+ if getattr(args, "watch", False):
834
+ # Handle watch mode using shared logic
835
+ _setup_and_start_watcher(args, args.repo)
836
+ else:
837
+ handle_index_main(args)
838
+
839
+
840
+ def handle_watch(args):
841
+ """Handle watch command for automatic reindexing on file changes."""
842
+ from cicada.version_check import check_for_updates
843
+
844
+ check_for_updates()
845
+
846
+ # Use shared watcher setup logic
847
+ _setup_and_start_watcher(args, args.repo)
848
+
849
+
850
+ def handle_index_pr(args):
851
+ from cicada.pr_indexer import PRIndexer
852
+ from cicada.utils import get_pr_index_path
853
+ from cicada.version_check import check_for_updates
854
+
855
+ check_for_updates()
856
+
857
+ try:
858
+ output_path = str(get_pr_index_path(args.repo))
859
+
860
+ indexer = PRIndexer(repo_path=args.repo)
861
+ indexer.index_repository(output_path=output_path, incremental=not args.clean)
862
+
863
+ print("\n✅ Indexing complete! You can now use the MCP tools for PR history lookups.")
864
+
865
+ except KeyboardInterrupt:
866
+ print("\n\n⚠️ Indexing interrupted by user.")
867
+ print("Partial index may have been saved. Run again to continue (incremental by default).")
868
+ sys.exit(130)
869
+
870
+ except Exception as e:
871
+ print(f"Error: {e}", file=sys.stderr)
872
+ sys.exit(1)
873
+
874
+
875
+ def handle_find_dead_code(args):
876
+ from cicada.dead_code.analyzer import DeadCodeAnalyzer
877
+ from cicada.dead_code.finder import filter_by_confidence, format_json, format_markdown
878
+ from cicada.utils import get_index_path, load_index
879
+
880
+ index_path = get_index_path(".")
881
+
882
+ if not index_path.exists():
883
+ print(f"Error: Index file not found: {index_path}", file=sys.stderr)
884
+ print("\nRun 'cicada index' first to create the index.", file=sys.stderr)
885
+ sys.exit(1)
886
+
887
+ try:
888
+ index = load_index(index_path, raise_on_error=True)
889
+ except Exception as e:
890
+ print(f"Error loading index: {e}", file=sys.stderr)
891
+ sys.exit(1)
892
+
893
+ assert index is not None, "Index should not be None after successful load"
894
+
895
+ analyzer = DeadCodeAnalyzer(index)
896
+ results = analyzer.analyze()
897
+
898
+ results = filter_by_confidence(results, args.min_confidence)
899
+
900
+ output = format_json(results) if args.format == "json" else format_markdown(results)
901
+
902
+ print(output)
903
+
904
+
905
+ def handle_clean(args):
906
+ from cicada.clean import (
907
+ clean_all_projects,
908
+ clean_index_only,
909
+ clean_pr_index_only,
910
+ clean_repository,
911
+ )
912
+
913
+ if args.all:
914
+ try:
915
+ clean_all_projects(force=args.force)
916
+ except Exception as e:
917
+ print(f"\nError: Cleanup failed: {e}", file=sys.stderr)
918
+ sys.exit(1)
919
+ return
920
+
921
+ flag_count = sum([args.index, args.pr_index])
922
+ if flag_count > 1:
923
+ print("Error: Cannot specify multiple clean options.", file=sys.stderr)
924
+ print("Choose only one: --index, --pr-index, or -f/--force", file=sys.stderr)
925
+ sys.exit(1)
926
+
927
+ repo_path = Path.cwd()
928
+
929
+ try:
930
+ if args.index:
931
+ clean_index_only(repo_path)
932
+ elif args.pr_index:
933
+ clean_pr_index_only(repo_path)
934
+ else:
935
+ clean_repository(repo_path, force=args.force)
936
+ except Exception as e:
937
+ print(f"\nError: Cleanup failed: {e}", file=sys.stderr)
938
+ sys.exit(1)
939
+
940
+
941
+ def handle_dir(args):
942
+ """Show the absolute path to the Cicada storage directory."""
943
+ from cicada.utils.storage import get_storage_dir
944
+
945
+ repo_path = Path(args.repo).resolve()
946
+
947
+ try:
948
+ storage_dir = get_storage_dir(repo_path)
949
+ print(str(storage_dir))
950
+ except Exception as e:
951
+ print(f"Error: {e}", file=sys.stderr)
952
+ sys.exit(1)
953
+
954
+
955
+ def handle_install(args) -> None:
956
+ """
957
+ Handle the install subcommand (interactive setup).
958
+
959
+ Behavior:
960
+ - INTERACTIVE: shows prompts and menus
961
+ - Can skip prompts with flags (--claude, --cursor, --vs, --fast, --regular, --max)
962
+ - Creates editor config and indexes repository
963
+ """
964
+ from typing import cast
965
+
966
+ from cicada.setup import EditorType, setup
967
+ from cicada.utils import get_config_path, get_index_path
968
+
969
+ # Determine and validate repository path
970
+ repo_path = Path(args.repo).resolve() if args.repo else Path.cwd().resolve()
971
+ _validate_elixir_project(repo_path)
972
+
973
+ # Validate tier flags
974
+ validate_tier_flags(args)
975
+
976
+ # Parse editor selection
977
+ editor = _determine_editor_from_args(args)
978
+
979
+ # Determine extraction and expansion methods from flags
980
+ extraction_method, expansion_method = get_extraction_expansion_methods(args)
981
+
982
+ # Check if index already exists
983
+ config_path = get_config_path(repo_path)
984
+ index_path = get_index_path(repo_path)
985
+ index_exists = config_path.exists() and index_path.exists()
986
+
987
+ # If no flags provided, use full interactive setup
988
+ if editor is None and extraction_method is None:
989
+ from cicada.interactive_setup import show_full_interactive_setup
990
+
991
+ show_full_interactive_setup(repo_path)
992
+ return
993
+
994
+ # If only model flags provided (no editor), prompt for editor
995
+ if editor is None:
996
+ editor = _prompt_for_editor()
997
+
998
+ # If only editor flag provided (no model), prompt for model (unless index exists)
999
+ if extraction_method is None and not index_exists:
1000
+ from cicada.interactive_setup import show_first_time_setup
1001
+
1002
+ extraction_method, expansion_method, _, _ = show_first_time_setup()
1003
+
1004
+ # If index exists but no model flags, use existing settings
1005
+ if extraction_method is None and index_exists:
1006
+ extraction_method, expansion_method = _load_existing_config(config_path)
1007
+
1008
+ # Run setup
1009
+ assert editor is not None
1010
+ try:
1011
+ setup(
1012
+ cast(EditorType, editor),
1013
+ repo_path,
1014
+ extraction_method=extraction_method,
1015
+ expansion_method=expansion_method,
1016
+ index_exists=index_exists,
1017
+ )
1018
+ except Exception as e:
1019
+ print(f"\nError: Setup failed: {e}", file=sys.stderr)
1020
+ sys.exit(1)
1021
+
1022
+
1023
+ def _validate_elixir_project(repo_path: Path) -> None:
1024
+ """Validate that the repository is an Elixir project.
1025
+
1026
+ Args:
1027
+ repo_path: Path to the repository
1028
+
1029
+ Raises:
1030
+ SystemExit: If not an Elixir project
1031
+ """
1032
+ if not (repo_path / "mix.exs").exists():
1033
+ print(f"Error: {repo_path} does not appear to be an Elixir project", file=sys.stderr)
1034
+ print("(mix.exs not found)", file=sys.stderr)
1035
+ sys.exit(1)
1036
+
1037
+
1038
+ def _determine_editor_from_args(args) -> str | None:
1039
+ """Determine editor from command-line arguments.
1040
+
1041
+ Args:
1042
+ args: Parsed command-line arguments
1043
+
1044
+ Returns:
1045
+ Editor type or None if not specified
1046
+
1047
+ Raises:
1048
+ SystemExit: If multiple editor flags specified
1049
+ """
1050
+ editor_flags = [args.claude, args.cursor, args.vs, args.gemini, args.codex]
1051
+ editor_count = sum(editor_flags)
1052
+
1053
+ if editor_count > 1:
1054
+ print("Error: Can only specify one editor flag for install command", file=sys.stderr)
1055
+ sys.exit(1)
1056
+
1057
+ if args.claude:
1058
+ return "claude"
1059
+ if args.cursor:
1060
+ return "cursor"
1061
+ if args.vs:
1062
+ return "vs"
1063
+ if args.gemini:
1064
+ return "gemini"
1065
+ if args.codex:
1066
+ return "codex"
1067
+ return None
1068
+
1069
+
1070
+ def _prompt_for_editor() -> str:
1071
+ """Prompt user to select an editor.
1072
+
1073
+ Returns:
1074
+ Selected editor type
1075
+
1076
+ Raises:
1077
+ SystemExit: If user cancels selection
1078
+ """
1079
+ from simple_term_menu import TerminalMenu
1080
+
1081
+ print("Select editor to configure:")
1082
+ print()
1083
+ editor_options = [
1084
+ "Claude Code (Claude AI assistant)",
1085
+ "Cursor (AI-powered code editor)",
1086
+ "VS Code (Visual Studio Code)",
1087
+ "Gemini CLI (Google Gemini command line interface)",
1088
+ "Codex (AI code editor)",
1089
+ ]
1090
+ editor_menu = TerminalMenu(editor_options, title="Choose your editor:")
1091
+ menu_idx = editor_menu.show()
1092
+
1093
+ if menu_idx is None:
1094
+ print("\nSetup cancelled.")
1095
+ sys.exit(0)
1096
+
1097
+ # Map menu index to editor type
1098
+ assert isinstance(menu_idx, int), "menu_idx must be an integer"
1099
+ editor_map: tuple[str, str, str, str, str] = ("claude", "cursor", "vs", "gemini", "codex")
1100
+ return editor_map[menu_idx]
1101
+
1102
+
1103
+ def handle_server(args) -> None:
1104
+ """
1105
+ Handle the server subcommand (silent MCP server with optional configs).
1106
+
1107
+ Behavior:
1108
+ - SILENT: no prompts, no interactive menus
1109
+ - Auto-setup if needed (uses default model: lemminflect)
1110
+ - Creates editor configs if flags provided (--claude, --cursor, --vs)
1111
+ - Starts MCP server on stdio
1112
+ """
1113
+ import asyncio
1114
+ import logging
1115
+
1116
+ from cicada.utils import create_storage_dir, get_config_path, get_index_path
1117
+
1118
+ logger = logging.getLogger(__name__)
1119
+
1120
+ # Determine and validate repository path
1121
+ repo_path = Path(args.repo).resolve() if args.repo else Path.cwd().resolve()
1122
+ _validate_elixir_project(repo_path)
1123
+
1124
+ # Validate tier flags
1125
+ validate_tier_flags(args)
1126
+
1127
+ # Create storage directory
1128
+ storage_dir = create_storage_dir(repo_path)
1129
+
1130
+ # Determine extraction and expansion methods
1131
+ extraction_method, expansion_method = get_extraction_expansion_methods(args)
1132
+
1133
+ # Check if setup is needed
1134
+ config_path = get_config_path(repo_path)
1135
+ index_path = get_index_path(repo_path)
1136
+ needs_setup = not (config_path.exists() and index_path.exists())
1137
+
1138
+ if needs_setup:
1139
+ _perform_silent_setup(repo_path, storage_dir, extraction_method, expansion_method)
1140
+
1141
+ # Create editor configs if requested
1142
+ _configure_editors_if_requested(args, repo_path, storage_dir)
1143
+
1144
+ # Start watch process if requested
1145
+ watch_enabled = getattr(args, "watch", False)
1146
+ if watch_enabled:
1147
+ _start_watch_for_server(args, repo_path)
1148
+
1149
+ # Start MCP server
1150
+ from cicada.mcp.server import async_main
1151
+
1152
+ try:
1153
+ asyncio.run(async_main())
1154
+ finally:
1155
+ # Ensure watch process is stopped when server exits
1156
+ if watch_enabled:
1157
+ _cleanup_watch_process(logger)
1158
+
1159
+
1160
+ def _perform_silent_setup(
1161
+ repo_path: Path, storage_dir: Path, extraction_method: str | None, expansion_method: str | None
1162
+ ) -> None:
1163
+ """Perform silent setup with defaults if needed.
1164
+
1165
+ Args:
1166
+ repo_path: Repository path
1167
+ storage_dir: Storage directory path
1168
+ extraction_method: Extraction method or None for defaults
1169
+ expansion_method: Expansion method or None for defaults
1170
+ """
1171
+ from cicada.setup import create_config_yaml, index_repository
1172
+
1173
+ # If no tier specified, default to fast tier (fastest, no downloads)
1174
+ if extraction_method is None:
1175
+ extraction_method = "regular"
1176
+ expansion_method = "lemmi"
1177
+
1178
+ # Create config.yaml (silent)
1179
+ create_config_yaml(repo_path, storage_dir, extraction_method, expansion_method, verbose=False)
1180
+
1181
+ # Index repository (silent)
1182
+ try:
1183
+ index_repository(repo_path, force_full=False, verbose=False)
1184
+ except Exception as e:
1185
+ print(f"Error during indexing: {e}", file=sys.stderr)
1186
+ sys.exit(1)
1187
+
1188
+
1189
+ def _configure_editors_if_requested(args, repo_path: Path, storage_dir: Path) -> None:
1190
+ """Configure editors if flags are provided.
1191
+
1192
+ Args:
1193
+ args: Parsed command-line arguments
1194
+ repo_path: Repository path
1195
+ storage_dir: Storage directory path
1196
+ """
1197
+ from cicada.setup import EditorType, setup_multiple_editors
1198
+
1199
+ editors_to_configure: list[EditorType] = []
1200
+ if args.claude:
1201
+ editors_to_configure.append("claude")
1202
+ if args.cursor:
1203
+ editors_to_configure.append("cursor")
1204
+ if args.vs:
1205
+ editors_to_configure.append("vs")
1206
+ if args.gemini:
1207
+ editors_to_configure.append("gemini")
1208
+ if args.codex:
1209
+ editors_to_configure.append("codex")
1210
+
1211
+ if editors_to_configure:
1212
+ try:
1213
+ setup_multiple_editors(editors_to_configure, repo_path, storage_dir, verbose=False)
1214
+ except Exception as e:
1215
+ print(f"Error creating editor configs: {e}", file=sys.stderr)
1216
+ sys.exit(1)
1217
+
1218
+
1219
+ def _start_watch_for_server(args, repo_path: Path) -> None:
1220
+ """Start watch process for the server.
1221
+
1222
+ Args:
1223
+ args: Parsed command-line arguments
1224
+ repo_path: Repository path
1225
+ """
1226
+ from cicada.watch_manager import start_watch_process
1227
+
1228
+ # Determine tier using helper
1229
+ tier = determine_tier(args, repo_path)
1230
+
1231
+ # Start the watch process
1232
+ try:
1233
+ if not start_watch_process(repo_path, tier=tier, debounce=DEFAULT_WATCH_DEBOUNCE):
1234
+ print("ERROR: Failed to start watch process as requested", file=sys.stderr)
1235
+ print("Server startup aborted. Run without --watch or fix the issue.", file=sys.stderr)
1236
+ sys.exit(1)
1237
+ except RuntimeError as e:
1238
+ print(f"ERROR: Cannot start watch process: {e}", file=sys.stderr)
1239
+ print("Server startup aborted. Run without --watch or fix the issue.", file=sys.stderr)
1240
+ sys.exit(1)
1241
+
1242
+
1243
+ def _cleanup_watch_process(logger) -> None:
1244
+ """Clean up watch process on server exit.
1245
+
1246
+ Args:
1247
+ logger: Logger instance
1248
+ """
1249
+ try:
1250
+ from cicada.watch_manager import stop_watch_process
1251
+
1252
+ stop_watch_process()
1253
+ except Exception as e:
1254
+ logger.exception("Error stopping watch process during cleanup")
1255
+ print(f"Warning: Error stopping watch process: {e}", file=sys.stderr)