cicada-mcp 0.1.5__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 +165 -132
  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 +110 -232
  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 +198 -89
  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.5.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.5.dist-info/RECORD +0 -47
  50. cicada_mcp-0.1.5.dist-info/entry_points.txt +0 -9
  51. {cicada_mcp-0.1.5.dist-info → cicada_mcp-0.2.0.dist-info}/WHEEL +0 -0
  52. {cicada_mcp-0.1.5.dist-info → cicada_mcp-0.2.0.dist-info}/licenses/LICENSE +0 -0
  53. {cicada_mcp-0.1.5.dist-info → cicada_mcp-0.2.0.dist-info}/top_level.txt +0 -0
cicada/ascii_art.py ADDED
@@ -0,0 +1,60 @@
1
+ """ASCII art and banner generation for Cicada CLI."""
2
+
3
+ from cicada.colors import CYAN, RESET, YELLOW
4
+
5
+
6
+ def generate_gradient_ascii_art():
7
+ """Generate cicada ASCII art with gradient colors from #E5C890 to #D17958."""
8
+ start_hex = "E5C890"
9
+ end_hex = "D17958"
10
+
11
+ # Parse hex colors to RGB
12
+ start_rgb = tuple(int(start_hex[i : i + 2], 16) for i in (0, 2, 4))
13
+ end_rgb = tuple(int(end_hex[i : i + 2], 16) for i in (0, 2, 4))
14
+
15
+ # ASCII art lines - explicitly defined to preserve all leading spaces
16
+ lines = [
17
+ ' :., . _.: "',
18
+ " > =.t@_. . . ._ j@F:++<",
19
+ " .a??_'dB_ a_ . > \" < . _: _Ba _??p",
20
+ ' ` \'_\'--m."=o.. , @|D,, ..=+".&--",,.',
21
+ " `.=mm=~\"'_. .+===+. \"'~=mm=",
22
+ " ..-_. '-. g_._g .-' . .",
23
+ " '.\" , mgggm, , \".'",
24
+ ' /! "BBB" !\\',
25
+ " / " + "'" + '"""' + "' \\",
26
+ ' "',
27
+ ]
28
+
29
+ num_lines = len(lines)
30
+ result_lines = []
31
+
32
+ for i, line in enumerate(lines):
33
+ # Calculate interpolation factor (0 to 1)
34
+ t = i / (num_lines - 1) if num_lines > 1 else 0
35
+
36
+ # Interpolate RGB values
37
+ r = int(start_rgb[0] + (end_rgb[0] - start_rgb[0]) * t)
38
+ g = int(start_rgb[1] + (end_rgb[1] - start_rgb[1]) * t)
39
+ b = int(start_rgb[2] + (end_rgb[2] - start_rgb[2]) * t)
40
+
41
+ # Create ANSI color code for this line
42
+ color_code = f"\033[38;2;{r};{g};{b}m"
43
+ # Cast to str to satisfy type checker's LiteralString requirement
44
+ colored_line: str = str(color_code + line + "\033[0m")
45
+ result_lines.append(colored_line) # type: ignore[arg-type]
46
+
47
+ return "\n" + "\n".join(result_lines) + "\n"
48
+
49
+
50
+ # Pre-generate the ASCII art banner
51
+ CICADA_ASCII_ART = generate_gradient_ascii_art()
52
+
53
+
54
+ def get_welcome_banner():
55
+ """Generate complete welcome banner with ASCII art and welcome message."""
56
+ banner = CICADA_ASCII_ART
57
+ banner += f"{CYAN}{'=' * 66}{RESET}\n"
58
+ banner += f"{YELLOW} Welcome to CICADA - Elixir Code Intelligence{RESET}\n"
59
+ banner += f"{CYAN}{'=' * 66}{RESET}\n"
60
+ return banner
cicada/clean.py CHANGED
@@ -9,9 +9,24 @@ import argparse
9
9
  import json
10
10
  import shutil
11
11
  import sys
12
+ from dataclasses import dataclass
12
13
  from pathlib import Path
13
14
 
