repr-cli 0.1.0__py3-none-any.whl → 0.2.2__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 CHANGED
@@ -1,52 +1,33 @@
1
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
2
+ repr CLI - understand what you've actually worked on.
3
+
4
+ Commands follow the CLI_SPECIFICATION.md structure:
5
+ - init: First-time setup
6
+ - generate: Create stories from commits
7
+ - week/since/standup: Quick reflection summaries
8
+ - stories: Manage stories
9
+ - push/sync/pull: Cloud operations
10
+ - repos/hooks: Repository management
11
+ - login/logout/whoami: Authentication
12
+ - llm: LLM configuration
13
+ - privacy/config/data: Privacy and data management
14
+ - profile: Profile management
15
+ - doctor: Health check
11
16
  """
12
17
 
13
18
  import asyncio
19
+ import json
20
+ import os
14
21
  import sys
22
+ from datetime import datetime, timedelta
15
23
  from pathlib import Path
16
24
  from typing import Optional, List
17
25
 
18
26
  import typer
19
- from rich.console import Console
20
- from rich.progress import Progress, SpinnerColumn, TextColumn
21
- from rich.prompt import Confirm
27
+ from rich.prompt import Confirm, Prompt
28
+ from rich.table import Table
22
29
 
23
30
  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
31
  from .ui import (
51
32
  console,
52
33
  print_header,
@@ -55,32 +36,96 @@ from .ui import (
55
36
  print_warning,
56
37
  print_info,
57
38
  print_next_steps,
58
- print_panel,
59
39
  print_markdown,
60
40
  print_auth_code,
61
- create_profile_table,
62
- create_simple_progress,
41
+ create_spinner,
42
+ create_table,
43
+ format_relative_time,
63
44
  format_bytes,
64
- AnalysisDisplay,
45
+ confirm,
65
46
  BRAND_PRIMARY,
66
47
  BRAND_SUCCESS,
48
+ BRAND_WARNING,
49
+ BRAND_ERROR,
67
50
  BRAND_MUTED,
68
51
  )
69
- from .analyzer import analyze_repo
70
- from .highlights import get_highlights
52
+ from .config import (
53
+ CONFIG_DIR,
54
+ CONFIG_FILE,
55
+ load_config,
56
+ save_config,
57
+ is_authenticated,
58
+ get_access_token,
59
+ get_llm_config,
60
+ get_litellm_config,
61
+ set_dev_mode,
62
+ is_dev_mode,
63
+ get_api_base,
64
+ get_tracked_repos,
65
+ add_tracked_repo,
66
+ remove_tracked_repo,
67
+ set_llm_config,
68
+ get_config_value,
69
+ set_config_value,
70
+ is_cloud_allowed,
71
+ lock_local_only,
72
+ unlock_local_only,
73
+ get_privacy_settings,
74
+ add_byok_provider,
75
+ remove_byok_provider,
76
+ get_byok_config,
77
+ list_byok_providers,
78
+ BYOK_PROVIDERS,
79
+ get_default_llm_mode,
80
+ set_repo_hook_status,
81
+ set_repo_paused,
82
+ get_profile_config,
83
+ set_profile_config,
84
+ )
85
+ from .storage import (
86
+ save_story,
87
+ load_story,
88
+ delete_story,
89
+ list_stories,
90
+ get_story_count,
91
+ get_unpushed_stories,
92
+ mark_story_pushed,
93
+ update_story_metadata,
94
+ STORIES_DIR,
95
+ backup_all_data,
96
+ restore_from_backup,
97
+ get_storage_stats,
98
+ )
99
+ from .auth import AuthFlow, AuthError, logout as auth_logout, get_current_user, migrate_plaintext_auth
100
+ from .api import APIError
71
101
 
72
102
  # Create Typer app
73
103
  app = typer.Typer(
74
104
  name="repr",
75
- help="🚀 Privacy-first CLI that analyzes your code and generates a developer profile.",
105
+ help="repr - understand what you've actually worked on",
76
106
  add_completion=False,
77
107
  no_args_is_help=True,
78
108
  )
79
109
 
110
+ # Sub-apps for command groups
111
+ hooks_app = typer.Typer(help="Manage git post-commit hooks")
112
+ llm_app = typer.Typer(help="Configure LLM (local/cloud/BYOK)")
113
+ privacy_app = typer.Typer(help="Privacy audit and controls")
114
+ config_app = typer.Typer(help="View and modify configuration")
115
+ data_app = typer.Typer(help="Backup, restore, and manage data")
116
+ profile_app = typer.Typer(help="View and manage profile")
117
+
118
+ app.add_typer(hooks_app, name="hooks")
119
+ app.add_typer(llm_app, name="llm")
120
+ app.add_typer(privacy_app, name="privacy")
121
+ app.add_typer(config_app, name="config")
122
+ app.add_typer(data_app, name="data")
123
+ app.add_typer(profile_app, name="profile")
124
+
80
125
 
81
126
  def version_callback(value: bool):
82
127
  if value:
83
- console.print(f"Repr CLI v{__version__}")
128
+ console.print(f"repr v{__version__}")
84
129
  raise typer.Exit()
85
130
 
86
131
 
@@ -92,765 +137,2200 @@ def dev_callback(value: bool):
92
137
  @app.callback()
93
138
  def main(
94
139
  version: bool = typer.Option(
95
- False,
96
- "--version",
97
- "-v",
140
+ False, "--version", "-v",
98
141
  callback=version_callback,
99
142
  is_eager=True,
100
143
  help="Show version and exit.",
101
144
  ),
102
145
  dev: bool = typer.Option(
103
- False,
104
- "--dev",
146
+ False, "--dev",
105
147
  callback=dev_callback,
106
148
  is_eager=True,
107
- help="Use localhost backend (http://localhost:8003).",
149
+ help="Use localhost backend.",
108
150
  ),
109
151
  ):
110
- """Repr CLI - Privacy-first developer profile generator."""
111
- pass
152
+ """repr - understand what you've actually worked on.
153
+
154
+ Cloud features require sign-in. Local generation always works offline.
155
+ """
156
+ # Migrate plaintext auth tokens on startup
157
+ migrate_plaintext_auth()
112
158
 
113
159
 
160
+ # =============================================================================
161
+ # INIT
162
+ # =============================================================================
163
+
114
164
  @app.command()
115
- def analyze(
116
- paths: List[Path] = typer.Argument(
117
- ...,
118
- help="Paths to directories containing git repositories.",
165
+ def init(
166
+ path: Optional[Path] = typer.Argument(
167
+ None,
168
+ help="Directory to scan for repositories (default: ~/code)",
119
169
  exists=True,
120
- file_okay=False,
121
170
  dir_okay=True,
122
171
  resolve_path=True,
123
172
  ),
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
173
  ):
163
174
  """
164
- Analyze repositories and generate profiles (one per repo).
175
+ Initialize repr - scan for repositories and set up local config.
165
176
 
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
177
+ Example:
178
+ repr init
179
+ repr init ~/projects
173
180
  """
174
181
  print_header()
182
+ console.print("Works locally first — sign in later for sync and sharing.")
183
+ console.print()
175
184
 
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")
185
+ # Determine scan path
186
+ scan_path = path or Path.home() / "code"
187
+ if not scan_path.exists():
188
+ scan_path = Path.cwd()
207
189
 
208
- console.print("Discovering repositories...")
190
+ console.print(f"Scanning {scan_path} for repositories...")
209
191
  console.print()
210
192
 
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)
193
+ # Import discovery
194
+ from .discovery import discover_repos
195
+
196
+ with create_spinner() as progress:
197
+ task = progress.add_task("Scanning...", total=None)
198
+ repos = discover_repos([scan_path], min_commits=5)
214
199
 
215
200
  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.")
201
+ print_warning(f"No repositories found in {scan_path}")
202
+ print_info("Try running: repr init <path-to-your-code>")
218
203
  raise typer.Exit(1)
219
204
 
220
- total_paths = sum(1 for p in paths)
221
- console.print(f"Found [bold]{len(repos)}[/bold] repositories in {total_paths} path(s)")
205
+ console.print(f"Found [bold]{len(repos)}[/] repositories")
222
206
  console.print()
223
207
 
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
208
+ for repo in repos[:10]: # Show first 10
209
+ lang = repo.primary_language or "Unknown"
210
+ console.print(f"✓ {repo.name} ({repo.commit_count} commits) [{lang}]")
228
211
 
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)
212
+ if len(repos) > 10:
213
+ console.print(f" ... and {len(repos) - 10} more")
237
214
 
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()
215
+ console.print()
241
216
 
242
- if not filtered_repos:
243
- print_warning("No analyzable repositories found (all were config-only).")
244
- raise typer.Exit(1)
217
+ # Ask to track
218
+ if confirm("Track these repositories?", default=True):
219
+ for repo in repos:
220
+ add_tracked_repo(str(repo.path))
221
+ print_success(f"Tracking {len(repos)} repositories")
245
222
 
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()
223
+ # Check for local LLM
224
+ console.print()
225
+ from .llm import detect_local_llm
226
+ llm_info = detect_local_llm()
227
+ if llm_info:
228
+ console.print(f"Local LLM: detected {llm_info.name} at {llm_info.url}")
229
+ else:
230
+ console.print(f"[{BRAND_MUTED}]Local LLM: not detected (install Ollama for offline generation)[/]")
303
231
 
304
- print_success(f"Completed: {len(filtered_repos)} repositories analyzed and pushed")
305
232
  console.print()
306
- print_next_steps(["Run [bold]repr profiles[/] to see all saved profiles"])
233
+ print_next_steps([
234
+ "repr week See what you worked on this week",
235
+ "repr generate --local Save stories permanently",
236
+ "repr login Unlock cloud sync and publishing",
237
+ ])
307
238
 
308
239
 
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
-
240
+ # =============================================================================
241
+ # GENERATE
242
+ # =============================================================================
426
243
 
427
244
  @app.command()
428
- def view(
429
- raw: bool = typer.Option(
430
- False,
431
- "--raw",
432
- "-r",
433
- help="Output plain markdown (for piping).",
245
+ def generate(
246
+ local: bool = typer.Option(
247
+ False, "--local", "-l",
248
+ help="Use local LLM (Ollama)",
434
249
  ),
435
- profile_name: Optional[str] = typer.Option(
436
- None,
437
- "--profile",
438
- "-p",
439
- help="View a specific profile by name.",
250
+ cloud: bool = typer.Option(
251
+ False, "--cloud",
252
+ help="Use cloud LLM (requires login)",
253
+ ),
254
+ repo: Optional[Path] = typer.Option(
255
+ None, "--repo",
256
+ help="Generate for specific repo",
257
+ exists=True,
258
+ dir_okay=True,
259
+ ),
260
+ commits: Optional[str] = typer.Option(
261
+ None, "--commits",
262
+ help="Generate from specific commits (comma-separated)",
263
+ ),
264
+ batch_size: int = typer.Option(
265
+ 5, "--batch-size",
266
+ help="Commits per story",
267
+ ),
268
+ dry_run: bool = typer.Option(
269
+ False, "--dry-run",
270
+ help="Preview what would be sent",
271
+ ),
272
+ template: str = typer.Option(
273
+ "resume", "--template", "-t",
274
+ help="Template: resume, changelog, narrative, interview",
275
+ ),
276
+ prompt: Optional[str] = typer.Option(
277
+ None, "--prompt", "-p",
278
+ help="Custom prompt to append",
440
279
  ),
441
280
  ):
442
281
  """
