@gaia-minds/assistant-cli 0.1.0

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.
@@ -0,0 +1,895 @@
1
+ #!/usr/bin/env python3
2
+ """Standalone Gaia personal assistant launcher.
3
+
4
+ This wrapper makes it easier for users to run Gaia's dual-track evolution loop
5
+ as a personal assistant runtime without OpenClaw as a hard dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import base64
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+
21
+
22
+ SCRIPT_DIR = Path(__file__).resolve().parent
23
+ REPO_ROOT = SCRIPT_DIR.parent
24
+ DEFAULT_HOME = Path(os.environ.get("GAIA_ASSISTANT_HOME", str(Path.home() / ".gaia-assistant"))).expanduser()
25
+ DEFAULT_STATE_DIR = DEFAULT_HOME / "state"
26
+ DEFAULT_CONFIG_PATH = DEFAULT_HOME / "config.json"
27
+ AGENT_CONFIG_PATH = SCRIPT_DIR / "agent-config.yml"
28
+ AGENT_LOOP_PATH = SCRIPT_DIR / "agent-loop.py"
29
+ DEFAULT_LAUNCHER_HINT = "python3 tools/gaia-assistant.py"
30
+ DEFAULT_GAIA_AUTH_STORE = DEFAULT_HOME / "auth-profiles.json"
31
+ DEFAULT_CODEX_AUTH_PATH = Path(
32
+ os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))
33
+ ).expanduser() / "auth.json"
34
+ DEFAULT_OPENCLAW_STATE_DIR = Path(
35
+ os.environ.get("OPENCLAW_STATE_DIR", str(Path.home() / ".openclaw"))
36
+ ).expanduser()
37
+
38
+
39
+ DEFAULT_CONFIG: Dict[str, Any] = {
40
+ "runtime": {
41
+ "mode": "continuous",
42
+ "interval_minutes": 60,
43
+ },
44
+ "auth": {
45
+ "providers": {
46
+ "anthropic": {
47
+ "subscription_oauth_supported": True,
48
+ "api_key_env": "ANTHROPIC_API_KEY",
49
+ },
50
+ "openai": {
51
+ "subscription_oauth_supported": True,
52
+ "api_key_env": "OPENAI_API_KEY",
53
+ },
54
+ }
55
+ },
56
+ "tracks": {
57
+ "default": "auto",
58
+ },
59
+ }
60
+
61
+
62
+ def _launcher_hint() -> str:
63
+ hint = os.environ.get("GAIA_ASSISTANT_CLI_HINT", "").strip()
64
+ return hint if hint else DEFAULT_LAUNCHER_HINT
65
+
66
+
67
+ def _load_json(path: Path) -> Dict[str, Any]:
68
+ if not path.exists():
69
+ return {}
70
+ try:
71
+ return json.loads(path.read_text(encoding="utf-8"))
72
+ except json.JSONDecodeError:
73
+ return {}
74
+
75
+
76
+ def _write_json(path: Path, payload: Dict[str, Any]) -> None:
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
79
+
80
+
81
+ def _write_secret_json(path: Path, payload: Dict[str, Any]) -> None:
82
+ _write_json(path, payload)
83
+ try:
84
+ os.chmod(path, 0o600)
85
+ except OSError:
86
+ # Best effort: on some filesystems chmod may not be supported.
87
+ pass
88
+
89
+
90
+ def _normalize_config(payload: Dict[str, Any]) -> Dict[str, Any]:
91
+ cfg: Dict[str, Any] = payload if isinstance(payload, dict) else {}
92
+
93
+ runtime = cfg.setdefault("runtime", {})
94
+ runtime.setdefault("mode", "continuous")
95
+ runtime.setdefault("interval_minutes", 60)
96
+
97
+ auth = cfg.setdefault("auth", {})
98
+ providers = auth.setdefault("providers", {})
99
+ auth.setdefault("store_path", str(DEFAULT_GAIA_AUTH_STORE))
100
+ default_providers = DEFAULT_CONFIG.get("auth", {}).get("providers", {})
101
+ for provider_name, provider_defaults in default_providers.items():
102
+ provider_cfg = providers.setdefault(provider_name, {})
103
+ if isinstance(provider_cfg, dict):
104
+ for key, value in provider_defaults.items():
105
+ provider_cfg.setdefault(key, value)
106
+ auth.setdefault("active_profile", None)
107
+
108
+ tracks = cfg.setdefault("tracks", {})
109
+ tracks.setdefault("default", "auto")
110
+ return cfg
111
+
112
+
113
+ def _ensure_config_exists(cfg_path: Path) -> Dict[str, Any]:
114
+ cfg = _load_json(cfg_path)
115
+ if not cfg:
116
+ cfg = _normalize_config(dict(DEFAULT_CONFIG))
117
+ _write_json(cfg_path, cfg)
118
+ return cfg
119
+
120
+ cfg = _normalize_config(cfg)
121
+ _write_json(cfg_path, cfg)
122
+ return cfg
123
+
124
+
125
+ def _resolve_openclaw_auth_store(openclaw_agent: str, override_path: Optional[str]) -> Path:
126
+ if override_path:
127
+ return Path(override_path).expanduser()
128
+ return DEFAULT_OPENCLAW_STATE_DIR / "agents" / openclaw_agent / "agent" / "auth-profiles.json"
129
+
130
+
131
+ def _resolve_codex_auth_path(override_path: Optional[str]) -> Path:
132
+ if override_path:
133
+ return Path(override_path).expanduser()
134
+ return DEFAULT_CODEX_AUTH_PATH
135
+
136
+
137
+ def _resolve_gaia_auth_store(cfg_path: Path, override_path: Optional[str]) -> Path:
138
+ if override_path:
139
+ return Path(override_path).expanduser()
140
+ cfg = _load_json(cfg_path)
141
+ if cfg:
142
+ store = cfg.get("auth", {}).get("store_path")
143
+ if isinstance(store, str) and store.strip():
144
+ return Path(store).expanduser()
145
+ return cfg_path.parent / "auth-profiles.json"
146
+
147
+
148
+ def _load_gaia_auth_store(path: Path) -> Dict[str, Any]:
149
+ payload = _load_json(path)
150
+ if not payload:
151
+ return {"version": 1, "profiles": {}}
152
+ if not isinstance(payload.get("profiles"), dict):
153
+ payload["profiles"] = {}
154
+ if "version" not in payload:
155
+ payload["version"] = 1
156
+ return payload
157
+
158
+
159
+ def _save_gaia_auth_store(path: Path, payload: Dict[str, Any]) -> None:
160
+ _write_secret_json(path, payload)
161
+
162
+
163
+ def _decode_jwt_payload(token: str) -> Dict[str, Any]:
164
+ parts = token.split(".")
165
+ if len(parts) < 2:
166
+ return {}
167
+ payload = parts[1]
168
+ payload += "=" * (-len(payload) % 4)
169
+ try:
170
+ raw = base64.urlsafe_b64decode(payload.encode("ascii")).decode("utf-8")
171
+ claims = json.loads(raw)
172
+ return claims if isinstance(claims, dict) else {}
173
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
174
+ return {}
175
+
176
+
177
+ def _read_codex_cli_credentials(codex_auth_path: Path) -> Optional[Dict[str, Any]]:
178
+ payload = _load_json(codex_auth_path)
179
+ if not payload:
180
+ return None
181
+ tokens = payload.get("tokens")
182
+ if not isinstance(tokens, dict):
183
+ return None
184
+
185
+ access = tokens.get("access_token")
186
+ refresh = tokens.get("refresh_token")
187
+ account_id = tokens.get("account_id")
188
+ if not isinstance(access, str) or not access.strip():
189
+ return None
190
+ if not isinstance(refresh, str) or not refresh.strip():
191
+ return None
192
+
193
+ claims = _decode_jwt_payload(access)
194
+ exp_s = claims.get("exp")
195
+ expires_ms: int
196
+ if isinstance(exp_s, (int, float)):
197
+ expires_ms = int(exp_s * 1000)
198
+ else:
199
+ # Conservative fallback if JWT claims aren't available.
200
+ expires_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + 3600 * 1000
201
+
202
+ profile_claims = claims.get("https://api.openai.com/profile")
203
+ email = ""
204
+ if isinstance(profile_claims, dict):
205
+ raw_email = profile_claims.get("email")
206
+ if isinstance(raw_email, str):
207
+ email = raw_email.strip()
208
+
209
+ auth_claims = claims.get("https://api.openai.com/auth")
210
+ if not account_id and isinstance(auth_claims, dict):
211
+ auth_account_id = auth_claims.get("chatgpt_account_id")
212
+ if isinstance(auth_account_id, str) and auth_account_id.strip():
213
+ account_id = auth_account_id.strip()
214
+
215
+ return {
216
+ "type": "oauth",
217
+ "provider": "openai-codex",
218
+ "access": access,
219
+ "refresh": refresh,
220
+ "expires": expires_ms,
221
+ "account_id": str(account_id).strip() if isinstance(account_id, str) else "",
222
+ "email": email,
223
+ "source": "codex-cli",
224
+ "updated_at": datetime.now(timezone.utc).isoformat(),
225
+ }
226
+
227
+
228
+ def _load_openclaw_profiles(path: Path) -> Dict[str, Any]:
229
+ payload = _load_json(path)
230
+ profiles = payload.get("profiles", {})
231
+ return profiles if isinstance(profiles, dict) else {}
232
+
233
+
234
+ def _is_expired(credential: Dict[str, Any]) -> bool:
235
+ expires = credential.get("expires")
236
+ if not isinstance(expires, (int, float)):
237
+ return False
238
+ return int(expires) <= int(datetime.now(timezone.utc).timestamp() * 1000)
239
+
240
+
241
+ def _format_expiry(credential: Dict[str, Any]) -> str:
242
+ expires = credential.get("expires")
243
+ if not isinstance(expires, (int, float)):
244
+ return "unknown"
245
+ try:
246
+ dt = datetime.fromtimestamp(int(expires) / 1000, tz=timezone.utc)
247
+ return dt.isoformat()
248
+ except (OSError, ValueError):
249
+ return "unknown"
250
+
251
+
252
+ def _collect_provider_profiles(profiles: Dict[str, Any], provider: str) -> Dict[str, Dict[str, Any]]:
253
+ out: Dict[str, Dict[str, Any]] = {}
254
+ for profile_id, credential in profiles.items():
255
+ if not isinstance(credential, dict):
256
+ continue
257
+ if credential.get("provider") != provider:
258
+ continue
259
+ out[profile_id] = credential
260
+ return out
261
+
262
+
263
+ def _pick_profile_id(profiles: Dict[str, Dict[str, Any]]) -> Optional[str]:
264
+ if not profiles:
265
+ return None
266
+
267
+ ranked: List[Tuple[int, int, str]] = []
268
+ for profile_id, credential in profiles.items():
269
+ expires = credential.get("expires")
270
+ exp = int(expires) if isinstance(expires, (int, float)) else 0
271
+ expired = 1 if _is_expired(credential) else 0
272
+ ranked.append((expired, -exp, profile_id))
273
+ ranked.sort()
274
+ return ranked[0][2]
275
+
276
+
277
+ def _profile_id_for_credential(provider: str, credential: Dict[str, Any]) -> str:
278
+ email = credential.get("email")
279
+ if isinstance(email, str) and email.strip():
280
+ return f"{provider}:{email.strip().lower()}"
281
+ account_id = credential.get("account_id")
282
+ if isinstance(account_id, str) and account_id.strip():
283
+ return f"{provider}:{account_id.strip()}"
284
+ return f"{provider}:default"
285
+
286
+
287
+ def _link_profile(
288
+ cfg_path: Path,
289
+ provider: str,
290
+ profile_id: str,
291
+ source: str,
292
+ store_path: Path,
293
+ ) -> int:
294
+ cfg = _ensure_config_exists(cfg_path)
295
+ auth = cfg.setdefault("auth", {})
296
+ auth["store_path"] = str(store_path)
297
+ auth["active_profile"] = {
298
+ "provider": provider,
299
+ "profile_id": profile_id,
300
+ "source": source,
301
+ "store_path": str(store_path),
302
+ "linked_at": datetime.now(timezone.utc).isoformat(),
303
+ }
304
+ _write_json(cfg_path, cfg)
305
+ return 0
306
+
307
+
308
+ def _codex_login() -> int:
309
+ if not shutil.which("codex"):
310
+ print(
311
+ "Codex CLI is not installed. Install it, then run:\n"
312
+ " codex login",
313
+ file=sys.stderr,
314
+ )
315
+ return 1
316
+ cmd = ["codex", "login", "--device-auth"]
317
+ return subprocess.run(cmd).returncode
318
+
319
+
320
+ def _import_codex_profile_to_gaia(
321
+ cfg_path: Path,
322
+ provider: str,
323
+ codex_auth_path: Path,
324
+ gaia_auth_store: Path,
325
+ ) -> Tuple[int, str]:
326
+ if provider != "openai-codex":
327
+ return 1, ""
328
+ credential = _read_codex_cli_credentials(codex_auth_path)
329
+ if credential is None:
330
+ print(
331
+ "Codex credentials not found after login.\n"
332
+ f"Expected auth file: {codex_auth_path}",
333
+ file=sys.stderr,
334
+ )
335
+ return 1, ""
336
+
337
+ store = _load_gaia_auth_store(gaia_auth_store)
338
+ profiles = store.setdefault("profiles", {})
339
+ if not isinstance(profiles, dict):
340
+ profiles = {}
341
+ store["profiles"] = profiles
342
+
343
+ profile_id = _profile_id_for_credential(provider, credential)
344
+ profiles[profile_id] = credential
345
+ _save_gaia_auth_store(gaia_auth_store, store)
346
+ _link_profile(cfg_path, provider, profile_id, "gaia-local", gaia_auth_store)
347
+ return 0, profile_id
348
+
349
+
350
+ def _link_openclaw_profile(
351
+ cfg_path: Path,
352
+ provider: str,
353
+ profile_id: str,
354
+ openclaw_store: Path,
355
+ ) -> int:
356
+ return _link_profile(cfg_path, provider, profile_id, "openclaw", openclaw_store)
357
+
358
+
359
+ def _read_linked_credential(active_profile: Dict[str, Any]) -> Tuple[Optional[Dict[str, Any]], str]:
360
+ source = str(active_profile.get("source", "")).strip()
361
+ provider = str(active_profile.get("provider", "")).strip()
362
+ profile_id = str(active_profile.get("profile_id", "")).strip()
363
+ store_raw = str(active_profile.get("store_path", "")).strip()
364
+ if not store_raw:
365
+ return None, "linked profile store_path is empty"
366
+
367
+ store_path = Path(store_raw).expanduser()
368
+ if not store_path.exists():
369
+ return None, f"linked profile store not found: {store_path}"
370
+
371
+ if source == "gaia-local":
372
+ profiles = _load_gaia_auth_store(store_path).get("profiles", {})
373
+ elif source == "openclaw":
374
+ profiles = _load_openclaw_profiles(store_path)
375
+ else:
376
+ return None, f"unknown auth source: {source}"
377
+
378
+ if not isinstance(profiles, dict):
379
+ return None, f"profile store is invalid: {store_path}"
380
+
381
+ credential = profiles.get(profile_id)
382
+ if not isinstance(credential, dict):
383
+ return None, f"linked profile missing in store: {profile_id} ({store_path})"
384
+
385
+ if provider and credential.get("provider") != provider:
386
+ return None, (
387
+ f"linked profile provider mismatch for {profile_id}: "
388
+ f"expected {provider}, got {credential.get('provider')}"
389
+ )
390
+
391
+ return credential, ""
392
+
393
+
394
+ def cmd_init(args: argparse.Namespace) -> int:
395
+ cfg_path = Path(args.config).expanduser()
396
+ state_dir = Path(args.state_dir).expanduser()
397
+ try:
398
+ state_dir.mkdir(parents=True, exist_ok=True)
399
+ except PermissionError:
400
+ print(
401
+ "Cannot create state directory due to permissions. "
402
+ "Set GAIA_ASSISTANT_HOME or pass --state-dir to a writable path.",
403
+ file=sys.stderr,
404
+ )
405
+ return 1
406
+ if cfg_path.exists() and not args.force:
407
+ print(
408
+ f"Config already exists at {cfg_path}. Use --force to overwrite.",
409
+ file=sys.stderr,
410
+ )
411
+ return 1
412
+ try:
413
+ _write_json(cfg_path, DEFAULT_CONFIG)
414
+ except PermissionError:
415
+ print(
416
+ "Cannot write config due to permissions. "
417
+ "Set GAIA_ASSISTANT_HOME or pass --config to a writable path.",
418
+ file=sys.stderr,
419
+ )
420
+ return 1
421
+ print(f"Initialized Gaia assistant config: {cfg_path}")
422
+ print(f"Initialized Gaia assistant state dir: {state_dir}")
423
+ return 0
424
+
425
+
426
+ def cmd_doctor(args: argparse.Namespace) -> int:
427
+ problems = 0
428
+ required_cmds = ["python3", "git"]
429
+ optional_cmds = ["gh", "codex", "openclaw"]
430
+ cfg_path = Path(args.config).expanduser()
431
+
432
+ print("Gaia assistant doctor")
433
+ print("====================")
434
+
435
+ for name in required_cmds:
436
+ if shutil.which(name):
437
+ print(f"[ok] required command found: {name}")
438
+ else:
439
+ problems += 1
440
+ print(f"[missing] required command not found: {name}")
441
+
442
+ for name in optional_cmds:
443
+ if shutil.which(name):
444
+ print(f"[ok] optional command found: {name}")
445
+ else:
446
+ print(f"[warn] optional command not found: {name}")
447
+
448
+ anth = os.environ.get("ANTHROPIC_API_KEY")
449
+ oai = os.environ.get("OPENAI_API_KEY")
450
+ if anth or oai:
451
+ print("[ok] at least one API auth env var is present")
452
+ else:
453
+ print("[warn] no API key env vars found (ANTHROPIC_API_KEY / OPENAI_API_KEY)")
454
+ print(" subscription OAuth profiles may still be used depending on your runtime setup")
455
+
456
+ cfg = _load_json(cfg_path)
457
+ if not cfg:
458
+ print(f"[warn] launcher config not found yet: {cfg_path}")
459
+ launcher = _launcher_hint()
460
+ print(f" run `{launcher} init` or `{launcher} onboard`")
461
+ else:
462
+ cfg = _normalize_config(cfg)
463
+ active = cfg.get("auth", {}).get("active_profile")
464
+ if not isinstance(active, dict):
465
+ print("[warn] no linked OAuth profile in launcher config")
466
+ print(f" run `{_launcher_hint()} onboard`")
467
+ else:
468
+ credential, error = _read_linked_credential(active)
469
+ if credential is None:
470
+ print(f"[warn] {error}")
471
+ else:
472
+ profile_id = str(active.get("profile_id", "")).strip()
473
+ expiry = _format_expiry(credential)
474
+ if _is_expired(credential):
475
+ print(f"[warn] linked OAuth profile is expired: {profile_id} (expires={expiry})")
476
+ else:
477
+ print(f"[ok] linked OAuth profile found: {profile_id} (expires={expiry})")
478
+
479
+ if not AGENT_CONFIG_PATH.exists():
480
+ problems += 1
481
+ print(f"[missing] agent config not found: {AGENT_CONFIG_PATH}")
482
+ else:
483
+ print(f"[ok] agent config found: {AGENT_CONFIG_PATH}")
484
+
485
+ if not AGENT_LOOP_PATH.exists():
486
+ problems += 1
487
+ print(f"[missing] agent loop not found: {AGENT_LOOP_PATH}")
488
+ else:
489
+ print(f"[ok] agent loop found: {AGENT_LOOP_PATH}")
490
+
491
+ if problems:
492
+ print(f"\nDoctor found {problems} blocking problem(s).", file=sys.stderr)
493
+ return 1
494
+ print("\nDoctor finished with no blocking problems.")
495
+ return 0
496
+
497
+
498
+ def cmd_onboard(args: argparse.Namespace) -> int:
499
+ cfg_path = Path(args.config).expanduser()
500
+ state_dir = Path(args.state_dir).expanduser()
501
+ state_dir.mkdir(parents=True, exist_ok=True)
502
+ cfg_created = not cfg_path.exists()
503
+ _ensure_config_exists(cfg_path)
504
+
505
+ print("Gaia assistant onboarding")
506
+ print("=========================")
507
+ if cfg_created:
508
+ print(f"[ok] created launcher config: {cfg_path}")
509
+ else:
510
+ print(f"[ok] using existing launcher config: {cfg_path}")
511
+ print(f"[ok] state directory ready: {state_dir}")
512
+ print("")
513
+ print("Auth flow selected: Gaia native profile store + Codex web OAuth broker")
514
+ print("This opens a browser/device auth flow through Codex CLI.")
515
+ print("Tokens are copied into Gaia local auth store (outside this repository).")
516
+ sys.stdout.flush()
517
+
518
+ proceed = "y"
519
+ if not args.yes:
520
+ proceed = input("Start web OAuth login now? [Y/n]: ").strip().lower()
521
+ if proceed in ("n", "no"):
522
+ print("Skipped OAuth login. Run this later:")
523
+ print(f" {_launcher_hint()} auth login --provider openai-codex")
524
+ return 0
525
+
526
+ login_args = argparse.Namespace(
527
+ config=str(cfg_path),
528
+ provider="openai-codex",
529
+ source="codex-cli",
530
+ codex_auth_path=args.codex_auth_path,
531
+ gaia_auth_store=args.gaia_auth_store,
532
+ openclaw_agent="main",
533
+ openclaw_auth_store=None,
534
+ no_prompt=True,
535
+ )
536
+ return cmd_auth_login(login_args)
537
+
538
+
539
+ def cmd_auth_login(args: argparse.Namespace) -> int:
540
+ cfg_path = Path(args.config).expanduser()
541
+ _ensure_config_exists(cfg_path)
542
+ provider = str(args.provider).strip()
543
+ source = str(args.source).strip()
544
+
545
+ if provider != "openai-codex":
546
+ print(f"Unsupported OAuth provider: {provider}", file=sys.stderr)
547
+ print("Supported provider for now: openai-codex", file=sys.stderr)
548
+ return 1
549
+
550
+ if source == "codex-cli":
551
+ if not args.no_prompt:
552
+ answer = input(
553
+ "Gaia will run Codex web OAuth login, then import credentials into Gaia local auth store.\n"
554
+ "Continue? [Y/n]: "
555
+ ).strip().lower()
556
+ if answer in ("n", "no"):
557
+ print("Canceled.")
558
+ return 1
559
+
560
+ rc = _codex_login()
561
+ if rc != 0:
562
+ return rc
563
+
564
+ codex_auth_path = _resolve_codex_auth_path(args.codex_auth_path)
565
+ gaia_auth_store = _resolve_gaia_auth_store(cfg_path, args.gaia_auth_store)
566
+ rc, profile_id = _import_codex_profile_to_gaia(cfg_path, provider, codex_auth_path, gaia_auth_store)
567
+ if rc != 0:
568
+ return rc
569
+
570
+ store = _load_gaia_auth_store(gaia_auth_store)
571
+ profiles = store.get("profiles", {}) if isinstance(store, dict) else {}
572
+ credential = profiles.get(profile_id) if isinstance(profiles, dict) else {}
573
+ expiry = _format_expiry(credential if isinstance(credential, dict) else {})
574
+
575
+ print("[ok] OAuth profile linked for Gaia assistant")
576
+ print(f" source: gaia-local (imported from codex-cli)")
577
+ print(f" provider: {provider}")
578
+ print(f" profile: {profile_id}")
579
+ print(f" store: {gaia_auth_store}")
580
+ print(f" expires: {expiry}")
581
+ print("")
582
+ print("Note: credentials are stored in local Gaia auth store, not in this repository.")
583
+ return 0
584
+
585
+ if source == "openclaw":
586
+ if not args.no_prompt:
587
+ answer = input(
588
+ "OAuth login opens a browser and stores tokens in OpenClaw local state.\n"
589
+ "Continue? [Y/n]: "
590
+ ).strip().lower()
591
+ if answer in ("n", "no"):
592
+ print("Canceled.")
593
+ return 1
594
+
595
+ if not shutil.which("openclaw"):
596
+ print(
597
+ "OpenClaw CLI is not installed. Install it, or use --source codex-cli.",
598
+ file=sys.stderr,
599
+ )
600
+ return 1
601
+
602
+ rc = subprocess.run(["openclaw", "models", "auth", "login", "--provider", provider]).returncode
603
+ if rc != 0:
604
+ return rc
605
+
606
+ store_path = _resolve_openclaw_auth_store(args.openclaw_agent, args.openclaw_auth_store)
607
+ profiles = _load_openclaw_profiles(store_path)
608
+ provider_profiles = _collect_provider_profiles(profiles, provider)
609
+ if not provider_profiles:
610
+ print(
611
+ "OAuth login finished but no matching OpenClaw profile was found.\n"
612
+ f"Expected store: {store_path}",
613
+ file=sys.stderr,
614
+ )
615
+ return 1
616
+
617
+ selected_profile_id = args.profile_id or _pick_profile_id(provider_profiles)
618
+ if selected_profile_id not in provider_profiles:
619
+ print(
620
+ f"Requested profile not found for provider {provider}: {selected_profile_id}",
621
+ file=sys.stderr,
622
+ )
623
+ return 1
624
+
625
+ _link_openclaw_profile(cfg_path, provider, selected_profile_id, store_path)
626
+ credential = provider_profiles[selected_profile_id]
627
+ print("[ok] OAuth profile linked for Gaia assistant")
628
+ print(f" source: openclaw")
629
+ print(f" provider: {provider}")
630
+ print(f" profile: {selected_profile_id}")
631
+ print(f" store: {store_path}")
632
+ print(f" expires: {_format_expiry(credential)}")
633
+ print("")
634
+ print("Note: tokens are not written to this repository.")
635
+ return 0
636
+
637
+ print(f"Unsupported auth source: {source}", file=sys.stderr)
638
+ print("Supported sources: codex-cli, openclaw", file=sys.stderr)
639
+ return 1
640
+
641
+
642
+ def cmd_auth_link(args: argparse.Namespace) -> int:
643
+ cfg_path = Path(args.config).expanduser()
644
+ _ensure_config_exists(cfg_path)
645
+ provider = str(args.provider).strip()
646
+ source = str(args.source).strip()
647
+
648
+ if source == "codex-cli":
649
+ codex_auth_path = _resolve_codex_auth_path(args.codex_auth_path)
650
+ gaia_auth_store = _resolve_gaia_auth_store(cfg_path, args.gaia_auth_store)
651
+ rc, profile_id = _import_codex_profile_to_gaia(cfg_path, provider, codex_auth_path, gaia_auth_store)
652
+ if rc != 0:
653
+ return rc
654
+ print("[ok] Imported and linked Codex OAuth profile into Gaia local store")
655
+ print(f" provider: {provider}")
656
+ print(f" profile: {profile_id}")
657
+ print(f" store: {gaia_auth_store}")
658
+ return 0
659
+
660
+ if source == "openclaw":
661
+ store_path = _resolve_openclaw_auth_store(args.openclaw_agent, args.openclaw_auth_store)
662
+ profiles = _load_openclaw_profiles(store_path)
663
+ provider_profiles = _collect_provider_profiles(profiles, provider)
664
+ if not provider_profiles:
665
+ print(
666
+ f"No profiles found for provider '{provider}' in {store_path}.",
667
+ file=sys.stderr,
668
+ )
669
+ return 1
670
+
671
+ selected_profile_id = args.profile_id or _pick_profile_id(provider_profiles)
672
+ if selected_profile_id not in provider_profiles:
673
+ print(f"Requested profile not found: {selected_profile_id}", file=sys.stderr)
674
+ return 1
675
+
676
+ _link_openclaw_profile(cfg_path, provider, selected_profile_id, store_path)
677
+ print("[ok] Linked existing OpenClaw auth profile")
678
+ print(f" provider: {provider}")
679
+ print(f" profile: {selected_profile_id}")
680
+ print(f" store: {store_path}")
681
+ return 0
682
+
683
+ print(f"Unsupported auth source: {source}", file=sys.stderr)
684
+ print("Supported sources: codex-cli, openclaw", file=sys.stderr)
685
+ return 1
686
+
687
+
688
+ def cmd_auth_status(args: argparse.Namespace) -> int:
689
+ cfg_path = Path(args.config).expanduser()
690
+ cfg = _load_json(cfg_path)
691
+ if not cfg:
692
+ print(f"Launcher config not found: {cfg_path}")
693
+ return 1
694
+
695
+ cfg = _normalize_config(cfg)
696
+ active = cfg.get("auth", {}).get("active_profile")
697
+ if not isinstance(active, dict):
698
+ print("No linked auth profile in launcher config.")
699
+ print(f"Run: {_launcher_hint()} auth login --provider openai-codex")
700
+ return 1
701
+
702
+ provider = str(active.get("provider", "")).strip()
703
+ profile_id = str(active.get("profile_id", "")).strip()
704
+ source = str(active.get("source", "")).strip()
705
+ store_path = Path(str(active.get("store_path", "")).strip()).expanduser()
706
+
707
+ print("Gaia assistant auth status")
708
+ print("==========================")
709
+ print(f"source: {source}")
710
+ print(f"provider: {provider}")
711
+ print(f"profile: {profile_id}")
712
+ print(f"store: {store_path}")
713
+
714
+ credential, error = _read_linked_credential(active)
715
+ if credential is None:
716
+ print(f"[warn] {error}")
717
+ return 1
718
+
719
+ cred_type = str(credential.get("type", "unknown"))
720
+ expires = _format_expiry(credential)
721
+ email = str(credential.get("email", "")).strip() or "n/a"
722
+ print(f"type: {cred_type}")
723
+ print(f"email: {email}")
724
+ print(f"expires: {expires}")
725
+ if _is_expired(credential):
726
+ print("[warn] OAuth profile is expired")
727
+ return 1
728
+ print("[ok] OAuth profile is ready")
729
+ return 0
730
+
731
+
732
+ def cmd_run(args: argparse.Namespace) -> int:
733
+ cfg_path = Path(args.config).expanduser()
734
+ try:
735
+ cfg = _ensure_config_exists(cfg_path)
736
+ except PermissionError:
737
+ print(
738
+ "Cannot create runtime config due to permissions. "
739
+ "Set GAIA_ASSISTANT_HOME or pass --config to a writable path.",
740
+ file=sys.stderr,
741
+ )
742
+ return 1
743
+
744
+ cmd = [
745
+ "python3",
746
+ str(AGENT_LOOP_PATH),
747
+ "--config",
748
+ str(AGENT_CONFIG_PATH),
749
+ "--mode",
750
+ args.mode,
751
+ ]
752
+ if args.dry_run:
753
+ cmd.append("--dry-run")
754
+ if args.verbose:
755
+ cmd.append("--verbose")
756
+
757
+ env = os.environ.copy()
758
+ if args.track != "auto":
759
+ env["GAIA_ACTIVE_TRACK_OVERRIDE"] = args.track
760
+
761
+ runtime_cfg = cfg.get("runtime", {})
762
+ if args.mode == "continuous" and "interval_minutes" in runtime_cfg:
763
+ print(f"Running Gaia assistant in continuous mode (interval={runtime_cfg['interval_minutes']}m)")
764
+ else:
765
+ print(f"Running Gaia assistant in {args.mode} mode")
766
+ if args.track != "auto":
767
+ print(f"Track override: {args.track}")
768
+
769
+ active_profile = cfg.get("auth", {}).get("active_profile")
770
+ if isinstance(active_profile, dict):
771
+ print(
772
+ "Auth profile: "
773
+ f"{active_profile.get('provider', '?')}/{active_profile.get('profile_id', '?')} "
774
+ f"({active_profile.get('source', '?')})"
775
+ )
776
+
777
+ result = subprocess.run(cmd, cwd=str(REPO_ROOT), env=env)
778
+ return result.returncode
779
+
780
+
781
+ def build_parser() -> argparse.ArgumentParser:
782
+ parser = argparse.ArgumentParser(
783
+ description="Gaia standalone personal assistant launcher",
784
+ )
785
+ sub = parser.add_subparsers(dest="command", required=True)
786
+
787
+ init = sub.add_parser("init", help="Initialize local Gaia assistant config")
788
+ init.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
789
+ init.add_argument("--state-dir", default=str(DEFAULT_STATE_DIR), help="Local state directory")
790
+ init.add_argument("--force", action="store_true", help="Overwrite config if it exists")
791
+ init.set_defaults(func=cmd_init)
792
+
793
+ onboard = sub.add_parser("onboard", help="Guided onboarding with Gaia-native OAuth linking")
794
+ onboard.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
795
+ onboard.add_argument("--state-dir", default=str(DEFAULT_STATE_DIR), help="Local state directory")
796
+ onboard.add_argument(
797
+ "--gaia-auth-store",
798
+ default=None,
799
+ help="Path to Gaia auth-profiles.json (optional override)",
800
+ )
801
+ onboard.add_argument(
802
+ "--codex-auth-path",
803
+ default=None,
804
+ help="Path to Codex auth.json (optional override)",
805
+ )
806
+ onboard.add_argument("--yes", action="store_true", help="Skip onboarding confirmations")
807
+ onboard.set_defaults(func=cmd_onboard)
808
+
809
+ doctor = sub.add_parser("doctor", help="Validate local environment and auth readiness")
810
+ doctor.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
811
+ doctor.set_defaults(func=cmd_doctor)
812
+
813
+ auth = sub.add_parser("auth", help="Manage OAuth profile linkage for Gaia assistant")
814
+ auth_sub = auth.add_subparsers(dest="auth_command", required=True)
815
+
816
+ auth_login = auth_sub.add_parser("login", help="Run web OAuth login and link profile")
817
+ auth_login.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
818
+ auth_login.add_argument("--provider", default="openai-codex", help="Provider id (default: openai-codex)")
819
+ auth_login.add_argument(
820
+ "--source",
821
+ choices=["codex-cli", "openclaw"],
822
+ default="codex-cli",
823
+ help="OAuth source (default: codex-cli)",
824
+ )
825
+ auth_login.add_argument(
826
+ "--gaia-auth-store",
827
+ default=None,
828
+ help="Path to Gaia auth-profiles.json (optional override)",
829
+ )
830
+ auth_login.add_argument(
831
+ "--codex-auth-path",
832
+ default=None,
833
+ help="Path to Codex auth.json (optional override)",
834
+ )
835
+ auth_login.add_argument("--openclaw-agent", default="main", help="OpenClaw agent id (openclaw source only)")
836
+ auth_login.add_argument(
837
+ "--openclaw-auth-store",
838
+ default=None,
839
+ help="Path to OpenClaw auth-profiles.json (openclaw source only)",
840
+ )
841
+ auth_login.add_argument("--profile-id", default=None, help="Explicit profile id to link")
842
+ auth_login.add_argument("--no-prompt", action="store_true", help="Skip confirmation prompts")
843
+ auth_login.set_defaults(func=cmd_auth_login)
844
+
845
+ auth_link = auth_sub.add_parser("link", help="Link an existing profile without logging in")
846
+ auth_link.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
847
+ auth_link.add_argument("--provider", default="openai-codex", help="Provider id (default: openai-codex)")
848
+ auth_link.add_argument(
849
+ "--source",
850
+ choices=["codex-cli", "openclaw"],
851
+ default="codex-cli",
852
+ help="Profile source (default: codex-cli)",
853
+ )
854
+ auth_link.add_argument(
855
+ "--gaia-auth-store",
856
+ default=None,
857
+ help="Path to Gaia auth-profiles.json (optional override)",
858
+ )
859
+ auth_link.add_argument(
860
+ "--codex-auth-path",
861
+ default=None,
862
+ help="Path to Codex auth.json (optional override)",
863
+ )
864
+ auth_link.add_argument("--openclaw-agent", default="main", help="OpenClaw agent id (openclaw source only)")
865
+ auth_link.add_argument(
866
+ "--openclaw-auth-store",
867
+ default=None,
868
+ help="Path to OpenClaw auth-profiles.json (openclaw source only)",
869
+ )
870
+ auth_link.add_argument("--profile-id", default=None, help="Explicit profile id to link")
871
+ auth_link.set_defaults(func=cmd_auth_link)
872
+
873
+ auth_status = auth_sub.add_parser("status", help="Show linked auth profile status")
874
+ auth_status.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
875
+ auth_status.set_defaults(func=cmd_auth_status)
876
+
877
+ run = sub.add_parser("run", help="Run Gaia assistant loop")
878
+ run.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Launcher config JSON path")
879
+ run.add_argument("--mode", choices=["single", "continuous"], default="single")
880
+ run.add_argument("--track", choices=["auto", "assistant", "framework"], default="auto")
881
+ run.add_argument("--dry-run", action="store_true", help="Plan only, do not execute actions")
882
+ run.add_argument("--verbose", action="store_true", help="Verbose logs")
883
+ run.set_defaults(func=cmd_run)
884
+
885
+ return parser
886
+
887
+
888
+ def main() -> int:
889
+ parser = build_parser()
890
+ args = parser.parse_args()
891
+ return int(args.func(args))
892
+
893
+
894
+ if __name__ == "__main__":
895
+ raise SystemExit(main())