repolens-cli 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.
repolens/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """RepoLens — AI-native codebase intelligence."""
repolens/ai_client.py ADDED
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Optional
5
+
6
+ from openai import OpenAI
7
+
8
+ from .models import RepoAnalysis
9
+
10
+
11
+ _PROVIDER_DEFAULTS: dict[str, dict] = {
12
+ "openai": {
13
+ "base_url": "https://api.openai.com/v1",
14
+ "model": "gpt-4o",
15
+ "key_env": "OPENAI_API_KEY",
16
+ },
17
+ "gemini": {
18
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
19
+ "model": "gemini-2.5-flash",
20
+ "key_env": "GEMINI_API_KEY",
21
+ },
22
+ "groq": {
23
+ "base_url": "https://api.groq.com/openai/v1",
24
+ "model": "llama-3.3-70b-versatile",
25
+ "key_env": "GROQ_API_KEY",
26
+ },
27
+ "ollama": {
28
+ "base_url": "http://localhost:11434/v1",
29
+ "model": "llama3.2",
30
+ "key_env": None, # no key needed
31
+ },
32
+ "anthropic": {
33
+ "base_url": "https://api.anthropic.com/v1",
34
+ "model": "claude-sonnet-4-6",
35
+ "key_env": "ANTHROPIC_API_KEY",
36
+ },
37
+ }
38
+
39
+ _client: Optional[OpenAI] = None
40
+ _model: str = ""
41
+
42
+
43
+ def is_configured() -> bool:
44
+ for p, cfg in _PROVIDER_DEFAULTS.items():
45
+ key_env = cfg.get("key_env")
46
+ if key_env and os.environ.get(key_env):
47
+ return True
48
+ if os.environ.get("REPOLENS_AI_PROVIDER") == "ollama":
49
+ return True
50
+ if os.environ.get("REPOLENS_AI_BASE_URL"):
51
+ return True
52
+ if os.environ.get("REPOLENS_AI_API_KEY"):
53
+ return True
54
+ return False
55
+
56
+
57
+ def _get_client() -> tuple[OpenAI, str]:
58
+ global _client, _model
59
+ if _client is not None:
60
+ return _client, _model
61
+
62
+ provider = os.environ.get("REPOLENS_AI_PROVIDER", "").lower()
63
+ if not provider:
64
+ # auto-detect from available keys
65
+ for p, cfg in _PROVIDER_DEFAULTS.items():
66
+ key_env = cfg.get("key_env")
67
+ if key_env and os.environ.get(key_env):
68
+ provider = p
69
+ break
70
+ if not provider:
71
+ if os.environ.get("REPOLENS_AI_API_KEY") and os.environ.get("REPOLENS_AI_BASE_URL"):
72
+ provider = "custom"
73
+ elif os.environ.get("REPOLENS_AI_BASE_URL"):
74
+ provider = "ollama" # no-auth local provider
75
+ else:
76
+ raise RuntimeError(
77
+ "No AI provider configured.\n"
78
+ "Set REPOLENS_AI_PROVIDER and a matching API key, e.g.:\n"
79
+ " REPOLENS_AI_PROVIDER=gemini GEMINI_API_KEY=...\n"
80
+ " REPOLENS_AI_PROVIDER=groq GROQ_API_KEY=...\n"
81
+ " REPOLENS_AI_PROVIDER=ollama (no key needed)\n"
82
+ " REPOLENS_AI_PROVIDER=openai OPENAI_API_KEY=...\n"
83
+ "See .env.example for full reference."
84
+ )
85
+
86
+ cfg = _PROVIDER_DEFAULTS.get(provider, {})
87
+ base_url = os.environ.get("REPOLENS_AI_BASE_URL") or cfg.get("base_url", "")
88
+ model = os.environ.get("REPOLENS_AI_MODEL") or cfg.get("model", "gpt-4o")
89
+
90
+ # Resolve API key
91
+ api_key = os.environ.get("REPOLENS_AI_API_KEY")
92
+ if not api_key:
93
+ key_env = cfg.get("key_env")
94
+ if key_env:
95
+ api_key = os.environ.get(key_env)
96
+ if not api_key:
97
+ api_key = "ollama" # openai SDK requires a non-empty string; local providers ignore it
98
+
99
+ _client = OpenAI(api_key=api_key, base_url=base_url or None)
100
+ _model = model
101
+ return _client, _model
102
+
103
+
104
+ def _build_repo_context(analysis: RepoAnalysis) -> str:
105
+ lines: list[str] = []
106
+ lines.append(f"# Repository: {analysis.root}")
107
+ lines.append(f"Files analysed: {len(analysis.file_analyses)}")
108
+ lines.append("")
109
+
110
+ lines.append("## File Tree")
111
+ for f in analysis.files[:100]:
112
+ in_deg = analysis.stats.in_degree.get(f.path, 0)
113
+ badge = f" [{in_deg}←]" if in_deg > 0 else ""
114
+ lines.append(f" {f.path} ({f.language}){badge}")
115
+ if len(analysis.files) > 100:
116
+ lines.append(f" … and {len(analysis.files) - 100} more")
117
+ lines.append("")
118
+
119
+ lines.append("## Import Graph (file → local deps)")
120
+ for path, deps in list(analysis.stats.import_edges.items())[:50]:
121
+ if deps:
122
+ lines.append(f" {path} → {', '.join(deps)}")
123
+ lines.append("")
124
+
125
+ if analysis.stats.circular_deps:
126
+ lines.append("## ⚠ Circular Dependencies")
127
+ for cycle in analysis.stats.circular_deps:
128
+ lines.append(" " + " → ".join(cycle) + " → " + cycle[0])
129
+ lines.append("")
130
+
131
+ lines.append("## Entry Points")
132
+ for ep in analysis.stats.entry_points[:20]:
133
+ lines.append(f" {ep}")
134
+ lines.append("")
135
+
136
+ lines.append("## Most-Imported Files")
137
+ for path, count in analysis.stats.hub_files[:10]:
138
+ if count > 0:
139
+ lines.append(f" {path} ({count} importers)")
140
+ lines.append("")
141
+
142
+ lines.append("## Functions per File (sample)")
143
+ items = sorted(
144
+ analysis.file_analyses.items(),
145
+ key=lambda x: len(x[1].functions),
146
+ reverse=True,
147
+ )[:20]
148
+ for path, fa in items:
149
+ if fa.functions:
150
+ names = ", ".join(f.name for f in fa.functions[:10])
151
+ lines.append(f" {path}: {names}")
152
+
153
+ return "\n".join(lines)
154
+
155
+
156
+ _SYSTEM_PROMPT = (
157
+ "You are RepoLens, an AI assistant that helps developers understand codebases. "
158
+ "You have a structured summary of a code repository: file tree, import dependency "
159
+ "graph, circular dependency alerts, entry points, and function listings. "
160
+ "Answer concisely, reference actual file names, and trace call chains step by step "
161
+ "when asked. If unsure, say so."
162
+ )
163
+
164
+
165
+ def ask(
166
+ analysis: RepoAnalysis,
167
+ question: str,
168
+ history: list[dict] | None = None,
169
+ ) -> str:
170
+ """Send *question* to the model, including prior *history* for multi-turn chat.
171
+
172
+ history: list of {"role": "user"|"assistant", "content": str} pairs
173
+ from previous turns (oldest first, excluding repo context).
174
+ """
175
+ client, model = _get_client()
176
+ context = _build_repo_context(analysis)
177
+
178
+ # First user turn carries the repo context; subsequent turns are plain text.
179
+ if history:
180
+ first_user = history[0]["content"]
181
+ if not first_user.startswith("<repo_context>"):
182
+ history[0] = {
183
+ "role": "user",
184
+ "content": f"<repo_context>\n{context}\n</repo_context>\n\n{first_user}",
185
+ }
186
+ messages = [{"role": "system", "content": _SYSTEM_PROMPT}] + history + [
187
+ {"role": "user", "content": question}
188
+ ]
189
+ else:
190
+ messages = [
191
+ {"role": "system", "content": _SYSTEM_PROMPT},
192
+ {
193
+ "role": "user",
194
+ "content": f"<repo_context>\n{context}\n</repo_context>\n\nQuestion: {question}",
195
+ },
196
+ ]
197
+
198
+ response = client.chat.completions.create(
199
+ model=model,
200
+ max_tokens=1024,
201
+ messages=messages,
202
+ )
203
+ return response.choices[0].message.content or ""
204
+
205
+
206
+ def generate_onboarding(analysis: RepoAnalysis) -> str:
207
+ client, model = _get_client()
208
+ context = _build_repo_context(analysis)
209
+ response = client.chat.completions.create(
210
+ model=model,
211
+ max_tokens=2048,
212
+ messages=[
213
+ {"role": "system", "content": _SYSTEM_PROMPT},
214
+ {
215
+ "role": "user",
216
+ "content": (
217
+ f"<repo_context>\n{context}\n</repo_context>\n\n"
218
+ "Generate a new developer onboarding guide. Include:\n"
219
+ "1. What this codebase does (1-2 sentences)\n"
220
+ "2. Key abstractions to understand first\n"
221
+ "3. Entry points — where to start reading\n"
222
+ "4. Most important files and what each does\n"
223
+ "5. Architectural patterns worth knowing\n"
224
+ "6. Circular dependencies or tech debt to be aware of\n\n"
225
+ "Be specific; reference actual file names."
226
+ ),
227
+ },
228
+ ],
229
+ )
230
+ return response.choices[0].message.content or ""
repolens/analyzer.py ADDED
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from .models import FileAnalysis, FileNode, FunctionNode
9
+
10
+
11
+ # ── Python ────────────────────────────────────────────────────────────────────
12
+
13
+ def _py_resolve(module: str, level: int, current_file: str, all_paths: set[str]) -> Optional[str]:
14
+ """Resolve a Python import to a repo-relative file path."""
15
+ parts = module.split(".") if module else []
16
+
17
+ if level > 0:
18
+ base = Path(current_file).parent
19
+ for _ in range(level - 1):
20
+ base = base.parent
21
+ candidate_parts = list(base.parts) + parts
22
+ else:
23
+ candidate_parts = parts
24
+
25
+ as_path = "/".join(candidate_parts)
26
+ for candidate in (f"{as_path}.py", f"{as_path}/__init__.py"):
27
+ if candidate in all_paths:
28
+ return candidate
29
+ return None
30
+
31
+
32
+ def _call_name(node: ast.expr) -> Optional[str]:
33
+ if isinstance(node, ast.Name):
34
+ return node.id
35
+ if isinstance(node, ast.Attribute):
36
+ obj = _call_name(node.value)
37
+ return f"{obj}.{node.attr}" if obj else node.attr
38
+ return None
39
+
40
+
41
+ def _analyze_python(file_node: FileNode, all_paths: set[str]) -> FileAnalysis:
42
+ fa = FileAnalysis(path=file_node.path, language="python")
43
+ if not file_node.content:
44
+ return fa
45
+ try:
46
+ tree = ast.parse(file_node.content, filename=file_node.path)
47
+ except SyntaxError:
48
+ return fa
49
+
50
+ for node in ast.walk(tree):
51
+ if isinstance(node, ast.Import):
52
+ for alias in node.names:
53
+ fa.raw_imports.append(alias.name)
54
+ r = _py_resolve(alias.name, 0, file_node.path, all_paths)
55
+ if r:
56
+ fa.resolved_imports.append(r)
57
+
58
+ elif isinstance(node, ast.ImportFrom):
59
+ module = node.module or ""
60
+ level = node.level
61
+ fa.raw_imports.append(("." * level) + module)
62
+ r = _py_resolve(module, level, file_node.path, all_paths)
63
+ if r:
64
+ fa.resolved_imports.append(r)
65
+
66
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
67
+ calls: list[str] = []
68
+ for child in ast.walk(node):
69
+ if child is node:
70
+ continue
71
+ if isinstance(child, ast.Call):
72
+ name = _call_name(child.func)
73
+ if name:
74
+ calls.append(name)
75
+ raw_doc = ast.get_docstring(node, clean=True)
76
+ # Trim to first paragraph so long docstrings don't flood the TUI
77
+ docstring = raw_doc.split("\n\n")[0].strip() if raw_doc else None
78
+ fa.functions.append(
79
+ FunctionNode(
80
+ name=node.name,
81
+ file_path=file_node.path,
82
+ line_start=node.lineno,
83
+ line_end=node.end_lineno or node.lineno,
84
+ calls=calls,
85
+ docstring=docstring,
86
+ )
87
+ )
88
+
89
+ elif isinstance(node, ast.ClassDef):
90
+ fa.classes.append(node.name)
91
+
92
+ return fa
93
+
94
+
95
+ # ── JavaScript / TypeScript ───────────────────────────────────────────────────
96
+
97
+ _JS_IMPORT_RE = re.compile(
98
+ r"""(?:
99
+ import\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]
100
+ | (?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)
101
+ | export\s+[^'"]*?\s+from\s+['"]([^'"]+)['"]
102
+ )""",
103
+ re.VERBOSE | re.MULTILINE,
104
+ )
105
+
106
+ _JS_FUNC_RE = re.compile(
107
+ r"""(?:
108
+ (?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+(\w+)\s*\(
109
+ | (?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s*)?\(
110
+ | (?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?function
111
+ )""",
112
+ re.VERBOSE | re.MULTILINE,
113
+ )
114
+
115
+ # Matches /** ... */ JSDoc block immediately before a function
116
+ _JSDOC_RE = re.compile(r'/\*\*(.*?)\*/', re.DOTALL)
117
+
118
+
119
+ def _js_resolve(import_path: str, current_file: str, all_paths: set[str]) -> Optional[str]:
120
+ if not import_path.startswith("."):
121
+ return None
122
+ base = Path(current_file).parent
123
+ candidate = (base / import_path).as_posix()
124
+ for ext in ("", ".js", ".jsx", ".ts", ".tsx", "/index.js", "/index.ts", "/index.tsx"):
125
+ p = candidate + ext
126
+ if p in all_paths:
127
+ return p
128
+ return None
129
+
130
+
131
+ def _analyze_js(file_node: FileNode, all_paths: set[str]) -> FileAnalysis:
132
+ fa = FileAnalysis(path=file_node.path, language=file_node.language)
133
+ if not file_node.content:
134
+ return fa
135
+ content = file_node.content
136
+
137
+ for m in _JS_IMPORT_RE.finditer(content):
138
+ raw = m.group(1) or m.group(2) or m.group(3)
139
+ if not raw:
140
+ continue
141
+ fa.raw_imports.append(raw)
142
+ r = _js_resolve(raw, file_node.path, all_paths)
143
+ if r:
144
+ fa.resolved_imports.append(r)
145
+
146
+ for m in _JS_FUNC_RE.finditer(content):
147
+ name = m.group(1) or m.group(2) or m.group(3)
148
+ if not name:
149
+ continue
150
+ line = content[: m.start()].count("\n") + 1
151
+ # Look for a JSDoc comment ending just before this function
152
+ preceding = content[: m.start()].rstrip()
153
+ jsdoc_match = _JSDOC_RE.search(preceding)
154
+ docstring: Optional[str] = None
155
+ if jsdoc_match and preceding.endswith("*/"):
156
+ raw = jsdoc_match.group(1)
157
+ # Strip leading " * " from each line and @param/@returns tags
158
+ lines = [re.sub(r'^\s*\*\s?', '', l) for l in raw.splitlines()]
159
+ desc_lines = [l for l in lines if l.strip() and not l.strip().startswith("@")]
160
+ if desc_lines:
161
+ docstring = " ".join(desc_lines).strip()
162
+ fa.functions.append(
163
+ FunctionNode(
164
+ name=name,
165
+ file_path=file_node.path,
166
+ line_start=line,
167
+ line_end=line,
168
+ docstring=docstring,
169
+ )
170
+ )
171
+ return fa
172
+
173
+
174
+ # ── Go ────────────────────────────────────────────────────────────────────────
175
+
176
+ _GO_IMPORT_BLOCK_RE = re.compile(r'import\s*\(([^)]+)\)', re.DOTALL)
177
+ _GO_IMPORT_SINGLE_RE = re.compile(r'^import\s+"([^"]+)"', re.MULTILINE)
178
+ _GO_FUNC_RE = re.compile(r'^func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(', re.MULTILINE)
179
+
180
+
181
+ def _analyze_go(file_node: FileNode, _all_paths: set[str]) -> FileAnalysis:
182
+ fa = FileAnalysis(path=file_node.path, language="go")
183
+ if not file_node.content:
184
+ return fa
185
+ content = file_node.content
186
+ for block in _GO_IMPORT_BLOCK_RE.findall(content):
187
+ for imp in re.findall(r'"([^"]+)"', block):
188
+ fa.raw_imports.append(imp)
189
+ for m in _GO_IMPORT_SINGLE_RE.finditer(content):
190
+ fa.raw_imports.append(m.group(1))
191
+ for m in _GO_FUNC_RE.finditer(content):
192
+ line = content[: m.start()].count("\n") + 1
193
+ fa.functions.append(
194
+ FunctionNode(name=m.group(1), file_path=file_node.path, line_start=line, line_end=line)
195
+ )
196
+ return fa
197
+
198
+
199
+ # ── Rust ──────────────────────────────────────────────────────────────────────
200
+
201
+ _RUST_USE_RE = re.compile(r'^use\s+([\w::{},\s*]+);', re.MULTILINE)
202
+ _RUST_FN_RE = re.compile(r'^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*[\(<]', re.MULTILINE)
203
+
204
+
205
+ def _analyze_rust(file_node: FileNode, _all_paths: set[str]) -> FileAnalysis:
206
+ fa = FileAnalysis(path=file_node.path, language="rust")
207
+ if not file_node.content:
208
+ return fa
209
+ content = file_node.content
210
+ for m in _RUST_USE_RE.finditer(content):
211
+ fa.raw_imports.append(m.group(1).strip())
212
+ for m in _RUST_FN_RE.finditer(content):
213
+ line = content[: m.start()].count("\n") + 1
214
+ fa.functions.append(
215
+ FunctionNode(name=m.group(1), file_path=file_node.path, line_start=line, line_end=line)
216
+ )
217
+ return fa
218
+
219
+
220
+ # ── Dispatcher ────────────────────────────────────────────────────────────────
221
+
222
+ def analyze_file(file_node: FileNode, all_paths: set[str]) -> FileAnalysis:
223
+ dispatch = {
224
+ "python": _analyze_python,
225
+ "javascript": _analyze_js,
226
+ "typescript": _analyze_js,
227
+ "go": _analyze_go,
228
+ "rust": _analyze_rust,
229
+ }
230
+ fn = dispatch.get(file_node.language)
231
+ if fn:
232
+ return fn(file_node, all_paths)
233
+ return FileAnalysis(path=file_node.path, language=file_node.language)
234
+
235
+
236
+ def analyze_all(files: list[FileNode]) -> dict[str, FileAnalysis]:
237
+ all_paths = {f.path for f in files}
238
+ return {
239
+ f.path: analyze_file(f, all_paths)
240
+ for f in files
241
+ if f.content is not None
242
+ }
repolens/cli.py ADDED
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def main() -> None:
9
+ parser = argparse.ArgumentParser(
10
+ prog="repolens",
11
+ description="RepoLens — AI-native codebase intelligence",
12
+ )
13
+ parser.add_argument(
14
+ "path",
15
+ nargs="?",
16
+ default=".",
17
+ help="Directory to analyse (default: current directory)",
18
+ )
19
+ parser.add_argument(
20
+ "--no-ai",
21
+ action="store_true",
22
+ help="Skip AI features",
23
+ )
24
+ parser.add_argument(
25
+ "--max-files",
26
+ type=int,
27
+ default=2000,
28
+ help="Max source files to scan (default: 2000)",
29
+ )
30
+ parser.add_argument(
31
+ "--json",
32
+ action="store_true",
33
+ help="Output analysis as JSON instead of launching TUI",
34
+ )
35
+ args = parser.parse_args()
36
+
37
+ root = Path(args.path).resolve()
38
+ if not root.is_dir():
39
+ print(f"Error: {root} is not a directory.", file=sys.stderr)
40
+ sys.exit(1)
41
+
42
+ print(f"RepoLens scanning {root} …")
43
+
44
+ try:
45
+ from dotenv import load_dotenv
46
+ load_dotenv()
47
+ except ImportError:
48
+ pass
49
+
50
+ from repolens.scanner import scan
51
+ from repolens.analyzer import analyze_all
52
+ from repolens.graph import build_graph
53
+ from repolens.models import RepoAnalysis
54
+
55
+ print(" Walking directory tree…")
56
+ files = scan(str(root), max_files=args.max_files)
57
+ print(f" Found {len(files)} source files.")
58
+
59
+ print(" Analysing imports and functions…")
60
+ file_analyses = analyze_all(files)
61
+ print(f" Analysed {len(file_analyses)} files.")
62
+
63
+ print(" Building dependency and call graphs…")
64
+ stats = build_graph(file_analyses)
65
+
66
+ analysis = RepoAnalysis(
67
+ root=str(root),
68
+ files=files,
69
+ file_analyses=file_analyses,
70
+ stats=stats,
71
+ )
72
+
73
+ n_cycles = len(stats.circular_deps)
74
+ print(f" Done. {len(stats.functions)} functions · {n_cycles} circular dep(s)")
75
+
76
+ if args.json:
77
+ _print_json(analysis)
78
+ return
79
+
80
+ print(" Launching TUI…\n")
81
+ from repolens.tui.app import RepoLensApp
82
+ app = RepoLensApp(analysis)
83
+ app.run()
84
+
85
+
86
+ def _print_json(analysis: "RepoAnalysis") -> None:
87
+ import json
88
+
89
+ stats = analysis.stats
90
+ output = {
91
+ "root": analysis.root,
92
+ "total_files": len(analysis.files),
93
+ "files": [
94
+ {"path": f.path, "language": f.language, "size": f.size}
95
+ for f in analysis.files
96
+ ],
97
+ "import_graph": {k: v for k, v in stats.import_edges.items() if v},
98
+ "circular_deps": stats.circular_deps,
99
+ "hub_files": [{"path": p, "in_degree": d} for p, d in stats.hub_files],
100
+ "entry_points": stats.entry_points,
101
+ "functions": [
102
+ {
103
+ "id": fid,
104
+ "name": fn.name,
105
+ "file": fn.file_path,
106
+ "line": fn.line_start,
107
+ "calls": fn.calls,
108
+ "callers": fn.callers,
109
+ }
110
+ for fid, fn in stats.functions.items()
111
+ ],
112
+ }
113
+ print(json.dumps(output, indent=2))
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()