443
- View the latest profile in the terminal.
282
+ Generate stories from commits.
444
283
 
445
- Example:
446
- repr view
447
- repr view --profile myrepo_2025-12-26
448
- repr view --raw > profile.md
284
+ Examples:
285
+ repr generate --local
286
+ repr generate --cloud
287
+ repr generate --template changelog
288
+ repr generate --commits abc123,def456
449
289
  """
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.")
290
+ from .privacy import check_cloud_permission, log_cloud_operation
291
+
292
+ # Determine mode
293
+ if cloud:
294
+ allowed, reason = check_cloud_permission("cloud_generation")
295
+ if not allowed:
296
+ print_error("Cloud generation blocked")
297
+ print_info(reason)
298
+ console.print()
299
+ print_info("Local generation is available:")
300
+ console.print(" repr generate --local")
461
301
  raise typer.Exit(1)
462
302
 
463
- content = profile_path.read_text()
303
+ if not local and not cloud:
304
+ # Default: local if not signed in, cloud if signed in
305
+ if is_authenticated() and is_cloud_allowed():
306
+ cloud = True
307
+ else:
308
+ local = True
464
309
 
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.
310
+ print_header()
490
311
 
491
- Opens a browser where you can sign in, then automatically
492
- completes the authentication.
312
+ mode_str = "local LLM" if local else "cloud LLM"
313
+ console.print(f"Generating stories ({mode_str})...")
314
+ console.print()
493
315
 
494
- Example:
495
- repr login
496
- """
497
- print_header()
316
+ # Get repos to analyze
317
+ if repo:
318
+ repo_paths = [repo]
319
+ else:
320
+ tracked = get_tracked_repos()
321
+ if not tracked:
322
+ print_warning("No repositories tracked.")
323
+ print_info("Run `repr init` or `repr repos add <path>` first.")
324
+ raise typer.Exit(1)
325
+ repo_paths = [Path(r["path"]) for r in tracked if Path(r["path"]).exists()]
498
326
 
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()
327
+ if not repo_paths:
328
+ print_error("No valid repositories found")
329
+ raise typer.Exit(1)
506
330
 
507
- console.print("[bold]🔐 Authenticate with Repr[/]")
508
- console.print()
331
+ # Get commits
332
+ from .tools import get_commits_with_diffs, get_commits_by_shas
333
+ from .discovery import analyze_repo
509
334
 
510
- async def run_auth():
511
- flow = AuthFlow()
335
+ total_stories = 0
336
+
337
+ for repo_path in repo_paths:
338
+ repo_info = analyze_repo(repo_path)
339
+ console.print(f"[bold]{repo_info.name}[/]")
512
340
 
513
- def on_code_received(device_code):
514
- console.print("To sign in, visit:")
341
+ if commits:
342
+ # Specific commits
343
+ commit_shas = [s.strip() for s in commits.split(",")]
344
+ commit_list = get_commits_by_shas(repo_path, commit_shas)
345
+ else:
346
+ # Recent commits
347
+ commit_list = get_commits_with_diffs(repo_path, count=50, days=90)
348
+
349
+ if not commit_list:
350
+ console.print(f" [{BRAND_MUTED}]No commits found[/]")
351
+ continue
352
+
353
+ # Dry run: show what would be sent
354
+ if dry_run:
355
+ from .openai_analysis import estimate_tokens, get_batch_size
356
+ from .config import load_config
357
+
358
+ config = load_config()
359
+ max_commits = config.get("generation", {}).get("max_commits_per_batch", 50)
360
+ token_limit = config.get("generation", {}).get("token_limit", 100000)
361
+
362
+ console.print(f" [bold]Dry Run Preview[/]")
363
+ console.print(f" Commits to analyze: {len(commit_list)}")
364
+ console.print(f" Template: {template}")
515
365
  console.print()
516
- console.print(f" [bold {BRAND_PRIMARY}]{device_code.verification_url}[/]")
366
+
367
+ # Estimate tokens
368
+ estimated_tokens = estimate_tokens(commit_list)
369
+ console.print(f" Estimated tokens: ~{estimated_tokens:,}")
370
+
371
+ # Check if we need to split into batches
372
+ if len(commit_list) > max_commits:
373
+ num_batches = (len(commit_list) + max_commits - 1) // max_commits
374
+ console.print()
375
+ console.print(f" ⚠ {len(commit_list)} commits exceeds {max_commits}-commit limit")
376
+ console.print()
377
+ console.print(f" Will split into {num_batches} batches:")
378
+ for batch_num in range(num_batches):
379
+ start = batch_num * max_commits + 1
380
+ end = min((batch_num + 1) * max_commits, len(commit_list))
381
+ batch_commits = commit_list[batch_num * max_commits:end]
382
+ batch_tokens = estimate_tokens(batch_commits)
383
+ console.print(f" Batch {batch_num + 1}: commits {start}-{end} (est. {batch_tokens // 1000}k tokens)")
384
+
517
385
  console.print()
518
- console.print("And enter this code:")
519
- print_auth_code(device_code.user_code)
386
+ console.print(" Sample commits:")
387
+ for c in commit_list[:5]:
388
+ console.print(f" • {c['sha'][:7]} {c['message'][:50]}")
389
+ if len(commit_list) > 5:
390
+ console.print(f" ... and {len(commit_list) - 5} more")
391
+ continue
520
392
 
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):
393
+ # Check token limits and prompt user if needed
394
+ from .openai_analysis import estimate_tokens
395
+ from .config import load_config
396
+
397
+ config = load_config()
398
+ max_commits = config.get("generation", {}).get("max_commits_per_batch", 50)
399
+ token_limit = config.get("generation", {}).get("token_limit", 100000)
400
+
401
+ # Check if we exceed limits (only warn for cloud generation)
402
+ if cloud and len(commit_list) > max_commits:
403
+ num_batches = (len(commit_list) + max_commits - 1) // max_commits
531
404
  console.print()
405
+ console.print(f" ⚠ {len(commit_list)} commits exceeds {max_commits}-commit limit")
532
406
  console.print()
533
- print_success(f"Authenticated as {token.email}")
407
+ console.print(f" Will split into {num_batches} batches:")
408
+ for batch_num in range(num_batches):
409
+ start = batch_num * max_commits + 1
410
+ end = min((batch_num + 1) * max_commits, len(commit_list))
411
+ batch_commits = commit_list[batch_num * max_commits:end]
412
+ batch_tokens = estimate_tokens(batch_commits)
413
+ console.print(f" Batch {batch_num + 1}: commits {start}-{end} (est. {batch_tokens // 1000}k tokens)")
534
414
  console.print()
535
- console.print(f" Token saved to {CONFIG_DIR / 'config.json'}")
415
+
416
+ if not confirm("Continue with generation?"):
417
+ console.print(f" [{BRAND_MUTED}]Skipped {repo_info.name}[/]")
418
+ continue
419
+
420
+ # Generate stories
421
+ stories = _generate_stories(
422
+ commits=commit_list,
423
+ repo_info=repo_info,
424
+ batch_size=batch_size,
425
+ local=local,
426
+ template=template,
427
+ custom_prompt=prompt,
428
+ )
536
429
 
537
- def on_error(error):
538
- console.print()
539
- print_error(str(error))
430
+ for story in stories:
431
+ console.print(f" • {story['summary']}")
432
+ total_stories += 1
540
433
 
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
434
+ # Log cloud operation if using cloud
435
+ if cloud and stories:
436
+ log_cloud_operation(
437
+ operation="cloud_generation",
438
+ destination="repr.dev",
439
+ payload_summary={
440
+ "repo": repo_info.name,
441
+ "commits": len(commit_list),
442
+ "stories_generated": len(stories),
443
+ },
444
+ bytes_sent=len(str(commit_list)) // 2, # Rough estimate
445
+ )
545
446
 
447
+ console.print()
448
+
449
+ if dry_run:
450
+ console.print()
451
+ console.print("Continue with generation? (run without --dry-run)")
452
+ else:
453
+ print_success(f"Generated {total_stories} stories")
454
+ console.print()
455
+ console.print(f"Stories saved to: {STORIES_DIR}")
456
+ print_next_steps([
457
+ "repr stories View your stories",
458
+ "repr push Publish to repr.dev (requires login)",
459
+ ])
460
+
461
+
462
+ def _generate_stories(
463
+ commits: list[dict],
464
+ repo_info,
465
+ batch_size: int,
466
+ local: bool,
467
+ template: str = "resume",
468
+ custom_prompt: str | None = None,
469
+ ) -> list[dict]:
470
+ """Generate stories from commits using LLM."""
471
+ from .openai_analysis import get_openai_client, extract_commit_batch
472
+ from .templates import build_generation_prompt
473
+
474
+ stories = []
475
+
476
+ # Split into batches
477
+ batches = [
478
+ commits[i:i + batch_size]
479
+ for i in range(0, len(commits), batch_size)
480
+ ]
481
+
482
+ for i, batch in enumerate(batches):
546
483
  try:
547
- await flow.run()
548
- except AuthError:
549
- raise typer.Exit(1)
484
+ # Build prompt with template
485
+ system_prompt, user_prompt = build_generation_prompt(
486
+ template_name=template,
487
+ repo_name=repo_info.name,
488
+ commits=batch,
489
+ custom_prompt=custom_prompt,
490
+ )
491
+
492
+ # Get LLM client
493
+ if local:
494
+ llm_config = get_llm_config()
495
+ client = get_openai_client(
496
+ api_key=llm_config.get("local_api_key") or "ollama",
497
+ base_url=llm_config.get("local_api_url") or "http://localhost:11434/v1",
498
+ )
499
+ model = llm_config.get("local_model") or llm_config.get("extraction_model") or "llama3.2"
500
+ else:
501
+ client = get_openai_client()
502
+ model = None # Use default
503
+
504
+ # Extract story from batch
505
+ content = asyncio.run(extract_commit_batch(
506
+ client=client,
507
+ commits=batch,
508
+ batch_num=i + 1,
509
+ total_batches=len(batches),
510
+ model=model,
511
+ system_prompt=system_prompt,
512
+ user_prompt=user_prompt,
513
+ ))
514
+
515
+ if not content or content.startswith("[Batch"):
516
+ continue
517
+
518
+ # Extract summary (first non-empty line)
519
+ lines = [l.strip() for l in content.split("\n") if l.strip()]
520
+ summary = lines[0][:100] if lines else "Story"
521
+ # Clean up summary
522
+ summary = summary.lstrip("#-•* ").strip()
523
+
524
+ # Build metadata
525
+ commit_shas = [c["full_sha"] for c in batch]
526
+ first_date = min(c["date"] for c in batch)
527
+ last_date = max(c["date"] for c in batch)
528
+ total_files = sum(len(c.get("files", [])) for c in batch)
529
+ total_adds = sum(c.get("insertions", 0) for c in batch)
530
+ total_dels = sum(c.get("deletions", 0) for c in batch)
531
+
532
+ metadata = {
533
+ "summary": summary,
534
+ "repo_name": repo_info.name,
535
+ "repo_path": str(repo_info.path),
536
+ "commit_shas": commit_shas,
537
+ "first_commit_at": first_date,
538
+ "last_commit_at": last_date,
539
+ "files_changed": total_files,
540
+ "lines_added": total_adds,
541
+ "lines_removed": total_dels,
542
+ "generated_locally": local,
543
+ "template": template,
544
+ "needs_review": False,
545
+ }
546
+
547
+ # Save story
548
+ story_id = save_story(content, metadata)
549
+ metadata["id"] = story_id
550
+ stories.append(metadata)
551
+
552
+ except Exception as e:
553
+ console.print(f" [{BRAND_MUTED}]Batch {i+1} failed: {e}[/]")
550
554
 
