repr-cli 0.2.8__py3-none-any.whl → 0.2.9__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/__main__.py CHANGED
@@ -4,3 +4,40 @@ from repr.cli import app
4
4
  if __name__ == "__main__":
5
5
  app()
6
6
 
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
repr/cli.py CHANGED
@@ -21,7 +21,8 @@ import os
21
21
  import sys
22
22
  from datetime import datetime, timedelta
23
23
  from pathlib import Path
24
- from typing import Optional, List
24
+ from typing import Optional, List, Dict
25
+ from collections import defaultdict
25
26
 
26
27
  import typer
27
28
  from rich.prompt import Confirm, Prompt
@@ -247,6 +248,81 @@ def init(
247
248
  # GENERATE
248
249
  # =============================================================================
249
250
 
251
+ def _parse_date_reference(date_str: str) -> str | None:
252
+ """
253
+ Parse a date reference string into an ISO date string.
254
+
255
+ Supports:
256
+ - ISO dates: "2024-01-01"
257
+ - Day names: "monday", "tuesday", etc.
258
+ - Relative: "3 days ago", "2 weeks ago", "1 month ago"
259
+
260
+ Returns ISO date string or None if parsing fails.
261
+ """
262
+ import re
263
+ from datetime import datetime, timedelta
264
+
265
+ date_str = date_str.lower().strip()
266
+
267
+ # Try ISO format first
268
+ try:
269
+ parsed = datetime.fromisoformat(date_str)
270
+ return parsed.isoformat()
271
+ except ValueError:
272
+ pass
273
+
274
+ # Day names (find previous occurrence)
275
+ day_names = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
276
+ if date_str in day_names:
277
+ today = datetime.now()
278
+ target_day = day_names.index(date_str)
279
+ current_day = today.weekday()
280
+
281
+ # Calculate days back to that day
282
+ days_back = (current_day - target_day) % 7
283
+ if days_back == 0:
284
+ days_back = 7 # Go to last week's occurrence
285
+
286
+ target_date = today - timedelta(days=days_back)
287
+ return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
288
+
289
+ # Relative time: "N days/weeks/months ago"
290
+ match = re.match(r"(\d+)\s+(day|days|week|weeks|month|months)\s+ago", date_str)
291
+ if match:
292
+ amount = int(match.group(1))
293
+ unit = match.group(2).rstrip("s") # Normalize to singular
294
+
295
+ if unit == "day":
296
+ delta = timedelta(days=amount)
297
+ elif unit == "week":
298
+ delta = timedelta(weeks=amount)
299
+ elif unit == "month":
300
+ delta = timedelta(days=amount * 30) # Approximate
301
+ else:
302
+ return None
303
+
304
+ target_date = datetime.now() - delta
305
+ return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
306
+
307
+ # "yesterday" / "today"
308
+ if date_str == "yesterday":
309
+ target_date = datetime.now() - timedelta(days=1)
310
+ return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
311
+ if date_str == "today":
312
+ target_date = datetime.now()
313
+ return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
314
+
315
+ # "last week" / "last month"
316
+ if date_str == "last week":
317
+ target_date = datetime.now() - timedelta(weeks=1)
318
+ return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
319
+ if date_str == "last month":
320
+ target_date = datetime.now() - timedelta(days=30)
321
+ return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
322
+
323
+ return None
324
+
325
+
250
326
  @app.command()
251
327
  def generate(
252
328
  local: bool = typer.Option(
@@ -267,6 +343,14 @@ def generate(
267
343
  None, "--commits",
268
344
  help="Generate from specific commits (comma-separated)",
269
345
  ),
346
+ since_date: Optional[str] = typer.Option(
347
+ None, "--since",
348
+ help="Generate from commits since date (e.g., '2024-01-01', 'monday', '2 weeks ago')",
349
+ ),
350
+ days: Optional[int] = typer.Option(
351
+ None, "--days",
352
+ help="Generate from commits in the last N days",
353
+ ),
270
354
  batch_size: int = typer.Option(
271
355
  5, "--batch-size",
272
356
  help="Commits per story",
@@ -283,6 +367,10 @@ def generate(
283
367
  None, "--prompt", "-p",
284
368
  help="Custom prompt to append",
285
369
  ),
370
+ json_output: bool = typer.Option(
371
+ False, "--json",
372
+ help="Output as JSON",
373
+ ),
286
374
  ):
287
375
  """
288
376
  Generate stories from commits.
@@ -290,6 +378,8 @@ def generate(
290
378
  Examples:
291
379
  repr generate --local
292
380
  repr generate --cloud
381
+ repr generate --since "2 weeks ago"
382
+ repr generate --days 30
293
383
  repr generate --template changelog
294
384
  repr generate --commits abc123,def456
295
385
  """
@@ -299,6 +389,9 @@ def generate(
299
389
  if cloud:
300
390
  allowed, reason = check_cloud_permission("cloud_generation")
301
391
  if not allowed:
392
+ if json_output:
393
+ print(json.dumps({"generated": 0, "stories": [], "error": f"Cloud generation blocked: {reason}"}, indent=2))
394
+ raise typer.Exit(1)
302
395
  print_error("Cloud generation blocked")
303
396
  print_info(reason)
304
397
  console.print()
@@ -313,11 +406,24 @@ def generate(
313
406
  else:
314
407
  local = True
315
408
 
316
- print_header()
409
+ if not json_output:
410
+ print_header()
317
411
 
318
412
  mode_str = "local LLM" if local else "cloud LLM"
319
- console.print(f"Generating stories ({mode_str})...")
320
- console.print()
413
+
414
+ # Build timeframe string for display
415
+ if commits:
416
+ timeframe_str = f"specific commits"
417
+ elif since_date:
418
+ timeframe_str = f"since {since_date}"
419
+ elif days:
420
+ timeframe_str = f"last {days} days"
421
+ else:
422
+ timeframe_str = "last 90 days"
423
+
424
+ if not json_output:
425
+ console.print(f"Generating stories ({mode_str}, {timeframe_str})...")
426
+ console.print()
321
427
 
322
428
  # Get repos to analyze
323
429
  if repo:
@@ -325,12 +431,18 @@ def generate(
325
431
  else:
326
432
  tracked = get_tracked_repos()
327
433
  if not tracked:
434
+ if json_output:
435
+ print(json.dumps({"generated": 0, "stories": [], "error": "No repositories tracked"}, indent=2))
436
+ raise typer.Exit(1)
328
437
  print_warning("No repositories tracked.")
329
438
  print_info("Run `repr init` or `repr repos add <path>` first.")
330
439
  raise typer.Exit(1)
331
440
  repo_paths = [Path(r["path"]) for r in tracked if Path(r["path"]).exists()]
332
441
 
333
442
  if not repo_paths:
443
+ if json_output:
444
+ print(json.dumps({"generated": 0, "stories": [], "error": "No valid repositories found"}, indent=2))
445
+ raise typer.Exit(1)
334
446
  print_error("No valid repositories found")
335
447
  raise typer.Exit(1)
336
448
 
@@ -339,21 +451,37 @@ def generate(
339
451
  from .discovery import analyze_repo
340
452
 
341
453
  total_stories = 0
454
+ all_stories = [] # Collect all generated stories for JSON output
342
455
 
343
456
  for repo_path in repo_paths:
344
457
  repo_info = analyze_repo(repo_path)
345
- console.print(f"[bold]{repo_info.name}[/]")
458
+ if not json_output:
459
+ console.print(f"[bold]{repo_info.name}[/]")
346
460
 
347
461
  if commits:
348
462
  # Specific commits
349
463
  commit_shas = [s.strip() for s in commits.split(",")]
350
464
  commit_list = get_commits_by_shas(repo_path, commit_shas)
351
465
  else:
352
- # Recent commits
353
- commit_list = get_commits_with_diffs(repo_path, count=50, days=90)
466
+ # Determine timeframe
467
+ timeframe_days = days if days is not None else 90 # Default 90 days
468
+ since_str = None
469
+
470
+ # Parse natural language date if provided
471
+ if since_date:
472
+ since_str = _parse_date_reference(since_date)
473
+
474
+ # Recent commits within timeframe
475
+ commit_list = get_commits_with_diffs(
476
+ repo_path,
477
+ count=500, # Higher limit when filtering by time
478
+ days=timeframe_days,
479
+ since=since_str,
480
+ )
354
481
 
355
482
  if not commit_list:
356
- console.print(f" [{BRAND_MUTED}]No commits found[/]")
483
+ if not json_output:
484
+ console.print(f" [{BRAND_MUTED}]No commits found[/]")
357
485
  continue
358
486
 
359
487
  # Dry run: show what would be sent
@@ -434,8 +562,10 @@ def generate(
434
562
  )
435
563
 
436
564
  for story in stories:
437
- console.print(f" • {story['summary']}")
565
+ if not json_output:
566
+ console.print(f" • {story['summary']}")
438
567
  total_stories += 1
568
+ all_stories.append(story)
439
569
 
440
570
  # Log cloud operation if using cloud
441
571
  if cloud and stories:
@@ -450,7 +580,12 @@ def generate(
450
580
  bytes_sent=len(str(commit_list)) // 2, # Rough estimate
451
581
  )
452
582
 
453
- console.print()
583
+ if not json_output:
584
+ console.print()
585
+
586
+ if json_output:
587
+ print(json.dumps({"generated": total_stories, "stories": all_stories}, indent=2, default=str))
588
+ return
454
589
 
455
590
  if dry_run:
456
591
  console.print()
@@ -465,7 +600,7 @@ def generate(
465
600
  ])
466
601
 
467
602
 
468
- def _generate_stories(
603
+ async def _generate_stories_async(
469
604
  commits: list[dict],
470
605
  repo_info,
471
606
  batch_size: int,
@@ -473,7 +608,7 @@ def _generate_stories(
473
608
  template: str = "resume",
474
609
  custom_prompt: str | None = None,
475
610
  ) -> list[dict]:
476
- """Generate stories from commits using LLM."""
611
+ """Generate stories from commits using LLM (async implementation)."""
477
612
  from .openai_analysis import get_openai_client, extract_commit_batch
478
613
  from .templates import build_generation_prompt
479
614
 
@@ -485,186 +620,103 @@ def _generate_stories(
485
620
  for i in range(0, len(commits), batch_size)
486
621
  ]
487
622
 
488
- for i, batch in enumerate(batches):
489
- try:
490
- # Build prompt with template
491
- system_prompt, user_prompt = build_generation_prompt(
492
- template_name=template,
493
- repo_name=repo_info.name,
494
- commits=batch,
495
- custom_prompt=custom_prompt,
496
- )
497
-
498
- # Get LLM client
499
- if local:
500
- llm_config = get_llm_config()
501
- client = get_openai_client(
502
- api_key=llm_config.get("local_api_key") or "ollama",
503
- base_url=llm_config.get("local_api_url") or "http://localhost:11434/v1",
623
+ # Create client once, reuse for all batches
624
+ if local:
625
+ llm_config = get_llm_config()
626
+ client = get_openai_client(
627
+ api_key=llm_config.get("local_api_key") or "ollama",
628
+ base_url=llm_config.get("local_api_url") or "http://localhost:11434/v1",
629
+ )
630
+ model = llm_config.get("local_model") or llm_config.get("extraction_model") or "llama3.2"
631
+ else:
632
+ client = get_openai_client()
633
+ model = None # Use default
634
+
635
+ try:
636
+ for i, batch in enumerate(batches):
637
+ try:
638
+ # Build prompt with template
639
+ system_prompt, user_prompt = build_generation_prompt(
640
+ template_name=template,
641
+ repo_name=repo_info.name,
642
+ commits=batch,
643
+ custom_prompt=custom_prompt,
504
644
  )
505
- model = llm_config.get("local_model") or llm_config.get("extraction_model") or "llama3.2"
506
- else:
507
- client = get_openai_client()
508
- model = None # Use default
509
-
510
- # Extract story from batch
511
- content = asyncio.run(extract_commit_batch(
512
- client=client,
513
- commits=batch,
514
- batch_num=i + 1,
515
- total_batches=len(batches),
516
- model=model,
517
- system_prompt=system_prompt,
518
- user_prompt=user_prompt,
519
- ))
520
-
521
- if not content or content.startswith("[Batch"):
522
- continue
523
-
524
- # Extract summary (first non-empty line)
525
- lines = [l.strip() for l in content.split("\n") if l.strip()]
526
- summary = lines[0][:100] if lines else "Story"
527
- # Clean up summary
528
- summary = summary.lstrip("#-•* ").strip()
529
-
530
- # Build metadata
531
- commit_shas = [c["full_sha"] for c in batch]
532
- first_date = min(c["date"] for c in batch)
533
- last_date = max(c["date"] for c in batch)
534
- total_files = sum(len(c.get("files", [])) for c in batch)
535
- total_adds = sum(c.get("insertions", 0) for c in batch)
536
- total_dels = sum(c.get("deletions", 0) for c in batch)
537
-
538
- metadata = {
539
- "summary": summary,
540
- "repo_name": repo_info.name,
541
- "repo_path": str(repo_info.path),
542
- "commit_shas": commit_shas,
543
- "first_commit_at": first_date,
544
- "last_commit_at": last_date,
545
- "files_changed": total_files,
546
- "lines_added": total_adds,
547
- "lines_removed": total_dels,
548
- "generated_locally": local,
549
- "template": template,
550
- "needs_review": False,
551
- }
552
-
553
- # Save story
554
- story_id = save_story(content, metadata)
555
- metadata["id"] = story_id
556
- stories.append(metadata)
557
-
558
- except Exception as e:
559
- console.print(f" [{BRAND_MUTED}]Batch {i+1} failed: {e}[/]")
645
+
646
+ # Extract story from batch
647
+ content = await extract_commit_batch(
648
+ client=client,
649
+ commits=batch,
650
+ batch_num=i + 1,
651
+ total_batches=len(batches),
652
+ model=model,
653
+ system_prompt=system_prompt,
654
+ user_prompt=user_prompt,
655
+ )
656
+
657
+ if not content or content.startswith("[Batch"):
658
+ continue
659
+
660
+ # Extract summary (first non-empty line)
661
+ lines = [l.strip() for l in content.split("\n") if l.strip()]
662
+ summary = lines[0][:100] if lines else "Story"
663
+ # Clean up summary
664
+ summary = summary.lstrip("#-•* ").strip()
665
+
666
+ # Build metadata
667
+ commit_shas = [c["full_sha"] for c in batch]
668
+ first_date = min(c["date"] for c in batch)
669
+ last_date = max(c["date"] for c in batch)
670
+ total_files = sum(len(c.get("files", [])) for c in batch)
671
+ total_adds = sum(c.get("insertions", 0) for c in batch)
672
+ total_dels = sum(c.get("deletions", 0) for c in batch)
673
+
674
+ metadata = {
675
+ "summary": summary,
676
+ "repo_name": repo_info.name,
677
+ "repo_path": str(repo_info.path),
678
+ "commit_shas": commit_shas,
679
+ "first_commit_at": first_date,
680
+ "last_commit_at": last_date,
681
+ "files_changed": total_files,
682
+ "lines_added": total_adds,
683
+ "lines_removed": total_dels,
684
+ "generated_locally": local,
685
+ "template": template,
686
+ "needs_review": False,
687
+ }
688
+
689
+ # Save story
690
+ story_id = save_story(content, metadata)
691
+ metadata["id"] = story_id
692
+ stories.append(metadata)
693
+
694
+ except Exception as e:
695
+ console.print(f" [{BRAND_MUTED}]Batch {i+1} failed: {e}[/]")
696
+ finally:
697
+ # Properly close the async client
698
+ await client.close()
560
699
 
561
700
  return stories
562
701
 
563
702
 
564
- # =============================================================================
565
- # QUICK REFLECTION COMMANDS
566
- # =============================================================================
567
-
568
- @app.command()
569
- def week(
570
- save: bool = typer.Option(
571
- False, "--save",
572
- help="Save as a permanent story",
573
- ),
574
- ):
575
- """
576
- Show what you worked on this week.
577
-
578
- Example:
579
- repr week
580
- repr week --save
581
- """
582
- _show_summary_since("this week", days=7, save=save)
583
-
584
-
585
- @app.command()
586
- def since(
587
- date: str = typer.Argument(..., help="Date or time reference (e.g., 'monday', '2026-01-01', '3 days ago')"),
588
- save: bool = typer.Option(
589
- False, "--save",
590
- help="Save as a permanent story",
591
- ),
592
- ):
593
- """
594
- Show work since a specific date or time.
595
-
596
- Examples:
597
- repr since monday
598
- repr since "3 days ago"
599
- repr since 2026-01-01
600
- """
601
- _show_summary_since(date, save=save)
602
-
603
-
604
- @app.command()
605
- def standup(
606
- days: int = typer.Option(
607
- 3, "--days",
608
- help="Number of days to look back",
609
- ),
610
- ):
611
- """
612
- Quick summary for daily standup (last 3 days).
613
-
614
- Example:
615
- repr standup
616
- repr standup --days 5
617
- """
618
- _show_summary_since(f"last {days} days", days=days, save=False)
619
-
620
-
621
- def _show_summary_since(label: str, days: int = None, save: bool = False):
622
- """Show summary of work since a date."""
623
- from .tools import get_commits_with_diffs
624
- from .discovery import analyze_repo
625
-
626
- print_header()
627
-
628
- tracked = get_tracked_repos()
629
- if not tracked:
630
- print_warning("No repositories tracked.")
631
- print_info("Run `repr init` first.")
632
- raise typer.Exit(1)
633
-
634
- console.print(f"📊 Work since {label}")
635
- console.print()
636
-
637
- total_commits = 0
638
- all_summaries = []
639
-
640
- for repo_info in tracked:
641
- repo_path = Path(repo_info["path"])
642
- if not repo_path.exists():
643
- continue
644
-
645
- commits = get_commits_with_diffs(repo_path, count=100, days=days or 30)
646
- if not commits:
647
- continue
648
-
649
- repo_name = repo_path.name
650
- console.print(f"[bold]{repo_name}[/] ({len(commits)} commits):")
651
-
652
- # Group commits by rough topic (simple heuristic)
653
- for c in commits[:5]:
654
- msg = c["message"].split("\n")[0][:60]
655
- console.print(f" • {msg}")
656
-
657
- if len(commits) > 5:
658
- console.print(f" [{BRAND_MUTED}]... and {len(commits) - 5} more[/]")
659
-
660
- total_commits += len(commits)
661
- console.print()
662
-
663
- console.print(f"Total: {total_commits} commits")
664
-
665
- if not save:
666
- console.print()
667
- console.print(f"[{BRAND_MUTED}]This summary wasn't saved. Run with --save to create a story.[/]")
703
+ def _generate_stories(
704
+ commits: list[dict],
705
+ repo_info,
706
+ batch_size: int,
707
+ local: bool,
708
+ template: str = "resume",
709
+ custom_prompt: str | None = None,
710
+ ) -> list[dict]:
711
+ """Generate stories from commits using LLM."""
712
+ return asyncio.run(_generate_stories_async(
713
+ commits=commits,
714
+ repo_info=repo_info,
715
+ batch_size=batch_size,
716
+ local=local,
717
+ template=template,
718
+ custom_prompt=custom_prompt,
719
+ ))
668
720
 
669
721
 
670
722
  # =============================================================================
@@ -698,24 +750,37 @@ def stories(
698
750
 
699
751
  console.print(f"[bold]Stories[/] ({len(story_list)} total)")
700
752
  console.print()
701
-
753
+
754
+ # Group stories by repository
755
+ by_repo = defaultdict(list)
702
756
  for story in story_list[:20]:
703
- # Status indicator
704
- if story.get("needs_review"):
705
- status = f"[{BRAND_WARNING}]⚠[/]"
706
- elif story.get("pushed_at"):
707
- status = f"[{BRAND_SUCCESS}]✓[/]"
708
- else:
709
- status = f"[{BRAND_MUTED}]○[/]"
710
-
711
- summary = story.get("summary", "Untitled")[:60]
712
- repo_name = story.get("repo_name", "unknown")
713
- created = format_relative_time(story.get("created_at", ""))
714
-
715
- console.print(f"{status} {summary}")
716
- console.print(f" [{BRAND_MUTED}]{repo_name} {created}[/]")
757
+ r_name = story.get("repo_name", "unknown")
758
+ by_repo[r_name].append(story)
759
+
760
+ # Sort repositories by their most recent story
761
+ sorted_repos = sorted(
762
+ by_repo.keys(),
763
+ key=lambda r: max(s.get("created_at", "") for s in by_repo[r]),
764
+ reverse=True
765
+ )
766
+
767
+ for repo_name in sorted_repos:
768
+ console.print(f"[bold]{repo_name}[/]")
769
+ for story in by_repo[repo_name]:
770
+ # Status indicator
771
+ if story.get("needs_review"):
772
+ status = f"[{BRAND_WARNING}]⚠[/]"
773
+ elif story.get("pushed_at"):
774
+ status = f"[{BRAND_SUCCESS}]✓[/]"
775
+ else:
776
+ status = f"[{BRAND_MUTED}]○[/]"
777
+
778
+ summary = story.get("summary", "Untitled")
779
+ created = format_relative_time(story.get("created_at", ""))
780
+
781
+ console.print(f" {status} {summary} [{BRAND_MUTED}]• {created}[/]")
717
782
  console.print()
718
-
783
+
719
784
  if len(story_list) > 20:
720
785
  console.print(f"[{BRAND_MUTED}]... and {len(story_list) - 20} more[/]")
721
786
 
@@ -1025,15 +1090,20 @@ def pull():
1025
1090
  def list_commits(
1026
1091
  repo: Optional[str] = typer.Option(None, "--repo", help="Filter by repo name"),
1027
1092
  limit: int = typer.Option(50, "--limit", "-n", help="Number of commits"),
1028
- since: Optional[str] = typer.Option(None, "--since", help="Since date"),
1093
+ days: Optional[int] = typer.Option(None, "--days", "-d", help="Show commits from last N days (e.g., 7 for week, 3 for standup)"),
1094
+ since: Optional[str] = typer.Option(None, "--since", help="Since date (e.g., '2024-01-01', 'monday', '2 weeks ago')"),
1029
1095
  json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
1030
1096
  ):
1031
1097
  """
1032
1098
  List recent commits across all tracked repos.
1033
1099
 
1034
- Example:
1100
+ Examples:
1035
1101
  repr commits --limit 10
1036
1102
  repr commits --repo myproject
1103
+ repr commits --days 7 # Last week
1104
+ repr commits --days 3 # For standup
1105
+ repr commits --since monday
1106
+ repr commits --since "2 weeks ago"
1037
1107
  """
1038
1108
  from .tools import get_commits_with_diffs
1039
1109
 
@@ -1042,6 +1112,18 @@ def list_commits(
1042
1112
  print_warning("No repositories tracked.")
1043
1113
  raise typer.Exit(1)
1044
1114
 
1115
+ # Determine days filter
1116
+ filter_days = days if days is not None else 90
1117
+
1118
+ # Parse natural language date if --since provided
1119
+ since_str = None
1120
+ if since:
1121
+ since_str = _parse_date_reference(since)
1122
+ if not since_str:
1123
+ print_error(f"Could not parse date: {since}")
1124
+ print_info("Try: '2024-01-01', 'monday', '3 days ago', 'last week'")
1125
+ raise typer.Exit(1)
1126
+
1045
1127
  all_commits = []
1046
1128
 
1047
1129
  for repo_info in tracked:
@@ -1051,7 +1133,7 @@ def list_commits(
1051
1133
  if not repo_path.exists():
1052
1134
  continue
1053
1135
 
1054
- commits = get_commits_with_diffs(repo_path, count=limit, days=90)
1136
+ commits = get_commits_with_diffs(repo_path, count=limit, days=filter_days, since=since_str)
1055
1137
  for c in commits:
1056
1138
  c["repo_name"] = repo_path.name
1057
1139
  all_commits.extend(commits)
@@ -1068,7 +1150,15 @@ def list_commits(
1068
1150
  print_info("No commits found")
1069
1151
  raise typer.Exit()
1070
1152
 
1071
- console.print(f"[bold]Recent Commits[/] ({len(all_commits)})")
1153
+ # Build label for display
1154
+ if days:
1155
+ label = f"last {days} days"
1156
+ elif since:
1157
+ label = f"since {since}"
1158
+ else:
1159
+ label = "recent"
1160
+
1161
+ console.print(f"[bold]Commits ({label})[/] — {len(all_commits)} total")
1072
1162
  console.print()
1073
1163
 
1074
1164
  current_repo = None
@@ -2331,22 +2421,64 @@ def status(
2331
2421
 
2332
2422
 
2333
2423
  @app.command()
2334
- def mode():
2424
+ def mode(
2425
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
2426
+ ):
2335
2427
  """
2336
2428
  Show current execution mode and settings.
2337
-
2429
+
2338
2430
  Example:
2339
2431
  repr mode
2432
+ repr mode --json
2340
2433
  """
2341
2434
  from .llm import get_llm_status
2342
-
2435
+
2343
2436
  authenticated = is_authenticated()
2344
2437
  privacy = get_privacy_settings()
2345
2438
  llm_status = get_llm_status()
2346
-
2439
+
2440
+ # Determine mode
2441
+ if privacy.get("lock_local_only"):
2442
+ if privacy.get("lock_permanent"):
2443
+ mode = "LOCAL-ONLY"
2444
+ locked = True
2445
+ else:
2446
+ mode = "LOCAL-ONLY"
2447
+ locked = True
2448
+ elif authenticated:
2449
+ mode = "CLOUD"
2450
+ locked = False
2451
+ else:
2452
+ mode = "LOCAL-ONLY"
2453
+ locked = False
2454
+
2455
+ # Build LLM provider string
2456
+ if llm_status["local"]["available"]:
2457
+ llm_provider = f"{llm_status['local']['name']} ({llm_status['local']['model']})"
2458
+ else:
2459
+ llm_provider = "none"
2460
+
2461
+ # Check cloud allowed
2462
+ cloud_allowed = authenticated and is_cloud_allowed()
2463
+
2464
+ if json_output:
2465
+ import json
2466
+ output = {
2467
+ "mode": mode,
2468
+ "locked": locked,
2469
+ "llm_provider": llm_provider,
2470
+ "network_policy": "cloud-enabled" if authenticated else "local-only",
2471
+ "authenticated": authenticated,
2472
+ "cloud_generation_available": cloud_allowed,
2473
+ "sync_available": cloud_allowed,
2474
+ "publishing_available": cloud_allowed,
2475
+ }
2476
+ print(json.dumps(output, indent=2))
2477
+ return
2478
+
2347
2479
  console.print("[bold]Mode[/]")
2348
2480
  console.print()
2349
-
2481
+
2350
2482
  if privacy.get("lock_local_only"):
2351
2483
  if privacy.get("lock_permanent"):
2352
2484
  console.print("Data boundary: local only (permanently locked)")
@@ -2356,22 +2488,22 @@ def mode():
2356
2488
  console.print("Data boundary: cloud-enabled")
2357
2489
  else:
2358
2490
  console.print("Data boundary: local only")
2359
-
2491
+
2360
2492
  console.print(f"Default inference: {llm_status['default_mode']}")
2361
-
2493
+
2362
2494
  console.print()
2363
-
2495
+
2364
2496
  # LLM info
2365
2497
  if llm_status["local"]["available"]:
2366
2498
  console.print(f"Local LLM: {llm_status['local']['name']} ({llm_status['local']['model']})")
2367
2499
  else:
2368
2500
  console.print(f"[{BRAND_MUTED}]Local LLM: not detected[/]")
2369
-
2501
+
2370
2502
  console.print()
2371
-
2503
+
2372
2504
  console.print("Available:")
2373
2505
  console.print(f" [{BRAND_SUCCESS}]✓[/] Local generation")
2374
- if authenticated and is_cloud_allowed():
2506
+ if cloud_allowed:
2375
2507
  console.print(f" [{BRAND_SUCCESS}]✓[/] Cloud generation")
2376
2508
  console.print(f" [{BRAND_SUCCESS}]✓[/] Sync")
2377
2509
  console.print(f" [{BRAND_SUCCESS}]✓[/] Publishing")
repr/doctor.py CHANGED
repr/llm.py CHANGED
@@ -504,3 +504,40 @@ def get_effective_llm_mode() -> tuple[str, dict[str, Any]]:
504
504
  "model": llm_config.get("local_model"),
505
505
  }
506
506
 
507
+
508
+
509
+
510
+
511
+
512
+
513
+
514
+
515
+
516
+
517
+
518
+
519
+
520
+
521
+
522
+
523
+
524
+
525
+
526
+
527
+
528
+
529
+
530
+
531
+
532
+
533
+
534
+
535
+
536
+
537
+
538
+
539
+
540
+
541
+
542
+
543
+
repr/openai_analysis.py CHANGED
@@ -122,6 +122,8 @@ async def extract_commit_batch(
122
122
  batch_num: int,
123
123
  total_batches: int,
124
124
  model: str = None,
125
+ system_prompt: str = None,
126
+ user_prompt: str = None,
125
127
  ) -> str:
126
128
  """
127
129
  Extraction phase: Extract accomplishments from a batch of commits.
@@ -132,6 +134,8 @@ async def extract_commit_batch(
132
134
  batch_num: Current batch number (for context)
133
135
  total_batches: Total number of batches
134
136
  model: Model name to use (defaults to stored config or DEFAULT_EXTRACTION_MODEL)
137
+ system_prompt: Custom system prompt (optional, uses default if not provided)
138
+ user_prompt: Custom user prompt (optional, uses default if not provided)
135
139
 
136
140
  Returns:
137
141
  Summary of technical accomplishments in this batch
@@ -139,36 +143,40 @@ async def extract_commit_batch(
139
143
  if not model:
140
144
  llm_config = get_llm_config()
141
145
  model = llm_config.get("extraction_model") or DEFAULT_EXTRACTION_MODEL
142
- # Format commits for the prompt
143
- commits_text = []
144
- for commit in commits:
145
- commit_text = f"""
146
+
147
+ # Use provided prompts or build defaults
148
+ if not system_prompt or not user_prompt:
149
+ # Format commits for the prompt
150
+ commits_text = []
151
+ for commit in commits:
152
+ commit_text = f"""
146
153
  Commit: {commit['sha']}
147
154
  Date: {commit['date']}
148
155
  Message: {commit['message']}
149
156
 
150
157
  Files changed:"""
151
-
152
- for file_info in commit['files'][:10]: # Limit files per commit
153
- change_type = {
154
- 'A': 'Added',
155
- 'D': 'Deleted',
156
- 'M': 'Modified',
157
- 'R': 'Renamed'
158
- }.get(file_info['change_type'], 'Changed')
159
158
 
160
- commit_text += f"\n {change_type}: {file_info['path']}"
159
+ for file_info in commit['files'][:10]: # Limit files per commit
160
+ change_type = {
161
+ 'A': 'Added',
162
+ 'D': 'Deleted',
163
+ 'M': 'Modified',
164
+ 'R': 'Renamed'
165
+ }.get(file_info['change_type'], 'Changed')
166
+
167
+ commit_text += f"\n {change_type}: {file_info['path']}"
168
+
169
+ if file_info['diff']:
170
+ # Truncate diff if too long (for token management)
171
+ diff = file_info['diff'][:2000]
172
+ commit_text += f"\n```diff\n{diff}\n```"
161
173
 
162
- if file_info['diff']:
163
- # Truncate diff if too long (for token management)
164
- diff = file_info['diff'][:2000]
165
- commit_text += f"\n```diff\n{diff}\n```"
174
+ commits_text.append(commit_text)
166
175
 
167
- commits_text.append(commit_text)
168
-
169
- commits_formatted = "\n\n---\n".join(commits_text)
170
-
171
- system_prompt = """You are analyzing a developer's actual code commits to extract specific technical accomplishments WITH the reasoning behind them.
176
+ commits_formatted = "\n\n---\n".join(commits_text)
177
+
178
+ if not system_prompt:
179
+ system_prompt = """You are analyzing a developer's actual code commits to extract specific technical accomplishments WITH the reasoning behind them.
172
180
 
173
181
  Your job: Read the commit messages and diffs, then list CONCRETE technical accomplishments with SPECIFIC details AND infer WHY those decisions were made.
174
182
 
@@ -194,7 +202,8 @@ What NOT to do:
194
202
  - Don't include process/methodology unless there's evidence
195
203
  - Don't fabricate motivations that aren't supported by the code/commits"""
196
204
 
197
- user_prompt = f"""Analyze commits batch {batch_num}/{total_batches} and extract technical accomplishments:
205
+ if not user_prompt:
206
+ user_prompt = f"""Analyze commits batch {batch_num}/{total_batches} and extract technical accomplishments:
198
207
 
199
208
  {commits_formatted}
200
209
 
repr/telemetry.py CHANGED
repr/templates.py CHANGED
@@ -162,7 +162,12 @@ def format_commits_for_prompt(commits: list[dict[str, Any]]) -> str:
162
162
 
163
163
  lines.append(f"- [{sha}] {msg}")
164
164
  if files:
165
- lines.append(f" Files: {', '.join(files[:5])}")
165
+ # Handle files as either list of dicts or list of strings
166
+ file_names = [
167
+ f["path"] if isinstance(f, dict) else f
168
+ for f in files[:5]
169
+ ]
170
+ lines.append(f" Files: {', '.join(file_names)}")
166
171
  if len(files) > 5:
167
172
  lines.append(f" ... and {len(files) - 5} more files")
168
173
  if c.get("insertions") or c.get("deletions"):
repr/ui.py CHANGED
@@ -143,3 +143,40 @@ def confirm(message: str, default: bool = False) -> bool:
143
143
  """Prompt for confirmation."""
144
144
  return Confirm.ask(message, default=default)
145
145
 
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+
167
+
168
+
169
+
170
+
171
+
172
+
173
+
174
+
175
+
176
+
177
+
178
+
179
+
180
+
181
+
182
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repr-cli
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: A beautiful, privacy-first CLI that analyzes your code repositories and generates a compelling developer profile
5
5
  Author-email: Repr <hello@repr.dev>
6
6
  License: MIT License
@@ -71,7 +71,7 @@ Turn commits into professional narratives for interviews, performance reviews, a
71
71
  > *"I used repr to prep for my Meta interview in 30 minutes. Turned 2 years of commits into 8 STAR-format stories. Nailed every behavioral question."*
72
72
  > **— Sarah, Senior Backend Engineer**
73
73
 
74
- > *"Our sprint demos went from chaos to polished in 5 minutes. Just run `repr since '2 weeks ago'` and export. Stakeholders love it."*
74
+ > *"Our sprint demos went from chaos to polished in 5 minutes. Just run `repr commits --days 14` and export. Stakeholders love it."*
75
75
  > **— Marcus, Engineering Manager**
76
76
 
77
77
  > *"I run repr in a fully air-gapped environment. Zero network calls, 100% local. It's the only tool I trust for this."*
@@ -167,7 +167,7 @@ For full step-by-step guides, see the [documentation](https://repr.dev/docs/cli/
167
167
 
168
168
  ```bash
169
169
  repr init ~/code
170
- repr week
170
+ repr commits --days 7
171
171
  repr generate --local
172
172
  ```
173
173
 
@@ -186,7 +186,7 @@ repr review
186
186
  ### Weekly reflection
187
187
 
188
188
  ```bash
189
- repr week
189
+ repr commits --days 7
190
190
  repr generate --local
191
191
  repr story edit <id>
192
192
  repr story feature <id>
@@ -204,6 +204,20 @@ repr story view <id>
204
204
 
205
205
  [Full guide →](https://repr.dev/docs/cli/workflows/interview-prep)
206
206
 
207
+ ### Generate from a specific timeframe
208
+
209
+ ```bash
210
+ # Last 30 days
211
+ repr generate --days 30 --local
212
+
213
+ # Since a specific date
214
+ repr generate --since 2024-01-01 --local
215
+
216
+ # Natural language dates
217
+ repr generate --since "2 weeks ago" --local
218
+ repr generate --since monday --local
219
+ ```
220
+
207
221
  ### Publish your profile (optional)
208
222
 
209
223
  ```bash
@@ -274,7 +288,7 @@ repr llm use byok:openai
274
288
  | **Local LLM** | `repr generate --local` | Talks only to your local endpoint. |
275
289
  | **BYOK** | `repr llm add <provider>` | Calls your provider directly with your key. |
276
290
  | **Cloud** | `repr generate --cloud` | Requires login; you initiate all network calls. |
277
- | **Offline** | `repr week` / `repr stories` | Pure local operations. |
291
+ | **Offline** | `repr commits` / `repr stories` | Pure local operations. |
278
292
 
279
293
  ## Command help
280
294
 
@@ -0,0 +1,26 @@
1
+ repr/__init__.py,sha256=jraImidqaPxv03Uy76zPtnAcNnOl5KLZSXYBzxI85BI,446
2
+ repr/__main__.py,sha256=edYQ5TsuidoAKR1DSuTNcRKudj4lijHZGURb_CSem1M,164
3
+ repr/api.py,sha256=Rr6MEUkjf7LJ6TcxbdVstfpUM_mDpTKhllbFwy9jK2w,11893
4
+ repr/auth.py,sha256=-tqd2MMgFlowbhAqLHeSnVpDBkintkZ4kmPDZmczQFU,11682
5
+ repr/cli.py,sha256=jmIn86UxSAPLM7i2GWvmnoF2ewg_a69sQ3UGGxuc8e4,83784
6
+ repr/config.py,sha256=GZf5ucrBFIfOo9UtKE-DAZ9Ns1suAKG0jvUAY64oGIc,30601
7
+ repr/discovery.py,sha256=2RYmJleqV7TbxIMMYP2izkEBUeKH7U1F-U4KAUlUNww,14816
8
+ repr/doctor.py,sha256=cD-XLCVXfME0DsgOWU4VUOv28O4avjSQmMK6X_Kddyk,13441
9
+ repr/extractor.py,sha256=lGPN8gwTF_ZSezoQoPBMnf95nCJArGIteNiInfb39FM,10566
10
+ repr/hooks.py,sha256=DRpVXVv5Lesn9ARKHr-I91bUScab2It2TPjdwM38bT4,16864
11
+ repr/keychain.py,sha256=CpKU3tjFZVEPgiHiplSAtBQFDPA6qOSovv4IXXgJXbY,6957
12
+ repr/llm.py,sha256=kLcRq2wZTiGJKmkzUIBCpybD4S4efTGc87f_GL4IlDU,14635
13
+ repr/openai_analysis.py,sha256=KtluzntxRoBY_KylkMLPTb90UpV7fLJxDJ4UNOB5qwQ,26255
14
+ repr/privacy.py,sha256=r-HvQ4C56whI9Cbp4AHMwULvueBdYaO0pu3U1AoqB9M,9832
15
+ repr/storage.py,sha256=72nfFcR2Y98vpSjaO7zVHisq_Ln2UrHmGyDhEqEmDjU,14863
16
+ repr/telemetry.py,sha256=OR9w3v4VrVYu3C4Q0GQsX3-2JXSQvUhiYnkC6S5YOGc,6998
17
+ repr/templates.py,sha256=3YlhiUtnmsj6eQw3BPvvDty0-lf7oLxuYlbISE0KQrI,6733
18
+ repr/tools.py,sha256=QoGeti5Sye2wVuE-7UPxd_TDNXoen-xYfsFoT9rYRPs,20737
19
+ repr/ui.py,sha256=3Fc1ugwtfySIuB5LSE5R18tMHbz211TZNolVDlwKDAU,4031
20
+ repr/updater.py,sha256=sn3VEwtPkn1LLla2hGO53EmrY_ToRfMlDbFQawKhSZ4,7333
21
+ repr_cli-0.2.9.dist-info/licenses/LICENSE,sha256=tI16Ry3IQhjsde6weJ_in6czzWW2EF4Chz1uicyDLAA,1061
22
+ repr_cli-0.2.9.dist-info/METADATA,sha256=KA77WQLqSuOZIp9LMAVD24ev2aSAUZOt9glkbcNVYXk,11196
23
+ repr_cli-0.2.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
+ repr_cli-0.2.9.dist-info/entry_points.txt,sha256=SJoKgNB-fRy6O2T_lztFr9T3ND_BQl0ijWxNW-J7dUU,38
25
+ repr_cli-0.2.9.dist-info/top_level.txt,sha256=LNgPqdJPQnlicRve7uzI4a6rEUdcxHrNkUq_2w7eeiA,5
26
+ repr_cli-0.2.9.dist-info/RECORD,,
@@ -1,26 +0,0 @@
1
- repr/__init__.py,sha256=jraImidqaPxv03Uy76zPtnAcNnOl5KLZSXYBzxI85BI,446
2
- repr/__main__.py,sha256=M0ECtxOrmmYoYrYV5XI9UhDnOjWThxn0-PPysKs3RT0,127
3
- repr/api.py,sha256=Rr6MEUkjf7LJ6TcxbdVstfpUM_mDpTKhllbFwy9jK2w,11893
4
- repr/auth.py,sha256=-tqd2MMgFlowbhAqLHeSnVpDBkintkZ4kmPDZmczQFU,11682
5
- repr/cli.py,sha256=xCz40ZRx_sOznTxS7GP4m0aArN7d7c4QCpFLh2t_FYU,78218
6
- repr/config.py,sha256=GZf5ucrBFIfOo9UtKE-DAZ9Ns1suAKG0jvUAY64oGIc,30601
7
- repr/discovery.py,sha256=2RYmJleqV7TbxIMMYP2izkEBUeKH7U1F-U4KAUlUNww,14816
8
- repr/doctor.py,sha256=6cI21xXIlTNRzHi2fRfHpm__erO8jBZc6vge8-29ip4,13404
9
- repr/extractor.py,sha256=lGPN8gwTF_ZSezoQoPBMnf95nCJArGIteNiInfb39FM,10566
10
- repr/hooks.py,sha256=DRpVXVv5Lesn9ARKHr-I91bUScab2It2TPjdwM38bT4,16864
11
- repr/keychain.py,sha256=CpKU3tjFZVEPgiHiplSAtBQFDPA6qOSovv4IXXgJXbY,6957
12
- repr/llm.py,sha256=gTYloz7ONTpFQm73YFIVGOrjsk0iyocMTM4YkF4s4xI,14598
13
- repr/openai_analysis.py,sha256=-9POoLF6B15_oBKJw_CjKH2DuWEIgIlOmtyjS4Gjbck,25764
14
- repr/privacy.py,sha256=HITso2pzwN8R0Izh3SjUsrzcpjVw5bJEhbippAGeMiY,9795
15
- repr/storage.py,sha256=72nfFcR2Y98vpSjaO7zVHisq_Ln2UrHmGyDhEqEmDjU,14863
16
- repr/telemetry.py,sha256=7ANJJUB4Dd7A_HFVPqc92Gy77ruREzlmgayFQkwuC9s,6961
17
- repr/templates.py,sha256=RQl7nUfy8IK6QFKzgpcebkBbQH0E_brbYh83pzym1TM,6530
18
- repr/tools.py,sha256=QoGeti5Sye2wVuE-7UPxd_TDNXoen-xYfsFoT9rYRPs,20737
19
- repr/ui.py,sha256=5jycUT-5Q0az4FFUzgarI8CfVAEEUPSEsT24Fad2kG8,3994
20
- repr/updater.py,sha256=E2ZRfeQxA4_UrWmIphJkafrUHU2UGUpfINNVLuTIcfI,7296
21
- repr_cli-0.2.8.dist-info/licenses/LICENSE,sha256=tI16Ry3IQhjsde6weJ_in6czzWW2EF4Chz1uicyDLAA,1061
22
- repr_cli-0.2.8.dist-info/METADATA,sha256=5YVmEcJf4Dt2OfAxaIND95w-wkZ-XBhddndijKFjTfw,10898
23
- repr_cli-0.2.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
- repr_cli-0.2.8.dist-info/entry_points.txt,sha256=SJoKgNB-fRy6O2T_lztFr9T3ND_BQl0ijWxNW-J7dUU,38
25
- repr_cli-0.2.8.dist-info/top_level.txt,sha256=LNgPqdJPQnlicRve7uzI4a6rEUdcxHrNkUq_2w7eeiA,5
26
- repr_cli-0.2.8.dist-info/RECORD,,