repr-cli 0.2.7__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/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")
@@ -2382,6 +2514,34 @@ def mode():
2382
2514
  console.print(f" [{BRAND_MUTED}]✗[/] Publishing ({reason})")
2383
2515
 
2384
2516
 
2517
+ @app.command()
2518
+ def update(
2519
+ check: bool = typer.Option(False, "--check", "-c", help="Only check for updates, don't install"),
2520
+ force: bool = typer.Option(False, "--force", "-f", help="Force update even if already up to date"),
2521
+ ):
2522
+ """
2523
+ Update repr to the latest version.
2524
+
2525
+ Automatically detects installation method (Homebrew, pip, or binary)
2526
+ and updates accordingly.
2527
+
2528
+ Examples:
2529
+ repr update # Update to latest version
2530
+ repr update --check # Just check if update available
2531
+ """
2532
+ from .updater import check_for_update, perform_update
2533
+
2534
+ if check:
2535
+ new_version = check_for_update()
2536
+ if new_version:
2537
+ print_info(f"New version available: v{new_version}")
2538
+ print_info("Run 'repr update' to install")
2539
+ else:
2540
+ print_success(f"Already up to date (v{__version__})")
2541
+ else:
2542
+ perform_update(force=force)
2543
+
2544
+
2385
2545
  @app.command()
2386
2546
  def doctor():
2387
2547
  """