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.
- janet/__init__.py +3 -0
- janet/__main__.py +6 -0
- janet/api/__init__.py +0 -0
- janet/api/client.py +128 -0
- janet/api/models.py +92 -0
- janet/api/organizations.py +57 -0
- janet/api/projects.py +57 -0
- janet/api/tickets.py +125 -0
- janet/auth/__init__.py +0 -0
- janet/auth/callback_server.py +360 -0
- janet/auth/oauth_flow.py +276 -0
- janet/auth/token_manager.py +92 -0
- janet/cli.py +602 -0
- janet/config/__init__.py +0 -0
- janet/config/manager.py +116 -0
- janet/config/models.py +66 -0
- janet/markdown/__init__.py +0 -0
- janet/markdown/generator.py +272 -0
- janet/markdown/yjs_converter.py +225 -0
- janet/sync/__init__.py +0 -0
- janet/sync/file_manager.py +199 -0
- janet/sync/readme_generator.py +174 -0
- janet/sync/sync_engine.py +271 -0
- janet/utils/__init__.py +0 -0
- janet/utils/console.py +39 -0
- janet/utils/errors.py +49 -0
- janet/utils/paths.py +66 -0
- janet_cli-0.2.2.dist-info/METADATA +220 -0
- janet_cli-0.2.2.dist-info/RECORD +33 -0
- janet_cli-0.2.2.dist-info/WHEEL +5 -0
- janet_cli-0.2.2.dist-info/entry_points.txt +2 -0
- janet_cli-0.2.2.dist-info/licenses/LICENSE +21 -0
- janet_cli-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -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()
|
janet/config/__init__.py
ADDED
|
File without changes
|