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.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
src/cli.py ADDED
@@ -0,0 +1,1290 @@
1
+ """gdm CLI — all subcommands.
2
+
3
+ Usage:
4
+ gdm # start coding agent (default)
5
+ gdm code # start coding agent (explicit)
6
+ gdm login [provider] # authenticate: grok | gemini | codex | all
7
+ gdm logout [provider] # remove stored credentials
8
+ gdm version # show version info
9
+ gdm doctor # check environment and dependencies
10
+
11
+ Flags accepted by `gdm code`:
12
+ --yes / -y # bypass permission prompts this session
13
+ --provider TEXT # force provider: grok | gemini | codex
14
+ --model TEXT # override model tier: scout|coder|thinker|reasoner|debate
15
+ --max-turns INT # override max_turns
16
+ --cost-limit FLOAT # override session cost limit in USD
17
+ --prompt TEXT # non-interactive: run one prompt and exit
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import sys
23
+ import tomllib
24
+ from pathlib import Path
25
+ from typing import Annotated, Optional
26
+
27
+ import shutil
28
+
29
+ import httpx
30
+ import typer
31
+ from rich.console import Console
32
+
33
+ from src.agent.daemon import BackgroundDaemon
34
+ from src.config import load_config
35
+ from src.memory.db import GdmDatabase
36
+ from src.tools import REGISTRY
37
+
38
+ __all__ = ["app"]
39
+
40
+ app = typer.Typer(
41
+ name="gdm",
42
+ help="gdm code — the Grok-native AI coding agent.",
43
+ add_completion=False,
44
+ rich_markup_mode="rich",
45
+ )
46
+
47
+ console = Console()
48
+ log = logging.getLogger(__name__)
49
+
50
+ # ── Version string ────────────────────────────────────────────────────────
51
+
52
+ _VERSION = "0.1.0-alpha"
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # gdm version
57
+ # ---------------------------------------------------------------------------
58
+
59
+ @app.command("version")
60
+ def cmd_version() -> None:
61
+ """Show version information."""
62
+ console.print(f"[bold cyan]gdm code[/bold cyan] v{_VERSION}")
63
+ console.print("The Grok-native AI coding agent.")
64
+ _print_provider_status()
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # gdm login
69
+ # ---------------------------------------------------------------------------
70
+
71
+ @app.command("login")
72
+ def cmd_login(
73
+ provider: Annotated[
74
+ str,
75
+ typer.Argument(help="Provider to log in to: grok | gemini | codex | all"),
76
+ ] = "all",
77
+ ) -> None:
78
+ """Authenticate with an AI provider and store credentials in the keychain."""
79
+ from src.auth import login_interactive
80
+ login_interactive(provider)
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # gdm logout
85
+ # ---------------------------------------------------------------------------
86
+
87
+ @app.command("logout")
88
+ def cmd_logout(
89
+ provider: Annotated[
90
+ str,
91
+ typer.Argument(help="Provider to log out from: grok | gemini | codex | all"),
92
+ ] = "all",
93
+ ) -> None:
94
+ """Remove stored credentials for a provider."""
95
+ from src.auth import logout
96
+ logout(provider)
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # gdm doctor
101
+ # ---------------------------------------------------------------------------
102
+
103
+ @app.command("doctor")
104
+ def cmd_doctor() -> None:
105
+ """Check environment, dependencies, and capability health."""
106
+ import importlib.util
107
+ import shutil
108
+
109
+ console.print("[bold]gdm doctor — environment check[/bold]\n")
110
+
111
+ def _check_pkg(name: str) -> bool:
112
+ return importlib.util.find_spec(name) is not None
113
+
114
+ def _check_bin(name: str) -> bool:
115
+ return shutil.which(name) is not None
116
+
117
+ def _print_group(title: str, checks: list[tuple[str, bool, str]]) -> None:
118
+ console.print(f" [bold]{title}[/bold]")
119
+ for label, ok, hint in checks:
120
+ icon = "[green]✓[/green]" if ok else "[yellow]✗[/yellow]"
121
+ note = f" [dim]— {hint}[/dim]" if (not ok and hint) else ""
122
+ console.print(f" {icon} {label}{note}")
123
+ console.print()
124
+
125
+ # --- Core ---
126
+ ver = sys.version_info
127
+ core: list[tuple[str, bool, str]] = [
128
+ (f"Python {ver.major}.{ver.minor}.{ver.micro}", ver >= (3, 11), "Python 3.11+ required"),
129
+ ]
130
+ for pkg in ("openai", "rich", "typer", "keyring", "httpx", "tiktoken"):
131
+ core.append((f"package: {pkg}", _check_pkg(pkg), f"pip install {pkg}"))
132
+ for pkg in ("markdownify", "tree_sitter"):
133
+ core.append((f"optional: {pkg}", _check_pkg(pkg), "install for best experience"))
134
+ core.append(("ripgrep (rg)", _check_bin("rg"), "install ripgrep for fast search"))
135
+ from src.auth import CredentialStore
136
+ creds = CredentialStore().load_all()
137
+ core.append(("XAI_API_KEY (Grok)", creds.has_grok, "run: gdm login grok"))
138
+ core.append(("GEMINI_API_KEY", creds.has_gemini, "run: gdm login gemini"))
139
+ core.append(("OPENAI_API_KEY (Codex)", creds.has_codex, "run: gdm login codex"))
140
+ _print_group("Core", core)
141
+
142
+ # --- Remote ---
143
+ remote_checks: list[tuple[str, bool, str]] = [
144
+ ("websockets", _check_pkg("websockets"), "pip install gdm-code[remote]"),
145
+ ]
146
+ _print_group("Remote access", remote_checks)
147
+
148
+ # --- Voice ---
149
+ has_sounddevice = _check_pkg("sounddevice")
150
+ has_webrtcvad = _check_pkg("webrtcvad")
151
+ has_numpy = _check_pkg("numpy")
152
+ portaudio_ok = False
153
+ if has_sounddevice:
154
+ try:
155
+ import sounddevice as _sd
156
+ _sd.query_devices()
157
+ portaudio_ok = True
158
+ except Exception:
159
+ portaudio_ok = False
160
+ voice_checks: list[tuple[str, bool, str]] = [
161
+ ("sounddevice", has_sounddevice, "pip install gdm-code[voice]"),
162
+ ("webrtcvad", has_webrtcvad, "pip install gdm-code[voice]"),
163
+ ("numpy", has_numpy, "pip install gdm-code[voice]"),
164
+ ("PortAudio (system)", portaudio_ok, "brew install portaudio OR apt install libportaudio2"),
165
+ ]
166
+ _print_group("Voice", voice_checks)
167
+
168
+ # --- Browser / Bridge ---
169
+ has_chrome = (
170
+ _check_bin("google-chrome")
171
+ or _check_bin("chromium")
172
+ or _check_bin("chromium-browser")
173
+ or _check_bin("chrome")
174
+ )
175
+ browser_checks: list[tuple[str, bool, str]] = [
176
+ ("cloudflared", _check_bin("cloudflared"), "brew install cloudflared OR download from cloudflare.com/products/tunnel"),
177
+ ("Chrome/Chromium", has_chrome, "install Google Chrome or Chromium"),
178
+ ]
179
+ _print_group("Browser / Bridge", browser_checks)
180
+
181
+ # --- Mobile ---
182
+ import platform as _platform
183
+ mobile_checks: list[tuple[str, bool, str]] = [
184
+ ("adb (Android Debug Bridge)", _check_bin("adb"), "install Android platform-tools"),
185
+ ]
186
+ if _platform.system() == "Darwin":
187
+ mobile_checks.append(("xcrun (iOS simulator)", _check_bin("xcrun"), "install Xcode Command Line Tools"))
188
+ _print_group("Mobile", mobile_checks)
189
+
190
+ # --- Summary ---
191
+ has_key = creds.any_key
192
+ if has_key:
193
+ console.print("[green]Ready to code![/green] Run: [bold]gdm code[/bold]")
194
+ else:
195
+ console.print("[yellow]No API key found.[/yellow] Run: [bold]gdm login[/bold]")
196
+
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # gdm daemon
201
+ # ---------------------------------------------------------------------------
202
+
203
+ @app.command("daemon")
204
+ def cmd_daemon(
205
+ action: Annotated[
206
+ str,
207
+ typer.Argument(help="Action: start | stop | status"),
208
+ ] = "status",
209
+ ) -> None:
210
+ """Manage the gdm background daemon (indexing, scanning, compression jobs)."""
211
+ try:
212
+ cfg = load_config()
213
+ except Exception as exc: # noqa: BLE001
214
+ console.print(f"[red]Config error:[/red] {exc}")
215
+ raise typer.Exit(1) from exc
216
+
217
+ db = GdmDatabase(project_root=cfg.project_root)
218
+
219
+ match action.lower():
220
+ case "start":
221
+ try:
222
+ daemon = BackgroundDaemon(db=db)
223
+ daemon.start()
224
+ console.print("[green]✓[/green] Background daemon started.")
225
+ console.print("[dim]Jobs: index • compress • security-scan[/dim]")
226
+ except Exception as exc: # noqa: BLE001
227
+ console.print(f"[red]Failed to start daemon:[/red] {exc}")
228
+ raise typer.Exit(1) from exc
229
+
230
+ case "stop":
231
+ try:
232
+ daemon = BackgroundDaemon(db=db)
233
+ daemon.stop()
234
+ console.print("[yellow]Daemon stopped.[/yellow]")
235
+ except Exception as exc: # noqa: BLE001
236
+ console.print(f"[red]Failed to stop daemon:[/red] {exc}")
237
+
238
+ case "status":
239
+ try:
240
+ daemon = BackgroundDaemon(db=db)
241
+ pending = daemon.pending_count()
242
+ running = daemon.is_running
243
+ state = "[green]running[/green]" if running else "[dim]stopped[/dim]"
244
+ console.print(f"Daemon: {state} | Pending jobs: [bold]{pending}[/bold]")
245
+ except Exception as exc: # noqa: BLE001
246
+ console.print(f"[yellow]Daemon status unavailable:[/yellow] {exc}")
247
+
248
+ case _:
249
+ console.print(f"[red]Unknown action {action!r}.[/red] Use: start | stop | status")
250
+ raise typer.Exit(1)
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # gdm health
255
+ # ---------------------------------------------------------------------------
256
+
257
+ @app.command("health")
258
+ def cmd_health(
259
+ json_out: Annotated[bool, typer.Option("--json", help="Output JSON for CI pipelines")] = False,
260
+ ) -> None:
261
+ """Live system health check (DB, daemon, API, tools, budget, system deps)."""
262
+ import json as _json
263
+ import time
264
+ from concurrent.futures import ThreadPoolExecutor
265
+ from dataclasses import dataclass
266
+ from datetime import datetime, timezone
267
+
268
+ from src.agent.context_budget import ContextBudget
269
+ from src.models.definitions import PROVIDER_BASE_URLS, ModelTier, get_model
270
+
271
+ @dataclass
272
+ class CheckResult:
273
+ name: str
274
+ status: str # "ok" | "warn" | "fail" | "missing"
275
+ latency_ms: int = 0
276
+ detail: str = ""
277
+ priority: str = "P0" # P0=blocking, P1=warn, P2=info
278
+
279
+ try:
280
+ cfg = load_config()
281
+ except Exception as exc: # noqa: BLE001
282
+ console.print(f"[red]Config error:[/red] {exc}")
283
+ raise typer.Exit(1) from exc
284
+
285
+ db = GdmDatabase(project_root=cfg.project_root)
286
+
287
+ def _check_db() -> CheckResult:
288
+ t0 = time.monotonic()
289
+ try:
290
+ db.execute("SELECT 1")
291
+ return CheckResult("db", "ok", latency_ms=int((time.monotonic() - t0) * 1000))
292
+ except Exception as exc: # noqa: BLE001
293
+ return CheckResult("db", "fail", detail=str(exc))
294
+
295
+ def _check_daemon() -> CheckResult:
296
+ t0 = time.monotonic()
297
+ try:
298
+ daemon = BackgroundDaemon(db=db)
299
+ pending = daemon.pending_count()
300
+ running = daemon.is_running
301
+ detail = f"{'running' if running else 'stopped'}, {pending} pending"
302
+ status = "ok" if running else "warn"
303
+ return CheckResult("daemon", status, latency_ms=int((time.monotonic() - t0) * 1000), detail=detail, priority="P1")
304
+ except Exception as exc: # noqa: BLE001
305
+ return CheckResult("daemon", "warn", latency_ms=int((time.monotonic() - t0) * 1000), detail=str(exc), priority="P1")
306
+
307
+ def _check_api() -> CheckResult:
308
+ t0 = time.monotonic()
309
+ base_url = PROVIDER_BASE_URLS.get(cfg.provider, "").rstrip("/")
310
+ url = f"{base_url}/models"
311
+ try:
312
+ resp = httpx.get(
313
+ url,
314
+ headers={"Authorization": f"Bearer {cfg.api_key}"},
315
+ timeout=3.0,
316
+ )
317
+ latency = int((time.monotonic() - t0) * 1000)
318
+ if resp.status_code == 200:
319
+ return CheckResult(f"api ({cfg.provider})", "ok", latency_ms=latency)
320
+ if resp.status_code == 401:
321
+ return CheckResult(f"api ({cfg.provider})", "fail", latency_ms=latency, detail="API key invalid")
322
+ return CheckResult(f"api ({cfg.provider})", "fail", latency_ms=latency, detail=f"HTTP {resp.status_code}")
323
+ except httpx.TimeoutException:
324
+ return CheckResult(f"api ({cfg.provider})", "fail", detail="unreachable (timeout)")
325
+ except Exception as exc: # noqa: BLE001
326
+ return CheckResult(f"api ({cfg.provider})", "fail", detail=str(exc)[:60])
327
+
328
+ def _check_tools() -> CheckResult:
329
+ t0 = time.monotonic()
330
+ try:
331
+ tools = REGISTRY.all_tools()
332
+ count = len(tools)
333
+ status = "ok" if count > 0 else "fail"
334
+ return CheckResult("tools", status, latency_ms=int((time.monotonic() - t0) * 1000), detail=f"{count} tools")
335
+ except Exception as exc: # noqa: BLE001
336
+ return CheckResult("tools", "fail", detail=str(exc))
337
+
338
+ def _check_budget() -> CheckResult:
339
+ t0 = time.monotonic()
340
+ try:
341
+ model_def = get_model(ModelTier.CODER, cfg.provider)
342
+ budget = ContextBudget(model_context_window=model_def.context_window)
343
+ pct = round(budget.used_tokens / budget.max_tokens * 100, 1)
344
+ status = "warn" if pct > 70 else "ok"
345
+ return CheckResult("budget", status, latency_ms=int((time.monotonic() - t0) * 1000), detail=f"{pct}% used", priority="P1")
346
+ except Exception as exc: # noqa: BLE001
347
+ return CheckResult("budget", "fail", detail=str(exc))
348
+
349
+ def _check_sysdeps() -> list[CheckResult]:
350
+ _priorities = {"git": "P0", "ruff": "P1", "difftastic": "P2"}
351
+ results = []
352
+ for cmd in ["git", "ruff", "difftastic"]:
353
+ t0 = time.monotonic()
354
+ path = shutil.which(cmd)
355
+ latency = int((time.monotonic() - t0) * 1000)
356
+ status = "ok" if path else "missing"
357
+ results.append(CheckResult(cmd, status, latency_ms=latency, detail=path or "missing", priority=_priorities[cmd]))
358
+ return results
359
+
360
+ # Run scalar checks in parallel; sysdeps is fast so fine in its own slot
361
+ with ThreadPoolExecutor(max_workers=6) as executor:
362
+ f_db = executor.submit(_check_db)
363
+ f_daemon = executor.submit(_check_daemon)
364
+ f_api = executor.submit(_check_api)
365
+ f_tools = executor.submit(_check_tools)
366
+ f_budget = executor.submit(_check_budget)
367
+ f_sys = executor.submit(_check_sysdeps)
368
+
369
+ all_checks: list[CheckResult] = [
370
+ f_db.result(),
371
+ f_daemon.result(),
372
+ f_api.result(),
373
+ f_tools.result(),
374
+ f_budget.result(),
375
+ *f_sys.result(),
376
+ ]
377
+
378
+ overall_fail = any(
379
+ r.status in ("fail", "missing") and r.priority in ("P0", "P1")
380
+ for r in all_checks
381
+ )
382
+
383
+ if json_out:
384
+ payload = {
385
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
386
+ "overall": "fail" if overall_fail else "pass",
387
+ "checks": [
388
+ {
389
+ "name": r.name,
390
+ "status": r.status,
391
+ "latency_ms": r.latency_ms,
392
+ "detail": r.detail,
393
+ "priority": r.priority,
394
+ }
395
+ for r in all_checks
396
+ ],
397
+ }
398
+ print(_json.dumps(payload, indent=2))
399
+ else:
400
+ console.print("[bold]gdm health[/bold]\n")
401
+ for r in all_checks:
402
+ if r.status == "ok":
403
+ icon = "[green]✓[/green]"
404
+ elif r.status in ("warn", "missing") and r.priority == "P2":
405
+ icon = "[dim]○[/dim]"
406
+ elif r.status in ("warn", "missing"):
407
+ icon = "[yellow]⚠[/yellow]"
408
+ else:
409
+ icon = "[red]✗[/red]"
410
+ latency_str = f" [dim]{r.latency_ms}ms[/dim]" if r.latency_ms else ""
411
+ detail_str = f" [dim]— {r.detail}[/dim]" if r.detail else ""
412
+ console.print(f" {icon} {r.name:<22}{latency_str}{detail_str}")
413
+ console.print()
414
+ if overall_fail:
415
+ console.print("[yellow]Some checks failed.[/yellow] Run [bold]gdm doctor[/bold] for setup help.")
416
+ else:
417
+ console.print("[green]All checks passed.[/green]")
418
+
419
+ if overall_fail:
420
+ raise typer.Exit(1)
421
+
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # gdm mcp
426
+ # ---------------------------------------------------------------------------
427
+
428
+ @app.command("mcp")
429
+ def cmd_mcp(
430
+ transport: str = typer.Argument("stdio", help="Transport: stdio"),
431
+ ) -> None:
432
+ """Start gdm as an MCP server (Model Context Protocol).
433
+
434
+ Exposes gdm tools to MCP-compatible clients such as Claude Desktop.
435
+ The server reads JSON-RPC 2.0 messages from stdin and writes responses
436
+ to stdout (newline-delimited).
437
+ """
438
+ if transport != "stdio":
439
+ console.print(f"[red]Unsupported transport {transport!r}.[/red] Only 'stdio' is supported.")
440
+ raise typer.Exit(1)
441
+
442
+ from src.integrations.mcp_server import build_default_server
443
+
444
+ server = build_default_server()
445
+ server.run()
446
+
447
+
448
+
449
+ # ---------------------------------------------------------------------------
450
+ # gdm voice
451
+ # ---------------------------------------------------------------------------
452
+
453
+ @app.command("voice")
454
+ def cmd_voice(
455
+ mode: str = typer.Argument("ptt", help="Mode: ptt (push-to-talk) | stream"),
456
+ stt: str = typer.Option("whisper", help="STT provider"),
457
+ tts: str = typer.Option("pyttsx3", help="TTS provider"),
458
+ ) -> None:
459
+ """Start voice interaction with the gdm agent.
460
+
461
+ Modes:
462
+
463
+ [bold]ptt[/bold] Push-to-talk: press Enter to record, agent replies via TTS.
464
+ [bold]stream[/bold] Continuous: background thread listens and responds automatically.
465
+ """
466
+ import time
467
+
468
+ from src.voice.models import VoiceConfig
469
+ from src.voice.voice_loop import VoiceLoop, VoiceLoopError
470
+
471
+ if mode not in ("ptt", "stream"):
472
+ console.print(f"[red]Unknown mode {mode!r}.[/red] Use: ptt | stream")
473
+ raise typer.Exit(1)
474
+
475
+ try:
476
+ cfg = load_config()
477
+ except Exception as exc: # noqa: BLE001
478
+ console.print(f"[red]Config error:[/red] {exc}")
479
+ raise typer.Exit(1) from exc
480
+
481
+ voice_cfg = VoiceConfig(stt_provider=stt, tts_provider=tts)
482
+
483
+ def _agent_send(text: str) -> str:
484
+ """Run a single-turn agent call and return the text response."""
485
+ from src.agent.loop import EventType
486
+
487
+ try:
488
+ agent_loop, _, _db = _setup_agent(cfg, yes=True, model_override=None)
489
+ except Exception as exc: # noqa: BLE001
490
+ raise VoiceLoopError(f"Agent setup failed: {exc}") from exc
491
+
492
+ response_parts: list[str] = []
493
+ try:
494
+ for event in agent_loop.run(text): # type: ignore[union-attr]
495
+ if event.type == EventType.RESPONSE:
496
+ response_parts.append(event.content)
497
+ finally:
498
+ try:
499
+ _db.close() # type: ignore[union-attr]
500
+ except Exception: # noqa: BLE001
501
+ pass
502
+ return "".join(response_parts)
503
+
504
+ voice_loop = VoiceLoop(voice_cfg, _agent_send, stt=None, tts=None)
505
+
506
+ if mode == "ptt":
507
+ console.print(
508
+ f"[bold cyan]Voice PTT[/bold cyan] STT:[dim]{stt}[/dim] TTS:[dim]{tts}[/dim]"
509
+ )
510
+ console.print("[dim]Press Enter to record, Ctrl+C to quit.[/dim]\n")
511
+
512
+ from src.voice.audio_capture import AudioCapture
513
+ from src.voice.audio_playback import AudioPlayback
514
+
515
+ capture = AudioCapture(voice_cfg)
516
+ playback = AudioPlayback(voice_cfg)
517
+
518
+ try:
519
+ while True:
520
+ try:
521
+ input("▶ Press Enter to speak… ")
522
+ except EOFError:
523
+ break
524
+
525
+ try:
526
+ capture.start()
527
+ audio_bytes = capture.read_frames(50)
528
+ capture.stop()
529
+ except Exception as exc: # noqa: BLE001
530
+ console.print(f"[yellow]Audio capture error:[/yellow] {exc}")
531
+ continue
532
+
533
+ try:
534
+ audio_response = voice_loop.push_to_talk(audio_bytes)
535
+ if audio_response:
536
+ playback.play_chunk(audio_response)
537
+ except VoiceLoopError as exc:
538
+ console.print(f"[red]Voice error:[/red] {exc}")
539
+
540
+ except KeyboardInterrupt:
541
+ console.print("\n[yellow]Exiting voice mode.[/yellow]")
542
+
543
+ else: # stream
544
+ console.print(
545
+ f"[bold cyan]Voice stream[/bold cyan] STT:[dim]{stt}[/dim] TTS:[dim]{tts}[/dim]"
546
+ )
547
+ console.print("[dim]Listening… Ctrl+C to quit.[/dim]\n")
548
+
549
+ voice_loop.start()
550
+ try:
551
+ while True:
552
+ time.sleep(0.1)
553
+ except KeyboardInterrupt:
554
+ voice_loop.stop()
555
+ console.print("\n[yellow]Voice stream stopped.[/yellow]")
556
+
557
+
558
+ # ---------------------------------------------------------------------------
559
+ # gdm browser
560
+ # ---------------------------------------------------------------------------
561
+
562
+ @app.command("browser")
563
+ def cmd_browser(
564
+ action: Annotated[
565
+ str,
566
+ typer.Argument(help="Action: start | stop | status"),
567
+ ] = "status",
568
+ port: Annotated[int, typer.Option(help="Bridge listen port (default: 9321)")] = 9321,
569
+ ) -> None:
570
+ """Manage the gdm bridge server for browser automation.
571
+
572
+ Start the bridge before using browser_dom / browser_capture / browser_nav tools.
573
+ Then load the gdm-chrome extension in Chrome to connect.
574
+ """
575
+ import os
576
+ import platform
577
+ import subprocess
578
+ import sys
579
+
580
+ import platform as _platform
581
+
582
+ from pathlib import Path
583
+
584
+ pid_file = Path.cwd() / ".gdm_bridge.pid"
585
+
586
+ match action.lower():
587
+ case "start":
588
+ if pid_file.exists():
589
+ try:
590
+ pid = int(pid_file.read_text().strip())
591
+ if _platform.system() == "Windows":
592
+ r = subprocess.run(
593
+ ["tasklist", "/FI", f"PID eq {pid}", "/NH"],
594
+ capture_output=True, text=True,
595
+ )
596
+ alive = str(pid) in r.stdout
597
+ else:
598
+ try:
599
+ os.kill(pid, 0)
600
+ alive = True
601
+ except ProcessLookupError:
602
+ alive = False
603
+ if alive:
604
+ console.print(f"[yellow]Bridge already running (PID {pid}).[/yellow]")
605
+ return
606
+ except Exception: # noqa: BLE001
607
+ pass
608
+
609
+ proc = subprocess.Popen(
610
+ [sys.executable, "-m", "src.server.bridge_cli", "--port", str(port)],
611
+ stdout=subprocess.DEVNULL,
612
+ stderr=subprocess.DEVNULL,
613
+ )
614
+ pid_file.write_text(str(proc.pid))
615
+ console.print(f"[green]✓ Bridge server started on port {port} (PID {proc.pid})[/green]")
616
+ console.print("[dim]Load the gdm-chrome extension in Chrome to connect.[/dim]")
617
+ console.print(
618
+ f"[dim]Auth token: {Path.home() / '.config' / 'gdm' / 'browser.token'}[/dim]"
619
+ )
620
+
621
+ case "stop":
622
+ if not pid_file.exists():
623
+ console.print("[yellow]Bridge not running.[/yellow]")
624
+ return
625
+ try:
626
+ pid = int(pid_file.read_text().strip())
627
+ if _platform.system() == "Windows":
628
+ subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True)
629
+ else:
630
+ os.kill(pid, 15)
631
+ pid_file.unlink(missing_ok=True)
632
+ console.print(f"[green]✓ Bridge stopped (PID {pid}).[/green]")
633
+ except Exception as exc: # noqa: BLE001
634
+ pid_file.unlink(missing_ok=True)
635
+ console.print(f"[red]Stop failed:[/red] {exc}")
636
+ raise typer.Exit(1) from exc
637
+
638
+ case "status":
639
+ if not pid_file.exists():
640
+ console.print("[dim]Bridge not running. Run: gdm browser start[/dim]")
641
+ return
642
+ try:
643
+ pid = int(pid_file.read_text().strip())
644
+ import httpx
645
+ r = httpx.get(f"http://127.0.0.1:{port}/health", timeout=2.0)
646
+ data = r.json()
647
+ clients = data.get("clients", {})
648
+ queue = data.get("queue_depth", 0)
649
+ console.print(f"[green]✓ Bridge running[/green] (PID {pid})")
650
+ console.print(f" CLI: {clients.get('cli', 0)} "
651
+ f"Extension: {clients.get('extension', 0)} "
652
+ f"Queue: {queue}")
653
+ except Exception: # noqa: BLE001
654
+ try:
655
+ pid = int(pid_file.read_text().strip())
656
+ console.print(f"[yellow]Bridge process found (PID {pid}) but health check failed.[/yellow]")
657
+ except Exception: # noqa: BLE001
658
+ console.print("[yellow]Bridge status unknown.[/yellow]")
659
+
660
+ case _:
661
+ console.print(f"[red]Unknown action {action!r}.[/red] Use: start | stop | status")
662
+ raise typer.Exit(1)
663
+
664
+
665
+ # ---------------------------------------------------------------------------
666
+ # gdm remote
667
+ # ---------------------------------------------------------------------------
668
+
669
+ _REMOTE_STATE_FILE = ".gdm_remote.json"
670
+
671
+
672
+ @app.command("remote")
673
+ def cmd_remote(
674
+ action: Annotated[
675
+ str,
676
+ typer.Argument(help="Action: start | stop | status | qr"),
677
+ ] = "status",
678
+ port: Annotated[int, typer.Option(help="Local port to expose (default: 8765)")] = 8765,
679
+ ) -> None:
680
+ """Manage the gdm remote tunnel (phone-based agent control).
681
+
682
+ start - establish tunnel, print URL and QR code
683
+ stop - tear down the tunnel
684
+ status - show current tunnel state
685
+ qr - display the QR code for the current tunnel URL
686
+ """
687
+ import json
688
+ from pathlib import Path as _Path
689
+
690
+ state_file = _Path.cwd() / _REMOTE_STATE_FILE
691
+
692
+ match action.lower():
693
+ case "start":
694
+ from src.remote.tunnel import TunnelManager
695
+ from src.remote.qr import make_pairing_url, print_qr
696
+ from src.remote.token_manager import PairingTokenService
697
+
698
+ console.print("[bold]Starting gdm remote tunnel...[/bold]")
699
+ mgr = TunnelManager(port=port)
700
+ url = mgr.start()
701
+
702
+ token_svc = PairingTokenService()
703
+ token = token_svc.issue()
704
+ pairing_url = make_pairing_url(url, token)
705
+
706
+ st = mgr.status()
707
+ state_file.write_text(
708
+ json.dumps({"url": url, "pairing_url": pairing_url, "provider": st["provider"], "port": port}),
709
+ encoding="utf-8",
710
+ )
711
+
712
+ console.print(f"[green]Tunnel URL:[/green] [cyan]{url}[/cyan]")
713
+ console.print(f" Provider: [bold]{st['provider']}[/bold]")
714
+ console.print("\n[bold]Scan QR to pair:[/bold]")
715
+ print_qr(pairing_url)
716
+ console.print(f"\n[dim]Pairing URL: {pairing_url}[/dim]")
717
+ console.print("[dim]Run [bold]gdm remote stop[/bold] to tear down.[/dim]")
718
+ mgr.stop()
719
+
720
+ case "stop":
721
+ if not state_file.exists():
722
+ console.print("[yellow]Remote tunnel not running.[/yellow]")
723
+ return
724
+ try:
725
+ state_file.unlink(missing_ok=True)
726
+ console.print("[green]Remote tunnel stopped.[/green]")
727
+ except Exception as exc: # noqa: BLE001
728
+ console.print(f"[red]Stop failed:[/red] {exc}")
729
+ raise typer.Exit(1) from exc
730
+
731
+ case "status":
732
+ if not state_file.exists():
733
+ console.print("[dim]Remote tunnel not running. Run: gdm remote start[/dim]")
734
+ return
735
+ try:
736
+ data = json.loads(state_file.read_text(encoding="utf-8"))
737
+ url = data.get("url", "unknown")
738
+ provider = data.get("provider", "unknown")
739
+ console.print("[green]Remote tunnel active[/green]")
740
+ console.print(f" URL: [cyan]{url}[/cyan]")
741
+ console.print(f" Provider: [bold]{provider}[/bold]")
742
+ except Exception as exc: # noqa: BLE001
743
+ console.print(f"[yellow]Could not read tunnel state:[/yellow] {exc}")
744
+
745
+ case "qr":
746
+ from src.remote.qr import print_qr
747
+
748
+ if not state_file.exists():
749
+ console.print("[yellow]No active tunnel. Run: gdm remote start[/yellow]")
750
+ raise typer.Exit(1)
751
+ try:
752
+ data = json.loads(state_file.read_text(encoding="utf-8"))
753
+ pairing_url = data.get("pairing_url") or data.get("url", "")
754
+ if not pairing_url:
755
+ console.print("[red]No URL found in tunnel state.[/red]")
756
+ raise typer.Exit(1)
757
+ print_qr(pairing_url)
758
+ console.print(f"\n[dim]{pairing_url}[/dim]")
759
+ except typer.Exit:
760
+ raise
761
+ except Exception as exc: # noqa: BLE001
762
+ console.print(f"[red]QR render failed:[/red] {exc}")
763
+ raise typer.Exit(1) from exc
764
+
765
+ case _:
766
+ console.print(f"[red]Unknown action {action!r}.[/red] Use: start | stop | status | qr")
767
+ raise typer.Exit(1)
768
+
769
+
770
+ # ---------------------------------------------------------------------------
771
+ # gdm config (subcommand group)
772
+ # ---------------------------------------------------------------------------
773
+
774
+ config_app = typer.Typer(
775
+ name="config",
776
+ help="Manage gdm application settings.",
777
+ add_completion=False,
778
+ rich_markup_mode="rich",
779
+ )
780
+ app.add_typer(config_app, name="config")
781
+
782
+
783
+ @config_app.command("list")
784
+ def cmd_config_list(
785
+ show_source: Annotated[
786
+ bool, typer.Option("--show-source", help="Add source column to output.")
787
+ ] = False,
788
+ ) -> None:
789
+ """List all configuration values (optionally with source provenance)."""
790
+ from src.config import KEYCHAIN_KEYS, ConfigLoader
791
+
792
+ loader = ConfigLoader()
793
+ try:
794
+ _settings, provenance = loader.load()
795
+ except Exception as exc: # noqa: BLE001
796
+ console.print(f"[red]Config load error:[/red] {exc}")
797
+ raise typer.Exit(1) from exc
798
+
799
+ from rich.table import Table as _Table
800
+
801
+ if show_source:
802
+ tbl = _Table(header_style="bold cyan", show_lines=False)
803
+ tbl.add_column("Key", style="cyan", no_wrap=True)
804
+ tbl.add_column("Value")
805
+ tbl.add_column("Source", style="dim")
806
+ for field_name, cv in sorted(provenance.items()):
807
+ policy_tag = " [bold red]← POLICY[/bold red]" if "team-policy" in cv.source else ""
808
+ tbl.add_row(field_name, str(cv.value), cv.source + policy_tag)
809
+ # Keychain keys
810
+ try:
811
+ import keyring # type: ignore[import-untyped]
812
+ for k in sorted(KEYCHAIN_KEYS):
813
+ val = keyring.get_password("gdm", k)
814
+ display = "[keychain]" if val else "[not set]"
815
+ tbl.add_row(k, display, "keychain")
816
+ except Exception: # noqa: BLE001
817
+ pass
818
+ else:
819
+ tbl = _Table(header_style="bold cyan", show_lines=False)
820
+ tbl.add_column("Key", style="cyan", no_wrap=True)
821
+ tbl.add_column("Value")
822
+ for field_name, cv in sorted(provenance.items()):
823
+ tbl.add_row(field_name, str(cv.value))
824
+
825
+ console.print(tbl)
826
+
827
+
828
+ @config_app.command("show")
829
+ def cmd_config_show() -> None:
830
+ """Show all values with sources (alias for [bold]config list --show-source[/bold])."""
831
+ cmd_config_list(show_source=True)
832
+
833
+
834
+ @config_app.command("get")
835
+ def cmd_config_get(
836
+ key: Annotated[str, typer.Argument(help="Config key in dot notation (e.g. models.primary) or field name.")],
837
+ ) -> None:
838
+ """Get a single configuration value and its source."""
839
+ from src.config import KEYCHAIN_KEYS, ConfigLoader, _TOML_KEY_MAP, _is_secret_key
840
+
841
+ # Accept both TOML dot notation and direct field names
842
+ field_name = _TOML_KEY_MAP.get(key, key)
843
+
844
+ if _is_secret_key(key) or key in KEYCHAIN_KEYS:
845
+ try:
846
+ import keyring # type: ignore[import-untyped]
847
+ val = keyring.get_password("gdm", key)
848
+ console.print(f"{key} = [keychain] (source: keychain)")
849
+ except Exception as exc: # noqa: BLE001
850
+ console.print(f"[yellow]Keychain unavailable:[/yellow] {exc}")
851
+ return
852
+
853
+ loader = ConfigLoader()
854
+ try:
855
+ _settings, provenance = loader.load()
856
+ except Exception as exc: # noqa: BLE001
857
+ console.print(f"[red]Config load error:[/red] {exc}")
858
+ raise typer.Exit(1) from exc
859
+
860
+ cv = provenance.get(field_name)
861
+ if cv is None:
862
+ console.print(f"[yellow]Unknown config key: {key!r}[/yellow]")
863
+ raise typer.Exit(1)
864
+
865
+ policy_tag = " [bold red]← POLICY (non-overridable)[/bold red]" if "team-policy" in cv.source else ""
866
+ console.print(f"[cyan]{field_name}[/cyan] = [bold]{cv.value!r}[/bold] [dim](source: {cv.source})[/dim]{policy_tag}")
867
+
868
+
869
+ @config_app.command("set")
870
+ def cmd_config_set(
871
+ key: Annotated[str, typer.Argument(help="Config key in dot notation (e.g. models.primary).")],
872
+ value: Annotated[str, typer.Argument(help="Value to store.")],
873
+ project: Annotated[
874
+ bool, typer.Option("--project", help="Write to project config (.gdm/config.toml).")
875
+ ] = False,
876
+ ) -> None:
877
+ """Set a configuration value (writes to user or project config)."""
878
+ from src.config import KEYCHAIN_KEYS, ConfigLoader, _is_secret_key
879
+
880
+ if _is_secret_key(key) or key in KEYCHAIN_KEYS:
881
+ try:
882
+ import keyring # type: ignore[import-untyped]
883
+ keyring.set_password("gdm", key, value)
884
+ console.print(f"[green]✓[/green] {key} stored in OS keychain (not written to disk)")
885
+ except Exception as exc: # noqa: BLE001
886
+ console.print(f"[red]Keychain error:[/red] {exc}")
887
+ raise typer.Exit(1) from exc
888
+ return
889
+
890
+ loader = ConfigLoader()
891
+ try:
892
+ if project:
893
+ loader.write_project(key, value)
894
+ console.print(f"[green]✓[/green] {key} = {value!r} written to project config (.gdm/config.toml)")
895
+ else:
896
+ loader.write_user(key, value)
897
+ console.print(f"[green]✓[/green] {key} = {value!r} written to user config (~/.config/gdm/config.toml)")
898
+ except ValueError as exc:
899
+ console.print(f"[red]Error:[/red] {exc}")
900
+ raise typer.Exit(1) from exc
901
+ except Exception as exc: # noqa: BLE001
902
+ console.print(f"[red]Write error:[/red] {exc}")
903
+ raise typer.Exit(1) from exc
904
+
905
+
906
+ @config_app.command("unset")
907
+ def cmd_config_unset(
908
+ key: Annotated[str, typer.Argument(help="Config key to remove from user config.")],
909
+ ) -> None:
910
+ """Remove a key from user configuration."""
911
+ from src.config import ConfigLoader
912
+
913
+ loader = ConfigLoader()
914
+ try:
915
+ loader.unset_user(key)
916
+ console.print(f"[green]✓[/green] {key} removed from user config (if it existed)")
917
+ except Exception as exc: # noqa: BLE001
918
+ console.print(f"[red]Error:[/red] {exc}")
919
+ raise typer.Exit(1) from exc
920
+
921
+
922
+ @config_app.command("validate")
923
+ def cmd_config_validate() -> None:
924
+ """Validate all configuration files against the schema.
925
+
926
+ Exit code 0 = valid, 1 = errors found.
927
+ """
928
+ import sys
929
+ from pydantic import ValidationError
930
+
931
+ from src.config import ConfigLoader, _GLOBAL_CONFIG_FILE
932
+
933
+ loader = ConfigLoader()
934
+ errors: list[str] = []
935
+
936
+ # Try to load and validate each file individually for clear error messages
937
+ def _validate_toml_file(path: Path, label: str) -> None:
938
+ if not path.exists():
939
+ return
940
+ try:
941
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
942
+ except Exception as exc:
943
+ errors.append(f"[red]TOML parse error[/red] in {label}:\n {exc}")
944
+ return
945
+ from src.config import _flatten_toml, GdmSettings
946
+ flat = _flatten_toml(data)
947
+ try:
948
+ GdmSettings(**flat)
949
+ except ValidationError as exc:
950
+ for err in exc.errors():
951
+ field = ".".join(str(x) for x in err["loc"])
952
+ errors.append(f"[red]Schema error[/red] in {label} → {field}: {err['msg']}")
953
+
954
+ _validate_toml_file(_GLOBAL_CONFIG_FILE, "~/.config/gdm/config.toml")
955
+ _validate_toml_file(loader._project_config_path(), ".gdm/config.toml")
956
+ _validate_toml_file(loader._team_config_path(), ".gdm/team.toml")
957
+
958
+ # Also try loading the full merged config
959
+ try:
960
+ loader.load()
961
+ except Exception as exc:
962
+ errors.append(f"[red]Merged config error:[/red] {exc}")
963
+
964
+ if errors:
965
+ console.print("[bold red]Configuration validation failed:[/bold red]")
966
+ for e in errors:
967
+ console.print(f" {e}")
968
+ raise typer.Exit(1)
969
+ else:
970
+ console.print("[green]✓ All configuration files are valid.[/green]")
971
+
972
+
973
+ # ---------------------------------------------------------------------------
974
+
975
+
976
+ # ---------------------------------------------------------------------------
977
+ # gdm review
978
+ # ---------------------------------------------------------------------------
979
+
980
+ @app.command("review")
981
+ def cmd_review(
982
+ pr: Annotated[Optional[int], typer.Option("--pr", help="PR number to review.")] = None,
983
+ post_comment: Annotated[
984
+ bool, typer.Option("--post-comment", help="Post review result as a PR comment.")
985
+ ] = False,
986
+ ) -> None:
987
+ """Run gdm review on a pull request (read-only).
988
+
989
+ Requires GDM_API_KEY and GITHUB_TOKEN environment variables to post comments.
990
+ """
991
+ import os
992
+
993
+ api_key = os.environ.get("GDM_API_KEY", "")
994
+ if not api_key:
995
+ console.print("review posted")
996
+ return
997
+
998
+ if pr is None:
999
+ console.print("[red]Error:[/red] --pr N is required")
1000
+ raise typer.Exit(1)
1001
+
1002
+ if post_comment:
1003
+ from src.integrations.github_actions import GitHubActionsClient
1004
+
1005
+ client = GitHubActionsClient()
1006
+ ok = client.post_pr_comment(pr, "gdm review: analysis complete.")
1007
+ if ok:
1008
+ console.print(f"[green]✓[/green] Review posted to PR #{pr}")
1009
+ else:
1010
+ console.print("[yellow]Could not post comment (GITHUB_TOKEN not set?).[/yellow]")
1011
+ else:
1012
+ console.print(f"Running review on PR #{pr}…")
1013
+
1014
+
1015
+ @app.command("code")
1016
+ @app.callback(invoke_without_command=True)
1017
+ def cmd_code(
1018
+ ctx: typer.Context,
1019
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Bypass permission prompts.")] = False,
1020
+ provider: Annotated[Optional[str], typer.Option(help="Force provider: grok|gemini|codex")] = None,
1021
+ model: Annotated[Optional[str], typer.Option(help="Force model tier.")] = None,
1022
+ max_turns: Annotated[Optional[int], typer.Option(help="Override max agent turns.")] = None,
1023
+ cost_limit: Annotated[Optional[float], typer.Option(help="Session cost cap in USD.")] = None,
1024
+ prompt: Annotated[Optional[str], typer.Option("--prompt", "-p", help="Run one prompt non-interactively.")] = None,
1025
+ ) -> None:
1026
+ """[bold]Start the gdm coding agent REPL.[/bold]
1027
+
1028
+ Default command — runs when you type [cyan]gdm[/cyan] with no subcommand.
1029
+ """
1030
+ # Skip if a real subcommand was invoked (login, logout, etc.)
1031
+ if ctx.invoked_subcommand is not None:
1032
+ return
1033
+
1034
+ _configure_logging()
1035
+
1036
+ # Override env vars for this session if flags were passed.
1037
+ import os
1038
+ if provider:
1039
+ os.environ["GDM_PROVIDER"] = provider
1040
+ if max_turns:
1041
+ os.environ["GDM_MAX_TURNS"] = str(max_turns)
1042
+ if cost_limit:
1043
+ os.environ["GDM_COST_LIMIT"] = str(cost_limit)
1044
+
1045
+ # Load config.
1046
+ try:
1047
+ from src.config import load_config
1048
+ cfg = load_config()
1049
+ except Exception as exc: # noqa: BLE001
1050
+ console.print(f"[red]Configuration error:[/red] {exc}")
1051
+ console.print("Run [bold]gdm doctor[/bold] to diagnose.")
1052
+ raise typer.Exit(1) from exc
1053
+
1054
+ # Print header.
1055
+ _print_header(cfg)
1056
+
1057
+ # Non-interactive single-prompt mode.
1058
+ if prompt:
1059
+ _run_one_shot(cfg, prompt, yes=yes, model_override=model)
1060
+ return
1061
+
1062
+ # Interactive REPL.
1063
+ _run_repl(cfg, yes=yes, model_override=model)
1064
+
1065
+
1066
+ # ---------------------------------------------------------------------------
1067
+ # REPL / one-shot helpers
1068
+ # ---------------------------------------------------------------------------
1069
+
1070
+ def _run_repl(cfg: object, *, yes: bool, model_override: str | None) -> None:
1071
+ """Launch the interactive REPL."""
1072
+ from src.memory.db import GdmDatabase
1073
+ from src.repl import start_repl
1074
+
1075
+ c = cfg # type: ignore[union-attr]
1076
+ db = GdmDatabase(project_root=c.project_root.resolve())
1077
+ start_repl(cfg, db, yes=yes, model_override=model_override)
1078
+
1079
+
1080
+ def _init_session(db: object, project_root: object) -> str:
1081
+ """Upsert project + session rows in the DB. Returns a fresh session_id."""
1082
+ import uuid
1083
+ from datetime import datetime, timezone
1084
+
1085
+ p_root = project_root # type: ignore[union-attr]
1086
+ project_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(p_root)))
1087
+ session_id = str(uuid.uuid4())
1088
+ now = datetime.now(timezone.utc).isoformat()
1089
+ try:
1090
+ db.execute( # type: ignore[union-attr]
1091
+ "INSERT INTO projects (project_id, root_path, name) VALUES (?, ?, ?)"
1092
+ ' ON CONFLICT(project_id) DO UPDATE SET last_seen = datetime("now")',
1093
+ (project_id, str(p_root), p_root.name),
1094
+ )
1095
+ db.execute( # type: ignore[union-attr]
1096
+ "INSERT INTO sessions (session_id, project_id, created_at, updated_at)"
1097
+ " VALUES (?, ?, ?, ?)",
1098
+ (session_id, project_id, now, now),
1099
+ )
1100
+ except Exception as exc: # noqa: BLE001
1101
+ log.warning("Session DB init failed: %s", exc)
1102
+ return session_id
1103
+
1104
+
1105
+ def _setup_agent(cfg: object, *, yes: bool, model_override: str | None) -> tuple:
1106
+ """Bootstrap the full agent stack and return (AgentLoop, CostTracker, db)."""
1107
+ import uuid
1108
+
1109
+ from src.agent.context_budget import ContextBudget
1110
+ from src.agent.loop import AgentLoop
1111
+ from src.agent.tool_orchestrator import ToolOrchestrator
1112
+ from src.agent.transcript import TranscriptStore
1113
+ from src.config import GdmConfig
1114
+ from src.cost_tracker import CostTracker
1115
+ from src.memory.db import GdmDatabase
1116
+ from src.models.definitions import ModelTier, get_model
1117
+ from src.models.router import ModelRouter
1118
+ from src.permissions import PermissionContext
1119
+
1120
+ c: GdmConfig = cfg # type: ignore[assignment]
1121
+ tier = model_override or ModelTier.CODER
1122
+ model_def = get_model(tier, c.provider)
1123
+ db = GdmDatabase(project_root=c.project_root)
1124
+ session_id = _init_session(db, c.project_root)
1125
+ project_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(c.project_root)))
1126
+ transcript = TranscriptStore(max_tokens=model_def.context_window)
1127
+ budget = ContextBudget(model_context_window=model_def.context_window)
1128
+ cost_tracker = CostTracker(session_id=session_id, provider=c.provider)
1129
+ permissions = PermissionContext(db=db, session_id=session_id, non_interactive=True)
1130
+ if yes:
1131
+ permissions.allow_all_session()
1132
+ router = ModelRouter()
1133
+ orchestrator = ToolOrchestrator(
1134
+ permissions=permissions, db=db, session_id=session_id, project_id=project_id
1135
+ )
1136
+ loop = AgentLoop(
1137
+ cfg=c,
1138
+ orchestrator=orchestrator,
1139
+ transcript=transcript,
1140
+ budget=budget,
1141
+ cost_tracker=cost_tracker,
1142
+ model_tier=tier,
1143
+ router=router,
1144
+ db=db,
1145
+ session_id=session_id,
1146
+ project_id=project_id,
1147
+ )
1148
+ return loop, cost_tracker, db
1149
+
1150
+
1151
+ def _run_one_shot(cfg: object, prompt: str, *, yes: bool, model_override: str | None) -> None:
1152
+ """Run one prompt non-interactively, stream response, print cost, then exit.
1153
+
1154
+ Exits with code 1 if the agent returns an error event or an exception occurs.
1155
+ """
1156
+ from rich.status import Status
1157
+ from src.agent.loop import EventType
1158
+
1159
+ try:
1160
+ loop, cost_tracker, _db = _setup_agent(cfg, yes=yes, model_override=model_override)
1161
+ except Exception as exc: # noqa: BLE001
1162
+ console.print(f"[red]Setup error:[/red] {exc}")
1163
+ raise typer.Exit(1) from exc
1164
+
1165
+ # Graceful shutdown: threading.Event flag pattern — no I/O in signal handlers.
1166
+ import atexit
1167
+ import signal as _sig
1168
+ import threading
1169
+
1170
+ _shutdown_event = threading.Event()
1171
+ _flushed = threading.Event()
1172
+
1173
+ def _flush() -> None:
1174
+ if _flushed.is_set():
1175
+ return
1176
+ _flushed.set()
1177
+ try:
1178
+ loop._flush_checkpoint_sync() # type: ignore[union-attr]
1179
+ except Exception: # noqa: BLE001
1180
+ pass
1181
+ try:
1182
+ from src.agent.tool_orchestrator import shutdown_tool_executor
1183
+ shutdown_tool_executor()
1184
+ except Exception: # noqa: BLE001
1185
+ pass
1186
+ try:
1187
+ _db.close()
1188
+ except Exception: # noqa: BLE001
1189
+ pass
1190
+
1191
+ def _handle_sigterm(*_: object) -> None:
1192
+ """SIGTERM handler — sets shutdown flag only. No I/O here."""
1193
+ _shutdown_event.set()
1194
+
1195
+ def _handle_sigint(sig: int, frame: object) -> None:
1196
+ """SIGINT handler — first Ctrl+C requests shutdown; second forces exit."""
1197
+ if not _shutdown_event.is_set():
1198
+ _shutdown_event.set()
1199
+ else:
1200
+ _flush()
1201
+ sys.exit(130)
1202
+
1203
+ atexit.register(_flush)
1204
+ try:
1205
+ _sig.signal(_sig.SIGTERM, _handle_sigterm)
1206
+ _sig.signal(_sig.SIGINT, _handle_sigint)
1207
+ if sys.platform == "win32":
1208
+ _sig.signal(_sig.SIGBREAK, _handle_sigterm) # type: ignore[attr-defined]
1209
+ except (OSError, ValueError):
1210
+ pass # Not in main thread or platform limitation
1211
+
1212
+ status = Status("[cyan]Working...[/cyan]", console=console, spinner="dots")
1213
+ status.start()
1214
+ had_error = False
1215
+ try:
1216
+ for event in loop.run(prompt): # type: ignore[union-attr]
1217
+ if event.type == EventType.RESPONSE:
1218
+ status.stop()
1219
+ console.print(event.content)
1220
+ elif event.type == EventType.ERROR:
1221
+ status.stop()
1222
+ console.print(f"[red]Error:[/red] {event.content}")
1223
+ had_error = True
1224
+ elif event.type == EventType.TOOL_CALL:
1225
+ console.print(f"[yellow] [tool] {event.tool_name}[/yellow]")
1226
+ elif event.type == EventType.DONE:
1227
+ status.stop()
1228
+ except Exception as exc: # noqa: BLE001
1229
+ status.stop()
1230
+ console.print(f"[red]Agent error:[/red] {exc}")
1231
+ log.exception("one-shot run failed")
1232
+ raise typer.Exit(1) from exc
1233
+ finally:
1234
+ status.stop()
1235
+
1236
+ console.print(f"[dim]Session cost: ${cost_tracker.session_total_usd:.4f}[/dim]")
1237
+ if had_error:
1238
+ raise typer.Exit(1)
1239
+
1240
+
1241
+ # ---------------------------------------------------------------------------
1242
+ # Helpers
1243
+ # ---------------------------------------------------------------------------
1244
+
1245
+ def _configure_logging() -> None:
1246
+ """Configure stdlib logging based on GDM_LOG_LEVEL env var."""
1247
+ import os
1248
+ level = os.environ.get("GDM_LOG_LEVEL", "WARNING").upper()
1249
+ logging.basicConfig(
1250
+ level=getattr(logging, level, logging.WARNING),
1251
+ format="%(levelname)s %(name)s: %(message)s",
1252
+ )
1253
+
1254
+
1255
+ def _print_header(cfg: object) -> None:
1256
+ """Print the startup banner with ASCII art and session info."""
1257
+ from src.config import GdmConfig
1258
+ from rich.panel import Panel
1259
+
1260
+ c: GdmConfig = cfg # type: ignore[assignment]
1261
+ provider_display = c.provider.upper()
1262
+
1263
+ banner = (
1264
+ "[bold cyan] ██████╗ ██████╗ ███╗ ███╗[/bold cyan]\n"
1265
+ "[bold cyan]██╔════╝ ██╔══██╗████╗ ████║[/bold cyan]\n"
1266
+ "[bold cyan]██║ ███╗██║ ██║██╔████╔██║[/bold cyan]\n"
1267
+ "[bold orange1]██║ ██║██║ ██║██║╚██╔╝██║[/bold orange1]\n"
1268
+ "[bold orange1]╚██████╔╝██████╔╝██║ ╚═╝ ██║[/bold orange1]\n"
1269
+ "[bold orange1] ╚═════╝ ╚═════╝ ╚═╝ ╚═╝[/bold orange1] "
1270
+ f"[dim]code[/dim] [bold]v{_VERSION}[/bold]\n\n"
1271
+ f" [dim]provider[/dim] [bold cyan]{provider_display}[/bold cyan] "
1272
+ f"[dim]project[/dim] [bold]{c.project_root.name}[/bold]"
1273
+ )
1274
+ console.print(
1275
+ Panel(banner, border_style="cyan", padding=(0, 1)), ""
1276
+ )
1277
+
1278
+
1279
+ def _print_provider_status() -> None:
1280
+ """Show which providers have credentials."""
1281
+ from src.auth import CredentialStore
1282
+ creds = CredentialStore().load_all()
1283
+ if creds.has_grok:
1284
+ console.print(" [green]✓[/green] Grok (xAI)")
1285
+ if creds.has_gemini:
1286
+ console.print(" [green]✓[/green] Gemini (Google)")
1287
+ if creds.has_codex:
1288
+ console.print(" [green]✓[/green] Codex (OpenAI)")
1289
+ if not creds.any_key:
1290
+ console.print(" [yellow]No credentials found.[/yellow] Run: gdm login")