janet-cli 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ """Token storage, validation, and refresh logic."""
2
+
3
+ from datetime import datetime, timedelta
4
+ from typing import Optional
5
+
6
+ from janet.config.manager import ConfigManager
7
+ from janet.utils.errors import AuthenticationError, TokenExpiredError
8
+
9
+
10
+ class TokenManager:
11
+ """Manages authentication tokens."""
12
+
13
+ def __init__(self, config_manager: ConfigManager):
14
+ """
15
+ Initialize token manager.
16
+
17
+ Args:
18
+ config_manager: Configuration manager instance
19
+ """
20
+ self.config_manager = config_manager
21
+
22
+ def get_access_token(self) -> str:
23
+ """
24
+ Get current access token.
25
+
26
+ Returns:
27
+ Access token string
28
+
29
+ Raises:
30
+ AuthenticationError: If not authenticated
31
+ TokenExpiredError: If token is expired
32
+ """
33
+ config = self.config_manager.get()
34
+
35
+ if not config.auth.access_token:
36
+ raise AuthenticationError("Not authenticated. Run 'janet login' first.")
37
+
38
+ if self.is_token_expired():
39
+ raise TokenExpiredError("Access token has expired. Attempting refresh...")
40
+
41
+ return config.auth.access_token
42
+
43
+ def is_token_expired(self, buffer_seconds: int = 300) -> bool:
44
+ """
45
+ Check if access token is expired or about to expire.
46
+
47
+ Args:
48
+ buffer_seconds: Consider token expired if it expires within this many seconds
49
+
50
+ Returns:
51
+ True if token is expired or about to expire
52
+ """
53
+ config = self.config_manager.get()
54
+
55
+ if not config.auth.expires_at:
56
+ # If no expiration time, assume token is valid
57
+ return False
58
+
59
+ # Check if token expires within buffer period
60
+ buffer_time = datetime.utcnow() + timedelta(seconds=buffer_seconds)
61
+ return config.auth.expires_at <= buffer_time
62
+
63
+ def clear_tokens(self) -> None:
64
+ """Clear all authentication tokens from configuration."""
65
+ config = self.config_manager.get()
66
+ config.auth.access_token = None
67
+ config.auth.refresh_token = None
68
+ config.auth.expires_at = None
69
+ config.auth.user_id = None
70
+ config.auth.user_email = None
71
+ config.selected_organization = None
72
+ self.config_manager.update(config)
73
+
74
+ def get_user_email(self) -> Optional[str]:
75
+ """
76
+ Get authenticated user's email.
77
+
78
+ Returns:
79
+ User email or None
80
+ """
81
+ config = self.config_manager.get()
82
+ return config.auth.user_email
83
+
84
+ def get_user_id(self) -> Optional[str]:
85
+ """
86
+ Get authenticated user's ID.
87
+
88
+ Returns:
89
+ User ID or None
90
+ """
91
+ config = self.config_manager.get()
92
+ return config.auth.user_id
janet/cli.py ADDED
@@ -0,0 +1,602 @@
1
+ """Main CLI application using Typer."""
2
+
3
+ import typer
4
+ from typing_extensions import Annotated
5
+
6
+ from janet import __version__
7
+ from janet.config.manager import ConfigManager
8
+ from janet.utils.console import console, print_success, print_error, print_info
9
+ from janet.utils.errors import JanetCLIError
10
+
11
+ # Initialize Typer app
12
+ app = typer.Typer(
13
+ name="janet",
14
+ help="Janet AI CLI - Sync tickets to local markdown files",
15
+ add_completion=False,
16
+ )
17
+
18
+ # Sub-commands
19
+ auth_app = typer.Typer(help="Authentication commands")
20
+ org_app = typer.Typer(help="Organization management")
21
+ project_app = typer.Typer(help="Project management")
22
+ config_app = typer.Typer(help="Configuration management")
23
+
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
+
29
+ # Initialize config manager
30
+ config_manager = ConfigManager()
31
+
32
+
33
+ def version_callback(value: bool) -> None:
34
+ """Show version and exit."""
35
+ if value:
36
+ console.print(f"Janet CLI v{__version__}")
37
+ raise typer.Exit()
38
+
39
+
40
+ @app.callback()
41
+ def main(
42
+ version: Annotated[
43
+ bool, typer.Option("--version", "-v", callback=version_callback, is_eager=True)
44
+ ] = False,
45
+ ) -> None:
46
+ """Janet AI CLI - Sync tickets to local markdown files."""
47
+ pass
48
+
49
+
50
+ # =============================================================================
51
+ # Authentication Commands
52
+ # =============================================================================
53
+
54
+
55
+ @app.command(name="login")
56
+ def login() -> None:
57
+ """Authenticate with Janet AI and select organization."""
58
+ try:
59
+ from janet.auth.oauth_flow import OAuthFlow
60
+ from janet.api.organizations import OrganizationAPI
61
+ from InquirerPy import inquirer
62
+
63
+ print_info("Starting authentication flow...")
64
+
65
+ # Start OAuth flow
66
+ oauth_flow = OAuthFlow(config_manager)
67
+ oauth_flow.start_login()
68
+
69
+ # Fetch available organizations
70
+ print_info("Fetching your organizations...")
71
+ org_api = OrganizationAPI(config_manager)
72
+ organizations = org_api.list_organizations()
73
+
74
+ if not organizations:
75
+ print_error("No organizations found for your account")
76
+ raise typer.Exit(1)
77
+
78
+ # Select organization
79
+ if len(organizations) == 1:
80
+ # Auto-select if only one org
81
+ selected_org = organizations[0]
82
+ print_success(f"Auto-selected organization: {selected_org['name']}")
83
+ else:
84
+ # Show interactive selection
85
+ console.print("\n[bold]Select an organization:[/bold]\n")
86
+
87
+ org_choices = []
88
+ for org in organizations:
89
+ role = org.get("userRole", "member")
90
+ label = f"{org['name']} ({role})"
91
+ org_choices.append({"name": label, "value": org})
92
+
93
+ selected_org = inquirer.select(
94
+ message="Select organization:",
95
+ choices=org_choices,
96
+ ).execute()
97
+
98
+ # Save selected organization
99
+ from janet.config.models import OrganizationInfo
100
+
101
+ config = config_manager.get()
102
+ config.selected_organization = OrganizationInfo(
103
+ id=selected_org["id"], name=selected_org["name"], uuid=selected_org["uuid"]
104
+ )
105
+ config_manager.update(config)
106
+
107
+ print_success(f"Selected organization: {selected_org['name']}")
108
+ console.print("\n[green]✓ Authentication complete![/green]")
109
+ console.print("Run 'janet sync' to start syncing tickets.")
110
+
111
+ except JanetCLIError as e:
112
+ print_error(str(e))
113
+ raise typer.Exit(1)
114
+
115
+
116
+ @app.command(name="logout")
117
+ def logout() -> None:
118
+ """Clear stored credentials."""
119
+ try:
120
+ config = config_manager.get()
121
+ if not config_manager.is_authenticated():
122
+ print_info("Not currently logged in")
123
+ return
124
+
125
+ # Clear authentication data
126
+ config.auth.access_token = None
127
+ config.auth.refresh_token = None
128
+ config.auth.expires_at = None
129
+ config.auth.user_id = None
130
+ config.auth.user_email = None
131
+ config.selected_organization = None
132
+
133
+ config_manager.update(config)
134
+ print_success("Logged out successfully")
135
+ except JanetCLIError as e:
136
+ print_error(str(e))
137
+ raise typer.Exit(1)
138
+
139
+
140
+ @auth_app.command(name="status")
141
+ def auth_status() -> None:
142
+ """Show current authentication status."""
143
+ try:
144
+ config = config_manager.get()
145
+
146
+ if not config_manager.is_authenticated():
147
+ console.print("[yellow]Not authenticated[/yellow]")
148
+ console.print("Run 'janet login' to authenticate")
149
+ return
150
+
151
+ console.print("[bold green]Authenticated[/bold green]")
152
+ if config.auth.user_email:
153
+ console.print(f"User: [cyan]{config.auth.user_email}[/cyan]")
154
+ if config.selected_organization:
155
+ console.print(f"Organization: [cyan]{config.selected_organization.name}[/cyan]")
156
+ console.print(f"Organization ID: [dim]{config.selected_organization.id}[/dim]")
157
+
158
+ if config.auth.expires_at:
159
+ console.print(f"Token expires: [dim]{config.auth.expires_at}[/dim]")
160
+ except JanetCLIError as e:
161
+ print_error(str(e))
162
+ raise typer.Exit(1)
163
+
164
+
165
+ # =============================================================================
166
+ # Organization Commands
167
+ # =============================================================================
168
+
169
+
170
+ @org_app.command(name="list")
171
+ def org_list() -> None:
172
+ """List available organizations."""
173
+ try:
174
+ from janet.api.organizations import OrganizationAPI
175
+ from rich.table import Table
176
+
177
+ if not config_manager.is_authenticated():
178
+ print_error("Not authenticated. Run 'janet login' first.")
179
+ raise typer.Exit(1)
180
+
181
+ print_info("Fetching organizations...")
182
+ org_api = OrganizationAPI(config_manager)
183
+ organizations = org_api.list_organizations()
184
+
185
+ if not organizations:
186
+ print_info("No organizations found")
187
+ return
188
+
189
+ # Display as table
190
+ table = Table(title="Organizations", show_header=True, header_style="bold cyan")
191
+ table.add_column("ID", style="dim")
192
+ table.add_column("Name", style="bold")
193
+ table.add_column("Role")
194
+
195
+ for org in organizations:
196
+ table.add_row(
197
+ org.get("id", ""), org.get("name", ""), org.get("userRole", "member")
198
+ )
199
+
200
+ console.print(table)
201
+ except JanetCLIError as e:
202
+ print_error(str(e))
203
+ raise typer.Exit(1)
204
+
205
+
206
+ @org_app.command(name="select")
207
+ def org_select(org_id: str) -> None:
208
+ """
209
+ Switch active organization.
210
+
211
+ Args:
212
+ org_id: Organization ID to select
213
+ """
214
+ try:
215
+ from janet.api.organizations import OrganizationAPI
216
+ from janet.config.models import OrganizationInfo
217
+
218
+ if not config_manager.is_authenticated():
219
+ print_error("Not authenticated. Run 'janet login' first.")
220
+ raise typer.Exit(1)
221
+
222
+ print_info(f"Selecting organization: {org_id}")
223
+ org_api = OrganizationAPI(config_manager)
224
+
225
+ # Fetch organization details
226
+ org_data = org_api.get_organization(org_id)
227
+
228
+ # Update config
229
+ config = config_manager.get()
230
+ config.selected_organization = OrganizationInfo(
231
+ id=org_data["id"], name=org_data["name"], uuid=org_data.get("uuid", org_id)
232
+ )
233
+ config_manager.update(config)
234
+
235
+ print_success(f"Selected organization: {org_data['name']}")
236
+ except JanetCLIError as e:
237
+ print_error(str(e))
238
+ raise typer.Exit(1)
239
+
240
+
241
+ @org_app.command(name="current")
242
+ def org_current() -> None:
243
+ """Show current organization."""
244
+ try:
245
+ config = config_manager.get()
246
+
247
+ if not config_manager.has_organization():
248
+ print_info("No organization selected")
249
+ console.print("Run 'janet org list' to see available organizations")
250
+ return
251
+
252
+ org = config.selected_organization
253
+ console.print(f"[bold]Current Organization:[/bold]")
254
+ console.print(f" Name: [cyan]{org.name}[/cyan]")
255
+ console.print(f" ID: [dim]{org.id}[/dim]")
256
+ console.print(f" UUID: [dim]{org.uuid}[/dim]")
257
+ except JanetCLIError as e:
258
+ print_error(str(e))
259
+ raise typer.Exit(1)
260
+
261
+
262
+ # =============================================================================
263
+ # Project Commands
264
+ # =============================================================================
265
+
266
+
267
+ @project_app.command(name="list")
268
+ def project_list() -> None:
269
+ """List projects in current organization."""
270
+ try:
271
+ from janet.api.projects import ProjectAPI
272
+ from rich.table import Table
273
+
274
+ if not config_manager.is_authenticated():
275
+ print_error("Not authenticated. Run 'janet login' first.")
276
+ raise typer.Exit(1)
277
+
278
+ if not config_manager.has_organization():
279
+ print_error("No organization selected. Run 'janet org select' first.")
280
+ raise typer.Exit(1)
281
+
282
+ print_info("Fetching projects...")
283
+ project_api = ProjectAPI(config_manager)
284
+ projects = project_api.list_projects()
285
+
286
+ if not projects:
287
+ print_info("No projects found")
288
+ return
289
+
290
+ # Display as table
291
+ table = Table(title="Projects", show_header=True, header_style="bold cyan")
292
+ table.add_column("Key", style="bold")
293
+ table.add_column("Name")
294
+ table.add_column("Tickets", justify="right")
295
+ table.add_column("Role")
296
+
297
+ for project in projects:
298
+ table.add_row(
299
+ project.get("project_identifier", ""),
300
+ project.get("project_name", ""),
301
+ str(project.get("ticket_count", 0)),
302
+ project.get("user_role", ""),
303
+ )
304
+
305
+ console.print(table)
306
+ except JanetCLIError as e:
307
+ print_error(str(e))
308
+ raise typer.Exit(1)
309
+
310
+
311
+ # =============================================================================
312
+ # Sync Commands
313
+ # =============================================================================
314
+
315
+
316
+ @app.command(name="sync")
317
+ def sync(
318
+ directory: Annotated[str, typer.Option("--dir", "-d", help="Sync directory")] = None,
319
+ all_projects: Annotated[bool, typer.Option("--all", help="Sync all projects")] = False,
320
+ ) -> None:
321
+ """
322
+ Sync tickets to local markdown files.
323
+
324
+ Interactive mode: prompts for project selection and directory.
325
+ """
326
+ try:
327
+ from janet.sync.sync_engine import SyncEngine
328
+ from janet.api.projects import ProjectAPI
329
+ import os
330
+
331
+ if not config_manager.is_authenticated():
332
+ print_error("Not authenticated. Run 'janet login' first.")
333
+ raise typer.Exit(1)
334
+
335
+ if not config_manager.has_organization():
336
+ print_error("No organization selected. Run 'janet org select' first.")
337
+ raise typer.Exit(1)
338
+
339
+ org_name = config_manager.get().selected_organization.name
340
+
341
+ # Step 1: Select projects to sync
342
+ console.print(f"\n[bold]Sync tickets for {org_name}[/bold]\n")
343
+
344
+ # Fetch projects
345
+ print_info("Fetching projects...")
346
+ project_api = ProjectAPI(config_manager)
347
+ all_project_list = project_api.list_projects()
348
+
349
+ if not all_project_list:
350
+ print_error("No projects found")
351
+ raise typer.Exit(1)
352
+
353
+ # Filter out projects with no tickets
354
+ available_projects = [p for p in all_project_list if p.get("ticket_count", 0) > 0]
355
+
356
+ if not available_projects:
357
+ print_info("No projects with tickets found")
358
+ return
359
+
360
+ # Show project selection
361
+ selected_projects = []
362
+
363
+ if all_projects:
364
+ # Skip selection, use all projects
365
+ selected_projects = available_projects
366
+ console.print(f"Syncing all {len(selected_projects)} projects")
367
+ else:
368
+ # Interactive project selection with checkboxes
369
+ from InquirerPy import inquirer
370
+
371
+ console.print("\n[bold]Select projects to sync:[/bold]")
372
+ console.print("[dim]Use ↑/↓ to move, SPACE to toggle selection, ENTER to confirm[/dim]\n")
373
+
374
+ # Build choices with formatted display
375
+ choices = []
376
+ for project in available_projects:
377
+ key = project.get("project_identifier", "")
378
+ name = project.get("project_name", "")
379
+ count = project.get("ticket_count", 0)
380
+ label = f"{key:8s} - {name:30s} ({count} tickets)"
381
+ choices.append({"name": label, "value": project, "enabled": True})
382
+
383
+ # Show checkbox multi-select
384
+ import sys
385
+ import os as os_module
386
+
387
+ # Temporarily suppress InquirerPy's result output
388
+ selected = inquirer.checkbox(
389
+ message="Select projects:",
390
+ choices=choices,
391
+ validate=lambda result: len(result) > 0 or "Please select at least one project",
392
+ instruction="(SPACE to toggle, ENTER to confirm)",
393
+ amark="✓",
394
+ transformer=lambda result: "", # Suppress the result display
395
+ ).execute()
396
+
397
+ if not selected:
398
+ print_info("No projects selected")
399
+ return
400
+
401
+ selected_projects = selected
402
+
403
+ # Show selected projects cleanly
404
+ console.print(f"\n[green]✓ Selected {len(selected_projects)} project(s):[/green]")
405
+ for proj in selected_projects:
406
+ key = proj.get("project_identifier", "")
407
+ name = proj.get("project_name", "")
408
+ count = proj.get("ticket_count", 0)
409
+ console.print(f" • {key} - {name} ({count} tickets)")
410
+
411
+ # Step 2: Select sync directory
412
+ if directory:
413
+ sync_dir = directory
414
+ else:
415
+ # Get current directory
416
+ current_dir = os.getcwd()
417
+ from InquirerPy import inquirer
418
+
419
+ console.print(f"\n[bold]Where should tickets be synced?[/bold]")
420
+ console.print(f"[dim]Current directory: {current_dir}[/dim]\n")
421
+
422
+ # Build directory choices
423
+ dir_choices = [
424
+ {
425
+ "name": f"Current directory ({current_dir}/janet-tickets)",
426
+ "value": os.path.join(current_dir, "janet-tickets"),
427
+ },
428
+ {
429
+ "name": "Home directory (~/janet-tickets)",
430
+ "value": "~/janet-tickets",
431
+ },
432
+ {
433
+ "name": "Custom path...",
434
+ "value": "__custom__",
435
+ },
436
+ ]
437
+
438
+ choice = inquirer.select(
439
+ message="Select sync location:",
440
+ choices=dir_choices,
441
+ ).execute()
442
+
443
+ if choice == "__custom__":
444
+ sync_dir = inquirer.filepath(
445
+ message="Enter custom path:",
446
+ default=current_dir,
447
+ validate=lambda x: len(x) > 0 or "Path cannot be empty",
448
+ ).execute()
449
+ if not sync_dir:
450
+ print_info("Sync cancelled")
451
+ return
452
+ else:
453
+ sync_dir = choice
454
+
455
+ # Expand path
456
+ from janet.utils.paths import expand_path
457
+ expanded_dir = expand_path(sync_dir)
458
+
459
+ console.print(f"\n[green]✓ Sync directory: {expanded_dir}[/green]")
460
+
461
+ # Confirm
462
+ from InquirerPy import inquirer
463
+ confirmed = inquirer.confirm(
464
+ message=f"Sync {len(selected_projects)} project(s) to {expanded_dir}?",
465
+ default=True,
466
+ ).execute()
467
+
468
+ if not confirmed:
469
+ print_info("Sync cancelled")
470
+ return
471
+
472
+ # Step 3: Start sync
473
+ console.print(f"\n[bold]Starting sync...[/bold]\n")
474
+
475
+ # Update config with new directory
476
+ config = config_manager.get()
477
+ config.sync.root_directory = str(expanded_dir)
478
+ config_manager.update(config)
479
+
480
+ # Initialize sync engine with new directory
481
+ sync_engine = SyncEngine(config_manager)
482
+
483
+ # Sync selected projects
484
+ total_tickets = 0
485
+ for project in selected_projects:
486
+ project_key = project.get("project_identifier", "")
487
+ project_name = project.get("project_name", "")
488
+
489
+ synced = sync_engine.sync_project(project["id"], project_key, project_name)
490
+ total_tickets += synced
491
+
492
+ # Generate README for AI agents
493
+ from janet.sync.readme_generator import ReadmeGenerator
494
+ readme_gen = ReadmeGenerator()
495
+ readme_path = readme_gen.write_readme(
496
+ sync_dir=expanded_dir,
497
+ org_name=org_name,
498
+ projects=selected_projects,
499
+ total_tickets=total_tickets,
500
+ )
501
+
502
+ # Show summary
503
+ console.print(f"\n[bold green]✓ Sync complete![/bold green]")
504
+ console.print(f" Projects: {len(selected_projects)}")
505
+ console.print(f" Tickets: {total_tickets}")
506
+ console.print(f"\n[cyan]Tickets saved to: {expanded_dir}[/cyan]")
507
+ console.print(f"[dim]README for AI agents: {readme_path}[/dim]")
508
+
509
+ except JanetCLIError as e:
510
+ print_error(str(e))
511
+ raise typer.Exit(1)
512
+ except Exception as e:
513
+ print_error(f"Sync failed: {e}")
514
+ raise typer.Exit(1)
515
+
516
+
517
+ # =============================================================================
518
+ # Status Command
519
+ # =============================================================================
520
+
521
+
522
+ @app.command(name="status")
523
+ def status() -> None:
524
+ """Show overall status (auth, org, last sync)."""
525
+ try:
526
+ config = config_manager.get()
527
+
528
+ console.print("[bold]Janet CLI Status[/bold]\n")
529
+
530
+ # Authentication status
531
+ if config_manager.is_authenticated():
532
+ console.print("✓ [green]Authenticated[/green]")
533
+ if config.auth.user_email:
534
+ console.print(f" User: {config.auth.user_email}")
535
+ else:
536
+ console.print("✗ [yellow]Not authenticated[/yellow]")
537
+ console.print(" Run 'janet login' to authenticate\n")
538
+ return
539
+
540
+ # Organization status
541
+ if config_manager.has_organization():
542
+ console.print(f"✓ [green]Organization selected: {config.selected_organization.name}[/green]")
543
+ else:
544
+ console.print("✗ [yellow]No organization selected[/yellow]")
545
+ console.print(" Run 'janet org list' to select an organization\n")
546
+ return
547
+
548
+ # Sync status
549
+ console.print(f"\n[bold]Sync Directory:[/bold] {config.sync.root_directory}")
550
+ if config.sync.last_sync_times:
551
+ console.print(f"[bold]Last Synced Projects:[/bold] {len(config.sync.last_sync_times)}")
552
+ else:
553
+ console.print("[dim]No projects synced yet[/dim]")
554
+ except JanetCLIError as e:
555
+ print_error(str(e))
556
+ raise typer.Exit(1)
557
+
558
+
559
+ # =============================================================================
560
+ # Config Commands
561
+ # =============================================================================
562
+
563
+
564
+ @config_app.command(name="show")
565
+ def config_show() -> None:
566
+ """Display current configuration."""
567
+ try:
568
+ config = config_manager.get()
569
+ console.print_json(config.model_dump_json(indent=2))
570
+ except JanetCLIError as e:
571
+ print_error(str(e))
572
+ raise typer.Exit(1)
573
+
574
+
575
+ @config_app.command(name="path")
576
+ def config_path() -> None:
577
+ """Show config file location."""
578
+ console.print(f"Config file: [cyan]{config_manager.config_path}[/cyan]")
579
+
580
+
581
+ @config_app.command(name="reset")
582
+ def config_reset(
583
+ confirm: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
584
+ ) -> None:
585
+ """Reset configuration to defaults."""
586
+ try:
587
+ if not confirm:
588
+ console.print("[yellow]This will reset all configuration to defaults.[/yellow]")
589
+ confirmed = typer.confirm("Are you sure?")
590
+ if not confirmed:
591
+ print_info("Reset cancelled")
592
+ return
593
+
594
+ config_manager.reset()
595
+ print_success("Configuration reset to defaults")
596
+ except JanetCLIError as e:
597
+ print_error(str(e))
598
+ raise typer.Exit(1)
599
+
600
+
601
+ if __name__ == "__main__":
602
+ app()
File without changes