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/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()