551
- asyncio.run(run_auth())
555
+ return stories
556
+
552
557
 
558
+ # =============================================================================
559
+ # QUICK REFLECTION COMMANDS
560
+ # =============================================================================
553
561
 
554
562
  @app.command()
555
- def logout():
563
+ def week(
564
+ save: bool = typer.Option(
565
+ False, "--save",
566
+ help="Save as a permanent story",
567
+ ),
568
+ ):
556
569
  """
557
- Clear authentication and logout.
570
+ Show what you worked on this week.
558
571
 
559
572
  Example:
560
- repr logout
573
+ repr week
574
+ repr week --save
561
575
  """
562
- if not is_authenticated():
563
- print_info("Not currently authenticated.")
564
- raise typer.Exit()
565
-
566
- auth_logout()
576
+ _show_summary_since("this week", days=7, save=save)
577
+
578
+
579
+ @app.command()
580
+ def since(
581
+ date: str = typer.Argument(..., help="Date or time reference (e.g., 'monday', '2026-01-01', '3 days ago')"),
582
+ save: bool = typer.Option(
583
+ False, "--save",
584
+ help="Save as a permanent story",
585
+ ),
586
+ ):
587
+ """
588
+ Show work since a specific date or time.
567
589
 
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}")
590
+ Examples:
591
+ repr since monday
592
+ repr since "3 days ago"
593
+ repr since 2026-01-01
594
+ """
595
+ _show_summary_since(date, save=save)
572
596
 
573
597
 
574
598
  @app.command()
575
- def profiles():
599
+ def standup(
600
+ days: int = typer.Option(
601
+ 3, "--days",
602
+ help="Number of days to look back",
603
+ ),
604
+ ):
576
605
  """
577
- List all saved profiles.
606
+ Quick summary for daily standup (last 3 days).
578
607
 
579
608
  Example:
580
- repr profiles
609
+ repr standup
610
+ repr standup --days 5
581
611
  """
582
- saved_profiles = list_profiles()
612
+ _show_summary_since(f"last {days} days", days=days, save=False)
613
+
614
+
615
+ def _show_summary_since(label: str, days: int = None, save: bool = False):
616
+ """Show summary of work since a date."""
617
+ from .tools import get_commits_with_diffs
618
+ from .discovery import analyze_repo
583
619
 
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()
620
+ print_header()
588
621
 
589
- print_panel("Saved Profiles", "", border_color=BRAND_PRIMARY)
622
+ tracked = get_tracked_repos()
623
+ if not tracked:
624
+ print_warning("No repositories tracked.")
625
+ print_info("Run `repr init` first.")
626
+ raise typer.Exit(1)
627
+
628
+ console.print(f"📊 Work since {label}")
590
629
  console.print()
591
630
 
592
- table = create_profile_table()
631
+ total_commits = 0
632
+ all_summaries = []
593
633
 
594
- for profile in saved_profiles:
595
- status = f"[{BRAND_SUCCESS}]✓ synced[/]" if profile["synced"] else f"[{BRAND_MUTED}]○ local only[/]"
634
+ for repo_info in tracked:
635
+ repo_path = Path(repo_info["path"])
636
+ if not repo_path.exists():
637
+ continue
596
638
 
597
- table.add_row(
598
- profile["name"],
599
- str(profile["project_count"]),
600
- format_bytes(profile["size"]),
601
- status,
602
- )
639
+ commits = get_commits_with_diffs(repo_path, count=100, days=days or 30)
640
+ if not commits:
641
+ continue
642
+
643
+ repo_name = repo_path.name
644
+ console.print(f"[bold]{repo_name}[/] ({len(commits)} commits):")
645
+
646
+ # Group commits by rough topic (simple heuristic)
647
+ for c in commits[:5]:
648
+ msg = c["message"].split("\n")[0][:60]
649
+ console.print(f" • {msg}")
650
+
651
+ if len(commits) > 5:
652
+ console.print(f" [{BRAND_MUTED}]... and {len(commits) - 5} more[/]")
653
+
654
+ total_commits += len(commits)
655
+ console.print()
603
656
 
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']}")
657
+ console.print(f"Total: {total_commits} commits")
658
+
659
+ if not save:
660
+ console.print()
661
+ console.print(f"[{BRAND_MUTED}]This summary wasn't saved. Run with --save to create a story.[/]")
612
662
 
613
663
 
664
+ # =============================================================================
665
+ # STORIES MANAGEMENT
666
+ # =============================================================================
667
+
614
668
  @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
- ),
669
+ def stories(
670
+ repo: Optional[str] = typer.Option(None, "--repo", help="Filter by repository"),
671
+ needs_review: bool = typer.Option(False, "--needs-review", help="Show only stories needing review"),
672
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
622
673
  ):
623
674
  """
624
- Push local profiles to repr.dev.
675
+ List all stories.
625
676
 
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
677
+ Example:
678
+ repr stories
679
+ repr stories --repo myproject
680
+ repr stories --needs-review
631
681
  """
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()
682
+ story_list = list_stories(repo_name=repo, needs_review=needs_review)
637
683
 
638
- # Get profiles to push
639
- all_profiles = list_profiles()
684
+ if json_output:
685
+ print(json.dumps(story_list, indent=2, default=str))
686
+ return
640
687
 
641
- if not all_profiles:
642
- print_info("No profiles found.")
643
- print_info("Run 'repr analyze <paths>' to generate profiles first.")
688
+ if not story_list:
689
+ print_info("No stories found.")
690
+ print_info("Run `repr generate` to create stories from your commits.")
644
691
  raise typer.Exit()
645
692
 
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...")
693
+ console.print(f"[bold]Stories[/] ({len(story_list)} total)")
661
694
  console.print()
662
695
 
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()
696
+ for story in story_list[:20]:
697
+ # Status indicator
698
+ if story.get("needs_review"):
699
+ status = f"[{BRAND_WARNING}]⚠[/]"
700
+ elif story.get("pushed_at"):
701
+ status = f"[{BRAND_SUCCESS}]✓[/]"
702
+ else:
703
+ status = f"[{BRAND_MUTED}]○[/]"
669
704
 
670
- # Load metadata if available
671
- metadata = get_profile_metadata(profile["name"])
705
+ summary = story.get("summary", "Untitled")[:60]
706
+ repo_name = story.get("repo_name", "unknown")
707
+ created = format_relative_time(story.get("created_at", ""))
672
708
 
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:
709
+ console.print(f"{status} {summary}")
710
+ console.print(f" [{BRAND_MUTED}]{repo_name} {created}[/]")
706
711
  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
- ])
712
+
713
+ if len(story_list) > 20:
714
+ console.print(f"[{BRAND_MUTED}]... and {len(story_list) - 20} more[/]")
711
715
 
712
716
 
713
- @app.command(name="config")
714
- def show_config():
717
+ @app.command()
718
+ def story(
719
+ action: str = typer.Argument(..., help="Action: view, edit, delete, hide, feature, regenerate"),
720
+ story_id: str = typer.Argument(..., help="Story ID (ULID)"),
721
+ ):
715
722
  """
716
- Show current configuration and API endpoints.
723
+ Manage a single story.
717
724
 
718
- Useful for debugging and verifying backend connection.
719
-
720
- Example:
721
- repr config
722
- repr --dev config
725
+ Examples:
726
+ repr story view 01ARYZ6S41TSV4RRFFQ69G5FAV
727
+ repr story delete 01ARYZ6S41TSV4RRFFQ69G5FAV
723
728
  """
729
+ result = load_story(story_id)
724
730
 
725
- console.print("[bold]Repr CLI Configuration[/]")
726
- console.print()
731
+ if not result:
732
+ print_error(f"Story not found: {story_id}")
733
+ raise typer.Exit(1)
727
734
 
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()
735
+ content, metadata = result
732
736
 