14
- from cicada.utils import get_storage_dir
15
+ from cicada.utils import (
16
+ get_hashes_path,
17
+ get_index_path,
18
+ get_pr_index_path,
19
+ get_storage_dir,
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class CleanItem:
25
+ """Represents an item to be cleaned/removed."""
26
+
27
+ description: str
28
+ path: Path
29
+ is_mcp_config: bool = False
15
30
 
16
31
 
17
32
  def remove_mcp_config_entry(config_path: Path, server_key: str = "cicada") -> bool:
@@ -29,7 +44,7 @@ def remove_mcp_config_entry(config_path: Path, server_key: str = "cicada") -> bo
29
44
  return False
30
45
 
31
46
  try:
32
- with open(config_path, "r") as f:
47
+ with open(config_path) as f:
33
48
  config = json.load(f)
34
49
 
35
50
  # Determine the config key based on editor type
@@ -50,12 +65,121 @@ def remove_mcp_config_entry(config_path: Path, server_key: str = "cicada") -> bo
50
65
 
51
66
  return True
52
67
 
53
- except (json.JSONDecodeError, IOError) as e:
68
+ except (OSError, json.JSONDecodeError) as e:
54
69
  print(f"Warning: Could not process {config_path}: {e}")
55
70
 
56
71
  return False
57
72
 
58
73
 
74
+ def clean_index_only(repo_path: Path) -> None:
75
+ """
76
+ Remove only the main index files (index.json and hashes.json).
77
+
78
+ Args:
79
+ repo_path: Path to the repository
80
+ """
81
+ repo_path = repo_path.resolve()
82
+
83
+ print("=" * 60)
84
+ print("Cicada Clean - Main Index")
85
+ print("=" * 60)
86
+ print()
87
+ print(f"Repository: {repo_path}")
88
+ print()
89
+
90
+ # Collect index files to remove
91
+ items_to_remove: list[CleanItem] = []
92
+
93
+ index_path = get_index_path(repo_path)
94
+ hashes_path = get_hashes_path(repo_path)
95
+
96
+ if index_path.exists():
97
+ items_to_remove.append(CleanItem("Main index", index_path))
98
+ if hashes_path.exists():
99
+ items_to_remove.append(CleanItem("File hashes", hashes_path))
100
+
101
+ # Show what will be removed
102
+ if not items_to_remove:
103
+ print("✓ No main index files found.")
104
+ print()
105
+ return
106
+
107
+ print("The following items will be removed:")
108
+ print()
109
+ for item in items_to_remove:
110
+ print(f" • {item.description}: {item.path}")
111
+ print()
112
+
113
+ # Remove items
114
+ removed_count = 0
115
+ errors = []
116
+ for item in items_to_remove:
117
+ try:
118
+ item.path.unlink()
119
+ print(f"✓ Removed {item.description}")
120
+ removed_count += 1
121
+ except (OSError, PermissionError) as e:
122
+ error_msg = f"Failed to remove {item.description}: {e}"
123
+ print(f"✗ {error_msg}")
124
+ errors.append(error_msg)
125
+
126
+ print()
127
+ print("=" * 60)
128
+ if errors:
129
+ print(
130
+ f"⚠ Cleanup completed with errors ({removed_count}/{len(items_to_remove)} items removed)"
131
+ )
132
+ print("=" * 60)
133
+ print()
134
+ sys.exit(1)
135
+ else:
136
+ print(f"✓ Cleanup Complete! ({removed_count} items removed)")
137
+ print("=" * 60)
138
+ print()
139
+
140
+
141
+ def clean_pr_index_only(repo_path: Path) -> None:
142
+ """
143
+ Remove only the PR index file (pr_index.json).
144
+
145
+ Args:
146
+ repo_path: Path to the repository
147
+ """
148
+ repo_path = repo_path.resolve()
149
+
150
+ print("=" * 60)
151
+ print("Cicada Clean - PR Index")
152
+ print("=" * 60)
153
+ print()
154
+ print(f"Repository: {repo_path}")
155
+ print()
156
+
157
+ pr_index_path = get_pr_index_path(repo_path)
158
+
159
+ if not pr_index_path.exists():
160
+ print("✓ No PR index file found.")
161
+ print()
162
+ return
163
+
164
+ print("The following item will be removed:")
165
+ print()
166
+ print(f" • PR index: {pr_index_path}")
167
+ print()
168
+
169
+ # Remove PR index
170
+ try:
171
+ pr_index_path.unlink()
172
+ print("✓ Removed PR index")
173
+ print()
174
+ print("=" * 60)
175
+ print("✓ Cleanup Complete!")
176
+ print("=" * 60)
177
+ print()
178
+ except (OSError, PermissionError) as e:
179
+ print(f"✗ Failed to remove PR index: {e}")
180
+ sys.exit(1)
181
+
182
+
59
183
  def clean_repository(repo_path: Path, force: bool = False) -> None:
60
184
  """
61
185
  Remove all Cicada configuration and indexes for a repository.
@@ -74,17 +198,17 @@ def clean_repository(repo_path: Path, force: bool = False) -> None:
74
198
  print()
75
199
 
76
200
  # Collect items to remove
77
- items_to_remove = []
201
+ items_to_remove: list[CleanItem] = []
78
202
 
79
203
  # 1. Storage directory (~/.cicada/projects/<repo_hash>/)
80
204
  storage_dir = get_storage_dir(repo_path)
81
205
  if storage_dir.exists():
82
- items_to_remove.append(("Storage directory", storage_dir))
206
+ items_to_remove.append(CleanItem("Storage directory", storage_dir))
83
207
 
84
208
  # 2. Old .cicada directory (backward compatibility)
85
209
  old_cicada_dir = repo_path / ".cicada"
86
210
  if old_cicada_dir.exists():
87
- items_to_remove.append(("Legacy .cicada directory", old_cicada_dir))
211
+ items_to_remove.append(CleanItem("Legacy .cicada directory", old_cicada_dir))
88
212
 
89
213
  # 3. MCP config files
90
214
  mcp_configs = [
@@ -97,18 +221,14 @@ def clean_repository(repo_path: Path, force: bool = False) -> None:
97
221
  if config_path.exists():
98
222
  # Check if cicada entry exists
99
223
  try:
100
- with open(config_path, "r") as f:
224
+ with open(config_path) as f:
101
225
  config = json.load(f)
102
226
 
103
- config_key = (
104
- "mcpServers" if ".vscode" not in str(config_path) else "mcp.servers"
105
- )
227
+ config_key = "mcpServers" if ".vscode" not in str(config_path) else "mcp.servers"
106
228
 
107
229
  if config_key in config and "cicada" in config[config_key]:
108
- items_to_remove.append(
109
- (desc, config_path, True)
110
- ) # True = is MCP config
111
- except (json.JSONDecodeError, IOError):
230
+ items_to_remove.append(CleanItem(desc, config_path, is_mcp_config=True))
231
+ except (OSError, json.JSONDecodeError):
112
232
  pass
113
233
 
114
234
  # Show what will be removed
@@ -120,10 +240,10 @@ def clean_repository(repo_path: Path, force: bool = False) -> None:
120
240
  print("The following items will be removed:")
121
241
  print()
122
242
  for item in items_to_remove:
123
- if len(item) == 3 and item[2]: # MCP config entry
124
- print(f" • {item[0]}: Remove 'cicada' entry from {item[1]}")
243
+ if item.is_mcp_config:
244
+ print(f" • {item.description}: Remove 'cicada' entry from {item.path}")
125
245
  else:
126
- print(f" • {item[0]}: {item[1]}")
246
+ print(f" • {item.description}: {item.path}")
127
247
  print()
128
248
 
129
249
  # Confirmation prompt
@@ -139,33 +259,46 @@ def clean_repository(repo_path: Path, force: bool = False) -> None:
139
259
 
140
260
  # Remove items
141
261
  removed_count = 0
262
+ errors = []
142
263
  for item in items_to_remove:
143
- if len(item) == 3 and item[2]: # MCP config entry
144
- desc, config_path, _ = item
145
- if remove_mcp_config_entry(config_path):
146
- print(f"✓ Removed 'cicada' entry from {desc}")
264
+ if item.is_mcp_config:
265
+ if remove_mcp_config_entry(item.path):
266
+ print(f"✓ Removed 'cicada' entry from {item.description}")
147
267
  removed_count += 1
268
+ else:
269
+ error_msg = f"Failed to remove 'cicada' entry from {item.description}"
270
+ print(f"✗ {error_msg}")
271
+ errors.append(error_msg)
148
272
  else:
149
- desc, path = item
150
273
  try:
151
- if path.is_dir():
152
- shutil.rmtree(path)
274
+ if item.path.is_dir():
275
+ shutil.rmtree(item.path)
153
276
  else:
154
- path.unlink()
155
- print(f"✓ Removed {desc}")
277
+ item.path.unlink()
278
+ print(f"✓ Removed {item.description}")
156
279
  removed_count += 1
157
280
  except (OSError, PermissionError) as e:
158
- print(f"Failed to remove {desc}: {e}")
281
+ error_msg = f"Failed to remove {item.description}: {e}"
282
+ print(f"✗ {error_msg}")
283
+ errors.append(error_msg)
159
284
 
160
285
  print()
161
286
  print("=" * 60)
162
- print(f"✓ Cleanup Complete! ({removed_count} items removed)")
163
- print("=" * 60)
164
- print()
165
- print("Next steps:")
166
- print("1. Restart your editor if it's currently running")
167
- print("2. Run 'uvx cicada <editor>' to set up Cicada again")
168
- print()
287
+ if errors:
288
+ print(
289
+ f"⚠ Cleanup completed with errors ({removed_count}/{len(items_to_remove)} items removed)"
290
+ )
291
+ print("=" * 60)
292
+ print()
293
+ sys.exit(1)
294
+ else:
295
+ print(f"✓ Cleanup Complete! ({removed_count} items removed)")
296
+ print("=" * 60)
297
+ print()
298
+ print("Next steps:")
299
+ print("1. Restart your editor if it's currently running")
300
+ print("2. Run 'uvx cicada <editor>' to set up Cicada again")
301
+ print()
169
302
 
170
303
 
171
304
  def clean_all_projects(force: bool = False) -> None:
@@ -219,36 +352,48 @@ def clean_all_projects(force: bool = False) -> None:
219
352
 
220
353
  # Remove all project directories
221
354
  removed_count = 0
355
+ errors = []
222
356
  for proj_dir in project_dirs:
223
357
  try:
224
358
  shutil.rmtree(proj_dir)
225
359
  print(f"✓ Removed {proj_dir.name}/")
226
360
  removed_count += 1
227
361
  except (OSError, PermissionError) as e:
228
- print(f"Failed to remove {proj_dir.name}/: {e}")
362
+ error_msg = f"Failed to remove {proj_dir.name}/: {e}"
363
+ print(f"✗ {error_msg}")
364
+ errors.append(error_msg)
229
365
 
230
366
  print()
231
367
  print("=" * 60)
232
- print(f"✓ Cleanup Complete! ({removed_count}/{len(project_dirs)} projects removed)")
233
- print("=" * 60)
234
- print()
368
+ if errors:
369
+ print(
370
+ f"⚠ Cleanup completed with errors ({removed_count}/{len(project_dirs)} projects removed)"
371
+ )
372
+ print("=" * 60)
373
+ print()
374
+ sys.exit(1)
375
+ else:
376
+ print(f"✓ Cleanup Complete! ({removed_count}/{len(project_dirs)} projects removed)")
377
+ print("=" * 60)
378
+ print()
235
379
 
236
380
 
237
381
  def main():
238
- """Main entry point for the clean command."""
382
+ """
383
+ Main entry point for the clean command.
384
+
385
+ Note: This function is kept for backward compatibility but the unified CLI
386
+ in cli.py should be used instead (cicada clean).
387
+ """
239
388
  parser = argparse.ArgumentParser(
240
- description="Remove all Cicada configuration and indexes for a repository",
389
+ description="Remove all Cicada configuration and indexes for current repository",
241
390
  epilog="Examples:\n"
242
- " cicada-clean -f # Clean current repository\n"
243
- " cicada-clean --all -f # Remove ALL project storage\n",
391
+ " cicada clean # Clean current repository\n"
392
+ " cicada clean -f # Clean current repository (skip confirmation)\n"
393
+ " cicada clean --all # Remove ALL project storage\n"
394
+ " cicada clean --all -f # Remove ALL project storage (skip confirmation)\n",
244
395
  formatter_class=argparse.RawDescriptionHelpFormatter,
245
396
  )
246
- parser.add_argument(
247
- "repo",
248
- nargs="?",
249
- default=None,
250
- help="Path to the repository (default: current directory)",
251
- )
252
397
  parser.add_argument(
253
398
  "-f",
254
399
  "--force",
@@ -272,18 +417,8 @@ def main():
272
417
  sys.exit(1)
273
418
  return
274
419
 
275
- # Determine repo path
276
- repo_path = Path(args.repo) if args.repo else Path.cwd()
277
-
278
- # Validate path exists
279
- if not repo_path.exists():
280
- print(f"Error: Path does not exist: {repo_path}")
281
- sys.exit(1)
282
-
283
- # Validate path is a directory
284
- if not repo_path.is_dir():
285
- print(f"Error: Path is not a directory: {repo_path}")
286
- sys.exit(1)
420
+ # Clean current directory
421
+ repo_path = Path.cwd()
287
422
 
288
423
  # Run cleanup
289
424
  try: