repr-cli 0.2.8__py3-none-any.whl → 0.2.11__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,9 +608,9 @@ 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
- from .templates import build_generation_prompt
613
+ from .templates import build_generation_prompt, StoryOutput
479
614
 
480
615
  stories = []
481
616
 
@@ -485,186 +620,119 @@ 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 using structured output
647
+ result = 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
+ structured=True,
656
+ )
657
+
658
+ # Handle structured output - now returns list[StoryOutput]
659
+ story_outputs: list[StoryOutput] = []
660
+ if isinstance(result, list):
661
+ story_outputs = result
662
+ elif isinstance(result, StoryOutput):
663
+ story_outputs = [result]
664
+ else:
665
+ # Fallback for string response
666
+ content = result
667
+ if not content or content.startswith("[Batch"):
668
+ continue
669
+ lines = [l.strip() for l in content.split("\n") if l.strip()]
670
+ summary = lines[0] if lines else "Story"
671
+ summary = summary.lstrip("#-•* ").strip()
672
+ story_outputs = [StoryOutput(summary=summary, content=content)]
673
+
674
+ # Build shared metadata for all stories from this batch
675
+ commit_shas = [c["full_sha"] for c in batch]
676
+ first_date = min(c["date"] for c in batch)
677
+ last_date = max(c["date"] for c in batch)
678
+ total_files = sum(len(c.get("files", [])) for c in batch)
679
+ total_adds = sum(c.get("insertions", 0) for c in batch)
680
+ total_dels = sum(c.get("deletions", 0) for c in batch)
681
+
682
+ # Save each story from this batch
683
+ for story_output in story_outputs:
684
+ content = story_output.content
685
+ summary = story_output.summary
686
+
687
+ if not content or content.startswith("[Batch"):
688
+ continue
689
+
690
+ metadata = {
691
+ "summary": summary,
692
+ "repo_name": repo_info.name,
693
+ "repo_path": str(repo_info.path),
694
+ "commit_shas": commit_shas,
695
+ "first_commit_at": first_date,
696
+ "last_commit_at": last_date,
697
+ "files_changed": total_files,
698
+ "lines_added": total_adds,
699
+ "lines_removed": total_dels,
700
+ "generated_locally": local,
701
+ "template": template,
702
+ "needs_review": False,
703
+ }
704
+
705
+ # Save story
706
+ story_id = save_story(content, metadata)
707
+ metadata["id"] = story_id
708
+ stories.append(metadata)
709
+
710
+ except Exception as e:
711
+ console.print(f" [{BRAND_MUTED}]Batch {i+1} failed: {e}[/]")
712
+ finally:
713
+ # Properly close the async client
714
+ await client.close()
560
715
 
561
716
  return stories
562
717
 
563
718
 
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.[/]")
719
+ def _generate_stories(
720
+ commits: list[dict],
721
+ repo_info,
722
+ batch_size: int,
723
+ local: bool,
724
+ template: str = "resume",
725
+ custom_prompt: str | None = None,
726
+ ) -> list[dict]:
727
+ """Generate stories from commits using LLM."""
728
+ return asyncio.run(_generate_stories_async(
729
+ commits=commits,
730
+ repo_info=repo_info,
731
+ batch_size=batch_size,
732
+ local=local,
733
+ template=template,
734
+ custom_prompt=custom_prompt,
735
+ ))
668
736
 
669
737
 
670
738
  # =============================================================================
@@ -698,24 +766,37 @@ def stories(
698
766
 
699
767
  console.print(f"[bold]Stories[/] ({len(story_list)} total)")
700
768
  console.print()
701
-
769
+
770
+ # Group stories by repository
771
+ by_repo = defaultdict(list)
702
772
  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}[/]")
773
+ r_name = story.get("repo_name", "unknown")
774
+ by_repo[r_name].append(story)
775
+
776
+ # Sort repositories by their most recent story
777
+ sorted_repos = sorted(
778
+ by_repo.keys(),
779
+ key=lambda r: max(s.get("created_at", "") for s in by_repo[r]),
780
+ reverse=True
781
+ )
782
+
783
+ for repo_name in sorted_repos:
784
+ console.print(f"[bold]{repo_name}[/]")
785
+ for story in by_repo[repo_name]:
786
+ # Status indicator
787
+ if story.get("needs_review"):
788
+ status = f"[{BRAND_WARNING}]⚠[/]"
789
+ elif story.get("pushed_at"):
790
+ status = f"[{BRAND_SUCCESS}]✓[/]"
791
+ else:
792
+ status = f"[{BRAND_MUTED}]○[/]"
793
+
794
+ summary = story.get("summary", "Untitled")
795
+ created = format_relative_time(story.get("created_at", ""))
796
+
797
+ console.print(f" {status} {summary} [{BRAND_MUTED}]• {created}[/]")
717
798
  console.print()
718
-
799
+
719
800
  if len(story_list) > 20:
720
801
  console.print(f"[{BRAND_MUTED}]... and {len(story_list) - 20} more[/]")
