codegraph-nav 0.1.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 (41) hide show
  1. codegraph_nav/__init__.py +194 -0
  2. codegraph_nav/ast_grep_analyzer.py +448 -0
  3. codegraph_nav/cli.py +223 -0
  4. codegraph_nav/code_navigator.py +1328 -0
  5. codegraph_nav/code_search.py +1009 -0
  6. codegraph_nav/colors.py +209 -0
  7. codegraph_nav/completions.py +354 -0
  8. codegraph_nav/dart_analyzer.py +301 -0
  9. codegraph_nav/dependency_graph.py +814 -0
  10. codegraph_nav/domain/__init__.py +20 -0
  11. codegraph_nav/domain/routes.py +337 -0
  12. codegraph_nav/domain/schemas.py +229 -0
  13. codegraph_nav/domain/tags.py +87 -0
  14. codegraph_nav/exporters.py +563 -0
  15. codegraph_nav/go_analyzer.py +273 -0
  16. codegraph_nav/graph/__init__.py +72 -0
  17. codegraph_nav/graph/builder.py +409 -0
  18. codegraph_nav/graph/communities.py +402 -0
  19. codegraph_nav/graph/flows.py +311 -0
  20. codegraph_nav/graph/query.py +380 -0
  21. codegraph_nav/graph/schema.py +266 -0
  22. codegraph_nav/graph/search.py +257 -0
  23. codegraph_nav/graph/store.py +517 -0
  24. codegraph_nav/hints.py +195 -0
  25. codegraph_nav/import_resolver.py +891 -0
  26. codegraph_nav/js_ts_analyzer.py +564 -0
  27. codegraph_nav/line_reader.py +664 -0
  28. codegraph_nav/mcp/__init__.py +39 -0
  29. codegraph_nav/mcp/__main__.py +5 -0
  30. codegraph_nav/mcp/server.py +2228 -0
  31. codegraph_nav/py.typed +2 -0
  32. codegraph_nav/ruby_analyzer.py +259 -0
  33. codegraph_nav/rust_analyzer.py +379 -0
  34. codegraph_nav/token_efficient_renderer.py +743 -0
  35. codegraph_nav/watcher.py +382 -0
  36. codegraph_nav-0.1.0.dist-info/METADATA +487 -0
  37. codegraph_nav-0.1.0.dist-info/RECORD +41 -0
  38. codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
  39. codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
  40. codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. codegraph_nav-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,664 @@
