gdmcode 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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
src/auth.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Credential management for gdm — keychain storage + interactive login.
|
|
2
|
+
|
|
3
|
+
Providers:
|
|
4
|
+
- xAI Grok: API key (no OAuth, just a string)
|
|
5
|
+
- Google Gemini: API key OR OAuth device flow (RFC 8628)
|
|
6
|
+
- OpenAI Codex: API key
|
|
7
|
+
|
|
8
|
+
Storage priority (highest → lowest):
|
|
9
|
+
1. Environment variables (XAI_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY)
|
|
10
|
+
2. System keychain (via `keyring` — macOS Keychain, Windows Credential Store, etc.)
|
|
11
|
+
3. ~/.config/gdm/config.toml [api] section
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from src.auth import CredentialStore
|
|
16
|
+
store = CredentialStore()
|
|
17
|
+
store.set("grok", "xai-abc123")
|
|
18
|
+
key = store.get("grok") # "xai-abc123"
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from src.exceptions import ConfigError
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CredentialStore",
|
|
36
|
+
"GdmCredentials",
|
|
37
|
+
"login_interactive",
|
|
38
|
+
"logout",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
_KEYRING_SERVICE = "gdm-code"
|
|
44
|
+
_KEYRING_KEYS: dict[str, str] = {
|
|
45
|
+
"grok": "xai_api_key",
|
|
46
|
+
"gemini": "gemini_api_key",
|
|
47
|
+
"gemini_refresh": "gemini_refresh_token",
|
|
48
|
+
"codex": "openai_api_key",
|
|
49
|
+
"proxy": "proxy_token",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Gemini OAuth constants (bring-your-own GCP project recommended for now).
|
|
53
|
+
# Users can set GDM_GOOGLE_CLIENT_ID / GDM_GOOGLE_CLIENT_SECRET env vars.
|
|
54
|
+
_GEMINI_AUTH_URI = "https://accounts.google.com/o/oauth2/device/code"
|
|
55
|
+
_GEMINI_TOKEN_URI = "https://oauth2.googleapis.com/token"
|
|
56
|
+
_GEMINI_SCOPE = "https://www.googleapis.com/auth/generative-language"
|
|
57
|
+
_GEMINI_DEVICE_POLL_INTERVAL = 5 # seconds between polls
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# GdmCredentials
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class GdmCredentials:
|
|
66
|
+
"""All resolved credentials for a session."""
|
|
67
|
+
|
|
68
|
+
xai_api_key: str | None = None
|
|
69
|
+
gemini_api_key: str | None = None
|
|
70
|
+
openai_api_key: str | None = None
|
|
71
|
+
proxy_token: str | None = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def has_grok(self) -> bool:
|
|
75
|
+
return bool(self.xai_api_key)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def has_gemini(self) -> bool:
|
|
79
|
+
return bool(self.gemini_api_key)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def has_codex(self) -> bool:
|
|
83
|
+
return bool(self.openai_api_key)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def any_key(self) -> bool:
|
|
87
|
+
return self.has_grok or self.has_gemini or self.has_codex
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# CredentialStore
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
class CredentialStore:
|
|
95
|
+
"""Unified credential store with keychain backend.
|
|
96
|
+
|
|
97
|
+
Falls back gracefully if `keyring` is not installed — writes/reads from
|
|
98
|
+
~/.config/gdm/.credentials (plain text, user-readable only).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
self._keyring_available = _check_keyring()
|
|
103
|
+
self._fallback_path = Path.home() / ".config" / "gdm" / ".credentials"
|
|
104
|
+
|
|
105
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def set(self, provider: str, value: str) -> None:
|
|
108
|
+
"""Persist a credential for `provider`."""
|
|
109
|
+
key = _KEYRING_KEYS.get(provider, provider)
|
|
110
|
+
if self._keyring_available:
|
|
111
|
+
import keyring # type: ignore[import]
|
|
112
|
+
keyring.set_password(_KEYRING_SERVICE, key, value)
|
|
113
|
+
log.debug("Stored %r in system keychain", key)
|
|
114
|
+
else:
|
|
115
|
+
self._set_fallback(key, value)
|
|
116
|
+
log.debug("Stored %r in fallback credentials file", key)
|
|
117
|
+
|
|
118
|
+
def get(self, provider: str) -> str | None:
|
|
119
|
+
"""Retrieve a credential for `provider`, or None if not found."""
|
|
120
|
+
key = _KEYRING_KEYS.get(provider, provider)
|
|
121
|
+
if self._keyring_available:
|
|
122
|
+
import keyring # type: ignore[import]
|
|
123
|
+
try:
|
|
124
|
+
return keyring.get_password(_KEYRING_SERVICE, key) or None
|
|
125
|
+
except Exception as exc: # noqa: BLE001
|
|
126
|
+
log.warning("keyring.get_password failed: %s", exc)
|
|
127
|
+
return self._get_fallback(key)
|
|
128
|
+
|
|
129
|
+
def delete(self, provider: str) -> None:
|
|
130
|
+
"""Remove a stored credential."""
|
|
131
|
+
key = _KEYRING_KEYS.get(provider, provider)
|
|
132
|
+
if self._keyring_available:
|
|
133
|
+
import keyring # type: ignore[import]
|
|
134
|
+
try:
|
|
135
|
+
keyring.delete_password(_KEYRING_SERVICE, key)
|
|
136
|
+
except Exception: # noqa: BLE001
|
|
137
|
+
pass
|
|
138
|
+
self._delete_fallback(key)
|
|
139
|
+
|
|
140
|
+
def load_all(self) -> GdmCredentials:
|
|
141
|
+
"""Return all credentials, checking env vars first, then keychain."""
|
|
142
|
+
return GdmCredentials(
|
|
143
|
+
xai_api_key=(
|
|
144
|
+
os.environ.get("XAI_API_KEY")
|
|
145
|
+
or self.get("grok")
|
|
146
|
+
or None
|
|
147
|
+
),
|
|
148
|
+
gemini_api_key=(
|
|
149
|
+
os.environ.get("GEMINI_API_KEY")
|
|
150
|
+
or self.get("gemini")
|
|
151
|
+
or None
|
|
152
|
+
),
|
|
153
|
+
openai_api_key=(
|
|
154
|
+
os.environ.get("OPENAI_API_KEY")
|
|
155
|
+
or self.get("codex")
|
|
156
|
+
or None
|
|
157
|
+
),
|
|
158
|
+
proxy_token=(
|
|
159
|
+
os.environ.get("GDM_PROXY_TOKEN")
|
|
160
|
+
or self.get("proxy")
|
|
161
|
+
or None
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# ── Fallback file (when keyring not available) ────────────────────────
|
|
166
|
+
|
|
167
|
+
def _set_fallback(self, key: str, value: str) -> None:
|
|
168
|
+
self._fallback_path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
data = self._read_fallback_file()
|
|
170
|
+
data[key] = value
|
|
171
|
+
lines = "\n".join(f"{k}={v}" for k, v in data.items())
|
|
172
|
+
self._fallback_path.write_text(lines, encoding="utf-8")
|
|
173
|
+
# Restrict permissions to owner-only on POSIX.
|
|
174
|
+
try:
|
|
175
|
+
os.chmod(self._fallback_path, 0o600)
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
log.warning(
|
|
178
|
+
"chmod failed for credential file %s: %s — file may be world-readable",
|
|
179
|
+
self._fallback_path,
|
|
180
|
+
exc,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _get_fallback(self, key: str) -> str | None:
|
|
184
|
+
data = self._read_fallback_file()
|
|
185
|
+
return data.get(key)
|
|
186
|
+
|
|
187
|
+
def _delete_fallback(self, key: str) -> None:
|
|
188
|
+
data = self._read_fallback_file()
|
|
189
|
+
data.pop(key, None)
|
|
190
|
+
lines = "\n".join(f"{k}={v}" for k, v in data.items())
|
|
191
|
+
self._fallback_path.write_text(lines, encoding="utf-8")
|
|
192
|
+
|
|
193
|
+
def _read_fallback_file(self) -> dict[str, str]:
|
|
194
|
+
if not self._fallback_path.exists():
|
|
195
|
+
return {}
|
|
196
|
+
try:
|
|
197
|
+
result: dict[str, str] = {}
|
|
198
|
+
for line in self._fallback_path.read_text(encoding="utf-8").splitlines():
|
|
199
|
+
if "=" in line:
|
|
200
|
+
k, _, v = line.partition("=")
|
|
201
|
+
result[k.strip()] = v.strip()
|
|
202
|
+
return result
|
|
203
|
+
except OSError:
|
|
204
|
+
return {}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Interactive login / logout
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def login_interactive(provider: str, store: CredentialStore | None = None) -> None:
|
|
212
|
+
"""Interactively prompt the user to log in for a provider.
|
|
213
|
+
|
|
214
|
+
Supports:
|
|
215
|
+
- "grok" → paste API key
|
|
216
|
+
- "gemini" → paste API key OR OAuth device flow
|
|
217
|
+
- "codex" → paste API key
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
provider: one of "grok", "gemini", "codex", "all"
|
|
221
|
+
store: CredentialStore instance (creates a new one if None)
|
|
222
|
+
"""
|
|
223
|
+
from rich.console import Console
|
|
224
|
+
from rich.prompt import Prompt
|
|
225
|
+
|
|
226
|
+
console = Console()
|
|
227
|
+
s = store or CredentialStore()
|
|
228
|
+
|
|
229
|
+
providers_to_login = (
|
|
230
|
+
["grok", "gemini", "codex"] if provider == "all" else [provider.lower()]
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
for prov in providers_to_login:
|
|
234
|
+
match prov:
|
|
235
|
+
case "grok":
|
|
236
|
+
_login_grok(s, console)
|
|
237
|
+
case "gemini":
|
|
238
|
+
_login_gemini(s, console)
|
|
239
|
+
case "codex":
|
|
240
|
+
_login_codex(s, console)
|
|
241
|
+
case _:
|
|
242
|
+
console.print(f"[red]Unknown provider: {prov!r}[/red]")
|
|
243
|
+
console.print("Valid providers: grok, gemini, codex, all")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def logout(provider: str, store: CredentialStore | None = None) -> None:
|
|
247
|
+
"""Remove stored credentials for a provider.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
provider: one of "grok", "gemini", "codex", "all"
|
|
251
|
+
store: CredentialStore instance (creates a new one if None)
|
|
252
|
+
"""
|
|
253
|
+
from rich.console import Console
|
|
254
|
+
console = Console()
|
|
255
|
+
s = store or CredentialStore()
|
|
256
|
+
|
|
257
|
+
if provider == "all":
|
|
258
|
+
for prov in ("grok", "gemini", "gemini_refresh", "codex"):
|
|
259
|
+
s.delete(prov)
|
|
260
|
+
console.print("[green]✓[/green] Logged out from all providers.")
|
|
261
|
+
else:
|
|
262
|
+
s.delete(provider)
|
|
263
|
+
if provider == "gemini":
|
|
264
|
+
s.delete("gemini_refresh")
|
|
265
|
+
console.print(f"[green]✓[/green] Logged out from [bold]{provider}[/bold].")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# Per-provider login helpers
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def _login_grok(store: CredentialStore, console: object) -> None:
|
|
273
|
+
from rich.console import Console
|
|
274
|
+
from rich.prompt import Prompt
|
|
275
|
+
c: Console = console # type: ignore[assignment]
|
|
276
|
+
c.print("\n[bold cyan]xAI Grok login[/bold cyan]")
|
|
277
|
+
c.print("Get your API key at: [link=https://console.x.ai]https://console.x.ai[/link]")
|
|
278
|
+
key = Prompt.ask("Paste your XAI_API_KEY", password=True, console=c)
|
|
279
|
+
if key.strip():
|
|
280
|
+
store.set("grok", key.strip())
|
|
281
|
+
c.print("[green]✓[/green] Grok API key saved to keychain.")
|
|
282
|
+
else:
|
|
283
|
+
c.print("[yellow]No key entered — skipped.[/yellow]")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _login_codex(store: CredentialStore, console: object) -> None:
|
|
287
|
+
from rich.console import Console
|
|
288
|
+
from rich.prompt import Prompt
|
|
289
|
+
c: Console = console # type: ignore[assignment]
|
|
290
|
+
c.print("\n[bold cyan]OpenAI / Codex login[/bold cyan]")
|
|
291
|
+
c.print("Get your API key at: [link=https://platform.openai.com/api-keys]platform.openai.com/api-keys[/link]")
|
|
292
|
+
key = Prompt.ask("Paste your OPENAI_API_KEY", password=True, console=c)
|
|
293
|
+
if key.strip():
|
|
294
|
+
store.set("codex", key.strip())
|
|
295
|
+
c.print("[green]✓[/green] OpenAI API key saved to keychain.")
|
|
296
|
+
else:
|
|
297
|
+
c.print("[yellow]No key entered — skipped.[/yellow]")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _login_gemini(store: CredentialStore, console: object) -> None:
|
|
301
|
+
from rich.console import Console
|
|
302
|
+
from rich.prompt import Prompt
|
|
303
|
+
c: Console = console # type: ignore[assignment]
|
|
304
|
+
c.print("\n[bold cyan]Google Gemini login[/bold cyan]")
|
|
305
|
+
c.print("1. API key (quick) — no OAuth needed")
|
|
306
|
+
c.print("2. OAuth device flow — uses Google account, no key required\n")
|
|
307
|
+
choice = Prompt.ask("Choose login method", choices=["1", "2"], default="1", console=c)
|
|
308
|
+
|
|
309
|
+
if choice == "1":
|
|
310
|
+
c.print("Get your API key at: [link=https://aistudio.google.com/app/apikey]aistudio.google.com/app/apikey[/link]")
|
|
311
|
+
key = Prompt.ask("Paste your GEMINI_API_KEY", password=True, console=c)
|
|
312
|
+
if key.strip():
|
|
313
|
+
store.set("gemini", key.strip())
|
|
314
|
+
c.print("[green]✓[/green] Gemini API key saved to keychain.")
|
|
315
|
+
else:
|
|
316
|
+
c.print("[yellow]No key entered — skipped.[/yellow]")
|
|
317
|
+
else:
|
|
318
|
+
_login_gemini_oauth(store, c)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _login_gemini_oauth(store: CredentialStore, console: object) -> None:
|
|
322
|
+
"""Complete Gemini OAuth device authorization flow (RFC 8628)."""
|
|
323
|
+
from rich.console import Console
|
|
324
|
+
c: Console = console # type: ignore[assignment]
|
|
325
|
+
|
|
326
|
+
client_id = os.environ.get("GDM_GOOGLE_CLIENT_ID")
|
|
327
|
+
client_secret = os.environ.get("GDM_GOOGLE_CLIENT_SECRET")
|
|
328
|
+
|
|
329
|
+
if not client_id or not client_secret:
|
|
330
|
+
c.print(
|
|
331
|
+
"[yellow]Warning:[/yellow] Gemini OAuth requires a GCP OAuth app.\n"
|
|
332
|
+
"Set GDM_GOOGLE_CLIENT_ID and GDM_GOOGLE_CLIENT_SECRET, or use method 1 (API key).\n"
|
|
333
|
+
"See: https://console.cloud.google.com/apis/credentials"
|
|
334
|
+
)
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
import requests
|
|
339
|
+
except ImportError:
|
|
340
|
+
c.print("[red]`requests` not installed. Run: pip install requests[/red]")
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
# Step 1: Request device code.
|
|
344
|
+
resp = requests.post(
|
|
345
|
+
_GEMINI_AUTH_URI,
|
|
346
|
+
data={"client_id": client_id, "scope": _GEMINI_SCOPE},
|
|
347
|
+
timeout=20,
|
|
348
|
+
)
|
|
349
|
+
if resp.status_code != 200:
|
|
350
|
+
c.print(f"[red]Device code request failed: {resp.status_code} {resp.text}[/red]")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
data = resp.json()
|
|
354
|
+
device_code: str = data["device_code"]
|
|
355
|
+
user_code: str = data["user_code"]
|
|
356
|
+
verification_url: str = data["verification_url"]
|
|
357
|
+
expires_in: int = data.get("expires_in", 1800)
|
|
358
|
+
interval: int = data.get("interval", _GEMINI_DEVICE_POLL_INTERVAL)
|
|
359
|
+
|
|
360
|
+
c.print(f"\n[bold]Visit:[/bold] {verification_url}")
|
|
361
|
+
c.print(f"[bold]Enter code:[/bold] {user_code}\n")
|
|
362
|
+
c.print(f"Waiting for authorization (expires in {expires_in}s)...")
|
|
363
|
+
|
|
364
|
+
# Step 2: Poll for token.
|
|
365
|
+
deadline = time.time() + expires_in
|
|
366
|
+
while time.time() < deadline:
|
|
367
|
+
time.sleep(interval)
|
|
368
|
+
token_resp = requests.post(
|
|
369
|
+
_GEMINI_TOKEN_URI,
|
|
370
|
+
data={
|
|
371
|
+
"client_id": client_id,
|
|
372
|
+
"client_secret": client_secret,
|
|
373
|
+
"device_code": device_code,
|
|
374
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
375
|
+
},
|
|
376
|
+
timeout=20,
|
|
377
|
+
)
|
|
378
|
+
token_data = token_resp.json()
|
|
379
|
+
error = token_data.get("error")
|
|
380
|
+
if error == "authorization_pending":
|
|
381
|
+
continue
|
|
382
|
+
if error == "slow_down":
|
|
383
|
+
interval += 5
|
|
384
|
+
continue
|
|
385
|
+
if "access_token" in token_data:
|
|
386
|
+
access_token: str = token_data["access_token"]
|
|
387
|
+
refresh_token: str | None = token_data.get("refresh_token")
|
|
388
|
+
store.set("gemini", access_token)
|
|
389
|
+
if refresh_token:
|
|
390
|
+
store.set("gemini_refresh", refresh_token)
|
|
391
|
+
c.print("[green]✓[/green] Gemini OAuth complete. Access token saved to keychain.")
|
|
392
|
+
return
|
|
393
|
+
# Unrecoverable error.
|
|
394
|
+
c.print(f"[red]OAuth error: {token_data.get('error_description', error)}[/red]")
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
c.print("[red]Authorization timed out. Please try again.[/red]")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Helpers
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def _check_keyring() -> bool:
|
|
405
|
+
"""Return True if keyring is available and functional."""
|
|
406
|
+
try:
|
|
407
|
+
import keyring # noqa: F401
|
|
408
|
+
return True
|
|
409
|
+
except ImportError:
|
|
410
|
+
log.info("keyring not installed — using fallback credential file")
|
|
411
|
+
return False
|