733
- if is_authenticated():
734
- user = get_current_user()
737
+ if action == "view":
738
+ print_markdown(content)
739
+ console.print()
740
+ console.print(f"[{BRAND_MUTED}]ID: {story_id}[/]")
741
+ console.print(f"[{BRAND_MUTED}]Created: {metadata.get('created_at', 'unknown')}[/]")
742
+
743
+ elif action == "edit":
744
+ # Open in $EDITOR
745
+ import subprocess
746
+
747
+ editor = os.environ.get("EDITOR", "vim")
748
+ md_path = STORIES_DIR / f"{story_id}.md"
749
+
750
+ subprocess.run([editor, str(md_path)])
751
+ print_success("Story updated")
752
+
753
+ elif action == "delete":
754
+ if confirm(f"Delete story '{metadata.get('summary', story_id)}'?"):
755
+ delete_story(story_id)
756
+ print_success("Story deleted")
757
+ else:
758
+ print_info("Cancelled")
759
+
760
+ elif action == "hide":
761
+ update_story_metadata(story_id, {"is_hidden": True})
762
+ print_success("Story hidden from profile")
763
+
764
+ elif action == "feature":
765
+ update_story_metadata(story_id, {"is_featured": True})
766
+ print_success("Story featured on profile")
767
+
768
+ elif action == "regenerate":
769
+ print_info("Regeneration not yet implemented")
770
+ print_info("Use `repr generate --commits <sha>` with specific commits")
771
+
772
+ else:
773
+ print_error(f"Unknown action: {action}")
774
+ print_info("Valid actions: view, edit, delete, hide, feature, regenerate")
775
+ raise typer.Exit(1)
776
+
777
+
778
+ @app.command("review")
779
+ def stories_review():
780
+ """
781
+ Interactive review workflow for stories needing review.
782
+
783
+ Example:
784
+ repr review
785
+ """
786
+ from .storage import get_stories_needing_review
787
+
788
+ needs_review = get_stories_needing_review()
789
+
790
+ if not needs_review:
791
+ print_success("No stories need review!")
792
+ raise typer.Exit()
793
+
794
+ console.print(f"[bold]{len(needs_review)} stories need review[/]")
795
+ console.print()
796
+
797
+ for i, story in enumerate(needs_review):
798
+ console.print(f"[bold]Story {i+1} of {len(needs_review)}[/]")
799
+ console.print()
800
+
801
+ # Show story summary
802
+ console.print(f"⚠ \"{story.get('summary', 'Untitled')}\"")
803
+ console.print(f" {story.get('repo_name', 'unknown')} • {len(story.get('commit_shas', []))} commits")
804
+ console.print()
805
+
806
+ # Load and show content preview
807
+ result = load_story(story["id"])
808
+ if result:
809
+ content, metadata = result
810
+
811
+ # Check if this is a STAR format story (interview template)
812
+ template = metadata.get('template', '')
813
+ if template == 'interview':
814
+ # Display STAR format
815
+ console.print("[bold]What was built:[/]")
816
+ # Extract "what was built" section from content
817
+ lines = content.split('\n')
818
+ for line in lines[:10]: # Show first 10 lines as preview
819
+ console.print(f" {line}")
820
+
821
+ console.print()
822
+ console.print("[bold]Technologies:[/]")
823
+ # Try to extract technologies from metadata or content
824
+ techs = metadata.get('technologies', [])
825
+ if techs:
826
+ console.print(f" {', '.join(techs)}")
827
+ else:
828
+ console.print(" (Not specified)")
829
+
830
+ console.print()
831
+ console.print("[bold]Confidence:[/] {0}".format(
832
+ metadata.get('confidence', 'Medium')
833
+ ))
834
+ else:
835
+ # Show first 500 chars as preview
836
+ preview = content[:500]
837
+ if len(content) > 500:
838
+ preview += "..."
839
+ console.print(preview)
840
+
841
+ console.print()
842
+
843
+ # Prompt for action (added regenerate option)
844
+ action = Prompt.ask(
845
+ "[a] Approve [e] Edit [r] Regenerate [d] Delete [s] Skip [q] Quit",
846
+ choices=["a", "e", "r", "d", "s", "q"],
847
+ default="s",
848
+ )
849
+
850
+ if action == "a":
851
+ update_story_metadata(story["id"], {"needs_review": False})
852
+ print_success("Story approved")
853
+ elif action == "e":
854
+ editor = os.environ.get("EDITOR", "vim")
855
+ md_path = STORIES_DIR / f"{story['id']}.md"
856
+ import subprocess
857
+ subprocess.run([editor, str(md_path)])
858
+ update_story_metadata(story["id"], {"needs_review": False})
859
+ print_success("Story updated and approved")
860
+ elif action == "r":
861
+ # Regenerate story with different template
862
+ print_info("Regenerate action not yet implemented")
863
+ print_info("This feature is planned for a future release")
864
+ print_info("For now, you can delete and re-generate the story manually")
865
+ elif action == "d":
866
+ if confirm("Delete this story?"):
867
+ delete_story(story["id"])
868
+ print_success("Story deleted")
869
+ elif action == "q":
870
+ break
871
+
872
+ console.print()
873
+
874
+
875
+ # =============================================================================
876
+ # CLOUD OPERATIONS
877
+ # =============================================================================
878
+
879
+ @app.command()
880
+ def push(
881
+ story_id: Optional[str] = typer.Option(None, "--story", help="Push specific story"),
882
+ all_stories: bool = typer.Option(False, "--all", help="Push all unpushed stories"),
883
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview what would be pushed"),
884
+ ):
885
+ """
886
+ Publish stories to repr.dev.
887
+
888
+ Examples:
889
+ repr push
890
+ repr push --story 01ARYZ6S41TSV4RRFFQ69G5FAV
891
+ """
892
+ from .privacy import check_cloud_permission, log_cloud_operation
893
+
894
+ allowed, reason = check_cloud_permission("push")
895
+ if not allowed:
896
+ print_error("Publishing blocked")
897
+ print_info(reason)
898
+ raise typer.Exit(1)
899
+
900
+ # Get stories to push
901
+ if story_id:
902
+ result = load_story(story_id)
903
+ if not result:
904
+ print_error(f"Story not found: {story_id}")
905
+ raise typer.Exit(1)
906
+ content, metadata = result
907
+ to_push = [{"id": story_id, "content": content, **metadata}]
908
+ else:
909
+ to_push = get_unpushed_stories()
910
+
911
+ if not to_push:
912
+ print_success("All stories already synced!")
913
+ raise typer.Exit()
914
+
915
+ console.print(f"Publishing {len(to_push)} story(ies) to repr.dev...")
916
+ console.print()
917
+
918
+ if dry_run:
919
+ for s in to_push:
920
+ console.print(f" • {s.get('summary', s.get('id'))}")
921
+ console.print()
922
+ console.print("Run without --dry-run to publish")
923
+ raise typer.Exit()
924
+
925
+ # Actually push
926
+ from .api import push_story as api_push_story
927
+
928
+ pushed = 0
929
+ for s in to_push:
930
+ try:
931
+ content, meta = load_story(s["id"])
932
+ asyncio.run(api_push_story({**meta, "content": content}))
933
+ mark_story_pushed(s["id"])
934
+ console.print(f" [{BRAND_SUCCESS}]✓[/] {s.get('summary', s.get('id'))[:50]}")
935
+ pushed += 1
936
+ except (APIError, AuthError) as e:
937
+ console.print(f" [{BRAND_ERROR}]✗[/] {s.get('summary', s.get('id'))[:50]}: {e}")
938
+
939
+ # Log operation
940
+ if pushed > 0:
941
+ log_cloud_operation(
942
+ operation="push",
943
+ destination="repr.dev",
944
+ payload_summary={"stories_pushed": pushed},
945
+ bytes_sent=0,
946
+ )
947
+
948
+ console.print()
949
+ print_success(f"Pushed {pushed}/{len(to_push)} stories")
950
+
951
+
952
+ @app.command()
953
+ def sync():
954
+ """
955
+ Sync stories across devices.
956
+
957
+ Example:
958
+ repr sync
959
+ """
960
+ from .privacy import check_cloud_permission
961
+
962
+ allowed, reason = check_cloud_permission("sync")
963
+ if not allowed:
964
+ print_error("Sync blocked")
965
+ print_info(reason)
966
+ raise typer.Exit(1)
967
+
968
+ console.print("Syncing with repr.dev...")
969
+ console.print()
970
+
971
+ unpushed = get_unpushed_stories()
972
+
973
+ if unpushed:
974
+ console.print(f"↑ {len(unpushed)} local stories to push")
975
+ if confirm("Upload these stories?"):
976
+ # Reuse push logic
977
+ from .api import push_story as api_push_story
978
+ for s in unpushed:
979
+ try:
980
+ content, meta = load_story(s["id"])
981
+ asyncio.run(api_push_story({**meta, "content": content}))
982
+ mark_story_pushed(s["id"])
983
+ except Exception:
984
+ pass
985
+ print_success(f"Pushed {len(unpushed)} stories")
986
+ else:
987
+ console.print(f"[{BRAND_SUCCESS}]✓[/] All stories synced")
988
+
989
+
990
+ @app.command()
991
+ def pull():
992
+ """
993
+ Pull remote stories to local device.
994
+
995
+ Example:
996
+ repr pull
997
+ """
998
+ from .privacy import check_cloud_permission
999
+
1000
+ allowed, reason = check_cloud_permission("pull")
1001
+ if not allowed:
1002
+ print_error("Pull blocked")
1003
+ print_info(reason)
1004
+ raise typer.Exit(1)
1005
+
1006
+ console.print("Pulling from repr.dev...")
1007
+ console.print()
1008
+
1009
+ # TODO: Implement actual pull from API
1010
+ print_info("Pull from cloud not yet implemented")
1011
+ print_info("Local stories are preserved")
1012
+
1013
+
1014
+ # =============================================================================
1015
+ # COMMITS
1016
+ # =============================================================================
1017
+
1018
+ @app.command("commits")
1019
+ def list_commits(
1020
+ repo: Optional[str] = typer.Option(None, "--repo", help="Filter by repo name"),
1021
+ limit: int = typer.Option(50, "--limit", "-n", help="Number of commits"),
1022
+ since: Optional[str] = typer.Option(None, "--since", help="Since date"),
1023
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
1024
+ ):
1025
+ """
1026
+ List recent commits across all tracked repos.
1027
+
1028
+ Example:
1029
+ repr commits --limit 10
1030
+ repr commits --repo myproject
1031
+ """
1032
+ from .tools import get_commits_with_diffs
1033
+
1034
+ tracked = get_tracked_repos()
1035
+ if not tracked:
1036
+ print_warning("No repositories tracked.")
1037
+ raise typer.Exit(1)
1038
+
1039
+ all_commits = []
1040
+
1041
+ for repo_info in tracked:
1042
+ repo_path = Path(repo_info["path"])
1043
+ if repo and repo_path.name != repo:
1044
+ continue
1045
+ if not repo_path.exists():
1046
+ continue
1047
+
1048
+ commits = get_commits_with_diffs(repo_path, count=limit, days=90)
1049
+ for c in commits:
1050
+ c["repo_name"] = repo_path.name
1051
+ all_commits.extend(commits)
1052
+
1053
+ # Sort by date
1054
+ all_commits.sort(key=lambda c: c.get("date", ""), reverse=True)
1055
+ all_commits = all_commits[:limit]
1056
+
1057
+ if json_output:
1058
+ print(json.dumps(all_commits, indent=2, default=str))
1059
+ return
1060
+
1061
+ if not all_commits:
1062
+ print_info("No commits found")
1063
+ raise typer.Exit()
1064
+
1065
+ console.print(f"[bold]Recent Commits[/] ({len(all_commits)})")
1066
+ console.print()
1067
+
1068
+ current_repo = None
1069
+ for c in all_commits:
1070
+ if c["repo_name"] != current_repo:
1071
+ current_repo = c["repo_name"]
1072
+ console.print(f"[bold]{current_repo}:[/]")
1073
+
1074
+ sha = c.get("sha", "")[:7]
1075
+ msg = c.get("message", "").split("\n")[0][:50]
1076
+ date = format_relative_time(c.get("date", ""))
1077
+ console.print(f" {sha} {msg} [{BRAND_MUTED}]{date}[/]")
1078
+
1079
+ console.print()
1080
+ print_info("Generate stories: repr generate --commits <sha1>,<sha2>")
1081
+
1082
+
1083
+ # =============================================================================
1084
+ # AUTHENTICATION
1085
+ # =============================================================================
1086
+
1087
+ @app.command()
1088
+ def login():
1089
+ """
1090
+ Authenticate with repr.dev.
1091
+
1092
+ Example:
1093
+ repr login
1094
+ """
1095
+ print_header()
1096
+
1097
+ if is_authenticated():
1098
+ user = get_current_user()
735
1099
  email = user.get("email", "unknown") if user else "unknown"