1
+ #!/usr/bin/env python3
2
+ """Line Reader - Read specific lines or ranges from files.
3
+
4
+ This module provides surgical precision for reading code - load only the exact
5
+ lines you need instead of entire files, dramatically reducing token usage.
6
+
7
+ Example:
8
+ Command line usage:
9
+ $ code-read src/api.py 45-60
10
+ $ code-read src/api.py "10-20,45-60" -c 3
11
+ $ code-read src/api.py --search "def process"
12
+
13
+ Python API usage:
14
+ >>> reader = LineReader('/path/to/project')
15
+ >>> result = reader.read_lines('src/api.py', 45, 60)
16
+ >>> for line in result['lines']:
17
+ ... print(f"{line['num']}: {line['content']}")
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import re
23
+ from pathlib import Path
24
+
25
+ from .colors import get_colors
26
+
27
+ __version__ = "0.1.0"
28
+
29
+
30
+ class LineReader:
31
+ """Read specific lines from files efficiently.
32
+
33
+ Provides methods to read single ranges, multiple ranges, and search
34
+ for patterns within files, all with minimal overhead.
35
+
36
+ Attributes:
37
+ root_path: Base path for resolving relative file paths.
38
+
39
+ Example:
40
+ >>> reader = LineReader('/my/project')
41
+ >>> result = reader.read_lines('src/api.py', 45, 60, context=2)
42
+ >>> print(f"Read lines {result['actual'][0]}-{result['actual'][1]}")
43
+
44
+ >>> # Read a function with smart truncation
45
+ >>> symbol = reader.read_symbol('src/api.py', 45, 150, max_lines=50)
46
+ >>> print(f"Truncated: {symbol['truncated']}")
47
+ """
48
+
49
+ def __init__(self, root_path: str | None = None):
50
+ """Initialize the line reader.
51
+
52
+ Args:
53
+ root_path: Base directory for resolving relative paths.
54
+ Defaults to current working directory.
55
+ """
56
+ self.root_path = Path(root_path) if root_path else Path.cwd()
57
+
58
+ def _resolve_path(self, file_path: str) -> Path:
59
+ """Resolve a file path relative to root with security validation.
60
+
61
+ Args:
62
+ file_path: Relative or absolute file path.
63
+
64
+ Returns:
65
+ Resolved absolute Path object.
66
+
67
+ Raises:
68
+ ValueError: If the resolved path escapes the root directory
69
+ (path traversal attempt).
70
+ """
71
+ # Security check: reject symlinked root paths to prevent traversal attacks
72
+ if self.root_path.is_symlink():
73
+ raise PermissionError(
74
+ f"Security error: root path '{self.root_path}' is a symlink. "
75
+ "Symlinked root paths are not allowed."
76
+ )
77
+
78
+ path = Path(file_path)
79
+ if not path.is_absolute():
80
+ path = self.root_path / path
81
+
82
+ resolved = path.resolve()
83
+ root_resolved = self.root_path.resolve()
84
+
85
+ # Security check: ensure path doesn't escape root directory
86
+ try:
87
+ resolved.relative_to(root_resolved)
88
+ except ValueError:
89
+ raise ValueError(
90
+ f"Security error: path '{file_path}' escapes root directory. "
91
+ f"Resolved to '{resolved}' which is outside '{root_resolved}'"
92
+ ) from None
93
+
94
+ return resolved
95
+
96
+ def read_lines(
97
+ self, file_path: str, start: int, end: int | None = None, context: int = 0
98
+ ) -> dict:
99
+ """Read specific lines from a file.
100
+
101
+ Args:
102
+ file_path: Path to the file to read.
103
+ start: Starting line number (1-indexed).
104
+ end: Ending line number (inclusive). Defaults to start.
105
+ context: Number of context lines before and after.
106
+
107
+ Returns:
108
+ Dict with:
109
+ - file: The file path
110
+ - requested: [start, end] as requested
111
+ - actual: [start, end] after applying context
112
+ - total_lines: Total lines in file
113
+ - lines: List of {num, content, in_range} dicts
114
+
115
+ Example:
116
+ >>> result = reader.read_lines('api.py', 45, 60, context=2)
117
+ >>> print(result['lines'][0]['content'])
118
+ """
119
+ try:
120
+ path = self._resolve_path(file_path)
121
+ except ValueError as e:
122
+ return {"error": str(e)}
123
+
124
+ if not path.exists():
125
+ return {"error": f"File not found: {file_path}"}
126
+
127
+ try:
128
+ with open(path, encoding="utf-8", errors="replace") as f:
129
+ all_lines = f.readlines()
130
+ except Exception as e:
131
+ return {"error": f"Failed to read file: {e}"}
132
+
133
+ total_lines = len(all_lines)
134
+ end = end or start
135
+
136
+ actual_start = max(1, start - context)
137
+ actual_end = min(total_lines, end + context)
138
+
139
+ extracted = all_lines[actual_start - 1 : actual_end]
140
+
141
+ lines_with_numbers = []
142
+ for i, line in enumerate(extracted, start=actual_start):
143
+ lines_with_numbers.append(
144
+ {"num": i, "content": line.rstrip("\n\r"), "in_range": start <= i <= end}
145
+ )
146
+
147
+ return {
148
+ "file": file_path,
149
+ "requested": [start, end],
150
+ "actual": [actual_start, actual_end],
151
+ "total_lines": total_lines,
152
+ "lines": lines_with_numbers,
153
+ }
154
+
155
+ def read_ranges(
156
+ self, file_path: str, ranges: list[tuple[int, int]], context: int = 0, collapse_gap: int = 5
157
+ ) -> dict:
158
+ """Read multiple line ranges from a file efficiently.
159
+
160
+ Intelligently merges overlapping or close ranges to minimize
161
+ redundant reads while preserving the requested range markers.
162
+
163
+ Args:
164
+ file_path: Path to the file to read.
165
+ ranges: List of (start, end) tuples (1-indexed).
166
+ context: Context lines for each range.
167
+ collapse_gap: Merge ranges if gap is smaller than this.
168
+
169
+ Returns:
170
+ Dict with:
171
+ - file: The file path
172
+ - total_lines: Total lines in file
173
+ - sections: List of merged sections with lines
174
+
175
+ Example:
176
+ >>> ranges = [(10, 20), (25, 35), (100, 110)]
177
+ >>> result = reader.read_ranges('api.py', ranges, context=2)
178
+ >>> print(f"Got {len(result['sections'])} sections")
179
+ """
180
+ try:
181
+ path = self._resolve_path(file_path)
182
+ except ValueError as e:
183
+ return {"error": str(e)}
184
+
185
+ if not path.exists():
186
+ return {"error": f"File not found: {file_path}"}
187
+
188
+ try:
189
+ with open(path, encoding="utf-8", errors="replace") as f:
190
+ all_lines = f.readlines()
191
+ except Exception as e:
192
+ return {"error": f"Failed to read file: {e}"}
193
+
194
+ total_lines = len(all_lines)
195
+
196
+ # Normalize and sort ranges
197
+ normalized: list[tuple[int, int, int, int]] = []
198
+ for start, end in ranges:
199
+ s = max(1, start - context)
200
+ ctx_end = min(total_lines, end + context)
201
+ normalized.append((s, ctx_end, start, end))
202
+
203
+ normalized.sort(key=lambda x: x[0])
204
+
205
+ # Merge overlapping or close ranges
206
+ merged: list[tuple[int, int, list[tuple[int, int]]]] = []
207
+ for s, ctx_end, os, oe in normalized:
208
+ if merged and s <= merged[-1][1] + collapse_gap:
209
+ prev = merged[-1]
210
+ merged[-1] = (prev[0], max(prev[1], ctx_end), prev[2])
211
+ merged[-1][2].append((os, oe))
212
+ else:
213
+ merged.append((s, ctx_end, [(os, oe)]))
214
+
215
+ # Extract lines for each merged range
216
+ sections = []
217
+ for actual_start, actual_end, original_ranges in merged:
218
+ lines_with_numbers = []
219
+ for i in range(actual_start - 1, actual_end):
220
+ if i < len(all_lines):
221
+ line_num = i + 1
222
+ in_range = any(os <= line_num <= oe for os, oe in original_ranges)
223
+ lines_with_numbers.append(
224
+ {
225
+ "num": line_num,
226
+ "content": all_lines[i].rstrip("\n\r"),
227
+ "in_range": in_range,
228
+ }
229
+ )
230
+
231
+ sections.append(
232
+ {
233
+ "range": [actual_start, actual_end],
234
+ "original_ranges": original_ranges,
235
+ "lines": lines_with_numbers,
236
+ }
237
+ )
238
+
239
+ return {"file": file_path, "total_lines": total_lines, "sections": sections}
240
+
241
+ def read_symbol(
242
+ self,
243
+ file_path: str,
244
+ start: int,
245
+ end: int,
246
+ include_context: bool = True,
247
+ max_lines: int = 100,
248
+ ) -> dict:
249
+ """Read a symbol (function, class, etc.) with smart truncation.
250
+
251
+ For large symbols, shows signature + beginning + ... + end.
252
+ This prevents large functions from consuming excessive tokens.
253
+
254
+ Args:
255
+ file_path: Path to the file.
256
+ start: Symbol start line (1-indexed).
257
+ end: Symbol end line (1-indexed).
258
+ include_context: Add 2 lines before and 1 after.
259
+ max_lines: Maximum lines before truncation kicks in.
260
+
261
+ Returns:
262
+ Dict with:
263
+ - file: The file path
264
+ - range: [start, end]
265
+ - truncated: Boolean indicating if truncation occurred
266
+ - skipped_lines: Number of lines omitted (if truncated)
267
+ - lines: List of line dicts
268
+
269
+ Example:
270
+ >>> # A 200-line function will be truncated
271
+ >>> result = reader.read_symbol('api.py', 100, 300, max_lines=50)
272
+ >>> print(result['truncated']) # True
273
+ >>> print(result['skipped_lines']) # ~150
274
+ """
275
+ try:
276
+ path = self._resolve_path(file_path)
277
+ except ValueError as e:
278
+ return {"error": str(e)}
279
+
280
+ if not path.exists():
281
+ return {"error": f"File not found: {file_path}"}
282
+
283
+ try:
284
+ with open(path, encoding="utf-8", errors="replace") as f:
285
+ all_lines = f.readlines()
286
+ except Exception as e:
287
+ return {"error": f"Failed to read file: {e}"}
288
+
289
+ total_lines = len(all_lines)
290
+ start = max(1, start)
291
+ end = min(total_lines, end)
292
+ symbol_length = end - start + 1
293
+
294
+ context_start = max(1, start - 2) if include_context else start
295
+ context_end = min(total_lines, end + 1) if include_context else end
296
+
297
+ if symbol_length <= max_lines:
298
+ # Return full symbol
299
+ lines = []
300
+ for i in range(context_start - 1, context_end):
301
+ if i < len(all_lines):
302
+ lines.append(
303
+ {
304
+ "num": i + 1,
305
+ "content": all_lines[i].rstrip("\n\r"),
306
+ "in_range": start <= (i + 1) <= end,
307
+ }
308
+ )
309
+
310
+ return {"file": file_path, "range": [start, end], "truncated": False, "lines": lines}
311
+ else:
312
+ # Truncate: show beginning and end
313
+ head_lines = max_lines // 2
314
+ tail_lines = max_lines - head_lines - 1
315
+
316
+ lines = []
317
+
318
+ # Context before
319
+ for i in range(context_start - 1, start - 1):
320
+ if i < len(all_lines):
321
+ lines.append(
322
+ {"num": i + 1, "content": all_lines[i].rstrip("\n\r"), "in_range": False}
323
+ )
324
+
325
+ # Head of symbol
326
+ for i in range(start - 1, start - 1 + head_lines):
327
+ if i < len(all_lines):
328
+ lines.append(
329
+ {"num": i + 1, "content": all_lines[i].rstrip("\n\r"), "in_range": True}
330
+ )
331
+
332
+ # Ellipsis marker
333
+ skipped = symbol_length - head_lines - tail_lines
334
+ lines.append(
335
+ {"num": None, "content": f"... ({skipped} lines omitted) ...", "in_range": True}
336
+ )
337
+
338
+ # Tail of symbol
339
+ for i in range(end - tail_lines, end):
340
+ if i < len(all_lines) and i >= 0:
341
+ lines.append(
342
+ {"num": i + 1, "content": all_lines[i].rstrip("\n\r"), "in_range": True}
343
+ )
344
+
345
+ # Context after
346
+ for i in range(end, context_end):
347
+ if i < len(all_lines):
348
+ lines.append(
349
+ {"num": i + 1, "content": all_lines[i].rstrip("\n\r"), "in_range": False}
350
+ )
351
+
352
+ return {
353
+ "file": file_path,
354
+ "range": [start, end],
355
+ "truncated": True,
356
+ "skipped_lines": skipped,
357
+ "lines": lines,
358
+ }
359
+
360
+ def search_in_file(
361
+ self, file_path: str, pattern: str, context: int = 2, max_matches: int = 10
362
+ ) -> dict:
363
+ """Search for a pattern in a file and return matching lines with context.
364
+
365
+ Args:
366
+ file_path: Path to the file.
367
+ pattern: Regex pattern or literal string to search.
368
+ context: Context lines around each match.
369
+ max_matches: Maximum matches to return.
370
+
371
+ Returns:
372
+ Dict with:
373
+ - file: The file path
374
+ - pattern: The search pattern
375
+ - matches: Number of matches found
376
+ - sections: List of matching sections with context
377
+
378
+ Example:
379
+ >>> result = reader.search_in_file('api.py', 'def process')
380
+ >>> print(f"Found {result['matches']} matches")
381
+ """
382
+ try:
383
+ path = self._resolve_path(file_path)
384
+ except ValueError as e:
385
+ return {"error": str(e)}
386
+
387
+ if not path.exists():
388
+ return {"error": f"File not found: {file_path}"}
389
+
390
+ try:
391
+ with open(path, encoding="utf-8", errors="replace") as f:
392
+ all_lines = f.readlines()
393
+ except Exception as e:
394
+ return {"error": f"Failed to read file: {e}"}
395
+
396
+ # Find matches
397
+ matches = []
398
+ try:
399
+ regex = re.compile(pattern, re.IGNORECASE)
400
+ except re.error as e:
401
+ return {
402
+ "error": f"Invalid regex pattern: {e}",
403
+ "file": file_path,
404
+ "pattern": pattern,
405
+ "matches": 0,
406
+ "sections": [],
407
+ }
408
+
409
+ for i, line in enumerate(all_lines):
410
+ if regex.search(line):
411
+ matches.append(i + 1)
412
+ if len(matches) >= max_matches:
413
+ break
414
+
415
+ if not matches:
416
+ return {"file": file_path, "pattern": pattern, "matches": 0, "sections": []}
417
+
418
+ # Convert matches to ranges with context
419
+ ranges = [(m, m) for m in matches]
420
+ result = self.read_ranges(file_path, ranges, context=context)
421
+ result["pattern"] = pattern
422
+ result["matches"] = len(matches)
423
+
424
+ return result
425
+
426
+
427
+ def format_output(
428
+ result: dict, style: str = "json", compact: bool = False, no_color: bool = False
429
+ ) -> str:
430
+ """Format the output for display.
431
+
432
+ Args:
433
+ result: The result dict to format.
434
+ style: Output style ('json' or 'code').
435
+ compact: If True, output compact JSON without indentation.
436
+ no_color: If True, disable colored output.
437
+
438
+ Returns:
439
+ Formatted string representation.
440
+ """
441
+ if style == "json":
442
+ if compact:
443
+ return json.dumps(result, separators=(",", ":"))
444
+ return json.dumps(result, indent=2)
445
+
446
+ elif style == "code":
447
+ c = get_colors(no_color=no_color)
448
+
449
+ if "error" in result:
450
+ return c.error(f"Error: {result['error']}")
451
+
452
+ output = []
453
+ output.append(c.cyan(f"# {result.get('file', 'Unknown file')}"))
454
+
455
+ if "lines" in result:
456
+ lines = result["lines"]
457
+ for line in lines:
458
+ num = line.get("num")
459
+ content = line.get("content", "")
460
+ if num is None:
461
+ # Ellipsis/omitted lines
462
+ output.append(c.dim(f" {content}"))
463
+ else:
464
+ in_range = line.get("in_range")
465
+ marker = c.green(">") if in_range else " "
466
+ line_num = c.cyan(f"{num:4d}")
467
+ if in_range:
468
+ output.append(f"{marker}{line_num} | {content}")
469
+ else:
470
+ # Context lines (dimmed)
471
+ output.append(f"{marker}{line_num} | {c.dim(content)}")
472
+
473
+ elif "sections" in result:
474
+ for i, section in enumerate(result["sections"]):
475
+ if i > 0:
476
+ output.append(c.dim("..."))
477
+ for line in section.get("lines", []):
478
+ num = line.get("num")
479
+ content = line.get("content", "")
480
+ if num is None:
481
+ output.append(c.dim(f" {content}"))
482
+ else:
483
+ in_range = line.get("in_range")
484
+ marker = c.green(">") if in_range else " "
485
+ line_num = c.cyan(f"{num:4d}")
486
+ if in_range:
487
+ output.append(f"{marker}{line_num} | {content}")
488
+ else:
489
+ output.append(f"{marker}{line_num} | {c.dim(content)}")
490
+
491
+ return "\n".join(output)
492
+
493
+ return json.dumps(result)
494
+
495
+
496
+ def add_read_arguments(parser: argparse.ArgumentParser) -> None:
497
+ """Add read command arguments to a parser.
498
+
499
+ Args:
500
+ parser: The argument parser to add arguments to.
501
+ """
502
+ parser.add_argument("file", help="Path to the file to read")
503
+ parser.add_argument("lines", nargs="?", help='Line range (e.g., "10", "10-20", "10,20,30-40")')
504
+ parser.add_argument("-r", "--root", help="Root directory for relative paths")
505
+ parser.add_argument(
506
+ "-c", "--context", type=int, default=0, help="Number of context lines (default: 0)"
507
+ )
508
+ parser.add_argument("-s", "--search", help="Search for pattern instead of line numbers")
509
+ parser.add_argument(
510
+ "--symbol", action="store_true", help="Read as symbol with smart truncation"
511
+ )
512
+ parser.add_argument(
513
+ "--max-lines", type=int, default=100, help="Maximum lines before truncation (default: 100)"
514
+ )
515
+ parser.add_argument(
516
+ "-o",
517
+ "--output",
518
+ choices=["json", "code"],
519
+ default="json",
520
+ help="Output format (default: json)",
521
+ )
522
+ parser.add_argument(
523
+ "--compact", action="store_true", help="Output compact JSON (default: pretty-printed)"
524
+ )
525
+ parser.add_argument("--no-color", action="store_true", help="Disable colored output")
526
+
527
+
528
+ def run_read(args: argparse.Namespace) -> None:
529
+ """Execute the read command with parsed arguments.
530
+
531
+ Args:
532
+ args: Parsed command-line arguments.
533
+ """
534
+ reader = LineReader(args.root)
535
+
536
+ if args.search:
537
+ result = reader.search_in_file(args.file, args.search, context=args.context)
538
+ elif args.lines:
539
+ # Parse line specification with validation
540
+ ranges = []
541
+ for part in args.lines.split(","):
542
+ part = part.strip()
543
+ if not part:
544
+ # Skip empty parts (e.g., "10,,20" or trailing comma)
545
+ continue
546
+ try:
547
+ if "-" in part:
548
+ start_str, end_str = part.split("-", 1)
549
+ start = int(start_str.strip())
550
+ end = int(end_str.strip())
551
+ if start < 1:
552
+ result = {
553
+ "error": f"Invalid line number: {start}. Line numbers must be >= 1"
554
+ }
555
+ print(
556
+ format_output(
557
+ result, args.output, compact=args.compact, no_color=args.no_color
558
+ )
559
+ )
560
+ return
561
+ if end < 1:
562
+ result = {"error": f"Invalid line number: {end}. Line numbers must be >= 1"}
563
+ print(
564
+ format_output(
565
+ result, args.output, compact=args.compact, no_color=args.no_color
566
+ )
567
+ )
568
+ return
569
+ if start > end:
570
+ result = {"error": f"Invalid range: {start}-{end}. Start must be <= end"}
571
+ print(
572
+ format_output(
573
+ result, args.output, compact=args.compact, no_color=args.no_color
574
+ )
575
+ )
576
+ return
577
+ ranges.append((start, end))
578
+ else:
579
+ line = int(part)
580
+ if line < 1:
581
+ result = {
582
+ "error": f"Invalid line number: {line}. Line numbers must be >= 1"
583
+ }
584
+ print(
585
+ format_output(
586
+ result, args.output, compact=args.compact, no_color=args.no_color
587
+ )
588
+ )
589
+ return
590
+ ranges.append((line, line))
591
+ except ValueError:
592
+ result = {
593
+ "error": f"Invalid line specification: '{part}'. Expected number or range (e.g., '10' or '10-20')"
594
+ }
595
+ print(
596
+ format_output(result, args.output, compact=args.compact, no_color=args.no_color)
597
+ )
598
+ return
599
+
600
+ if not ranges:
601
+ result = {"error": "No valid line ranges specified"}
602
+ print(format_output(result, args.output, compact=args.compact, no_color=args.no_color))
603
+ return
604
+
605
+ if len(ranges) == 1 and args.symbol:
606
+ result = reader.read_symbol(
607
+ args.file,
608
+ ranges[0][0],
609
+ ranges[0][1],
610
+ include_context=args.context > 0,
611
+ max_lines=args.max_lines,
612
+ )
613
+ elif len(ranges) == 1:
614
+ result = reader.read_lines(args.file, ranges[0][0], ranges[0][1], context=args.context)
615
+ else:
616
+ result = reader.read_ranges(args.file, ranges, context=args.context)
617
+ else:
618
+ # Default: show file info
619
+ try:
620
+ path = reader._resolve_path(args.file)
621
+ except ValueError as e:
622
+ result = {"error": str(e)}
623
+ print(format_output(result, args.output, compact=args.compact, no_color=args.no_color))
624
+ return
625
+
626
+ if path.exists():
627
+ with open(path, encoding="utf-8", errors="replace") as f:
628
+ lines = f.readlines()
629
+ result = {
630
+ "file": args.file,
631
+ "total_lines": len(lines),
632
+ "hint": 'Specify lines to read (e.g., "10-20") or use --search',
633
+ }
634
+ else:
635
+ result = {"error": f"File not found: {args.file}"}
636
+
637
+ print(format_output(result, args.output, compact=args.compact, no_color=args.no_color))
638
+
639
+
640
+ def main():
641
+ """Command-line interface for the line reader.
642
+
643
+ Usage:
644
+ code-read FILE LINES [-c CONTEXT] [--symbol] [-o FORMAT]
645
+ code-read FILE --search PATTERN
646
+
647
+ Examples:
648
+ $ code-read src/api.py 45-60 -c 2
649
+ $ code-read src/api.py "10,20-30,50" --symbol
650
+ $ code-read src/api.py --search "def process" -o code
651
+ """
652
+ parser = argparse.ArgumentParser(
653
+ description="Read specific lines from files for token-efficient code viewing",
654
+ epilog="Example: code-read src/api.py 45-60 -c 2 -o code",
655
+ )
656
+ add_read_arguments(parser)
657
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
658
+
659
+ args = parser.parse_args()
660
+ run_read(args)
661
+
662
+
663
+ if __name__ == "__main__":
664
+ main()
@@ -0,0 +1,39 @@
1
+ """Codenav MCP Server - Model Context Protocol integration.
2
+
3
+ This module exposes Codenav's functionality as MCP tools and resources,
4
+ enabling seamless integration with Claude Code (CLI and VS Code),
5
+ Claude Desktop, and other MCP-compatible AI assistants.
6
+
7
+ Requires the ``mcp`` extra: ``pip install codegraph-nav[mcp]``
8
+
9
+ Usage:
10
+ # Entry point (recommended)
11
+ codegraph-nav-mcp
12
+
13
+ # Or as a Python module
14
+ python -m codegraph_nav.mcp
15
+ """
16
+
17
+ try:
18
+ from .server import create_server, main, mcp, run_server
19
+
20
+ MCP_AVAILABLE = True
21
+ except ImportError:
22
+ MCP_AVAILABLE = False
23
+ mcp = None # type: ignore
24
+ create_server = None # type: ignore
25
+ run_server = None # type: ignore
26
+
27
+ def main(): # type: ignore
28
+ raise SystemExit(
29
+ "MCP dependencies not installed. Install with: pip install codegraph-nav[mcp]"
30
+ )
31
+
32
+
33
+ __all__ = [
34
+ "MCP_AVAILABLE",
35
+ "mcp",
36
+ "create_server",
37
+ "run_server",
38
+ "main",
39
+ ]