721
802
 
@@ -723,7 +804,8 @@ def stories(
723
804
  @app.command()
724
805
  def story(
725
806
  action: str = typer.Argument(..., help="Action: view, edit, delete, hide, feature, regenerate"),
726
- story_id: str = typer.Argument(..., help="Story ID (ULID)"),
807
+ story_id: Optional[str] = typer.Argument(None, help="Story ID (ULID)"),
808
+ all_stories: bool = typer.Option(False, "--all", help="Apply to all stories (for delete)"),
727
809
  ):
728
810
  """
729
811
  Manage a single story.
@@ -731,7 +813,38 @@ def story(
731
813
  Examples:
732
814
  repr story view 01ARYZ6S41TSV4RRFFQ69G5FAV
733
815
  repr story delete 01ARYZ6S41TSV4RRFFQ69G5FAV
816
+ repr story delete --all
734
817
  """
818
+ # Handle --all flag for delete
819
+ if all_stories:
820
+ if action != "delete":
821
+ print_error("--all flag only works with 'delete' action")
822
+ raise typer.Exit(1)
823
+
824
+ story_list = list_stories()
825
+ if not story_list:
826
+ print_info("No stories to delete")
827
+ raise typer.Exit()
828
+
829
+ console.print(f"This will delete [bold]{len(story_list)}[/] stories.")
830
+ if confirm("Delete all stories?"):
831
+ deleted = 0
832
+ for s in story_list:
833
+ try:
834
+ delete_story(s["id"])
835
+ deleted += 1
836
+ except Exception:
837
+ pass
838
+ print_success(f"Deleted {deleted} stories")
839
+ else:
840
+ print_info("Cancelled")
841
+ raise typer.Exit()
842
+
843
+ # Require story_id for single-story operations
844
+ if not story_id:
845
+ print_error("Story ID required (or use --all for delete)")
846
+ raise typer.Exit(1)
847
+
735
848
  result = load_story(story_id)
736
849
 
737
850
  if not result:
@@ -935,7 +1048,9 @@ def push(
935
1048
  for s in to_push:
936
1049
  try:
937
1050
  content, meta = load_story(s["id"])
938
- asyncio.run(api_push_story({**meta, "content": content}))
1051
+ # Use local story ID as client_id for sync
1052
+ payload = {**meta, "content": content, "client_id": s["id"]}
1053
+ asyncio.run(api_push_story(payload))
939
1054
  mark_story_pushed(s["id"])
940
1055
  console.print(f" [{BRAND_SUCCESS}]✓[/] {s.get('summary', s.get('id'))[:50]}")
941
1056
  pushed += 1
@@ -984,7 +1099,8 @@ def sync():
984
1099
  for s in unpushed:
985
1100
  try:
986
1101
  content, meta = load_story(s["id"])
987
- asyncio.run(api_push_story({**meta, "content": content}))
1102
+ payload = {**meta, "content": content, "client_id": s["id"]}
1103
+ asyncio.run(api_push_story(payload))
988
1104
  mark_story_pushed(s["id"])
989
1105
  except Exception:
990
1106
  pass
@@ -1025,15 +1141,20 @@ def pull():
1025
1141
  def list_commits(
1026
1142
  repo: Optional[str] = typer.Option(None, "--repo", help="Filter by repo name"),
1027
1143
  limit: int = typer.Option(50, "--limit", "-n", help="Number of commits"),
1028
- since: Optional[str] = typer.Option(None, "--since", help="Since date"),
1144
+ days: Optional[int] = typer.Option(None, "--days", "-d", help="Show commits from last N days (e.g., 7 for week, 3 for standup)"),
1145
+ since: Optional[str] = typer.Option(None, "--since", help="Since date (e.g., '2024-01-01', 'monday', '2 weeks ago')"),
1029
1146
  json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
1030
1147
  ):
1031
1148
  """
1032
1149
  List recent commits across all tracked repos.
1033
1150
 
1034
- Example:
1151
+ Examples:
1035
1152
  repr commits --limit 10
1036
1153
  repr commits --repo myproject
1154
+ repr commits --days 7 # Last week
1155
+ repr commits --days 3 # For standup
1156
+ repr commits --since monday
1157
+ repr commits --since "2 weeks ago"
1037
1158
  """
1038
1159
  from .tools import get_commits_with_diffs
1039
1160
 
@@ -1042,6 +1163,18 @@ def list_commits(
1042
1163
  print_warning("No repositories tracked.")
1043
1164
  raise typer.Exit(1)
1044
1165
 
1166
+ # Determine days filter
1167
+ filter_days = days if days is not None else 90
1168
+
1169
+ # Parse natural language date if --since provided
1170
+ since_str = None
1171
+ if since:
1172
+ since_str = _parse_date_reference(since)
1173
+ if not since_str:
1174
+ print_error(f"Could not parse date: {since}")
1175
+ print_info("Try: '2024-01-01', 'monday', '3 days ago', 'last week'")
1176
+ raise typer.Exit(1)
1177
+
1045
1178
  all_commits = []
1046
1179
 
1047
1180
  for repo_info in tracked:
@@ -1051,7 +1184,7 @@ def list_commits(
1051
1184
  if not repo_path.exists():
1052
1185
  continue
1053
1186
 
1054
- commits = get_commits_with_diffs(repo_path, count=limit, days=90)
1187
+ commits = get_commits_with_diffs(repo_path, count=limit, days=filter_days, since=since_str)
1055
1188
  for c in commits:
1056
1189
  c["repo_name"] = repo_path.name
1057
1190
  all_commits.extend(commits)
@@ -1068,7 +1201,15 @@ def list_commits(
1068
1201
  print_info("No commits found")
1069
1202
  raise typer.Exit()
1070
1203
 
1071
- console.print(f"[bold]Recent Commits[/] ({len(all_commits)})")
1204
+ # Build label for display
1205
+ if days:
1206
+ label = f"last {days} days"
1207
+ elif since:
1208
+ label = f"since {since}"
1209
+ else:
1210
+ label = "recent"
1211
+
1212
+ console.print(f"[bold]Commits ({label})[/] — {len(all_commits)} total")
1072
1213
  console.print()
1073
1214
 
1074
1215
  current_repo = None
@@ -2331,22 +2472,64 @@ def status(
2331
2472
 
2332
2473
 
2333
2474
  @app.command()
2334
- def mode():
2475
+ def mode(
2476
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
2477
+ ):
2335
2478
  """
2336
2479
  Show current execution mode and settings.
2337
-
2480
+
2338
2481
  Example:
2339
2482
  repr mode
2483
+ repr mode --json
2340
2484
  """
2341
2485
  from .llm import get_llm_status
2342
-
2486
+
2343
2487
  authenticated = is_authenticated()
2344
2488
  privacy = get_privacy_settings()
2345
2489
  llm_status = get_llm_status()
2346
-
2490
+
2491
+ # Determine mode
2492
+ if privacy.get("lock_local_only"):
2493
+ if privacy.get("lock_permanent"):
2494
+ mode = "LOCAL-ONLY"
2495
+ locked = True
2496
+ else:
2497
+ mode = "LOCAL-ONLY"
2498
+ locked = True
2499
+ elif authenticated:
2500
+ mode = "CLOUD"
2501
+ locked = False
2502
+ else:
2503
+ mode = "LOCAL-ONLY"
2504
+ locked = False
2505
+
2506
+ # Build LLM provider string
2507
+ if llm_status["local"]["available"]:
2508
+ llm_provider = f"{llm_status['local']['name']} ({llm_status['local']['model']})"
2509
+ else:
2510
+ llm_provider = "none"
2511
+
2512
+ # Check cloud allowed
2513
+ cloud_allowed = authenticated and is_cloud_allowed()
2514
+
2515
+ if json_output:
2516
+ import json
2517
+ output = {
2518
+ "mode": mode,
2519
+ "locked": locked,
2520
+ "llm_provider": llm_provider,
2521
+ "network_policy": "cloud-enabled" if authenticated else "local-only",
2522
+ "authenticated": authenticated,
2523
+ "cloud_generation_available": cloud_allowed,
2524
+ "sync_available": cloud_allowed,
2525
+ "publishing_available": cloud_allowed,
2526
+ }
2527
+ print(json.dumps(output, indent=2))
2528
+ return
2529
+
2347
2530
  console.print("[bold]Mode[/]")
2348
2531
  console.print()
2349
-
2532
+
2350
2533
  if privacy.get("lock_local_only"):
2351
2534
  if privacy.get("lock_permanent"):
2352
2535
  console.print("Data boundary: local only (permanently locked)")
@@ -2356,22 +2539,22 @@ def mode():
2356
2539
  console.print("Data boundary: cloud-enabled")
2357
2540
  else:
2358
2541
  console.print("Data boundary: local only")
2359
-
2542
+
2360
2543
  console.print(f"Default inference: {llm_status['default_mode']}")
2361
-
2544
+
2362
2545
  console.print()
2363
-
2546
+
2364
2547
  # LLM info
2365
2548
  if llm_status["local"]["available"]:
2366
2549
  console.print(f"Local LLM: {llm_status['local']['name']} ({llm_status['local']['model']})")
2367
2550
  else:
2368
2551
  console.print(f"[{BRAND_MUTED}]Local LLM: not detected[/]")
2369
-
2552
+
2370
2553
  console.print()
2371
-
2554
+
2372
2555
  console.print("Available:")
2373
2556
  console.print(f" [{BRAND_SUCCESS}]✓[/] Local generation")
2374
- if authenticated and is_cloud_allowed():
2557
+ if cloud_allowed:
2375
2558
  console.print(f" [{BRAND_SUCCESS}]✓[/] Cloud generation")
2376
2559
  console.print(f" [{BRAND_SUCCESS}]✓[/] Sync")
2377
2560
  console.print(f" [{BRAND_SUCCESS}]✓[/] Publishing")