736
- console.print(f" Auth: [green]✓ Authenticated as {email}[/]")
1100
+ print_info(f"Already authenticated as {email}")
1101
+
1102
+ if not confirm("Re-authenticate?"):
1103
+ raise typer.Exit()
1104
+
1105
+ console.print("[bold]Authenticate with repr[/]")
1106
+ console.print()
1107
+
1108
+ async def run_auth():
1109
+ flow = AuthFlow()
1110
+
1111
+ def on_code_received(device_code):
1112
+ console.print("To sign in, visit:")
1113
+ console.print()
1114
+ console.print(f" [bold {BRAND_PRIMARY}]{device_code.verification_url}[/]")
1115
+ console.print()
1116
+ console.print("And enter this code:")
1117
+ print_auth_code(device_code.user_code)
1118
+
1119
+ def on_progress(remaining):
1120
+ mins = int(remaining // 60)
1121
+ secs = int(remaining % 60)
1122
+ console.print(f"\rWaiting for authorization... {mins}:{secs:02d} ", end="")
1123
+
1124
+ def on_success(token):
1125
+ console.print()
1126
+ console.print()
1127
+ print_success(f"Authenticated as {token.email}")
1128
+
1129
+ def on_error(error):
1130
+ console.print()
1131
+ print_error(str(error))
1132
+
1133
+ flow.on_code_received = on_code_received
1134
+ flow.on_progress = on_progress
1135
+ flow.on_success = on_success
1136
+ flow.on_error = on_error
1137
+
1138
+ try:
1139
+ await flow.run()
1140
+ except AuthError:
1141
+ raise typer.Exit(1)
1142
+
1143
+ asyncio.run(run_auth())
1144
+
1145
+ # Check for local stories
1146
+ local_count = get_story_count()
1147
+ if local_count > 0:
1148
+ console.print()
1149
+ console.print(f"You have {local_count} local stories.")
1150
+ console.print("Run `repr sync` to upload them.")
1151
+
1152
+
1153
+ @app.command()
1154
+ def logout():
1155
+ """
1156
+ Sign out and clear credentials.
1157
+
1158
+ Example:
1159
+ repr logout
1160
+ """
1161
+ if not is_authenticated():
1162
+ print_info("Not currently authenticated.")
1163
+ raise typer.Exit()
1164
+
1165
+ auth_logout()
1166
+
1167
+ print_success("Signed out")
1168
+ console.print()
1169
+ console.print(f" Local stories preserved in {STORIES_DIR}")
1170
+
1171
+
1172
+ @app.command()
1173
+ def whoami(
1174
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
1175
+ ):
1176
+ """
1177
+ Show current user.
1178
+
1179
+ Example:
1180
+ repr whoami
1181
+ """
1182
+ if not is_authenticated():
1183
+ if json_output:
1184
+ print(json.dumps({"error": "Not authenticated"}))
1185
+ else:
1186
+ print_info("Not signed in")
1187
+ print_info("Run `repr login` to sign in")
1188
+ raise typer.Exit(1)
1189
+
1190
+ user = get_current_user()
1191
+
1192
+ if json_output:
1193
+ print(json.dumps(user, indent=2))
1194
+ return
1195
+
1196
+ email = user.get("email", "unknown")
1197
+ console.print(f"Signed in as: [bold]{email}[/]")
1198
+
1199
+
1200
+ # =============================================================================
1201
+ # REPOSITORY MANAGEMENT
1202
+ # =============================================================================
1203
+
1204
+ @app.command()
1205
+ def repos(
1206
+ action: str = typer.Argument("list", help="Action: list, add, remove, pause, resume"),
1207
+ path: Optional[Path] = typer.Argument(None, help="Repository path (for add/remove/pause/resume)"),
1208
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
1209
+ ):
1210
+ """
1211
+ Manage tracked repositories.
1212
+
1213
+ Examples:
1214
+ repr repos
1215
+ repr repos add ~/code/myproject
1216
+ repr repos remove ~/code/old-project
1217
+ repr repos pause ~/code/work-project
1218
+ """
1219
+ if action == "list":
1220
+ tracked = get_tracked_repos()
1221
+
1222
+ if json_output:
1223
+ print(json.dumps(tracked, indent=2))
1224
+ return
1225
+
1226
+ if not tracked:
1227
+ print_info("No repositories tracked.")
1228
+ print_info("Run `repr repos add <path>` to track a repository.")
1229
+ raise typer.Exit()
1230
+
1231
+ console.print(f"[bold]Tracked Repositories[/] ({len(tracked)})")
1232
+ console.print()
1233
+
1234
+ for repo in tracked:
1235
+ repo_path = Path(repo["path"])
1236
+ exists = repo_path.exists()
1237
+ paused = repo.get("paused", False)
1238
+ hook = repo.get("hook_installed", False)
1239
+
1240
+ if not exists:
1241
+ status = f"[{BRAND_ERROR}]✗[/]"
1242
+ elif paused:
1243
+ status = f"[{BRAND_WARNING}]○[/]"
1244
+ else:
1245
+ status = f"[{BRAND_SUCCESS}]✓[/]"
1246
+
1247
+ console.print(f" {status} {repo_path.name}")
1248
+ details = []
1249
+ if hook:
1250
+ details.append("hook: on")
1251
+ if paused:
1252
+ details.append("paused")
1253
+ if not exists:
1254
+ details.append("missing")
1255
+
1256
+ info_str = " • ".join(details) if details else repo["path"]
1257
+ console.print(f" [{BRAND_MUTED}]{info_str}[/]")
1258
+
1259
+ elif action == "add":
1260
+ if not path:
1261
+ print_error("Path required: repr repos add <path>")
1262
+ raise typer.Exit(1)
1263
+
1264
+ if not (path / ".git").exists():
1265
+ print_error(f"Not a git repository: {path}")
1266
+ raise typer.Exit(1)
1267
+
1268
+ add_tracked_repo(str(path))
1269
+ print_success(f"Added: {path.name}")
1270
+
1271
+ elif action == "remove":
1272
+ if not path:
1273
+ print_error("Path required: repr repos remove <path>")
1274
+ raise typer.Exit(1)
1275
+
1276
+ if remove_tracked_repo(str(path)):
1277
+ print_success(f"Removed: {path.name}")
1278
+ else:
1279
+ print_warning(f"Not tracked: {path.name}")
1280
+
1281
+ elif action == "pause":
1282
+ if not path:
1283
+ print_error("Path required: repr repos pause <path>")
1284
+ raise typer.Exit(1)
1285
+
1286
+ set_repo_paused(str(path), True)
1287
+ print_success(f"Paused auto-tracking for: {path.name}")
1288
+
1289
+ elif action == "resume":
1290
+ if not path:
1291
+ print_error("Path required: repr repos resume <path>")
1292
+ raise typer.Exit(1)
1293
+
1294
+ set_repo_paused(str(path), False)
1295
+ print_success(f"Resumed auto-tracking for: {path.name}")
1296
+
1297
+ else:
1298
+ print_error(f"Unknown action: {action}")
1299
+ print_info("Valid actions: list, add, remove, pause, resume")
1300
+
1301
+
1302
+ # =============================================================================
1303
+ # HOOKS MANAGEMENT
1304
+ # =============================================================================
1305
+
1306
+ @hooks_app.command("install")
1307
+ def hooks_install(
1308
+ all_repos: bool = typer.Option(False, "--all", help="Install in all tracked repos"),
1309
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Install in specific repo"),
1310
+ ):
1311
+ """
1312
+ Install git post-commit hooks for auto-tracking.
1313
+
1314
+ Example:
1315
+ repr hooks install --all
1316
+ repr hooks install --repo ~/code/myproject
1317
+ """
1318
+ from .hooks import install_hook
1319
+
1320
+ if repo:
1321
+ repos_to_install = [{"path": str(repo)}]
1322
+ elif all_repos:
1323
+ repos_to_install = get_tracked_repos()
1324
+ else:
1325
+ print_error("Specify --all or --repo <path>")
1326
+ raise typer.Exit(1)
1327
+
1328
+ if not repos_to_install:
1329
+ print_warning("No repositories to install hooks in")
1330
+ raise typer.Exit(1)
1331
+
1332
+ console.print(f"Installing hooks in {len(repos_to_install)} repositories...")
1333
+ console.print()
1334
+
1335
+ installed = 0
1336
+ already = 0
1337
+
1338
+ for repo_info in repos_to_install:
1339
+ repo_path = Path(repo_info["path"])
1340
+ result = install_hook(repo_path)
1341
+
1342
+ if result["success"]:
1343
+ if result["already_installed"]:
1344
+ console.print(f" [{BRAND_MUTED}]○[/] {repo_path.name}: already installed")
1345
+ already += 1
1346
+ else:
1347
+ console.print(f" [{BRAND_SUCCESS}]✓[/] {repo_path.name}: hook installed")
1348
+ set_repo_hook_status(str(repo_path), True)
1349
+ installed += 1
1350
+ else:
1351
+ console.print(f" [{BRAND_ERROR}]✗[/] {repo_path.name}: {result['message']}")
1352
+
1353
+ console.print()
1354
+ print_success(f"Hooks installed: {installed}, Already installed: {already}")
1355
+ console.print()
1356
+ console.print(f"[{BRAND_MUTED}]Hooks queue commits locally. Run `repr generate` to create stories.[/]")
1357
+
1358
+
1359
+ @hooks_app.command("remove")
1360
+ def hooks_remove(
1361
+ all_repos: bool = typer.Option(False, "--all", help="Remove from all tracked repos"),
1362
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Remove from specific repo"),
1363
+ ):
1364
+ """
1365
+ Remove git post-commit hooks.
1366
+
1367
+ Example:
1368
+ repr hooks remove --all
1369
+ """
1370
+ from .hooks import remove_hook
1371
+
1372
+ if repo:
1373
+ repos_to_remove = [{"path": str(repo)}]
1374
+ elif all_repos:
1375
+ repos_to_remove = get_tracked_repos()
1376
+ else:
1377
+ print_error("Specify --all or --repo <path>")
1378
+ raise typer.Exit(1)
1379
+
1380
+ removed = 0
1381
+ for repo_info in repos_to_remove:
1382
+ repo_path = Path(repo_info["path"])
1383
+ result = remove_hook(repo_path)
1384
+
1385
+ if result["success"]:
1386
+ console.print(f" [{BRAND_SUCCESS}]✓[/] {repo_path.name}: {result['message']}")
1387
+ set_repo_hook_status(str(repo_path), False)
1388
+ removed += 1
1389
+ else:
1390
+ console.print(f" [{BRAND_MUTED}]○[/] {repo_path.name}: {result['message']}")
1391
+
1392
+ print_success(f"Hooks removed: {removed}")
1393
+
1394
+
1395
+ @hooks_app.command("status")
1396
+ def hooks_status(
1397
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
1398
+ ):
1399
+ """
1400
+ Show hook status for all tracked repos.
1401
+
1402
+ Example:
1403
+ repr hooks status
1404
+ """
1405
+ from .hooks import get_hook_status, get_queue_stats
1406
+
1407
+ tracked = get_tracked_repos()
1408
+
1409
+ if not tracked:
1410
+ print_warning("No repositories tracked")
1411
+ raise typer.Exit()
1412
+
1413
+ results = []
1414
+ for repo_info in tracked:
1415
+ repo_path = Path(repo_info["path"])
1416
+ if not repo_path.exists():
1417
+ continue
1418
+
1419
+ status = get_hook_status(repo_path)
1420
+ queue = get_queue_stats(repo_path)
1421
+
1422
+ results.append({
1423
+ "name": repo_path.name,
1424
+ "path": str(repo_path),
1425
+ "installed": status["installed"],
1426
+ "executable": status["executable"],
1427
+ "queue_count": queue["count"],
1428
+ })
1429
+
1430
+ if json_output:
1431
+ print(json.dumps(results, indent=2))
1432
+ return
1433
+
1434
+ console.print("[bold]Hook Status[/]")
1435
+ console.print()
1436
+
1437
+ for r in results:
1438
+ if r["installed"]:
1439
+ status = f"[{BRAND_SUCCESS}]✓[/]"
1440
+ msg = "Hook installed and active"
1441
+ else:
1442
+ status = f"[{BRAND_MUTED}]○[/]"
1443
+ msg = "No hook installed"
1444
+
1445
+ console.print(f"{status} {r['name']}")
1446
+ console.print(f" [{BRAND_MUTED}]{msg}[/]")
1447
+ if r["queue_count"] > 0:
1448
+ console.print(f" [{BRAND_MUTED}]Queue: {r['queue_count']} commits[/]")
1449
+ console.print()
1450
+
1451
+
1452
+ @hooks_app.command("queue")
1453
+ def hooks_queue(
1454
+ commit_sha: str = typer.Argument(..., help="Commit SHA to queue"),
1455
+ repo: Path = typer.Option(..., "--repo", help="Repository path"),
1456
+ ):
1457
+ """
1458
+ Queue a commit (called by git hook).
1459
+
1460
+ This is an internal command used by the git post-commit hook.
1461
+ """
1462
+ from .hooks import queue_commit
1463
+
1464
+ success = queue_commit(repo, commit_sha)
1465
+ if not success:
1466
+ # Silent failure - don't block git commit
1467
+ pass
1468
+
1469
+
1470
+ # =============================================================================
1471
+ # LLM CONFIGURATION
1472
+ # =============================================================================
1473
+
1474
+ @llm_app.command("add")
1475
+ def llm_add(
1476
+ provider: str = typer.Argument(..., help="Provider: openai, anthropic, groq, together"),
1477
+ ):
1478
+ """
1479
+ Configure a BYOK provider.
1480
+
1481
+ Example:
1482
+ repr llm add openai
1483
+ """
1484
+ if provider not in BYOK_PROVIDERS:
1485
+ print_error(f"Unknown provider: {provider}")
1486
+ print_info(f"Available: {', '.join(BYOK_PROVIDERS.keys())}")
1487
+ raise typer.Exit(1)
1488
+
1489
+ provider_info = BYOK_PROVIDERS[provider]
1490
+ console.print(f"Configure {provider_info['name']}")
1491
+ console.print()
1492
+
1493
+ api_key = Prompt.ask("API Key", password=True)
1494
+ if not api_key:
1495
+ print_error("API key required")
1496
+ raise typer.Exit(1)
1497
+
1498
+ # Test the key
1499
+ console.print("Testing connection...")
1500
+ from .llm import test_byok_provider
1501
+ result = test_byok_provider(provider, api_key)
1502
+
1503
+ if result.success:
1504
+ add_byok_provider(provider, api_key)
1505
+ print_success(f"Added {provider_info['name']}")
1506
+ console.print(f" Response time: {result.response_time_ms:.0f}ms")
1507
+ else:
1508
+ print_error(f"Connection failed: {result.error}")
1509
+ if confirm("Save anyway?"):
1510
+ add_byok_provider(provider, api_key)
1511
+ print_success(f"Added {provider_info['name']}")
1512
+
1513
+
1514
+ @llm_app.command("remove")
1515
+ def llm_remove(
1516
+ provider: str = typer.Argument(..., help="Provider to remove"),
1517
+ ):
1518
+ """
1519
+ Remove a BYOK provider key.
1520
+
1521
+ Example:
1522
+ repr llm remove openai
1523
+ """
1524
+ if remove_byok_provider(provider):
1525
+ print_success(f"Removed {provider}")
1526
+ else:
1527
+ print_warning(f"Provider not configured: {provider}")
1528
+
1529
+
1530
+ @llm_app.command("use")
1531
+ def llm_use(
1532
+ mode: str = typer.Argument(..., help="Mode: local, cloud, byok:<provider>"),
1533
+ ):
1534
+ """
1535
+ Set default LLM mode.
1536
+
1537
+ Examples:
1538
+ repr llm use local
1539
+ repr llm use cloud
1540
+ repr llm use byok:openai
1541
+ """
1542
+ valid_modes = ["local", "cloud"]
1543
+ byok_providers = list_byok_providers()
1544
+ valid_modes.extend([f"byok:{p}" for p in byok_providers])
1545
+
1546
+ if mode not in valid_modes and not mode.startswith("byok:"):
1547
+ print_error(f"Invalid mode: {mode}")
1548
+ print_info(f"Valid modes: {', '.join(valid_modes)}")
1549
+ raise typer.Exit(1)
1550
+
1551
+ if mode == "cloud" and not is_authenticated():
1552
+ print_warning("Cloud mode requires sign-in")
1553
+ print_info("When not signed in, local mode will be used instead")
1554
+
1555
+ if mode.startswith("byok:"):
1556
+ provider = mode.split(":", 1)[1]
1557
+ if provider not in byok_providers:
1558
+ print_error(f"BYOK provider not configured: {provider}")
1559
+ print_info(f"Run `repr llm add {provider}` first")
1560
+ raise typer.Exit(1)
1561
+
1562
+ set_llm_config(default=mode)
1563
+ print_success(f"Default LLM mode: {mode}")
1564
+
1565
+
1566
+ @llm_app.command("configure")
1567
+ def llm_configure():
1568
+ """
1569
+ Configure local LLM endpoint interactively.
1570
+
1571
+ Example:
1572
+ repr llm configure
1573
+ """
1574
+ from .llm import detect_all_local_llms, list_ollama_models
1575
+
1576
+ console.print("[bold]Configure Local LLM[/]")
1577
+ console.print()
1578
+
1579
+ # Detect available LLMs
1580
+ detected = detect_all_local_llms()
1581
+
1582
+ if detected:
1583
+ console.print("Detected local LLMs:")
1584
+ for i, llm in enumerate(detected, 1):
1585
+ console.print(f" {i}. {llm.name} ({llm.url})")
1586
+ console.print(f" {len(detected) + 1}. Custom endpoint")
1587
+ console.print()
1588
+
1589
+ choice = Prompt.ask(
1590
+ "Select",
1591
+ choices=[str(i) for i in range(1, len(detected) + 2)],
1592
+ default="1",
1593
+ )
1594
+
1595
+ if int(choice) <= len(detected):
1596
+ selected = detected[int(choice) - 1]
1597
+ url = selected.url
1598
+ models = selected.models
1599
+ else:
1600
+ url = Prompt.ask("Custom endpoint URL")
1601
+ models = []
1602
+ else:
1603
+ console.print(f"[{BRAND_MUTED}]No local LLMs detected[/]")
1604
+ url = Prompt.ask("Endpoint URL", default="http://localhost:11434")
1605
+ models = []
1606
+
1607
+ # Select model
1608
+ if models:
1609
+ console.print()
1610
+ console.print("Available models:")
1611
+ for i, model in enumerate(models[:10], 1):
1612
+ console.print(f" {i}. {model}")
1613
+ console.print()
1614
+
1615
+ model_choice = Prompt.ask("Select model", default="1")
1616
+ try:
1617
+ model = models[int(model_choice) - 1]
1618
+ except (ValueError, IndexError):
1619
+ model = model_choice # Use as literal model name
1620
+ else:
1621
+ model = Prompt.ask("Model name", default="llama3.2")
1622
+
1623
+ # Save config
1624
+ set_llm_config(
1625
+ local_api_url=f"{url}/v1",
1626
+ local_model=model,
1627
+ )
1628
+
1629
+ print_success(f"Configured local LLM")
1630
+ console.print(f" Endpoint: {url}")
1631
+ console.print(f" Model: {model}")
1632
+
1633
+
1634
+ @llm_app.command("test")
1635
+ def llm_test():
1636
+ """
1637
+ Test LLM connection and generation.
1638
+
1639
+ Example:
1640
+ repr llm test
1641
+ """
1642
+ from .llm import test_local_llm, get_llm_status
1643
+
1644
+ console.print("Testing LLM connection...")
1645
+ console.print()
1646
+
1647
+ status = get_llm_status()
1648
+
1649
+ # Test local
1650
+ console.print("[bold]Local LLM:[/]")
1651
+ if status["local"]["available"]:
1652
+ result = test_local_llm(
1653
+ url=status["local"]["url"],
1654
+ model=status["local"]["model"],
1655
+ )
1656
+
1657
+ if result.success:
1658
+ console.print(f" [{BRAND_SUCCESS}]✓[/] Connection successful")
1659
+ console.print(f" Provider: {result.provider}")
1660
+ console.print(f" Model: {result.model}")
1661
+ console.print(f" Response time: {result.response_time_ms:.0f}ms")
1662
+ else:
1663
+ console.print(f" [{BRAND_ERROR}]✗[/] {result.error}")
1664
+ else:
1665
+ console.print(f" [{BRAND_MUTED}]Not detected[/]")
1666
+
1667
+ console.print()
1668
+
1669
+ # Show cloud status
1670
+ console.print("[bold]Cloud LLM:[/]")
1671
+ if status["cloud"]["available"]:
1672
+ console.print(f" [{BRAND_SUCCESS}]✓[/] Available")
1673
+ console.print(f" Model: {status['cloud']['model']}")
737
1674
  else:
738
- console.print(f" Auth: [yellow]○ Not authenticated[/]")
1675
+ console.print(f" [{BRAND_MUTED}][/] {status['cloud']['blocked_reason']}")
1676
+
1677
+ # Show BYOK status
1678
+ if status["byok"]["providers"]:
1679
+ console.print()
1680
+ console.print("[bold]BYOK Providers:[/]")
1681
+ for provider in status["byok"]["providers"]:
1682
+ console.print(f" [{BRAND_SUCCESS}]✓[/] {provider}")
1683
+
1684
+
1685
+ # =============================================================================
1686
+ # PRIVACY
1687
+ # =============================================================================
1688
+
1689
+ @privacy_app.command("explain")
1690
+ def privacy_explain():
1691
+ """
1692
+ One-screen summary of privacy guarantees.
1693
+
1694
+ Example:
1695
+ repr privacy explain
1696
+ """
1697
+ from .privacy import get_privacy_explanation
739
1698
 
1699
+ explanation = get_privacy_explanation()
1700
+
1701
+ console.print("[bold]repr Privacy Guarantees[/]")
1702
+ console.print("━" * 50)
1703
+ console.print()
1704
+
1705
+ console.print("[bold]ARCHITECTURE:[/]")
1706
+ console.print(f" ✓ {explanation['architecture']['guarantee']}")
1707
+ console.print(" ✓ No background daemons or silent uploads")
1708
+ console.print(" ✓ All network calls are foreground, user-initiated")
1709
+ console.print(" ✓ No telemetry by default (opt-in only)")
740
1710
  console.print()
741
1711
 
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[/]'}")
1712
+ console.print("[bold]CURRENT SETTINGS:[/]")
1713
+ state = explanation["current_state"]
1714
+ console.print(f" Auth: {'signed in' if state['authenticated'] else 'not signed in'}")
1715
+ console.print(f" Mode: {state['mode']}")
1716
+ console.print(f" Privacy lock: {'enabled' if state['privacy_lock'] else 'disabled'}")
1717
+ console.print(f" Telemetry: {'enabled' if state['telemetry_enabled'] else 'disabled'}")
751
1718
  console.print()
752
1719
 
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[/]'}")
1720
+ console.print("[bold]LOCAL MODE NETWORK POLICY:[/]")
1721
+ console.print(" Allowed: 127.0.0.1, localhost, ::1 (loopback only)")
1722
+ console.print(" Blocked: All external network, DNS, HTTP(S) to internet")
757
1723
  console.print()
758
1724
 
759
- console.print(f" Config: {CONFIG_DIR / 'config.json'}")
760
- console.print(f" Profiles: {PROFILES_DIR}")
1725
+ console.print("[bold]CLOUD MODE SETTINGS:[/]")
1726
+ cloud = explanation["cloud_mode_settings"]
1727
+ console.print(f" Path redaction: {'enabled' if cloud['path_redaction_enabled'] else 'disabled'}")
1728
+ console.print(f" Diffs: {'disabled' if cloud['diffs_disabled'] else 'enabled'}")
1729
+ console.print(f" Email redaction: {'enabled' if cloud['email_redaction_enabled'] else 'disabled'}")
761
1730
  console.print()
762
1731
 
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")
1732
+ console.print(f"[{BRAND_MUTED}]Run `repr privacy audit` to see all data sent[/]")
1733
+ console.print(f"[{BRAND_MUTED}]Run `repr privacy lock-local` to disable cloud permanently[/]")
766
1734
 
767
1735
 
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
- ),
1736
+ @privacy_app.command("audit")
1737
+ def privacy_audit(
1738
+ days: int = typer.Option(30, "--days", help="Number of days to show"),
1739
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
795
1740
  ):
