deepctl-core 0.1.0__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.
- deepctl_core/__init__.py +49 -0
- deepctl_core/auth.py +642 -0
- deepctl_core/base_command.py +331 -0
- deepctl_core/base_group_command.py +187 -0
- deepctl_core/client.py +325 -0
- deepctl_core/config.py +292 -0
- deepctl_core/models.py +44 -0
- deepctl_core/output.py +461 -0
- deepctl_core/plugin_manager.py +408 -0
- deepctl_core/py.typed +1 -0
- deepctl_core-0.1.0.dist-info/METADATA +35 -0
- deepctl_core-0.1.0.dist-info/RECORD +14 -0
- deepctl_core-0.1.0.dist-info/WHEEL +5 -0
- deepctl_core-0.1.0.dist-info/top_level.txt +1 -0
deepctl_core/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Core components for deepctl."""
|
|
2
|
+
|
|
3
|
+
from .auth import AuthenticationError, AuthManager
|
|
4
|
+
from .base_command import BaseCommand
|
|
5
|
+
from .base_group_command import BaseGroupCommand
|
|
6
|
+
from .client import DeepgramClient
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .models import (
|
|
9
|
+
BaseResult,
|
|
10
|
+
ErrorResult,
|
|
11
|
+
PluginInfo,
|
|
12
|
+
ProfileInfo,
|
|
13
|
+
ProfilesResult,
|
|
14
|
+
)
|
|
15
|
+
from .output import (
|
|
16
|
+
OutputFormatter,
|
|
17
|
+
get_console,
|
|
18
|
+
print_error,
|
|
19
|
+
print_info,
|
|
20
|
+
print_output,
|
|
21
|
+
print_success,
|
|
22
|
+
print_warning,
|
|
23
|
+
setup_output,
|
|
24
|
+
)
|
|
25
|
+
from .plugin_manager import PluginManager
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AuthManager",
|
|
29
|
+
"AuthenticationError",
|
|
30
|
+
"BaseCommand",
|
|
31
|
+
"BaseGroupCommand",
|
|
32
|
+
"BaseResult",
|
|
33
|
+
"Config",
|
|
34
|
+
"DeepgramClient",
|
|
35
|
+
"ErrorResult",
|
|
36
|
+
"OutputFormatter",
|
|
37
|
+
"PluginInfo",
|
|
38
|
+
"PluginManager",
|
|
39
|
+
"ProfileInfo",
|
|
40
|
+
"ProfilesResult",
|
|
41
|
+
"get_console",
|
|
42
|
+
"print_error",
|
|
43
|
+
"print_info",
|
|
44
|
+
"print_output",
|
|
45
|
+
"print_success",
|
|
46
|
+
"print_warning",
|
|
47
|
+
# Output utilities
|
|
48
|
+
"setup_output",
|
|
49
|
+
]
|
deepctl_core/auth.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""Cross-platform authentication system for deepctl based on the Go CLI
|
|
2
|
+
implementation."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import string
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import keyring
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
16
|
+
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .models import ProfileInfo, ProfilesResult
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
# Constants from Go implementation
|
|
23
|
+
COMMUNITY_BASE_URL = os.getenv(
|
|
24
|
+
"DEEPGRAM_CLI_BASE_URL", "https://community.deepgram.com"
|
|
25
|
+
)
|
|
26
|
+
DEVICE_CODE_URL = f"{COMMUNITY_BASE_URL}/api/auth/device/code"
|
|
27
|
+
TOKEN_POLL_URL = f"{COMMUNITY_BASE_URL}/api/auth/device/token"
|
|
28
|
+
|
|
29
|
+
# Keyring service identifier using reverse domain notation
|
|
30
|
+
KEYRING_SERVICE = "com.deepgram.dx.deepctl"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DeviceCodeResponse(BaseModel):
|
|
34
|
+
"""Response from device code request."""
|
|
35
|
+
|
|
36
|
+
device_code: str
|
|
37
|
+
user_code: str | None = None # Not used in current implementation
|
|
38
|
+
verification_uri: str
|
|
39
|
+
expires_in: int
|
|
40
|
+
interval: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TokenResponse(BaseModel):
|
|
44
|
+
"""Response from token request."""
|
|
45
|
+
|
|
46
|
+
access_token: str
|
|
47
|
+
project_id: str
|
|
48
|
+
token_type: str | None = None
|
|
49
|
+
expires_in: int | None = None
|
|
50
|
+
scope: str | None = None
|
|
51
|
+
|
|
52
|
+
# The access_token returned is the actual Deepgram API key
|
|
53
|
+
@property
|
|
54
|
+
def api_key(self) -> str:
|
|
55
|
+
"""Get the API key from the response."""
|
|
56
|
+
# The community server returns the actual API key in the
|
|
57
|
+
# access_token field
|
|
58
|
+
return self.access_token
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuthenticationError(Exception):
|
|
62
|
+
"""Authentication related errors."""
|
|
63
|
+
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AuthManager:
|
|
68
|
+
"""Cross-platform authentication manager."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, config: Config):
|
|
71
|
+
"""Initialize authentication manager.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
config: Configuration manager instance
|
|
75
|
+
"""
|
|
76
|
+
self.config = config
|
|
77
|
+
# Disable SSL verification for local development
|
|
78
|
+
verify = not COMMUNITY_BASE_URL.startswith("https://community-local")
|
|
79
|
+
self.client = httpx.Client(timeout=30.0, verify=verify)
|
|
80
|
+
|
|
81
|
+
def is_authenticated(self) -> bool:
|
|
82
|
+
"""Check if user is authenticated."""
|
|
83
|
+
# Check for API key in environment
|
|
84
|
+
if os.getenv("DEEPGRAM_API_KEY"):
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
# Check keyring
|
|
88
|
+
try:
|
|
89
|
+
profile_name = self.config.profile or "default"
|
|
90
|
+
api_key = keyring.get_password(
|
|
91
|
+
KEYRING_SERVICE, f"api-key.{profile_name}"
|
|
92
|
+
)
|
|
93
|
+
if api_key:
|
|
94
|
+
return True
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# Check for API key in config (backward compatibility)
|
|
99
|
+
current_profile = self.config.get_profile()
|
|
100
|
+
return bool(current_profile.api_key)
|
|
101
|
+
|
|
102
|
+
def is_ci_mode(self) -> bool:
|
|
103
|
+
"""Check if running in CI mode (credentials from environment)."""
|
|
104
|
+
# If both API key and project ID are provided via environment,
|
|
105
|
+
# we're in CI mode
|
|
106
|
+
return bool(
|
|
107
|
+
os.getenv("DEEPGRAM_API_KEY") and os.getenv("DEEPGRAM_PROJECT_ID")
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def get_api_key(self) -> str | None:
|
|
111
|
+
"""Get API key from keyring, then environment, then config."""
|
|
112
|
+
# Environment variable takes precedence (CI-friendly)
|
|
113
|
+
api_key = os.getenv("DEEPGRAM_API_KEY")
|
|
114
|
+
if api_key:
|
|
115
|
+
return api_key
|
|
116
|
+
|
|
117
|
+
# Check keyring next
|
|
118
|
+
try:
|
|
119
|
+
api_key = keyring.get_password(
|
|
120
|
+
KEYRING_SERVICE, f"api-key.{self.config.profile or 'default'}"
|
|
121
|
+
)
|
|
122
|
+
if api_key:
|
|
123
|
+
return api_key
|
|
124
|
+
except Exception:
|
|
125
|
+
pass # Keyring not available or error
|
|
126
|
+
|
|
127
|
+
# Fall back to config file (for backward compatibility)
|
|
128
|
+
current_profile = self.config.get_profile()
|
|
129
|
+
if current_profile.api_key:
|
|
130
|
+
return current_profile.api_key
|
|
131
|
+
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def get_project_id(self) -> str | None:
|
|
135
|
+
"""Get project ID from environment or config."""
|
|
136
|
+
# Environment variable takes precedence (CI-friendly)
|
|
137
|
+
project_id = os.getenv("DEEPGRAM_PROJECT_ID")
|
|
138
|
+
if project_id:
|
|
139
|
+
return project_id
|
|
140
|
+
|
|
141
|
+
# Check keyring for project ID (if stored there)
|
|
142
|
+
try:
|
|
143
|
+
project_id = keyring.get_password(
|
|
144
|
+
KEYRING_SERVICE,
|
|
145
|
+
f"project-id.{self.config.profile or 'default'}",
|
|
146
|
+
)
|
|
147
|
+
if project_id:
|
|
148
|
+
return project_id
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Check current profile config
|
|
153
|
+
current_profile = self.config.get_profile()
|
|
154
|
+
if current_profile.project_id:
|
|
155
|
+
return current_profile.project_id
|
|
156
|
+
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def verify_credentials(
|
|
160
|
+
self, api_key: str | None = None, project_id: str | None = None
|
|
161
|
+
) -> tuple[bool, str, str | None]:
|
|
162
|
+
"""Verify API key and project ID by making a request to the
|
|
163
|
+
Deepgram API.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
api_key: API key to verify (uses stored key if not provided)
|
|
167
|
+
project_id: Project ID to verify (uses stored ID if not provided)
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Tuple of (success, message, error_type)
|
|
171
|
+
- success: True if credentials are valid
|
|
172
|
+
- message: Human-readable message about the result
|
|
173
|
+
- error_type: 'auth' for API key issues, 'project' for project ID
|
|
174
|
+
issues, None if successful
|
|
175
|
+
"""
|
|
176
|
+
# Use provided credentials or get from storage
|
|
177
|
+
if not api_key:
|
|
178
|
+
api_key = self.get_api_key()
|
|
179
|
+
if not project_id:
|
|
180
|
+
project_id = self.get_project_id()
|
|
181
|
+
|
|
182
|
+
# Check if we have required credentials
|
|
183
|
+
if not api_key:
|
|
184
|
+
return False, "No API key provided or stored", "auth"
|
|
185
|
+
|
|
186
|
+
if not project_id:
|
|
187
|
+
return False, "No project ID provided or stored", "project"
|
|
188
|
+
|
|
189
|
+
# Make API request to verify credentials
|
|
190
|
+
try:
|
|
191
|
+
headers = {
|
|
192
|
+
"Authorization": f"Token {api_key}",
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# Make request to get project details
|
|
197
|
+
response = self.client.get(
|
|
198
|
+
f"https://api.deepgram.com/v1/projects/{project_id}",
|
|
199
|
+
headers=headers,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if response.status_code == 200:
|
|
203
|
+
return True, "Credentials verified successfully", None
|
|
204
|
+
elif response.status_code == 401:
|
|
205
|
+
return False, "Invalid API key - authentication failed", "auth"
|
|
206
|
+
elif response.status_code == 403:
|
|
207
|
+
return (
|
|
208
|
+
False,
|
|
209
|
+
"API key is valid but lacks permission for this project",
|
|
210
|
+
"auth",
|
|
211
|
+
)
|
|
212
|
+
elif response.status_code == 404:
|
|
213
|
+
return False, f"Project ID '{project_id}' not found", "project"
|
|
214
|
+
else:
|
|
215
|
+
return (
|
|
216
|
+
False,
|
|
217
|
+
f"Unexpected error: HTTP {response.status_code}",
|
|
218
|
+
"unknown",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
except httpx.RequestError as e:
|
|
222
|
+
return False, f"Network error during verification: {e}", "network"
|
|
223
|
+
except Exception as e:
|
|
224
|
+
return (
|
|
225
|
+
False,
|
|
226
|
+
f"Unexpected error during verification: {e}",
|
|
227
|
+
"unknown",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def guard(self) -> None:
|
|
231
|
+
"""Guard function to ensure authentication (replicated from Go
|
|
232
|
+
implementation)."""
|
|
233
|
+
api_key = self.get_api_key()
|
|
234
|
+
|
|
235
|
+
if not api_key:
|
|
236
|
+
console.print(
|
|
237
|
+
"[red]Error:[/red] DEEPGRAM_API_KEY is not set in the "
|
|
238
|
+
"configuration file "
|
|
239
|
+
f"({self.config.config_path}) or environment variable.\n"
|
|
240
|
+
)
|
|
241
|
+
console.print(
|
|
242
|
+
"[yellow]Run[/yellow] [bold]deepctl login[/bold] "
|
|
243
|
+
"[yellow]to configure the CLI with your Deepgram "
|
|
244
|
+
"account.[/yellow]\n"
|
|
245
|
+
)
|
|
246
|
+
raise AuthenticationError("DEEPGRAM_API_KEY is not set")
|
|
247
|
+
|
|
248
|
+
# Verify credentials before proceeding
|
|
249
|
+
success, message, error_type = self.verify_credentials()
|
|
250
|
+
if not success:
|
|
251
|
+
console.print(f"[red]Error:[/red] {message}")
|
|
252
|
+
|
|
253
|
+
if error_type == "auth":
|
|
254
|
+
console.print(
|
|
255
|
+
"[yellow]Your API key may have expired or been "
|
|
256
|
+
"revoked.[/yellow]\n"
|
|
257
|
+
"[yellow]Run[/yellow] [bold]deepctl login[/bold] "
|
|
258
|
+
"[yellow]to re-authenticate.[/yellow]"
|
|
259
|
+
)
|
|
260
|
+
raise AuthenticationError(message)
|
|
261
|
+
elif error_type == "project":
|
|
262
|
+
console.print(
|
|
263
|
+
"[yellow]The project may have been deleted or you may "
|
|
264
|
+
"need to specify a different project.[/yellow]\n"
|
|
265
|
+
"[yellow]Run[/yellow] [bold]deepctl login --project-id "
|
|
266
|
+
"<project_id>[/bold] [yellow]to set a valid project "
|
|
267
|
+
"ID.[/yellow]"
|
|
268
|
+
)
|
|
269
|
+
raise AuthenticationError(message)
|
|
270
|
+
else:
|
|
271
|
+
raise AuthenticationError(message)
|
|
272
|
+
|
|
273
|
+
def login_with_api_key(
|
|
274
|
+
self, api_key: str, project_id: str, _force_write: bool = False
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Login with API key directly (CI-friendly method).
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
api_key: Deepgram API key
|
|
280
|
+
project_id: Deepgram project ID
|
|
281
|
+
force_write: Skip confirmation prompts
|
|
282
|
+
"""
|
|
283
|
+
# Validate API key format (basic check)
|
|
284
|
+
if not api_key.startswith(("sk-", "pk-")):
|
|
285
|
+
console.print(
|
|
286
|
+
"[red]Warning:[/red] API key format doesn't match expected "
|
|
287
|
+
"pattern"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Verify credentials before storing
|
|
291
|
+
console.print("[dim]Verifying credentials...[/dim]")
|
|
292
|
+
success, message, error_type = self.verify_credentials(
|
|
293
|
+
api_key, project_id
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if not success:
|
|
297
|
+
console.print(f"[red]Error:[/red] {message}")
|
|
298
|
+
if error_type == "auth":
|
|
299
|
+
raise AuthenticationError(
|
|
300
|
+
f"API key verification failed: {message}"
|
|
301
|
+
)
|
|
302
|
+
elif error_type == "project":
|
|
303
|
+
raise AuthenticationError(
|
|
304
|
+
f"Project ID verification failed: {message}"
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
raise AuthenticationError(
|
|
308
|
+
f"Credential verification failed: {message}"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
console.print(f"[green]✓[/green] {message}")
|
|
312
|
+
|
|
313
|
+
# Store API key in keyring for security
|
|
314
|
+
profile_name = self.config.profile or "default"
|
|
315
|
+
keyring_available = False
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
keyring.set_password(
|
|
319
|
+
KEYRING_SERVICE, f"api-key.{profile_name}", api_key
|
|
320
|
+
)
|
|
321
|
+
if project_id:
|
|
322
|
+
keyring.set_password(
|
|
323
|
+
KEYRING_SERVICE, f"project-id.{profile_name}", project_id
|
|
324
|
+
)
|
|
325
|
+
console.print(
|
|
326
|
+
"[green]✓[/green] Credentials stored securely in system "
|
|
327
|
+
"keyring"
|
|
328
|
+
)
|
|
329
|
+
keyring_available = True
|
|
330
|
+
except Exception as e:
|
|
331
|
+
console.print(
|
|
332
|
+
f"[yellow]Warning:[/yellow] Could not store in keyring: {e}"
|
|
333
|
+
)
|
|
334
|
+
console.print("Credentials will be stored in config file instead")
|
|
335
|
+
|
|
336
|
+
# Update config with non-sensitive data
|
|
337
|
+
# Only store API key in config if keyring is not available
|
|
338
|
+
self.config.create_profile(
|
|
339
|
+
profile_name,
|
|
340
|
+
api_key=api_key if not keyring_available else None,
|
|
341
|
+
project_id=project_id,
|
|
342
|
+
base_url=self.config.get_profile(profile_name).base_url,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
console.print("[green]✓[/green] Successfully logged in with API key")
|
|
346
|
+
console.print(f"[dim]Profile:[/dim] {profile_name}")
|
|
347
|
+
|
|
348
|
+
if project_id:
|
|
349
|
+
console.print(f"[dim]Project ID:[/dim] {project_id}")
|
|
350
|
+
|
|
351
|
+
def _generate_client_id(self, length: int = 40) -> str:
|
|
352
|
+
"""Generate a random client ID for device flow.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
length: Length of the client ID
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Random URL-safe string
|
|
359
|
+
"""
|
|
360
|
+
# URL-friendly characters matching Go implementation
|
|
361
|
+
url_friendly_chars = string.ascii_letters + string.digits + "-._~"
|
|
362
|
+
return "".join(
|
|
363
|
+
random.choice(url_friendly_chars) for _ in range(length)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def login_with_device_flow(self) -> None:
|
|
367
|
+
"""Login using device flow (interactive method)."""
|
|
368
|
+
console.print("[blue]Starting device flow authentication...[/blue]")
|
|
369
|
+
|
|
370
|
+
# Check if already authenticated
|
|
371
|
+
if self.is_authenticated():
|
|
372
|
+
console.print("[yellow]You're already logged in.[/yellow]")
|
|
373
|
+
if (
|
|
374
|
+
not console.input("Do you want to login again? [y/N]: ")
|
|
375
|
+
.lower()
|
|
376
|
+
.startswith("y")
|
|
377
|
+
):
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
# Get hostname for device identification
|
|
382
|
+
hostname = (
|
|
383
|
+
os.uname().nodename if hasattr(os, "uname") else "unknown"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Request device code (returns device code response and client_id)
|
|
387
|
+
device_response, client_id = self._request_device_code()
|
|
388
|
+
|
|
389
|
+
# Build verification URI with query parameters like Go
|
|
390
|
+
# implementation
|
|
391
|
+
query_params = {
|
|
392
|
+
"device_code": device_response.device_code,
|
|
393
|
+
"client_id": client_id,
|
|
394
|
+
"hostname": hostname,
|
|
395
|
+
}
|
|
396
|
+
verification_uri = (
|
|
397
|
+
f"{device_response.verification_uri}?{urlencode(query_params)}"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Display prompt message like Go implementation
|
|
401
|
+
console.print(
|
|
402
|
+
"\n[bold]Hello from Deepgram![/bold] Press Enter to open "
|
|
403
|
+
"browser and login automatically."
|
|
404
|
+
)
|
|
405
|
+
console.print(
|
|
406
|
+
f"[dim]Here is your login link in case browser did not "
|
|
407
|
+
f"open:[/dim] [dim]{verification_uri}[/dim]\n"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Wait for Enter key
|
|
411
|
+
console.input()
|
|
412
|
+
|
|
413
|
+
# Open browser
|
|
414
|
+
try:
|
|
415
|
+
webbrowser.open(verification_uri)
|
|
416
|
+
console.print(
|
|
417
|
+
"[green]✓[/green] Opened browser for authentication"
|
|
418
|
+
)
|
|
419
|
+
except Exception as e:
|
|
420
|
+
console.print(
|
|
421
|
+
f"[yellow]Warning:[/yellow] Could not open browser: {e}"
|
|
422
|
+
)
|
|
423
|
+
console.print(
|
|
424
|
+
"Please manually navigate to the verification URL above"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Poll for token
|
|
428
|
+
token_response = self._poll_for_token(
|
|
429
|
+
device_response, client_id, hostname
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Store token and get user info
|
|
433
|
+
self._store_token(token_response)
|
|
434
|
+
|
|
435
|
+
console.print(
|
|
436
|
+
"\n[green]Key created and stored successfully.[/green]"
|
|
437
|
+
)
|
|
438
|
+
console.print("\nYou are now logged in. Happy coding!")
|
|
439
|
+
|
|
440
|
+
except Exception as e:
|
|
441
|
+
console.print(f"[red]Error during device flow:[/red] {e}")
|
|
442
|
+
raise AuthenticationError(f"Device flow failed: {e}")
|
|
443
|
+
|
|
444
|
+
def _request_device_code(self) -> tuple[DeviceCodeResponse, str]:
|
|
445
|
+
"""Request device code from community site.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Tuple of (DeviceCodeResponse, client_id)
|
|
449
|
+
"""
|
|
450
|
+
# Get hostname info (like Go implementation)
|
|
451
|
+
hostname = os.uname().nodename if hasattr(os, "uname") else "unknown"
|
|
452
|
+
|
|
453
|
+
# Generate random client ID like Go implementation
|
|
454
|
+
client_id = self._generate_client_id(40)
|
|
455
|
+
|
|
456
|
+
payload = {
|
|
457
|
+
"client_id": client_id,
|
|
458
|
+
"hostname": hostname,
|
|
459
|
+
# Full scopes needed for CLI
|
|
460
|
+
"scopes": ["admin"],
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
response = self.client.post(
|
|
465
|
+
DEVICE_CODE_URL,
|
|
466
|
+
json=payload,
|
|
467
|
+
headers={"Content-Type": "application/json"},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
if response.status_code == 201:
|
|
471
|
+
return DeviceCodeResponse(**response.json()), client_id
|
|
472
|
+
else:
|
|
473
|
+
raise AuthenticationError(
|
|
474
|
+
f"Device code request failed: {response.status_code}"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
except httpx.RequestError as e:
|
|
478
|
+
raise AuthenticationError(
|
|
479
|
+
f"Network error during device code request: {e}"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def _poll_for_token(
|
|
483
|
+
self,
|
|
484
|
+
device_response: DeviceCodeResponse,
|
|
485
|
+
client_id: str,
|
|
486
|
+
hostname: str,
|
|
487
|
+
) -> TokenResponse:
|
|
488
|
+
"""Poll for token using device code."""
|
|
489
|
+
console.print("\n[blue]Waiting for authentication...[/blue]")
|
|
490
|
+
|
|
491
|
+
start_time = time.time()
|
|
492
|
+
|
|
493
|
+
# Build query parameters like Go implementation
|
|
494
|
+
query_params = {
|
|
495
|
+
"device_code": device_response.device_code,
|
|
496
|
+
"client_id": client_id,
|
|
497
|
+
"hostname": hostname,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
poll_url = f"{TOKEN_POLL_URL}?{urlencode(query_params)}"
|
|
501
|
+
|
|
502
|
+
with Progress(
|
|
503
|
+
SpinnerColumn(),
|
|
504
|
+
TextColumn("[progress.description]{task.description}"),
|
|
505
|
+
console=console,
|
|
506
|
+
transient=True,
|
|
507
|
+
) as progress:
|
|
508
|
+
progress.add_task("Waiting for authentication...", total=None)
|
|
509
|
+
|
|
510
|
+
while time.time() - start_time < device_response.expires_in:
|
|
511
|
+
try:
|
|
512
|
+
# Use GET request like Go implementation
|
|
513
|
+
response = self.client.get(poll_url)
|
|
514
|
+
|
|
515
|
+
if response.status_code == 201:
|
|
516
|
+
response_data = response.json()
|
|
517
|
+
return TokenResponse(**response_data)
|
|
518
|
+
elif response.status_code == 404:
|
|
519
|
+
# Still pending - this is the expected status code from
|
|
520
|
+
# Go implementation
|
|
521
|
+
time.sleep(device_response.interval)
|
|
522
|
+
continue
|
|
523
|
+
else:
|
|
524
|
+
error_data = response.json()
|
|
525
|
+
raise AuthenticationError(
|
|
526
|
+
f"Token request failed: "
|
|
527
|
+
f"{error_data.get('error', 'Unknown error')}"
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
except httpx.RequestError as e:
|
|
531
|
+
console.print(f"[red]Network error:[/red] {e}")
|
|
532
|
+
time.sleep(device_response.interval)
|
|
533
|
+
continue
|
|
534
|
+
|
|
535
|
+
raise AuthenticationError("Authentication timed out")
|
|
536
|
+
|
|
537
|
+
def _store_token(self, token_response: TokenResponse) -> None:
|
|
538
|
+
"""Store authentication token."""
|
|
539
|
+
# The access_token from community site is already a Deepgram API key
|
|
540
|
+
api_key = token_response.access_token
|
|
541
|
+
project_id = token_response.project_id
|
|
542
|
+
|
|
543
|
+
profile_name = self.config.profile or "default"
|
|
544
|
+
keyring_available = False
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
keyring.set_password(
|
|
548
|
+
KEYRING_SERVICE, f"api-key.{profile_name}", api_key
|
|
549
|
+
)
|
|
550
|
+
if project_id:
|
|
551
|
+
keyring.set_password(
|
|
552
|
+
KEYRING_SERVICE, f"project-id.{profile_name}", project_id
|
|
553
|
+
)
|
|
554
|
+
console.print(
|
|
555
|
+
"[green]✓[/green] Credentials stored securely in system "
|
|
556
|
+
"keyring"
|
|
557
|
+
)
|
|
558
|
+
keyring_available = True
|
|
559
|
+
except Exception as e:
|
|
560
|
+
console.print(
|
|
561
|
+
f"[yellow]Warning:[/yellow] Could not store in keyring: {e}"
|
|
562
|
+
)
|
|
563
|
+
console.print("Credentials will be stored in config file instead")
|
|
564
|
+
|
|
565
|
+
# Update config - only store API key if keyring is not available
|
|
566
|
+
self.config.create_profile(
|
|
567
|
+
profile_name,
|
|
568
|
+
api_key=api_key if not keyring_available else None,
|
|
569
|
+
project_id=project_id,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def logout(self) -> None:
|
|
573
|
+
"""Logout user and clear credentials."""
|
|
574
|
+
profile_name = self.config.profile or "default"
|
|
575
|
+
|
|
576
|
+
# Clear keyring
|
|
577
|
+
try:
|
|
578
|
+
keyring.delete_password(KEYRING_SERVICE, f"api-key.{profile_name}")
|
|
579
|
+
keyring.delete_password(
|
|
580
|
+
KEYRING_SERVICE, f"project-id.{profile_name}"
|
|
581
|
+
)
|
|
582
|
+
console.print("[dim]Cleared credentials from system keyring[/dim]")
|
|
583
|
+
except Exception:
|
|
584
|
+
pass # Ignore errors if not stored
|
|
585
|
+
|
|
586
|
+
# Clear sensitive data from config but keep profile
|
|
587
|
+
if profile_name in self.config.list_profiles():
|
|
588
|
+
profile = self.config.get_profile(profile_name)
|
|
589
|
+
self.config.create_profile(
|
|
590
|
+
profile_name,
|
|
591
|
+
api_key=None, # Clear API key
|
|
592
|
+
project_id=profile.project_id, # Keep project ID
|
|
593
|
+
base_url=profile.base_url, # Keep base URL
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
console.print("[green]✓[/green] Successfully logged out")
|
|
597
|
+
|
|
598
|
+
def list_profiles(self) -> ProfilesResult:
|
|
599
|
+
"""Return all profiles wrapped in ProfilesResult model."""
|
|
600
|
+
profiles: dict[str, ProfileInfo] = {}
|
|
601
|
+
|
|
602
|
+
for profile_name in self.config.list_profiles():
|
|
603
|
+
profile = self.config.get_profile(profile_name)
|
|
604
|
+
|
|
605
|
+
# Try to get API key from keyring first
|
|
606
|
+
api_key = None
|
|
607
|
+
project_id = profile.project_id
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
api_key = keyring.get_password(
|
|
611
|
+
KEYRING_SERVICE, f"api-key.{profile_name}"
|
|
612
|
+
)
|
|
613
|
+
# Also check if project_id is in keyring
|
|
614
|
+
keyring_project_id = keyring.get_password(
|
|
615
|
+
KEYRING_SERVICE, f"project-id.{profile_name}"
|
|
616
|
+
)
|
|
617
|
+
if keyring_project_id:
|
|
618
|
+
project_id = keyring_project_id
|
|
619
|
+
except Exception:
|
|
620
|
+
# Fall back to config
|
|
621
|
+
api_key = profile.api_key
|
|
622
|
+
|
|
623
|
+
masked_key = None
|
|
624
|
+
if api_key:
|
|
625
|
+
masked_key = "****" + api_key[-4:]
|
|
626
|
+
|
|
627
|
+
profiles[profile_name] = ProfileInfo(
|
|
628
|
+
api_key=masked_key,
|
|
629
|
+
project_id=project_id,
|
|
630
|
+
base_url=profile.base_url,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
return ProfilesResult(
|
|
634
|
+
profiles=profiles,
|
|
635
|
+
current_profile=self.config.profile
|
|
636
|
+
or self.config._config.default_profile,
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
def __del__(self) -> None:
|
|
640
|
+
"""Cleanup HTTP client."""
|
|
641
|
+
if hasattr(self, "client"):
|
|
642
|
+
self.client.close()
|