repr-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.
- repr/__init__.py +10 -0
- repr/analyzer.py +915 -0
- repr/api.py +263 -0
- repr/auth.py +300 -0
- repr/cli.py +858 -0
- repr/config.py +392 -0
- repr/discovery.py +472 -0
- repr/extractor.py +388 -0
- repr/highlights.py +712 -0
- repr/openai_analysis.py +597 -0
- repr/tools.py +446 -0
- repr/ui.py +430 -0
- repr_cli-0.1.0.dist-info/METADATA +326 -0
- repr_cli-0.1.0.dist-info/RECORD +18 -0
- repr_cli-0.1.0.dist-info/WHEEL +5 -0
- repr_cli-0.1.0.dist-info/entry_points.txt +2 -0
- repr_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- repr_cli-0.1.0.dist-info/top_level.txt +1 -0
repr/cli.py
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repr CLI - Main command-line interface.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
analyze Analyze repositories and generate profiles (auto-pushed)
|
|
6
|
+
push Push local profiles to repr.dev
|
|
7
|
+
view View the latest profile
|
|
8
|
+
login Authenticate with Repr
|
|
9
|
+
logout Clear authentication
|
|
10
|
+
profiles List saved profiles
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional, List
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
21
|
+
from rich.prompt import Confirm
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .config import (
|
|
25
|
+
list_profiles,
|
|
26
|
+
get_latest_profile,
|
|
27
|
+
get_profile,
|
|
28
|
+
get_profile_metadata,
|
|
29
|
+
save_profile,
|
|
30
|
+
save_repo_profile,
|
|
31
|
+
get_sync_info,
|
|
32
|
+
update_sync_info,
|
|
33
|
+
clear_cache,
|
|
34
|
+
is_authenticated,
|
|
35
|
+
CONFIG_DIR,
|
|
36
|
+
PROFILES_DIR,
|
|
37
|
+
set_dev_mode,
|
|
38
|
+
is_dev_mode,
|
|
39
|
+
get_api_base,
|
|
40
|
+
get_litellm_config,
|
|
41
|
+
get_llm_config,
|
|
42
|
+
set_llm_config,
|
|
43
|
+
clear_llm_config,
|
|
44
|
+
)
|
|
45
|
+
from .discovery import discover_repos, is_config_only_repo, RepoInfo
|
|
46
|
+
from .extractor import get_primary_language, detect_languages
|
|
47
|
+
from .auth import AuthFlow, AuthError, logout as auth_logout, get_current_user
|
|
48
|
+
from .api import push_repo_profile, APIError
|
|
49
|
+
from .openai_analysis import analyze_repo_openai, DEFAULT_EXTRACTION_MODEL, DEFAULT_SYNTHESIS_MODEL
|
|
50
|
+
from .ui import (
|
|
51
|
+
console,
|
|
52
|
+
print_header,
|
|
53
|
+
print_success,
|
|
54
|
+
print_error,
|
|
55
|
+
print_warning,
|
|
56
|
+
print_info,
|
|
57
|
+
print_next_steps,
|
|
58
|
+
print_panel,
|
|
59
|
+
print_markdown,
|
|
60
|
+
print_auth_code,
|
|
61
|
+
create_profile_table,
|
|
62
|
+
create_simple_progress,
|
|
63
|
+
format_bytes,
|
|
64
|
+
AnalysisDisplay,
|
|
65
|
+
BRAND_PRIMARY,
|
|
66
|
+
BRAND_SUCCESS,
|
|
67
|
+
BRAND_MUTED,
|
|
68
|
+
)
|
|
69
|
+
from .analyzer import analyze_repo
|
|
70
|
+
from .highlights import get_highlights
|
|
71
|
+
|
|
72
|
+
# Create Typer app
|
|
73
|
+
app = typer.Typer(
|
|
74
|
+
name="repr",
|
|
75
|
+
help="🚀 Privacy-first CLI that analyzes your code and generates a developer profile.",
|
|
76
|
+
add_completion=False,
|
|
77
|
+
no_args_is_help=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def version_callback(value: bool):
|
|
82
|
+
if value:
|
|
83
|
+
console.print(f"Repr CLI v{__version__}")
|
|
84
|
+
raise typer.Exit()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def dev_callback(value: bool):
|
|
88
|
+
if value:
|
|
89
|
+
set_dev_mode(True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.callback()
|
|
93
|
+
def main(
|
|
94
|
+
version: bool = typer.Option(
|
|
95
|
+
False,
|
|
96
|
+
"--version",
|
|
97
|
+
"-v",
|
|
98
|
+
callback=version_callback,
|
|
99
|
+
is_eager=True,
|
|
100
|
+
help="Show version and exit.",
|
|
101
|
+
),
|
|
102
|
+
dev: bool = typer.Option(
|
|
103
|
+
False,
|
|
104
|
+
"--dev",
|
|
105
|
+
callback=dev_callback,
|
|
106
|
+
is_eager=True,
|
|
107
|
+
help="Use localhost backend (http://localhost:8003).",
|
|
108
|
+
),
|
|
109
|
+
):
|
|
110
|
+
"""Repr CLI - Privacy-first developer profile generator."""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def analyze(
|
|
116
|
+
paths: List[Path] = typer.Argument(
|
|
117
|
+
...,
|
|
118
|
+
help="Paths to directories containing git repositories.",
|
|
119
|
+
exists=True,
|
|
120
|
+
file_okay=False,
|
|
121
|
+
dir_okay=True,
|
|
122
|
+
resolve_path=True,
|
|
123
|
+
),
|
|
124
|
+
verbose: bool = typer.Option(
|
|
125
|
+
False,
|
|
126
|
+
"--verbose",
|
|
127
|
+
"-V",
|
|
128
|
+
help="Show detailed logs during analysis.",
|
|
129
|
+
),
|
|
130
|
+
no_cache: bool = typer.Option(
|
|
131
|
+
False,
|
|
132
|
+
"--no-cache",
|
|
133
|
+
help="Re-analyze all repositories (ignore cache).",
|
|
134
|
+
),
|
|
135
|
+
local: bool = typer.Option(
|
|
136
|
+
False,
|
|
137
|
+
"--local",
|
|
138
|
+
"-l",
|
|
139
|
+
help="Use local LLM (Ollama) instead of cloud.",
|
|
140
|
+
),
|
|
141
|
+
model: Optional[str] = typer.Option(
|
|
142
|
+
None,
|
|
143
|
+
"--model",
|
|
144
|
+
"-m",
|
|
145
|
+
help="Local model to use (default: llama3.2).",
|
|
146
|
+
),
|
|
147
|
+
api_base: Optional[str] = typer.Option(
|
|
148
|
+
None,
|
|
149
|
+
"--api-base",
|
|
150
|
+
help="Custom local LLM API endpoint.",
|
|
151
|
+
),
|
|
152
|
+
offline: bool = typer.Option(
|
|
153
|
+
False,
|
|
154
|
+
"--offline",
|
|
155
|
+
help="Stats only, no AI analysis (no push).",
|
|
156
|
+
),
|
|
157
|
+
min_commits: int = typer.Option(
|
|
158
|
+
10,
|
|
159
|
+
"--min-commits",
|
|
160
|
+
help="Minimum number of commits required.",
|
|
161
|
+
),
|
|
162
|
+
):
|
|
163
|
+
"""
|
|
164
|
+
Analyze repositories and generate profiles (one per repo).
|
|
165
|
+
|
|
166
|
+
Each repository is analyzed separately, saved as {repo}_{date}.md,
|
|
167
|
+
and pushed to repr.dev immediately. Process halts on any failure.
|
|
168
|
+
|
|
169
|
+
Examples:
|
|
170
|
+
repr analyze ~/code # OpenAI (default)
|
|
171
|
+
repr analyze ~/code --local --model llama3.2 # Local LLM (Ollama)
|
|
172
|
+
repr analyze ~/code --offline # Stats only, no push
|
|
173
|
+
"""
|
|
174
|
+
print_header()
|
|
175
|
+
|
|
176
|
+
mode = "offline" if offline else ("local" if local else "openai")
|
|
177
|
+
llm_config = get_llm_config()
|
|
178
|
+
|
|
179
|
+
local_api_key = None
|
|
180
|
+
local_base_url = None
|
|
181
|
+
local_extraction_model = None
|
|
182
|
+
local_synthesis_model = None
|
|
183
|
+
|
|
184
|
+
# Require authentication for non-offline modes (auto-push)
|
|
185
|
+
if mode != "offline":
|
|
186
|
+
if not is_authenticated():
|
|
187
|
+
print_error("Not logged in. Please run 'repr login' first.")
|
|
188
|
+
raise typer.Exit(1)
|
|
189
|
+
|
|
190
|
+
if mode == "openai":
|
|
191
|
+
_, litellm_key = get_litellm_config()
|
|
192
|
+
if not litellm_key:
|
|
193
|
+
print_error("Not authenticated for LLM access. Please run 'repr login' first.")
|
|
194
|
+
raise typer.Exit(1)
|
|
195
|
+
|
|
196
|
+
if mode == "local":
|
|
197
|
+
local_api_key = llm_config["local_api_key"] or "ollama"
|
|
198
|
+
local_base_url = api_base or llm_config["local_api_url"] or "http://localhost:11434/v1"
|
|
199
|
+
local_extraction_model = model or llm_config["extraction_model"] or "llama3.2"
|
|
200
|
+
local_synthesis_model = model or llm_config["synthesis_model"] or "llama3.2"
|
|
201
|
+
print_info(f"Using local LLM: {local_extraction_model} at {local_base_url}")
|
|
202
|
+
|
|
203
|
+
if no_cache:
|
|
204
|
+
clear_cache()
|
|
205
|
+
if verbose:
|
|
206
|
+
print_info("Cache cleared")
|
|
207
|
+
|
|
208
|
+
console.print("Discovering repositories...")
|
|
209
|
+
console.print()
|
|
210
|
+
|
|
211
|
+
with create_simple_progress() as progress:
|
|
212
|
+
task = progress.add_task("Scanning directories...", total=None)
|
|
213
|
+
repos = discover_repos(root_paths=paths, use_cache=not no_cache, min_commits=min_commits)
|
|
214
|
+
|
|
215
|
+
if not repos:
|
|
216
|
+
print_warning("No repositories found in the specified paths.")
|
|
217
|
+
print_info(f"Make sure the paths contain git repositories with at least {min_commits} commits.")
|
|
218
|
+
raise typer.Exit(1)
|
|
219
|
+
|
|
220
|
+
total_paths = sum(1 for p in paths)
|
|
221
|
+
console.print(f"Found [bold]{len(repos)}[/bold] repositories in {total_paths} path(s)")
|
|
222
|
+
console.print()
|
|
223
|
+
|
|
224
|
+
for repo in repos:
|
|
225
|
+
languages = detect_languages(repo.path)
|
|
226
|
+
repo.languages = {lang: int(pct) for lang, pct in languages.items()}
|
|
227
|
+
repo.primary_language = max(languages, key=languages.get) if languages else None
|
|
228
|
+
|
|
229
|
+
filtered_repos = []
|
|
230
|
+
skipped_repos = []
|
|
231
|
+
|
|
232
|
+
for repo in repos:
|
|
233
|
+
if is_config_only_repo(repo.path):
|
|
234
|
+
skipped_repos.append(repo)
|
|
235
|
+
else:
|
|
236
|
+
filtered_repos.append(repo)
|
|
237
|
+
|
|
238
|
+
if skipped_repos:
|
|
239
|
+
console.print(f"Skipping {len(skipped_repos)} config-only repos: {', '.join(r.name for r in skipped_repos)}")
|
|
240
|
+
console.print()
|
|
241
|
+
|
|
242
|
+
if not filtered_repos:
|
|
243
|
+
print_warning("No analyzable repositories found (all were config-only).")
|
|
244
|
+
raise typer.Exit(1)
|
|
245
|
+
|
|
246
|
+
console.print(f"Analyzing {len(filtered_repos)} repositories...")
|
|
247
|
+
console.print()
|
|
248
|
+
|
|
249
|
+
# Process each repo individually
|
|
250
|
+
for i, repo in enumerate(filtered_repos, 1):
|
|
251
|
+
console.print(f"[bold][{i}/{len(filtered_repos)}] {repo.name}[/bold]")
|
|
252
|
+
|
|
253
|
+
repo_metadata = {
|
|
254
|
+
"remote_url": repo.remote_url,
|
|
255
|
+
"commit_count": repo.commit_count,
|
|
256
|
+
"user_commit_count": repo.user_commit_count,
|
|
257
|
+
"primary_language": repo.primary_language,
|
|
258
|
+
"languages": repo.languages,
|
|
259
|
+
"contributors": repo.contributors,
|
|
260
|
+
"first_commit_date": repo.first_commit_date.isoformat() if repo.first_commit_date else None,
|
|
261
|
+
"last_commit_date": repo.last_commit_date.isoformat() if repo.last_commit_date else None,
|
|
262
|
+
"is_fork": repo.is_fork,
|
|
263
|
+
"description": repo.description,
|
|
264
|
+
"frameworks": repo.frameworks,
|
|
265
|
+
"has_tests": repo.has_tests,
|
|
266
|
+
"has_ci": repo.has_ci,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# Generate profile for this single repo
|
|
270
|
+
if mode == "offline":
|
|
271
|
+
profile_content = _generate_single_repo_offline_profile(repo, verbose=verbose)
|
|
272
|
+
else:
|
|
273
|
+
profile_content = asyncio.run(_run_single_repo_analysis(
|
|
274
|
+
repo,
|
|
275
|
+
api_key=local_api_key,
|
|
276
|
+
base_url=local_base_url,
|
|
277
|
+
extraction_model=local_extraction_model or llm_config["extraction_model"],
|
|
278
|
+
synthesis_model=local_synthesis_model or llm_config["synthesis_model"],
|
|
279
|
+
verbose=verbose,
|
|
280
|
+
))
|
|
281
|
+
|
|
282
|
+
if not profile_content:
|
|
283
|
+
print_error(f"Failed to generate profile for {repo.name}. Halting.")
|
|
284
|
+
raise typer.Exit(1)
|
|
285
|
+
|
|
286
|
+
# Save locally as {repo}_{date}.md
|
|
287
|
+
profile_path = save_repo_profile(profile_content, repo.name, repo_metadata)
|
|
288
|
+
console.print(f" Saved: {profile_path.name}")
|
|
289
|
+
|
|
290
|
+
# Push to backend (skip for offline mode)
|
|
291
|
+
if mode != "offline":
|
|
292
|
+
try:
|
|
293
|
+
result = asyncio.run(push_repo_profile(profile_content, repo.name, repo_metadata))
|
|
294
|
+
console.print(f" [green]Pushed to repr.dev[/green]")
|
|
295
|
+
|
|
296
|
+
# Mark as synced
|
|
297
|
+
update_sync_info(profile_path.name)
|
|
298
|
+
except (APIError, AuthError) as e:
|
|
299
|
+
print_error(f"Failed to push {repo.name}: {e}. Halting.")
|
|
300
|
+
raise typer.Exit(1)
|
|
301
|
+
|
|
302
|
+
console.print()
|
|
303
|
+
|
|
304
|
+
print_success(f"Completed: {len(filtered_repos)} repositories analyzed and pushed")
|
|
305
|
+
console.print()
|
|
306
|
+
print_next_steps(["Run [bold]repr profiles[/] to see all saved profiles"])
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _generate_single_repo_offline_profile(repo: RepoInfo, verbose: bool = False) -> str:
|
|
310
|
+
"""Generate offline profile for a single repository."""
|
|
311
|
+
lines = [f"# {repo.name}", ""]
|
|
312
|
+
|
|
313
|
+
if verbose:
|
|
314
|
+
console.print(f" Analyzing {repo.name}...", style=BRAND_MUTED)
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
analysis = analyze_repo(repo.path)
|
|
318
|
+
highlights = get_highlights(repo.path)
|
|
319
|
+
except Exception as e:
|
|
320
|
+
if verbose:
|
|
321
|
+
console.print(f" Error: {e}", style="red")
|
|
322
|
+
return f"# {repo.name}\n\nFailed to analyze repository: {e}"
|
|
323
|
+
|
|
324
|
+
meta = []
|
|
325
|
+
if repo.primary_language:
|
|
326
|
+
meta.append(f"**{repo.primary_language}**")
|
|
327
|
+
meta.append(f"{repo.commit_count} commits")
|
|
328
|
+
meta.append(repo.age_display)
|
|
329
|
+
lines.append(" | ".join(meta))
|
|
330
|
+
lines.append("")
|
|
331
|
+
|
|
332
|
+
if highlights and highlights.get("domain"):
|
|
333
|
+
lines.append(f"*{highlights['domain'].replace('-', ' ').title()} Application*")
|
|
334
|
+
lines.append("")
|
|
335
|
+
|
|
336
|
+
frameworks = analysis.get("frameworks", [])
|
|
337
|
+
if frameworks:
|
|
338
|
+
lines.append(f"**Frameworks:** {', '.join(frameworks)}")
|
|
339
|
+
|
|
340
|
+
code_lines = analysis.get("summary", {}).get("code_lines", 0)
|
|
341
|
+
lines.append(f"**Codebase:** {code_lines:,} lines of code")
|
|
342
|
+
|
|
343
|
+
has_tests = analysis.get("testing", {}).get("has_tests", False)
|
|
344
|
+
lines.append(f"**Testing:** {'Has tests' if has_tests else 'No tests detected'}")
|
|
345
|
+
lines.append("")
|
|
346
|
+
|
|
347
|
+
if highlights and highlights.get("features"):
|
|
348
|
+
lines.append("## What was built")
|
|
349
|
+
lines.append("")
|
|
350
|
+
for f in highlights["features"][:6]:
|
|
351
|
+
lines.append(f"- {f['description']}")
|
|
352
|
+
lines.append("")
|
|
353
|
+
|
|
354
|
+
if highlights and highlights.get("integrations"):
|
|
355
|
+
integration_names = [i["name"] for i in highlights["integrations"][:8]]
|
|
356
|
+
lines.append(f"**Integrations:** {', '.join(integration_names)}")
|
|
357
|
+
lines.append("")
|
|
358
|
+
|
|
359
|
+
if highlights and highlights.get("achievements"):
|
|
360
|
+
lines.append("## Key contributions")
|
|
361
|
+
lines.append("")
|
|
362
|
+
for achievement in highlights["achievements"][:5]:
|
|
363
|
+
clean = achievement.strip()
|
|
364
|
+
if len(clean) > 100:
|
|
365
|
+
clean = clean[:97] + "..."
|
|
366
|
+
lines.append(f"- {clean}")
|
|
367
|
+
lines.append("")
|
|
368
|
+
|
|
369
|
+
lines.append("---")
|
|
370
|
+
lines.append("*Generated by Repr CLI (offline mode)*")
|
|
371
|
+
|
|
372
|
+
return "\n".join(lines)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
async def _run_single_repo_analysis(
|
|
376
|
+
repo: RepoInfo,
|
|
377
|
+
api_key: str = None,
|
|
378
|
+
base_url: str = None,
|
|
379
|
+
extraction_model: str = None,
|
|
380
|
+
synthesis_model: str = None,
|
|
381
|
+
verbose: bool = False,
|
|
382
|
+
) -> str | None:
|
|
383
|
+
"""Run analysis for a single repository using OpenAI-compatible API."""
|
|
384
|
+
display = AnalysisDisplay()
|
|
385
|
+
display.repos.append({
|
|
386
|
+
"name": repo.name,
|
|
387
|
+
"language": repo.primary_language or "-",
|
|
388
|
+
"commits": str(repo.commit_count),
|
|
389
|
+
"age": repo.age_display,
|
|
390
|
+
"status": "pending",
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
def progress_callback(step: str, detail: str, repo: str, progress: float):
|
|
394
|
+
display.update_progress(step=step, detail=detail, repo=repo, progress=progress)
|
|
395
|
+
for repo_data in display.repos:
|
|
396
|
+
if repo_data["name"] == repo:
|
|
397
|
+
if step == "Complete":
|
|
398
|
+
repo_data["status"] = "completed"
|
|
399
|
+
elif step in ("Extracting", "Preparing", "Analyzing", "Synthesizing"):
|
|
400
|
+
repo_data["status"] = "analyzing"
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
display.start()
|
|
404
|
+
|
|
405
|
+
profile = await analyze_repo_openai(
|
|
406
|
+
repo,
|
|
407
|
+
api_key=api_key,
|
|
408
|
+
base_url=base_url,
|
|
409
|
+
extraction_model=extraction_model,
|
|
410
|
+
synthesis_model=synthesis_model,
|
|
411
|
+
verbose=verbose,
|
|
412
|
+
progress_callback=progress_callback,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
display.stop()
|
|
416
|
+
return profile
|
|
417
|
+
|
|
418
|
+
except Exception as e:
|
|
419
|
+
display.stop()
|
|
420
|
+
print_error(f"Analysis failed: {e}")
|
|
421
|
+
if verbose:
|
|
422
|
+
import traceback
|
|
423
|
+
console.print(traceback.format_exc(), style="red dim")
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@app.command()
|
|
428
|
+
def view(
|
|
429
|
+
raw: bool = typer.Option(
|
|
430
|
+
False,
|
|
431
|
+
"--raw",
|
|
432
|
+
"-r",
|
|
433
|
+
help="Output plain markdown (for piping).",
|
|
434
|
+
),
|
|
435
|
+
profile_name: Optional[str] = typer.Option(
|
|
436
|
+
None,
|
|
437
|
+
"--profile",
|
|
438
|
+
"-p",
|
|
439
|
+
help="View a specific profile by name.",
|
|
440
|
+
),
|
|
441
|
+
):
|
|
442
|
+
"""
|
|
443
|
+
View the latest profile in the terminal.
|
|
444
|
+
|
|
445
|
+
Example:
|
|
446
|
+
repr view
|
|
447
|
+
repr view --profile myrepo_2025-12-26
|
|
448
|
+
repr view --raw > profile.md
|
|
449
|
+
"""
|
|
450
|
+
# Get profile path
|
|
451
|
+
if profile_name:
|
|
452
|
+
profile_path = get_profile(profile_name)
|
|
453
|
+
if not profile_path:
|
|
454
|
+
print_error(f"Profile not found: {profile_name}")
|
|
455
|
+
raise typer.Exit(1)
|
|
456
|
+
else:
|
|
457
|
+
profile_path = get_latest_profile()
|
|
458
|
+
if not profile_path:
|
|
459
|
+
print_error("No profiles found.")
|
|
460
|
+
print_info("Run 'repr analyze <paths>' to generate a profile.")
|
|
461
|
+
raise typer.Exit(1)
|
|
462
|
+
|
|
463
|
+
content = profile_path.read_text()
|
|
464
|
+
|
|
465
|
+
if raw:
|
|
466
|
+
# Raw output for piping
|
|
467
|
+
print(content)
|
|
468
|
+
else:
|
|
469
|
+
# Pretty display
|
|
470
|
+
sync_info = get_sync_info()
|
|
471
|
+
is_synced = sync_info.get("last_profile") == profile_path.name
|
|
472
|
+
|
|
473
|
+
status = f"[{BRAND_SUCCESS}]✓ Synced[/]" if is_synced else f"[{BRAND_MUTED}]○ Local only[/]"
|
|
474
|
+
|
|
475
|
+
print_panel(
|
|
476
|
+
f"Profile: {profile_path.name}",
|
|
477
|
+
f"{status}",
|
|
478
|
+
border_color=BRAND_PRIMARY,
|
|
479
|
+
)
|
|
480
|
+
console.print()
|
|
481
|
+
print_markdown(content)
|
|
482
|
+
console.print()
|
|
483
|
+
console.print(f"[{BRAND_MUTED}]Press q to quit, ↑↓ to scroll[/]")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@app.command()
|
|
487
|
+
def login():
|
|
488
|
+
"""
|
|
489
|
+
Authenticate with Repr using device code flow.
|
|
490
|
+
|
|
491
|
+
Opens a browser where you can sign in, then automatically
|
|
492
|
+
completes the authentication.
|
|
493
|
+
|
|
494
|
+
Example:
|
|
495
|
+
repr login
|
|
496
|
+
"""
|
|
497
|
+
print_header()
|
|
498
|
+
|
|
499
|
+
if is_authenticated():
|
|
500
|
+
user = get_current_user()
|
|
501
|
+
email = user.get("email", "unknown") if user else "unknown"
|
|
502
|
+
print_info(f"Already authenticated as {email}")
|
|
503
|
+
|
|
504
|
+
if not Confirm.ask("Re-authenticate?"):
|
|
505
|
+
raise typer.Exit()
|
|
506
|
+
|
|
507
|
+
console.print("[bold]🔐 Authenticate with Repr[/]")
|
|
508
|
+
console.print()
|
|
509
|
+
|
|
510
|
+
async def run_auth():
|
|
511
|
+
flow = AuthFlow()
|
|
512
|
+
|
|
513
|
+
def on_code_received(device_code):
|
|
514
|
+
console.print("To sign in, visit:")
|
|
515
|
+
console.print()
|
|
516
|
+
console.print(f" [bold {BRAND_PRIMARY}]{device_code.verification_url}[/]")
|
|
517
|
+
console.print()
|
|
518
|
+
console.print("And enter this code:")
|
|
519
|
+
print_auth_code(device_code.user_code)
|
|
520
|
+
|
|
521
|
+
def on_progress(remaining):
|
|
522
|
+
mins = int(remaining // 60)
|
|
523
|
+
secs = int(remaining % 60)
|
|
524
|
+
# Use carriage return for updating in place
|
|
525
|
+
console.print(
|
|
526
|
+
f"\rWaiting for authorization... expires in {mins}:{secs:02d} ",
|
|
527
|
+
end="",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def on_success(token):
|
|
531
|
+
console.print()
|
|
532
|
+
console.print()
|
|
533
|
+
print_success(f"Authenticated as {token.email}")
|
|
534
|
+
console.print()
|
|
535
|
+
console.print(f" Token saved to {CONFIG_DIR / 'config.json'}")
|
|
536
|
+
|
|
537
|
+
def on_error(error):
|
|
538
|
+
console.print()
|
|
539
|
+
print_error(str(error))
|
|
540
|
+
|
|
541
|
+
flow.on_code_received = on_code_received
|
|
542
|
+
flow.on_progress = on_progress
|
|
543
|
+
flow.on_success = on_success
|
|
544
|
+
flow.on_error = on_error
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
await flow.run()
|
|
548
|
+
except AuthError:
|
|
549
|
+
raise typer.Exit(1)
|
|
550
|
+
|
|
551
|
+
asyncio.run(run_auth())
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@app.command()
|
|
555
|
+
def logout():
|
|
556
|
+
"""
|
|
557
|
+
Clear authentication and logout.
|
|
558
|
+
|
|
559
|
+
Example:
|
|
560
|
+
repr logout
|
|
561
|
+
"""
|
|
562
|
+
if not is_authenticated():
|
|
563
|
+
print_info("Not currently authenticated.")
|
|
564
|
+
raise typer.Exit()
|
|
565
|
+
|
|
566
|
+
auth_logout()
|
|
567
|
+
|
|
568
|
+
print_success("Logged out")
|
|
569
|
+
console.print()
|
|
570
|
+
console.print(f" Token removed from {CONFIG_DIR / 'config.json'}")
|
|
571
|
+
console.print(f" Local profiles preserved in {PROFILES_DIR}")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@app.command()
|
|
575
|
+
def profiles():
|
|
576
|
+
"""
|
|
577
|
+
List all saved profiles.
|
|
578
|
+
|
|
579
|
+
Example:
|
|
580
|
+
repr profiles
|
|
581
|
+
"""
|
|
582
|
+
saved_profiles = list_profiles()
|
|
583
|
+
|
|
584
|
+
if not saved_profiles:
|
|
585
|
+
print_info("No profiles found.")
|
|
586
|
+
print_info("Run 'repr analyze <paths>' to generate a profile.")
|
|
587
|
+
raise typer.Exit()
|
|
588
|
+
|
|
589
|
+
print_panel("Saved Profiles", "", border_color=BRAND_PRIMARY)
|
|
590
|
+
console.print()
|
|
591
|
+
|
|
592
|
+
table = create_profile_table()
|
|
593
|
+
|
|
594
|
+
for profile in saved_profiles:
|
|
595
|
+
status = f"[{BRAND_SUCCESS}]✓ synced[/]" if profile["synced"] else f"[{BRAND_MUTED}]○ local only[/]"
|
|
596
|
+
|
|
597
|
+
table.add_row(
|
|
598
|
+
profile["name"],
|
|
599
|
+
str(profile["project_count"]),
|
|
600
|
+
format_bytes(profile["size"]),
|
|
601
|
+
status,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
console.print(table)
|
|
605
|
+
console.print()
|
|
606
|
+
console.print(f"Storage: {PROFILES_DIR}")
|
|
607
|
+
console.print()
|
|
608
|
+
console.print("[bold]Commands:[/]")
|
|
609
|
+
console.print(f" repr view --profile {saved_profiles[0]['name']}")
|
|
610
|
+
if not saved_profiles[0]["synced"]:
|
|
611
|
+
console.print(f" repr push --profile {saved_profiles[0]['name']}")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@app.command()
|
|
615
|
+
def push(
|
|
616
|
+
profile_name: Optional[str] = typer.Option(
|
|
617
|
+
None,
|
|
618
|
+
"--profile",
|
|
619
|
+
"-p",
|
|
620
|
+
help="Push a specific profile by name (default: all unsynced profiles).",
|
|
621
|
+
),
|
|
622
|
+
):
|
|
623
|
+
"""
|
|
624
|
+
Push local profiles to repr.dev.
|
|
625
|
+
|
|
626
|
+
By default, pushes all unsynced profiles. Use --profile to push a specific one.
|
|
627
|
+
|
|
628
|
+
Examples:
|
|
629
|
+
repr push # Push all unsynced profiles
|
|
630
|
+
repr push --profile myrepo_2025-12-26 # Push specific profile
|
|
631
|
+
"""
|
|
632
|
+
if not is_authenticated():
|
|
633
|
+
print_error("Not logged in. Please run 'repr login' first.")
|
|
634
|
+
raise typer.Exit(1)
|
|
635
|
+
|
|
636
|
+
print_header()
|
|
637
|
+
|
|
638
|
+
# Get profiles to push
|
|
639
|
+
all_profiles = list_profiles()
|
|
640
|
+
|
|
641
|
+
if not all_profiles:
|
|
642
|
+
print_info("No profiles found.")
|
|
643
|
+
print_info("Run 'repr analyze <paths>' to generate profiles first.")
|
|
644
|
+
raise typer.Exit()
|
|
645
|
+
|
|
646
|
+
if profile_name:
|
|
647
|
+
# Push specific profile
|
|
648
|
+
profiles_to_push = [p for p in all_profiles if p["name"] == profile_name]
|
|
649
|
+
if not profiles_to_push:
|
|
650
|
+
print_error(f"Profile not found: {profile_name}")
|
|
651
|
+
raise typer.Exit(1)
|
|
652
|
+
else:
|
|
653
|
+
# Push all unsynced profiles
|
|
654
|
+
profiles_to_push = [p for p in all_profiles if not p["synced"]]
|
|
655
|
+
|
|
656
|
+
if not profiles_to_push:
|
|
657
|
+
print_success("All profiles are already synced!")
|
|
658
|
+
raise typer.Exit()
|
|
659
|
+
|
|
660
|
+
console.print(f"Pushing {len(profiles_to_push)} profile(s) to repr.dev...")
|
|
661
|
+
console.print()
|
|
662
|
+
|
|
663
|
+
pushed_count = 0
|
|
664
|
+
failed_count = 0
|
|
665
|
+
|
|
666
|
+
for profile in profiles_to_push:
|
|
667
|
+
# Read profile content
|
|
668
|
+
content = profile["path"].read_text()
|
|
669
|
+
|
|
670
|
+
# Load metadata if available
|
|
671
|
+
metadata = get_profile_metadata(profile["name"])
|
|
672
|
+
|
|
673
|
+
if metadata and "repo" in metadata:
|
|
674
|
+
# This is a per-repo profile
|
|
675
|
+
repo_metadata = metadata["repo"]
|
|
676
|
+
repo_name = repo_metadata.get("repo_name") or profile["name"].rsplit("_", 1)[0]
|
|
677
|
+
|
|
678
|
+
console.print(f" Pushing {repo_name}...", end=" ")
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
asyncio.run(push_repo_profile(content, repo_name, repo_metadata))
|
|
682
|
+
console.print("[green]✓[/]")
|
|
683
|
+
|
|
684
|
+
# Mark as synced
|
|
685
|
+
update_sync_info(profile["name"])
|
|
686
|
+
pushed_count += 1
|
|
687
|
+
|
|
688
|
+
except (APIError, AuthError) as e:
|
|
689
|
+
console.print(f"[red]✗ {e}[/]")
|
|
690
|
+
failed_count += 1
|
|
691
|
+
else:
|
|
692
|
+
# Legacy profile without metadata - skip with warning
|
|
693
|
+
console.print(f" [yellow]⚠[/] Skipping {profile['name']} (no metadata)")
|
|
694
|
+
console.print(f" Re-analyze to create proper repo profiles")
|
|
695
|
+
failed_count += 1
|
|
696
|
+
|
|
697
|
+
console.print()
|
|
698
|
+
|
|
699
|
+
if pushed_count > 0:
|
|
700
|
+
print_success(f"Pushed {pushed_count} profile(s)")
|
|
701
|
+
|
|
702
|
+
if failed_count > 0:
|
|
703
|
+
print_warning(f"Failed to push {failed_count} profile(s)")
|
|
704
|
+
|
|
705
|
+
if pushed_count > 0 and failed_count == 0:
|
|
706
|
+
console.print()
|
|
707
|
+
print_next_steps([
|
|
708
|
+
"View your profiles at https://repr.dev/profile",
|
|
709
|
+
"Run [bold]repr analyze <paths>[/] to update your profiles",
|
|
710
|
+
])
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@app.command(name="config")
|
|
714
|
+
def show_config():
|
|
715
|
+
"""
|
|
716
|
+
Show current configuration and API endpoints.
|
|
717
|
+
|
|
718
|
+
Useful for debugging and verifying backend connection.
|
|
719
|
+
|
|
720
|
+
Example:
|
|
721
|
+
repr config
|
|
722
|
+
repr --dev config
|
|
723
|
+
"""
|
|
724
|
+
|
|
725
|
+
console.print("[bold]Repr CLI Configuration[/]")
|
|
726
|
+
console.print()
|
|
727
|
+
|
|
728
|
+
mode = "[green]Development (localhost)[/]" if is_dev_mode() else "[blue]Production[/]"
|
|
729
|
+
console.print(f" Mode: {mode}")
|
|
730
|
+
console.print(f" API Base: {get_api_base()}")
|
|
731
|
+
console.print()
|
|
732
|
+
|
|
733
|
+
if is_authenticated():
|
|
734
|
+
user = get_current_user()
|
|
735
|
+
email = user.get("email", "unknown") if user else "unknown"
|
|
736
|
+
console.print(f" Auth: [green]✓ Authenticated as {email}[/]")
|
|
737
|
+
else:
|
|
738
|
+
console.print(f" Auth: [yellow]○ Not authenticated[/]")
|
|
739
|
+
|
|
740
|
+
console.print()
|
|
741
|
+
|
|
742
|
+
# LLM Configuration
|
|
743
|
+
llm_config = get_llm_config()
|
|
744
|
+
console.print("[bold]LLM Configuration:[/]")
|
|
745
|
+
extraction_model_display = llm_config['extraction_model'] or f"[dim]{DEFAULT_EXTRACTION_MODEL}[/]"
|
|
746
|
+
synthesis_model_display = llm_config['synthesis_model'] or f"[dim]{DEFAULT_SYNTHESIS_MODEL}[/]"
|
|
747
|
+
console.print(f" Extraction Model: {extraction_model_display}")
|
|
748
|
+
console.print(f" Synthesis Model: {synthesis_model_display}")
|
|
749
|
+
console.print(f" Local API URL: {llm_config['local_api_url'] or '[dim]not set[/]'}")
|
|
750
|
+
console.print(f" Local API Key: {'[green]✓ set[/]' if llm_config['local_api_key'] else '[dim]not set[/]'}")
|
|
751
|
+
console.print()
|
|
752
|
+
|
|
753
|
+
# Cloud LLM Access (authenticated via backend)
|
|
754
|
+
_, litellm_key = get_litellm_config()
|
|
755
|
+
console.print("[bold]Cloud LLM Access:[/]")
|
|
756
|
+
console.print(f" Status: {'[green]✓ authenticated[/]' if litellm_key else '[dim]not authenticated - run repr login[/]'}")
|
|
757
|
+
console.print()
|
|
758
|
+
|
|
759
|
+
console.print(f" Config: {CONFIG_DIR / 'config.json'}")
|
|
760
|
+
console.print(f" Profiles: {PROFILES_DIR}")
|
|
761
|
+
console.print()
|
|
762
|
+
|
|
763
|
+
console.print("[bold]Environment Variables:[/]")
|
|
764
|
+
console.print(" REPR_DEV=1 Enable dev mode (localhost)")
|
|
765
|
+
console.print(" REPR_API_BASE=URL Override API base URL")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
@app.command(name="config-set")
|
|
769
|
+
def config_set(
|
|
770
|
+
extraction_model: Optional[str] = typer.Option(
|
|
771
|
+
None,
|
|
772
|
+
"--extraction-model",
|
|
773
|
+
help="Model for extracting accomplishments from commits (e.g., gpt-4o-mini, llama3.2).",
|
|
774
|
+
),
|
|
775
|
+
synthesis_model: Optional[str] = typer.Option(
|
|
776
|
+
None,
|
|
777
|
+
"--synthesis-model",
|
|
778
|
+
help="Model for synthesizing the final profile (e.g., gpt-4o, llama3.2).",
|
|
779
|
+
),
|
|
780
|
+
local_api_url: Optional[str] = typer.Option(
|
|
781
|
+
None,
|
|
782
|
+
"--local-api-url",
|
|
783
|
+
help="Local LLM API base URL (e.g., http://localhost:11434/v1).",
|
|
784
|
+
),
|
|
785
|
+
local_api_key: Optional[str] = typer.Option(
|
|
786
|
+
None,
|
|
787
|
+
"--local-api-key",
|
|
788
|
+
help="Local LLM API key (e.g., 'ollama' for Ollama).",
|
|
789
|
+
),
|
|
790
|
+
clear: bool = typer.Option(
|
|
791
|
+
False,
|
|
792
|
+
"--clear",
|
|
793
|
+
help="Clear all LLM configuration.",
|
|
794
|
+
),
|
|
795
|
+
):
|
|
796
|
+
"""
|
|
797
|
+
Configure LLM models and local API settings.
|
|
798
|
+
|
|
799
|
+
Settings are saved to ~/.repr/config.json and used as defaults
|
|
800
|
+
for the analyze command.
|
|
801
|
+
|
|
802
|
+
Examples:
|
|
803
|
+
# Set models for cloud analysis
|
|
804
|
+
repr config-set --extraction-model gpt-4o-mini --synthesis-model gpt-4o
|
|
805
|
+
|
|
806
|
+
# Configure local LLM (Ollama)
|
|
807
|
+
repr config-set --local-api-url http://localhost:11434/v1 --local-api-key ollama
|
|
808
|
+
repr config-set --extraction-model llama3.2 --synthesis-model llama3.2
|
|
809
|
+
|
|
810
|
+
# Configure local LLM (LM Studio)
|
|
811
|
+
repr config-set --local-api-url http://localhost:1234/v1 --local-api-key lm-studio
|
|
812
|
+
|
|
813
|
+
# Clear all LLM settings
|
|
814
|
+
repr config-set --clear
|
|
815
|
+
"""
|
|
816
|
+
if clear:
|
|
817
|
+
clear_llm_config()
|
|
818
|
+
print_success("LLM configuration cleared")
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
if not any([extraction_model, synthesis_model, local_api_url, local_api_key]):
|
|
822
|
+
# No options provided, show current config
|
|
823
|
+
llm_config = get_llm_config()
|
|
824
|
+
console.print("[bold]Current LLM Configuration:[/]")
|
|
825
|
+
console.print()
|
|
826
|
+
console.print(f" Extraction Model: {llm_config['extraction_model'] or '[dim]default[/]'}")
|
|
827
|
+
console.print(f" Synthesis Model: {llm_config['synthesis_model'] or '[dim]default[/]'}")
|
|
828
|
+
console.print(f" Local API URL: {llm_config['local_api_url'] or '[dim]not set[/]'}")
|
|
829
|
+
console.print(f" Local API Key: {'[green]✓ set[/]' if llm_config['local_api_key'] else '[dim]not set[/]'}")
|
|
830
|
+
console.print()
|
|
831
|
+
console.print("Use [bold]repr config-set --help[/] to see available options.")
|
|
832
|
+
return
|
|
833
|
+
|
|
834
|
+
# Update configuration
|
|
835
|
+
set_llm_config(
|
|
836
|
+
extraction_model=extraction_model,
|
|
837
|
+
synthesis_model=synthesis_model,
|
|
838
|
+
local_api_url=local_api_url,
|
|
839
|
+
local_api_key=local_api_key,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
print_success("LLM configuration updated")
|
|
843
|
+
console.print()
|
|
844
|
+
|
|
845
|
+
# Show what was set
|
|
846
|
+
if extraction_model:
|
|
847
|
+
console.print(f" Extraction Model: {extraction_model}")
|
|
848
|
+
if synthesis_model:
|
|
849
|
+
console.print(f" Synthesis Model: {synthesis_model}")
|
|
850
|
+
if local_api_url:
|
|
851
|
+
console.print(f" Local API URL: {local_api_url}")
|
|
852
|
+
if local_api_key:
|
|
853
|
+
console.print(f" Local API Key: [green]✓ set[/]")
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
# Entry point
|
|
857
|
+
if __name__ == "__main__":
|
|
858
|
+
app()
|