796
1741
  """
797
- Configure LLM models and local API settings.
1742
+ Show what data was sent to cloud.
798
1743
 
799
- Settings are saved to ~/.repr/config.json and used as defaults
800
- for the analyze command.
1744
+ Example:
1745
+ repr privacy audit
1746
+ """
1747
+ from .privacy import get_audit_summary, get_data_sent_history
801
1748
 
802
- Examples:
803
- # Set models for cloud analysis
804
- repr config-set --extraction-model gpt-4o-mini --synthesis-model gpt-4o
1749
+ summary = get_audit_summary(days=days)
1750
+
1751
+ if json_output:
1752
+ print(json.dumps(summary, indent=2, default=str))
1753
+ return
1754
+
1755
+ console.print("[bold]Privacy Audit[/]")
1756
+ console.print()
1757
+ console.print(f"Last {days} days:")
1758
+ console.print()
1759
+
1760
+ if summary["total_operations"] == 0:
1761
+ if is_authenticated():
1762
+ console.print(f" [{BRAND_MUTED}]No data sent to cloud[/]")
1763
+ else:
1764
+ console.print(f" [{BRAND_MUTED}]None (not signed in)[/]")
1765
+ console.print()
1766
+ console.print("No data has been sent to repr.dev.")
1767
+ return
1768
+
1769
+ for op_name, op_data in summary["by_operation"].items():
1770
+ console.print(f"[bold]{op_name}[/] ({op_data['count']} times):")
1771
+ for entry in op_data["recent"]:
1772
+ payload = entry.get("payload_summary", {})
1773
+ console.print(f" • {entry.get('timestamp', '')[:10]}: {payload}")
1774
+ console.print()
1775
+
1776
+ console.print(f"Total data sent: ~{format_bytes(summary['total_bytes_sent'])}")
1777
+ console.print(f"Destinations: {', '.join(summary['destinations'])}")
1778
+
1779
+
1780
+ @privacy_app.command("lock-local")
1781
+ def privacy_lock_local(
1782
+ permanent: bool = typer.Option(False, "--permanent", help="Make lock irreversible"),
1783
+ ):
1784
+ """
1785
+ Disable cloud features (even after login).
1786
+
1787
+ Example:
1788
+ repr privacy lock-local
1789
+ repr privacy lock-local --permanent
1790
+ """
1791
+ if permanent:
1792
+ console.print(f"[{BRAND_WARNING}]⚠ This will PERMANENTLY disable cloud features.[/]")
1793
+ console.print(" You will not be able to unlock cloud mode.")
1794
+ console.print(" Local-only mode will be enforced forever.")
1795
+ console.print()
805
1796
 
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
1797
+ if not confirm("Continue?"):
1798
+ print_info("Cancelled")
1799
+ raise typer.Exit()
1800
+
1801
+ lock_local_only(permanent=permanent)
1802
+
1803
+ if permanent:
1804
+ print_success("Local-only mode PERMANENTLY enabled")
1805
+ else:
1806
+ print_success("Local-only mode enabled")
1807
+ console.print(f" [{BRAND_MUTED}]To unlock: repr privacy unlock-local[/]")
1808
+
1809
+
1810
+ @privacy_app.command("unlock-local")
1811
+ def privacy_unlock_local():
1812
+ """
1813
+ Unlock from local-only mode.
1814
+
1815
+ Example:
1816
+ repr privacy unlock-local
1817
+ """
1818
+ if unlock_local_only():
1819
+ print_success("Local-only mode disabled")
1820
+ print_info("Cloud features are now available")
1821
+ else:
1822
+ print_error("Cannot unlock: local-only mode is permanently locked")
1823
+
1824
+
1825
+ # =============================================================================
1826
+ # CONFIG
1827
+ # =============================================================================
1828
+
1829
+ @config_app.command("show")
1830
+ def config_show(
1831
+ key: Optional[str] = typer.Argument(None, help="Specific key (dot notation)"),
1832
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
1833
+ ):
1834
+ """
1835
+ Display configuration.
1836
+
1837
+ Examples:
1838
+ repr config show
1839
+ repr config show llm.default
1840
+ """
1841
+ if key:
1842
+ value = get_config_value(key)
1843
+ if json_output:
1844
+ print(json.dumps(value, indent=2, default=str))
1845
+ else:
1846
+ console.print(f"{key} = {value}")
1847
+ else:
1848
+ config = load_config()
1849
+ # Remove sensitive data
1850
+ if config.get("auth"):
1851
+ config["auth"] = {"signed_in": True, "email": config["auth"].get("email")}
809
1852
 
