janet-cli 0.2.7__py3-none-any.whl → 0.2.33__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.
janet/cli.py CHANGED
@@ -1,6 +1,9 @@
1
1
  """Main CLI application using Typer."""
2
2
 
3
+ import sys
4
+ import json
3
5
  import typer
6
+ from typing import Optional, List
4
7
  from typing_extensions import Annotated
5
8
 
6
9
  from janet import __version__
@@ -20,11 +23,13 @@ auth_app = typer.Typer(help="Authentication commands")
20
23
  org_app = typer.Typer(help="Organization management")
21
24
  project_app = typer.Typer(help="Project management")
22
25
  config_app = typer.Typer(help="Configuration management")
26
+ ticket_app = typer.Typer(help="Ticket management")
23
27
 
24
- app.add_typer(auth_app, name="auth")
25
- app.add_typer(org_app, name="org")
26
- app.add_typer(project_app, name="project")
27
- app.add_typer(config_app, name="config")
28
+ app.add_typer(auth_app, name="auth", rich_help_panel="Management")
29
+ app.add_typer(org_app, name="org", rich_help_panel="Management")
30
+ app.add_typer(project_app, name="project", rich_help_panel="Management")
31
+ app.add_typer(config_app, name="config", rich_help_panel="Management")
32
+ app.add_typer(ticket_app, name="ticket", rich_help_panel="Management")
28
33
 
29
34
  # Initialize config manager
30
35
  config_manager = ConfigManager()
@@ -52,7 +57,7 @@ def main(
52
57
  # =============================================================================
53
58
 
54
59
 
55
- @app.command(name="login")
60
+ @app.command(name="login", rich_help_panel="Authentication")
56
61
  def login() -> None:
57
62
  """Authenticate with Janet AI and select organization."""
58
63
  try:
@@ -106,14 +111,75 @@ def login() -> None:
106
111
 
107
112
  print_success(f"Selected organization: {selected_org['name']}")
108
113
  console.print("\n[green]✓ Authentication complete![/green]")
109
- console.print("Run 'janet sync' to start syncing tickets.")
114
+ console.print("Run [cyan]janet sync[/cyan] to sync tickets and watch for real-time updates.")
110
115
 
111
116
  except JanetCLIError as e:
112
117
  print_error(str(e))
113
118
  raise typer.Exit(1)
114
119
 
115
120
 
116
- @app.command(name="logout")
121
+ @app.command(name="update", rich_help_panel="Utilities")
122
+ def update(
123
+ test_pypi: bool = typer.Option(False, "--test", help="Update from Test PyPI (for development)")
124
+ ) -> None:
125
+ """Update Janet CLI to the latest version."""
126
+ import subprocess
127
+ import httpx
128
+ from janet import __version__
129
+
130
+ console.print("[cyan]Checking for updates...[/cyan]")
131
+
132
+ try:
133
+ # Determine PyPI URL based on flag
134
+ if test_pypi:
135
+ pypi_url = "https://test.pypi.org/pypi/janet-cli/json"
136
+ index_url = "https://test.pypi.org/simple/"
137
+ else:
138
+ pypi_url = "https://pypi.org/pypi/janet-cli/json"
139
+ index_url = None
140
+
141
+ # Fetch latest version from PyPI
142
+ try:
143
+ response = httpx.get(pypi_url, timeout=10)
144
+ response.raise_for_status()
145
+ latest_version = response.json()["info"]["version"]
146
+ except Exception as e:
147
+ print_error(f"Failed to check for updates: {e}")
148
+ raise typer.Exit(1)
149
+
150
+ current_version = __version__
151
+
152
+ console.print(f"[dim]Current version: {current_version}[/dim]")
153
+ console.print(f"[dim]Latest version: {latest_version}[/dim]")
154
+
155
+ # Compare versions
156
+ if current_version == latest_version:
157
+ console.print("[green]Janet CLI is already up to date.[/green]")
158
+ return
159
+
160
+ # Build pip command
161
+ pip_cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "janet-cli"]
162
+ if index_url:
163
+ pip_cmd.extend(["--index-url", index_url])
164
+
165
+ console.print(f"[cyan]Updating to version {latest_version}...[/cyan]")
166
+
167
+ result = subprocess.run(pip_cmd, capture_output=True, text=True)
168
+
169
+ if result.returncode == 0:
170
+ console.print(f"[green]✓ Janet CLI updated to {latest_version}![/green]")
171
+ console.print("[dim]Restart your terminal to use the new version.[/dim]")
172
+ else:
173
+ print_error(f"Update failed: {result.stderr}")
174
+ raise typer.Exit(1)
175
+ except typer.Exit:
176
+ raise
177
+ except Exception as e:
178
+ print_error(f"Update failed: {e}")
179
+ raise typer.Exit(1)
180
+
181
+
182
+ @app.command(name="logout", rich_help_panel="Authentication")
117
183
  def logout() -> None:
118
184
  """Clear stored credentials."""
119
185
  try:
@@ -227,9 +293,35 @@ def org_select(org_id: str) -> None:
227
293
 
228
294
  # Update config
229
295
  config = config_manager.get()
296
+ old_org_id = config.selected_organization.id if config.selected_organization else None
297
+
230
298
  config.selected_organization = OrganizationInfo(
231
299
  id=org_data["id"], name=org_data["name"], uuid=org_data.get("uuid", org_id)
232
300
  )
301
+
302
+ # Clear synced projects when switching orgs (they belong to the old org)
303
+ if old_org_id and old_org_id != org_data["id"]:
304
+ config.sync.synced_projects = []
305
+ config.sync.last_sync_org_id = None
306
+ config.sync.last_sync_total_tickets = 0
307
+
308
+ # Regenerate README with new org but empty projects (no statuses until sync)
309
+ from janet.utils.paths import expand_path
310
+ from pathlib import Path
311
+
312
+ sync_dir = expand_path(config.sync.root_directory)
313
+ if sync_dir.exists():
314
+ from janet.sync.readme_generator import ReadmeGenerator
315
+ readme_gen = ReadmeGenerator()
316
+ readme_gen.write_readme(
317
+ sync_dir=sync_dir,
318
+ org_name=org_data["name"],
319
+ projects=[],
320
+ total_tickets=0,
321
+ project_statuses={},
322
+ )
323
+ print_info(f"README updated for new organization. Run 'janet sync' to sync projects.")
324
+
233
325
  config_manager.update(config)
234
326
 
235
327
  print_success(f"Selected organization: {org_data['name']}")
@@ -313,15 +405,17 @@ def project_list() -> None:
313
405
  # =============================================================================
314
406
 
315
407
 
316
- @app.command(name="sync")
408
+ @app.command(name="sync", rich_help_panel="Syncing")
317
409
  def sync(
318
410
  directory: Annotated[str, typer.Option("--dir", "-d", help="Sync directory")] = None,
319
411
  all_projects: Annotated[bool, typer.Option("--all", help="Sync all projects")] = False,
412
+ no_watch: Annotated[bool, typer.Option("--no-watch", help="Exit after sync instead of watching for updates")] = False,
320
413
  ) -> None:
321
414
  """
322
- Sync tickets to local markdown files.
415
+ Sync tickets to local markdown files and watch for real-time updates.
323
416
 
324
417
  Interactive mode: prompts for project selection and directory.
418
+ After syncing, stays connected for real-time updates (Ctrl+C to stop).
325
419
  """
326
420
  try:
327
421
  from janet.sync.sync_engine import SyncEngine
