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/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")
|