810
- # Configure local LLM (LM Studio)
811
- repr config-set --local-api-url http://localhost:1234/v1 --local-api-key lm-studio
1853
+ if json_output:
1854
+ print(json.dumps(config, indent=2, default=str))
1855
+ else:
1856
+ console.print("[bold]Configuration[/]")
1857
+ console.print()
1858
+ console.print(json.dumps(config, indent=2, default=str))
1859
+
1860
+
1861
+ @config_app.command("set")
1862
+ def config_set(
1863
+ key: str = typer.Argument(..., help="Key (dot notation)"),
1864
+ value: str = typer.Argument(..., help="Value"),
1865
+ ):
1866
+ """
1867
+ Set a configuration value.
1868
+
1869
+ Examples:
1870
+ repr config set llm.default local
1871
+ repr config set generation.batch_size 10
1872
+ """
1873
+ # Parse value type
1874
+ if value.lower() == "true":
1875
+ parsed_value = True
1876
+ elif value.lower() == "false":
1877
+ parsed_value = False
1878
+ elif value.isdigit():
1879
+ parsed_value = int(value)
1880
+ else:
1881
+ try:
1882
+ parsed_value = float(value)
1883
+ except ValueError:
1884
+ parsed_value = value
1885
+
1886
+ set_config_value(key, parsed_value)
1887
+ print_success(f"Set {key} = {parsed_value}")
1888
+
1889
+
1890
+ @config_app.command("edit")
1891
+ def config_edit():
1892
+ """
1893
+ Open config file in $EDITOR.
1894
+
1895
+ Example:
1896
+ repr config edit
1897
+ """
1898
+ import subprocess
1899
+
1900
+ editor = os.environ.get("EDITOR", "vim")
1901
+ subprocess.run([editor, str(CONFIG_FILE)])
1902
+ print_success("Config file updated")
1903
+
1904
+
1905
+ # =============================================================================
1906
+ # DATA
1907
+ # =============================================================================
1908
+
1909
+ @data_app.callback(invoke_without_command=True)
1910
+ def data_default(ctx: typer.Context):
1911
+ """Show local data storage info."""
1912
+ if ctx.invoked_subcommand is None:
1913
+ stats = get_storage_stats()
812
1914
 
813
- # Clear all LLM settings
814
- repr config-set --clear
1915
+ console.print("[bold]Local Data[/]")
1916
+ console.print()
1917
+ console.print(f"Stories: {stats['stories']['count']} files ({format_bytes(stats['stories']['size_bytes'])})")
1918
+ console.print(f"Profiles: {stats['profiles']['count']} files ({format_bytes(stats['profiles']['size_bytes'])})")
1919
+ console.print(f"Cache: {format_bytes(stats['cache']['size_bytes'])}")
1920
+ console.print(f"Config: {format_bytes(stats['config']['size_bytes'])}")
1921
+ console.print()
1922
+ console.print(f"Total: {format_bytes(stats['total_size_bytes'])}")
1923
+ console.print()
1924
+ console.print(f"[{BRAND_MUTED}]Location: {stats['stories']['path']}[/]")
1925
+
1926
+
1927
+ @data_app.command("backup")
1928
+ def data_backup(
1929
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file"),
1930
+ ):
815
1931
  """
816
- if clear:
817
- clear_llm_config()
818
- print_success("LLM configuration cleared")
819
- return
1932
+ Backup all local data to JSON.
1933
+
1934
+ Example:
1935
+ repr data backup > backup.json
1936
+ repr data backup --output backup.json
1937
+ """
1938
+ backup_data = backup_all_data()
1939
+ json_str = json.dumps(backup_data, indent=2, default=str)
1940
+
1941
+ if output:
1942
+ output.write_text(json_str)
1943
+ print_success(f"Backup saved to {output}")
1944
+ console.print(f" Stories: {len(backup_data['stories'])}")
1945
+ console.print(f" Profiles: {len(backup_data['profiles'])}")
1946
+ else:
1947
+ print(json_str)
1948
+
1949
+
1950
+ @data_app.command("restore")
1951
+ def data_restore(
1952
+ input_file: Path = typer.Argument(..., help="Backup file to restore"),
1953
+ merge: bool = typer.Option(True, "--merge/--replace", help="Merge with existing data"),
1954
+ ):
1955
+ """
1956
+ Restore from backup.
1957
+
1958
+ Example:
1959
+ repr data restore backup.json
1960
+ """
1961
+ if not input_file.exists():
1962
+ print_error(f"File not found: {input_file}")
1963
+ raise typer.Exit(1)
1964
+
1965
+ try:
1966
+ backup_data = json.loads(input_file.read_text())
1967
+ except json.JSONDecodeError:
1968
+ print_error("Invalid JSON file")
1969
+ raise typer.Exit(1)
1970
+
1971
+ console.print("This will restore:")
1972
+ console.print(f" • {len(backup_data.get('stories', []))} stories")
1973
+ console.print(f" • {len(backup_data.get('profiles', []))} profiles")
1974
+ console.print(f" • Configuration settings")
1975
+ console.print()
1976
+ console.print(f"Mode: {'merge' if merge else 'replace'}")
820
1977
 
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:[/]")
1978
+ if not confirm("Continue?"):
1979
+ print_info("Cancelled")
1980
+ raise typer.Exit()
1981
+
1982
+ result = restore_from_backup(backup_data, merge=merge)
1983
+
1984
+ print_success("Restore complete")
1985
+ console.print(f" Stories: {result['stories']}")
1986
+ console.print(f" Profiles: {result['profiles']}")
1987
+
1988
+
1989
+ @data_app.command("clear-cache")
1990
+ def data_clear_cache():
1991
+ """
1992
+ Clear local cache.
1993
+
1994
+ Example:
1995
+ repr data clear-cache
1996
+ """
1997
+ from .config import clear_cache, get_cache_size
1998
+
1999
+ size = get_cache_size()
2000
+ clear_cache()
2001
+ print_success(f"Cache cleared ({format_bytes(size)} freed)")
2002
+ console.print(" Stories preserved")
2003
+ console.print(" Config preserved")
2004
+
2005
+
2006
+ # =============================================================================
2007
+ # PROFILE
2008
+ # =============================================================================
2009
+
2010
+ @profile_app.callback(invoke_without_command=True)
2011
+ def profile_default(ctx: typer.Context):
2012
+ """View profile in terminal."""
2013
+ if ctx.invoked_subcommand is None:
2014
+ profile_config = get_profile_config()
2015
+ story_list = list_stories(limit=5)
2016
+
2017
+ console.print("[bold]Profile[/]")
825
2018
  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[/]'}")
2019
+
2020
+ username = profile_config.get("username") or "Not set"
2021
+ console.print(f"Username: {username}")
2022
+
2023
+ if profile_config.get("bio"):
2024
+ console.print(f"Bio: {profile_config['bio']}")
2025
+ if profile_config.get("location"):
2026
+ console.print(f"Location: {profile_config['location']}")
2027
+
830
2028
  console.print()