@@ -489,6 +583,20 @@ def sync(
489
583
  synced = sync_engine.sync_project(project["id"], project_key, project_name)
490
584
  total_tickets += synced
491
585
 
586
+ # Fetch project statuses (kanban columns) for each project
587
+ project_statuses = {}
588
+ try:
589
+ for project in selected_projects:
590
+ project_id = project.get("id", "")
591
+ project_key = project.get("project_identifier", "")
592
+ if project_id:
593
+ columns = project_api.get_project_columns(project_id)
594
+ # Extract status values in order
595
+ statuses = [col.get("status_value", "") for col in sorted(columns, key=lambda x: x.get("column_order", 0))]
596
+ project_statuses[project_key] = statuses
597
+ except Exception as e:
598
+ print_info(f"Note: Could not fetch project statuses: {e}")
599
+
492
600
  # Generate README for AI agents
493
601
  from janet.sync.readme_generator import ReadmeGenerator
494
602
  readme_gen = ReadmeGenerator()
@@ -497,8 +605,24 @@ def sync(
497
605
  org_name=org_name,
498
606
  projects=selected_projects,
499
607
  total_tickets=total_tickets,
608
+ project_statuses=project_statuses,
500
609
  )
501
610
 
611
+ # Save synced projects to config for README regeneration on org change
612
+ from janet.config.models import SyncedProject
613
+ config.sync.synced_projects = [
614
+ SyncedProject(
615
+ id=p.get("id", ""),
616
+ project_identifier=p.get("project_identifier", ""),
617
+ project_name=p.get("project_name", ""),
618
+ ticket_count=p.get("ticket_count", 0),
619
+ )
620
+ for p in selected_projects
621
+ ]
622
+ config.sync.last_sync_org_id = config.selected_organization.id
623
+ config.sync.last_sync_total_tickets = total_tickets
624
+ config_manager.update(config)
625
+
502
626
  # Show summary
503
627
  console.print(f"\n[bold green]✓ Sync complete![/bold green]")
504
628
  console.print(f" Projects: {len(selected_projects)}")
@@ -506,6 +630,36 @@ def sync(
506
630
  console.print(f"\n[cyan]Tickets saved to: {expanded_dir}[/cyan]")
507
631
  console.print(f"[dim]README for AI agents: {readme_path}[/dim]")
508
632
 
633
+ # Start watch mode (default behavior, unless --no-watch)
634
+ if not no_watch:
635
+ from janet.sync.sse_watcher import SSEWatcher
636
+ from janet.api.organizations import OrganizationAPI
637
+
638
+ console.print(f"\n")
639
+
640
+ # Fetch org members for name resolution in SSE updates
641
+ org_members = None
642
+ try:
643
+ org_api = OrganizationAPI(config_manager)
644
+ org_id = config.selected_organization.id
645
+ response = org_api.get(f"/api/v1/organizations/{org_id}/members", include_org=False)
646
+ org_members = response.get("members", [])
647
+ except Exception:
648
+ pass # Will fall back to emails if members can't be fetched
649
+
650
+ # Create SSE watcher
651
+ watcher = SSEWatcher(
652
+ config_manager=config_manager,
653
+ projects=selected_projects,
654
+ org_name=org_name,
655
+ sync_dir=str(expanded_dir),
656
+ org_members=org_members,
657
+ project_statuses=project_statuses,
658
+ )
659
+
660
+ # This blocks until Ctrl+C
661
+ watcher.watch()
662
+
509
663
  except JanetCLIError as e:
510
664
  print_error(str(e))
511
665
  raise typer.Exit(1)
@@ -519,7 +673,7 @@ def sync(
519
673
  # =============================================================================
520
674
 
521
675
 
522
- @app.command(name="status")
676
+ @app.command(name="status", rich_help_panel="Syncing")
523
677
  def status() -> None:
524
678
  """Show overall status (auth, org, last sync)."""
525
679
  try:
@@ -598,5 +752,353 @@ def config_reset(
598
752
  raise typer.Exit(1)
599
753
 
600
754
 
755
+ # =============================================================================
756
+ # Ticket Commands
757
+ # =============================================================================
758
+
759
+
760
+ @ticket_app.command(name="create")
761
+ def ticket_create(
762
+ title: Annotated[str, typer.Argument(help="Ticket title")],
763
+ project: Annotated[Optional[str], typer.Option("--project", "-p", help="Project key (e.g., PROJ) or ID")] = None,
764
+ description: Annotated[Optional[str], typer.Option("--description", "-d", help="Ticket description")] = None,
765
+ status: Annotated[Optional[str], typer.Option("--status", "-s", help="Status (default: To Do)")] = None,
766
+ priority: Annotated[Optional[str], typer.Option("--priority", help="Priority: Low, Medium, High, Critical")] = None,
767
+ issue_type: Annotated[Optional[str], typer.Option("--type", "-t", help="Type: Task, Bug, Story, Epic")] = None,
768
+ assignee: Annotated[Optional[List[str]], typer.Option("--assignee", "-a", help="Assignee email (can repeat)")] = None,
769
+ tag: Annotated[Optional[List[str]], typer.Option("--tag", help="Tag (can repeat)")] = None,
770
+ output_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
771
+ ) -> None:
772
+ """
773
+ Create a new ticket.
774
+
775
+ Examples:
776
+ janet ticket create "Fix login bug" -p PROJ
777
+ janet ticket create "Add feature" -p PROJ -d "Details here" --priority High
778
+ echo "Description" | janet ticket create "Title" -p PROJ
779
+ """
780
+ try:
781
+ from janet.api.tickets import TicketAPI
782
+ from janet.api.projects import ProjectAPI
783
+
784
+ if not config_manager.is_authenticated():
785
+ print_error("Not authenticated. Run 'janet login' first.")
786
+ raise typer.Exit(1)
787
+
788
+ if not config_manager.has_organization():
789
+ print_error("No organization selected. Run 'janet org select' first.")
790
+ raise typer.Exit(1)
791
+
792
+ # Get project list
793
+ project_api = ProjectAPI(config_manager)
794
+ projects = project_api.list_projects()
795
+
796
+ if not projects:
797
+ print_error("No projects found in organization")
798
+ raise typer.Exit(1)
799
+
800
+ # Resolve project
801
+ project_id = None
802
+ project_key = None
803
+
804
+ if project:
805
+ # Try to match by key or ID
806
+ for p in projects:
807
+ if p.get("project_identifier", "").upper() == project.upper():
808
+ project_id = p["id"]
809
+ project_key = p["project_identifier"]
810
+ break
811
+ if p.get("id") == project:
812
+ project_id = p["id"]
813
+ project_key = p.get("project_identifier", "")
814
+ break
815
+
816
+ if not project_id:
817
+ print_error(f"Project '{project}' not found")
818
+ raise typer.Exit(1)
819
+ else:
820
+ # Interactive project selection
821
+ if not sys.stdin.isatty():
822
+ print_error("--project is required for non-interactive use")
823
+ raise typer.Exit(1)
824
+
825
+ from InquirerPy import inquirer
826
+
827
+ console.print("\n[bold]Select a project:[/bold]\n")
828
+
829
+ choices = []
830
+ for p in projects:
831
+ key = p.get("project_identifier", "")
832
+ name = p.get("project_name", "")
833
+ count = p.get("ticket_count", 0)
834
+ label_text = f"{key:8s} - {name} ({count} tickets)"
835
+ choices.append({"name": label_text, "value": p})
836
+
837
+ selected = inquirer.select(
838
+ message="Project:",
839
+ choices=choices,
840
+ ).execute()
841
+
842
+ project_id = selected["id"]
843
+ project_key = selected.get("project_identifier", "")
844
+
845
+ # Check for piped stdin for description
846
+ final_description = description
847
+ if not sys.stdin.isatty() and not description:
848
+ # Read from stdin
849
+ stdin_content = sys.stdin.read().strip()
850
+ if stdin_content:
851
+ final_description = stdin_content
852
+
853
+ # Create ticket
854
+ ticket_api = TicketAPI(config_manager)
855
+ result = ticket_api.create_ticket(
856
+ project_id=project_id,
857
+ title=title,
858
+ description=final_description,
859
+ status=status or "To Do",
860
+ priority=priority,
861
+ issue_type=issue_type,
862
+ assignees=assignee,
863
+ labels=tag,
864
+ )
865
+
866
+ ticket_key_result = result.get("ticket_key", f"{project_key}-{result.get('ticket_identifier', '?')}")
867
+
868
+ if output_json:
869
+ output = {
870
+ "success": True,
871
+ "ticket_id": result.get("ticket_id"),
872
+ "ticket_key": ticket_key_result,
873
+ "title": title,
874
+ "project_key": project_key,
875
+ }
876
+ console.print(json.dumps(output, indent=2))
877
+ else:
878
+ print_success(f"Created {ticket_key_result}: {title}")
879
+
880
+ except JanetCLIError as e:
881
+ if output_json:
882
+ console.print(json.dumps({"success": False, "error": str(e)}))
883
+ else:
884
+ print_error(str(e))
885
+ raise typer.Exit(1)
886
+ except Exception as e:
887
+ if output_json:
888
+ console.print(json.dumps({"success": False, "error": str(e)}))
889
+ else:
890
+ print_error(f"Failed to create ticket: {e}")
891
+ raise typer.Exit(1)
892
+
893
+
894
+ @ticket_app.command(name="update")
895
+ def ticket_update(
896
+ ticket_key: Annotated[str, typer.Argument(help="Ticket key (e.g., PROJ-123) or ticket ID")],
897
+ title: Annotated[Optional[str], typer.Option("--title", help="New title")] = None,
898
+ description: Annotated[Optional[str], typer.Option("--description", "-d", help="New description")] = None,
899
+ status: Annotated[Optional[str], typer.Option("--status", "-s", help="New status")] = None,
900
+ priority: Annotated[Optional[str], typer.Option("--priority", help="New priority: Low, Medium, High, Critical")] = None,
901
+ issue_type: Annotated[Optional[str], typer.Option("--type", "-t", help="New type: Task, Bug, Story, Epic")] = None,
902
+ assignee: Annotated[Optional[List[str]], typer.Option("--assignee", "-a", help="New assignee email (can repeat, replaces all)")] = None,
903
+ tag: Annotated[Optional[List[str]], typer.Option("--tag", help="New tag (can repeat, replaces all)")] = None,
904
+ due_date: Annotated[Optional[str], typer.Option("--due-date", help="New due date (YYYY-MM-DD)")] = None,
905
+ output_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
906
+ ) -> None:
907
+ """
908
+ Update an existing ticket.
909
+
910
+ Examples:
911
+ janet ticket update PROJ-123 --status "In Progress"
912
+ janet ticket update PROJ-123 --title "New title" --priority High
913
+ janet ticket update PROJ-123 -a user@example.com -a user2@example.com
914
+ """
915
+ try:
916
+ from janet.api.tickets import TicketAPI
917
+ from janet.api.projects import ProjectAPI
918
+
919
+ if not config_manager.is_authenticated():
920
+ print_error("Not authenticated. Run 'janet login' first.")
921
+ raise typer.Exit(1)
922
+
923
+ if not config_manager.has_organization():
924
+ print_error("No organization selected. Run 'janet org select' first.")
925
+ raise typer.Exit(1)
926
+
927
+ # Resolve ticket_key to ticket_id
928
+ ticket_id = None
929
+ resolved_ticket_key = ticket_key
930
+
931
+ # Check if it looks like a UUID (ticket ID)
932
+ if "-" in ticket_key and len(ticket_key) == 36:
933
+ ticket_id = ticket_key
934
+ else:
935
+ # It's a ticket key like PROJ-123, need to find the ticket ID
936
+ # Parse project key and ticket number
937
+ parts = ticket_key.upper().rsplit("-", 1)
938
+ if len(parts) != 2:
939
+ print_error(f"Invalid ticket key format: {ticket_key}. Expected format: PROJ-123")
940
+ raise typer.Exit(1)
941
+
942
+ project_key, ticket_num = parts
943
+
944
+ # Get project list to find the project
945
+ project_api = ProjectAPI(config_manager)
946
+ projects = project_api.list_projects()
947
+
948
+ project_id = None
949
+ for p in projects:
950
+ if p.get("project_identifier", "").upper() == project_key:
951
+ project_id = p["id"]
952
+ break
953
+
954
+ if not project_id:
955
+ print_error(f"Project '{project_key}' not found")
956
+ raise typer.Exit(1)
957
+
958
+ # Fetch tickets from project to find the one with matching identifier
959
+ ticket_api = TicketAPI(config_manager)
960
+ sync_result = ticket_api.sync_all_tickets(project_id)
961
+ tickets = sync_result.get("tickets", [])
962
+
963
+ for t in tickets:
964
+ if str(t.get("ticket_identifier")) == ticket_num:
965
+ ticket_id = t["id"]
966
+ break
967
+
968
+ if not ticket_id:
969
+ print_error(f"Ticket '{ticket_key}' not found")
970
+ raise typer.Exit(1)
971
+
972
+ # Check if any update field was provided
973
+ if not any([title, description, status, priority, issue_type, assignee, tag, due_date]):
974
+ print_error("No update fields provided. Use --help to see available options.")
975
+ raise typer.Exit(1)
976
+
977
+ # Update ticket
978
+ ticket_api = TicketAPI(config_manager)
979
+ result = ticket_api.update_ticket(
980
+ ticket_id=ticket_id,
981
+ title=title,
982
+ description=description,
983
+ status=status,
984
+ priority=priority,
985
+ issue_type=issue_type,
986
+ assignees=assignee,
987
+ labels=tag,
988
+ due_date=due_date,
989
+ )
990
+
991
+ if output_json:
992
+ output = {
993
+ "success": True,
994
+ "ticket_key": resolved_ticket_key,
995
+ "ticket_id": ticket_id,
996
+ "updated_fields": result.get("updated_fields", []),
997
+ }
998
+ console.print(json.dumps(output, indent=2))
999
+ else:
1000
+ updated = result.get("updated_fields", [])
1001
+ if updated:
1002
+ print_success(f"Updated {resolved_ticket_key}: {', '.join(updated)}")
1003
+ else:
1004
+ print_success(f"Updated {resolved_ticket_key}")
1005
+
1006
+ except JanetCLIError as e:
1007
+ if output_json:
1008
+ console.print(json.dumps({"success": False, "error": str(e)}))
1009
+ else:
1010
+ print_error(str(e))
1011
+ raise typer.Exit(1)
1012
+ except Exception as e:
1013
+ if output_json:
1014
+ console.print(json.dumps({"success": False, "error": str(e)}))
1015
+ else:
1016
+ print_error(f"Failed to update ticket: {e}")
1017
+ raise typer.Exit(1)
1018
+
1019
+
1020
+ # =============================================================================
1021
+ # Context Command (for AI agents)
1022
+ # =============================================================================
1023
+
1024
+
1025
+ @app.command(name="context", rich_help_panel="Syncing")
1026
+ def context(
1027
+ output_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
1028
+ ) -> None:
1029
+ """
1030
+ Show current context (org, projects) for AI agents.
1031
+
1032
+ Use --json for machine-readable output.
1033
+ """
1034
+ try:
1035
+ from janet.api.projects import ProjectAPI
1036
+
1037
+ config = config_manager.get()
1038
+
1039
+ context_data = {
1040
+ "authenticated": config_manager.is_authenticated(),
1041
+ "user_email": config.auth.user_email if config.auth else None,
1042
+ "organization": None,
1043
+ "projects": [],
1044
+ }
1045
+
1046
+ if config_manager.is_authenticated() and config.selected_organization:
1047
+ context_data["organization"] = {
1048
+ "id": config.selected_organization.id,
1049
+ "name": config.selected_organization.name,
1050
+ "uuid": config.selected_organization.uuid,
1051
+ }
1052
+
1053
+ # Fetch projects
1054
+ if config_manager.has_organization():
1055
+ try:
1056
+ project_api = ProjectAPI(config_manager)
1057
+ projects = project_api.list_projects()
1058
+ context_data["projects"] = [
1059
+ {
1060
+ "id": p.get("id"),
1061
+ "key": p.get("project_identifier"),
1062
+ "name": p.get("project_name"),
1063
+ "ticket_count": p.get("ticket_count", 0),
1064
+ }
1065
+ for p in projects
1066
+ ]
1067
+ except Exception:
1068
+ pass # Projects fetch failed, leave empty
1069
+
1070
+ if output_json:
1071
+ console.print(json.dumps(context_data, indent=2))
1072
+ else:
1073
+ console.print("[bold]Janet CLI Context[/bold]\n")
1074
+
1075
+ if not context_data["authenticated"]:
1076
+ console.print("[yellow]Not authenticated[/yellow]")
1077
+ console.print("Run 'janet login' to authenticate")
1078
+ return
1079
+
1080
+ console.print(f"[green]✓ Authenticated[/green] as {context_data['user_email']}")
1081
+
1082
+ if context_data["organization"]:
1083
+ console.print(f"[green]✓ Organization:[/green] {context_data['organization']['name']}")
1084
+ else:
1085
+ console.print("[yellow]No organization selected[/yellow]")
1086
+ return
1087
+
1088
+ if context_data["projects"]:
1089
+ console.print(f"\n[bold]Projects ({len(context_data['projects'])}):[/bold]")
1090
+ for p in context_data["projects"]:
1091
+ console.print(f" • {p['key']:8s} - {p['name']} ({p['ticket_count']} tickets)")
1092
+ else:
1093
+ console.print("\n[dim]No projects found[/dim]")
1094
+
1095
+ except JanetCLIError as e:
1096
+ if output_json:
1097
+ console.print(json.dumps({"authenticated": False, "error": str(e)}))
1098
+ else:
1099
+ print_error(str(e))
1100
+ raise typer.Exit(1)
1101
+
1102
+
601
1103
  if __name__ == "__main__":
602
1104
  app()
janet/config/models.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import os
4
4
  from datetime import datetime
5
- from typing import Dict, Optional
5
+ from typing import Dict, List, Optional
6
6
  from pydantic import BaseModel, Field
7
7
 
8
8
 
@@ -37,6 +37,15 @@ class APIConfig(BaseModel):
37
37
  timeout: int = 30
38
38
 
39
39
 
40
+ class SyncedProject(BaseModel):
41
+ """Information about a synced project."""
42
+
43
+ id: str
44
+ project_identifier: str
45
+ project_name: str
46
+ ticket_count: int = 0
47
+
48
+
40
49
  class SyncConfig(BaseModel):
41
50
  """Sync configuration."""
42
51
 
@@ -44,6 +53,9 @@ class SyncConfig(BaseModel):
44
53
  last_sync_times: Dict[str, str] = Field(default_factory=dict)
45
54
  sync_on_init: bool = False
46
55
  batch_size: int = 50
56
+ synced_projects: List[SyncedProject] = Field(default_factory=list)
57
+ last_sync_org_id: Optional[str] = None
58
+ last_sync_total_tickets: int = 0
47
59
 
48
60
 
49
61
  class MarkdownConfig(BaseModel):