831
- console.print("Use [bold]repr config-set --help[/] to see available options.")
2029
+ console.print(f"[bold]Recent Stories ({len(story_list)}):[/]")
2030
+ for s in story_list:
2031
+ console.print(f" • {s.get('summary', 'Untitled')[:60]}")
2032
+
2033
+ if is_authenticated():
2034
+ console.print()
2035
+ # TODO: Get profile URL from API
2036
+ console.print(f"[{BRAND_MUTED}]Profile URL: https://repr.dev/@{username}[/]")
2037
+
2038
+
2039
+ @profile_app.command("update")
2040
+ def profile_update(
2041
+ preview: bool = typer.Option(False, "--preview", help="Show changes before publishing"),
2042
+ local_only: bool = typer.Option(False, "--local", help="Update locally only"),
2043
+ ):
2044
+ """
2045
+ Generate/update profile from stories.
2046
+
2047
+ Example:
2048
+ repr profile update --preview
2049
+ """
2050
+ story_list = list_stories()
2051
+
2052
+ if not story_list:
2053
+ print_warning("No stories to build profile from")
2054
+ print_info("Run `repr generate` first")
2055
+ raise typer.Exit(1)
2056
+
2057
+ console.print(f"Building profile from {len(story_list)} stories...")
2058
+
2059
+ # TODO: Implement actual profile generation
2060
+ print_info("Profile generation not yet fully implemented")
2061
+ print_info("Stories are available for manual profile creation")
2062
+
2063
+
2064
+ @profile_app.command("set-bio")
2065
+ def profile_set_bio(
2066
+ bio: str = typer.Argument(..., help="Bio text"),
2067
+ ):
2068
+ """Set profile bio."""
2069
+ set_profile_config(bio=bio)
2070
+ print_success("Bio updated")
2071
+
2072
+
2073
+ @profile_app.command("set-location")
2074
+ def profile_set_location(
2075
+ location: str = typer.Argument(..., help="Location"),
2076
+ ):
2077
+ """Set profile location."""
2078
+ set_profile_config(location=location)
2079
+ print_success("Location updated")
2080
+
2081
+
2082
+ @profile_app.command("set-available")
2083
+ def profile_set_available(
2084
+ available: bool = typer.Argument(..., help="Available for work (true/false)"),
2085
+ ):
2086
+ """Set availability status."""
2087
+ set_profile_config(available=available)
2088
+ print_success(f"Availability: {'available' if available else 'not available'}")
2089
+
2090
+
2091
+ @profile_app.command("export")
2092
+ def profile_export(
2093
+ format: str = typer.Option("md", "--format", "-f", help="Format: md, html, json, pdf"),
2094
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file"),
2095
+ since: Optional[str] = typer.Option(None, "--since", help="Export only recent work (date filter)"),
2096
+ ):
2097
+ """
2098
+ Export profile to different formats.
2099
+
2100
+ Example:
2101
+ repr profile export --format md > profile.md
2102
+ repr profile export --format json --output profile.json
2103
+ repr profile export --format html --output profile.html
2104
+ """
2105
+ story_list = list_stories()
2106
+
2107
+ # Apply date filter if specified
2108
+ if since and isinstance(since, str):
2109
+ from datetime import datetime
2110
+ try:
2111
+ # Parse the date (support ISO format)
2112
+ since_date = datetime.fromisoformat(since)
2113
+ story_list = [
2114
+ s for s in story_list
2115
+ if s.get('created_at') and datetime.fromisoformat(s['created_at'][:10]) >= since_date
2116
+ ]
2117
+ except ValueError:
2118
+ print_error(f"Invalid date format: {since}")
2119
+ print_info("Use ISO format: YYYY-MM-DD")
2120
+ raise typer.Exit(1)
2121
+
2122
+ profile_config = get_profile_config()
2123
+
2124
+ if format == "json":
2125
+ data = {
2126
+ "profile": profile_config,
2127
+ "stories": story_list,
2128
+ }
2129
+ content = json.dumps(data, indent=2, default=str)
2130
+ elif format == "md":
2131
+ # Generate markdown
2132
+ lines = [f"# {profile_config.get('username', 'Developer Profile')}", ""]
2133
+ if profile_config.get("bio"):
2134
+ lines.extend([profile_config["bio"], ""])
2135
+ lines.extend(["## Stories", ""])
2136
+ for s in story_list:
2137
+ lines.append(f"### {s.get('summary', 'Untitled')}")
2138
+ lines.append(f"*{s.get('repo_name', 'unknown')} • {s.get('created_at', '')[:10]}*")
2139
+ lines.append("")
2140
+ content = "\n".join(lines)
2141
+ elif format == "html":
2142
+ # HTML export not yet implemented
2143
+ print_error("HTML export not yet implemented")
2144
+ print_info("Supported formats: md, json")
2145
+ print_info("HTML export is planned for a future release")
2146
+ raise typer.Exit(1)
2147
+ elif format == "pdf":
2148
+ # PDF export not yet implemented
2149
+ print_error("PDF export not yet implemented")
2150
+ print_info("Supported formats: md, json")
2151
+ print_info("PDF export is planned for a future release")
2152
+ raise typer.Exit(1)
2153
+ else:
2154
+ print_error(f"Unsupported format: {format}")
2155
+ print_info("Supported formats: md, json")
2156
+ raise typer.Exit(1)
2157
+
2158
+ if output:
2159
+ output.write_text(content)
2160
+ print_success(f"Exported to {output}")
2161
+ else:
2162
+ print(content)
2163
+
2164
+
2165
+ @profile_app.command("link")
2166
+ def profile_link():
2167
+ """
2168
+ Get shareable profile link.
2169
+
2170
+ Example:
2171
+ repr profile link
2172
+ """
2173
+ if not is_authenticated():
2174
+ print_error("Profile link requires sign-in")
2175
+ print_info("Run `repr login` first")
2176
+ raise typer.Exit(1)
2177
+
2178
+ profile_config = get_profile_config()
2179
+ username = profile_config.get("username")
2180
+
2181
+ if username:
2182
+ console.print(f"Your profile: https://repr.dev/@{username}")
2183
+ else:
2184
+ print_info("Username not set")
2185
+ print_info("Run `repr profile set-username <name>`")
2186
+
2187
+
2188
+ # =============================================================================
2189
+ # STATUS & INFO
2190
+ # =============================================================================
2191
+
2192
+ @app.command()
2193
+ def status(
2194
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
2195
+ ):
2196
+ """
2197
+ Show repr status and health.
2198
+
2199
+ Example:
2200
+ repr status
2201
+ """
2202
+ authenticated = is_authenticated()
2203
+ user = get_current_user() if authenticated else None
2204
+ tracked = get_tracked_repos()
2205
+ story_count = get_story_count()
2206
+ unpushed = len(get_unpushed_stories())
2207
+
2208
+ if json_output:
2209
+ print(json.dumps({
2210
+ "version": __version__,
2211
+ "authenticated": authenticated,
2212
+ "email": user.get("email") if user else None,
2213
+ "repos_tracked": len(tracked),
2214
+ "stories_total": story_count,
2215
+ "stories_unpushed": unpushed,
2216
+ }, indent=2))
832
2217
  return
833
2218
 
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
- )
2219
+ print_header()
2220
+
2221
+ # Auth status
2222
+ if authenticated:
2223
+ email = user.get("email", "unknown")
2224
+ console.print(f"Auth: [{BRAND_SUCCESS}]✓ Signed in as {email}[/]")
2225
+ else:
2226
+ console.print(f"Auth: [{BRAND_MUTED}]○ Not signed in[/]")
2227
+
2228
+ console.print()
2229
+
2230
+ # Stats
2231
+ console.print(f"Tracked repos: {len(tracked)}")
2232
+ console.print(f"Stories: {story_count} ({unpushed} unpushed)")
2233
+
2234
+ console.print()
2235
+
2236
+ # Next steps
2237
+ if not authenticated:
2238
+ print_info("Run `repr login` to enable cloud sync")
2239
+ elif unpushed > 0:
2240
+ print_info(f"Run `repr push` to publish {unpushed} stories")
2241
+
2242
+
2243
+ @app.command()
2244
+ def mode():
2245
+ """
2246
+ Show current execution mode and settings.
2247
+
2248
+ Example:
2249
+ repr mode
2250
+ """
2251
+ from .llm import get_llm_status
2252
+
2253
+ authenticated = is_authenticated()
2254
+ privacy = get_privacy_settings()
2255
+ llm_status = get_llm_status()
2256
+
2257
+ console.print("[bold]Mode[/]")
2258
+ console.print()
2259
+
2260
+ if privacy.get("lock_local_only"):
2261
+ if privacy.get("lock_permanent"):
2262
+ console.print("Data boundary: local only (permanently locked)")
2263
+ else:
2264
+ console.print("Data boundary: local only (locked)")
2265
+ elif authenticated:
2266
+ console.print("Data boundary: cloud-enabled")
2267
+ else:
2268
+ console.print("Data boundary: local only")
2269
+
2270
+ console.print(f"Default inference: {llm_status['default_mode']}")
2271
+
2272
+ console.print()
2273
+
2274
+ # LLM info
2275
+ if llm_status["local"]["available"]:
2276
+ console.print(f"Local LLM: {llm_status['local']['name']} ({llm_status['local']['model']})")
2277
+ else:
2278
+ console.print(f"[{BRAND_MUTED}]Local LLM: not detected[/]")
2279
+
2280
+ console.print()
2281
+
2282
+ console.print("Available:")
2283
+ console.print(f" [{BRAND_SUCCESS}]✓[/] Local generation")
2284
+ if authenticated and is_cloud_allowed():
2285
+ console.print(f" [{BRAND_SUCCESS}]✓[/] Cloud generation")
2286
+ console.print(f" [{BRAND_SUCCESS}]✓[/] Sync")
2287
+ console.print(f" [{BRAND_SUCCESS}]✓[/] Publishing")
2288
+ else:
2289
+ reason = "requires login" if not authenticated else "locked"
2290
+ console.print(f" [{BRAND_MUTED}]✗[/] Cloud generation ({reason})")
2291
+ console.print(f" [{BRAND_MUTED}]✗[/] Sync ({reason})")
2292
+ console.print(f" [{BRAND_MUTED}]✗[/] Publishing ({reason})")
2293
+
2294
+
2295
+ @app.command()
2296
+ def doctor():
2297
+ """
2298
+ Health check and diagnostics.
2299
+
2300
+ Example:
2301
+ repr doctor
2302
+ """
2303
+ from .doctor import run_all_checks
2304
+
2305
+ console.print("Checking repr health...")
2306
+ console.print()
2307
+
2308
+ report = run_all_checks()
2309
+
2310
+ for check in report.checks:
2311
+ if check.status == "ok":
2312
+ icon = f"[{BRAND_SUCCESS}]✓[/]"
2313
+ elif check.status == "warning":
2314
+ icon = f"[{BRAND_WARNING}]⚠[/]"
2315
+ else:
2316
+ icon = f"[{BRAND_ERROR}]✗[/]"
2317
+
2318
+ console.print(f"{icon} {check.name}: {check.message}")
841
2319
 
842
- print_success("LLM configuration updated")
843
2320
  console.print()
844
2321
 
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[/]")
2322
+ if report.recommendations:
2323
+ console.print("[bold]Recommendations:[/]")
2324
+ for i, rec in enumerate(report.recommendations, 1):
2325
+ console.print(f" {i}. {rec}")
2326
+ console.print()
2327
+
2328
+ if report.overall_status == "healthy":
2329
+ print_success("All systems healthy")
2330
+ elif report.overall_status == "warnings":
2331
+ print_warning("Some items need attention")
2332
+ else:
2333
+ print_error("Issues found - see recommendations above")
854
2334
 
855
2335
 
856
2336
  # Entry point