awnode 0.3.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.
awnode/cli.py ADDED
@@ -0,0 +1,884 @@
1
+ """
2
+ AwNode CLI
3
+ ===============
4
+
5
+ awnode start # Start on port 8090 (auto-detect backends)
6
+ awnode start --cloud # Force cloud mode
7
+ awnode start --port 9090 # Custom port
8
+ awnode status # Show backend status
9
+ awnode connect <api_key> # Register with Elysium
10
+ awnode setup # Detect GPU, pull models, configure
11
+ awnode mcp # Start MCP server (stdio, for Claude Code)
12
+ awnode mcp --transport sse # Start MCP server (HTTP SSE)
13
+ awnode mcp-config # Output .mcp.json config snippet
14
+ awnode deploy <service> # Deploy via AitherComet
15
+ """
16
+
17
+ import argparse
18
+ import asyncio
19
+ import json
20
+ import os
21
+ import sys
22
+
23
+
24
+ def main():
25
+ # License entitlement gate (logs tier, wires metering quotas; never blocks unless
26
+ # AITHER_LICENSE_REQUIRE=1). adk must be importable for this to apply.
27
+ try:
28
+ from adk.license_startup import gate_startup
29
+ gate_startup(product="node")
30
+ except Exception:
31
+ pass # adk/licensing unavailable — node runs unmetered (dev)
32
+
33
+ parser = argparse.ArgumentParser(
34
+ prog="awnode",
35
+ description="AwNode — lightweight local gateway for AitherOS",
36
+ )
37
+ sub = parser.add_subparsers(dest="command", help="Command")
38
+
39
+ # start
40
+ start_p = sub.add_parser("start", help="Start AwNode server")
41
+ start_p.add_argument("--port", type=int, default=8090, help="Port (default: 8090)")
42
+ start_p.add_argument("--host", default="127.0.0.1", help="Host (default: 127.0.0.1; set to 0.0.0.0 to listen on all interfaces with bearer token auth)")
43
+ start_p.add_argument("--cloud", action="store_true", help="Force cloud mode (Elysium)")
44
+ start_p.add_argument("--local", action="store_true", help="Force local mode (no cloud)")
45
+ start_p.add_argument("--vllm-url", type=str, help="vLLM URL (default: http://localhost:8120)")
46
+ start_p.add_argument("--ollama-url", type=str, help="Ollama URL (default: http://localhost:11434)")
47
+ start_p.add_argument("--genesis-url", type=str, help="Genesis URL (default: http://localhost:8001)")
48
+
49
+ # status
50
+ sub.add_parser("status", help="Show backend status")
51
+
52
+ # connect
53
+ connect_p = sub.add_parser("connect", help="Register with Elysium cloud")
54
+ connect_p.add_argument("api_key", nargs="?", help="API key (or set AITHER_API_KEY)")
55
+ connect_p.add_argument(
56
+ "--enroll-token", dest="enroll_token", help="Short-lived enrollment token"
57
+ )
58
+ connect_p.add_argument(
59
+ "--portal", dest="portal_url", help="Portal URL override"
60
+ )
61
+
62
+ # login — browser device flow (RFC 8628) against AitherIdentity.
63
+ # Delegates to adk's proven login so a standalone node binary (which bundles
64
+ # adk as a dependency) can authenticate with NO separate adk install.
65
+ login_p = sub.add_parser("login", help="Authenticate with Aitherium (browser device flow)")
66
+ login_p.add_argument("--api-key", dest="api_key", help="Skip the browser — save this API key directly")
67
+ login_p.add_argument("--email", help="Email/password login instead of device flow")
68
+ login_p.add_argument("--password", help="Password (prompted if omitted with --email)")
69
+ login_p.add_argument("--portal-url", dest="portal_url", help="Identity/portal base URL override")
70
+
71
+ # setup
72
+ setup_p = sub.add_parser("setup", help="Detect GPU, pull models, configure backends")
73
+ setup_p.add_argument(
74
+ "target", nargs="?", default=None,
75
+ help="Optional setup target: 'comfyui' to self-host ComfyUI and join the "
76
+ "AitherOS fabric. Omit for the default GPU/backend setup.",
77
+ )
78
+ setup_p.add_argument("--cloud", action="store_true", help="Cloud-only mode (skip local GPU)")
79
+ setup_p.add_argument("--model", type=str, help="Specific Ollama model to pull")
80
+ setup_p.add_argument("--skip-model", action="store_true", help="Skip model download")
81
+ # `setup comfyui` flags (ignored by the bare `setup` path)
82
+ setup_p.add_argument("--profile", type=str, default="studio",
83
+ help="[comfyui] Model profile from comfyui-model-profiles.yaml (default: studio)")
84
+ setup_p.add_argument("--workspace", "--workspace-id", dest="workspace_id", type=str, default=None,
85
+ help="[comfyui] Bind to this workspace (else from connect state)")
86
+ setup_p.add_argument("--tenant", type=str, default=None,
87
+ help="[comfyui] RBAC tenant (else from connect state)")
88
+ setup_p.add_argument("--mesh-url", dest="mesh_url", type=str, default=None,
89
+ help="[comfyui] AitherMesh base (default: env AITHERMESH_URL/AITHER_MESH_URL)")
90
+ setup_p.add_argument("--port", type=int, default=8188,
91
+ help="[comfyui] ComfyUI port (default: 8188)")
92
+ setup_p.add_argument("--skip-models", action="store_true",
93
+ help="[comfyui] Join + bind only, no model pull")
94
+ setup_p.add_argument("--dry-run", action="store_true",
95
+ help="[comfyui] Plan only — no install/network mutation")
96
+
97
+ # mcp
98
+ mcp_p = sub.add_parser("mcp", help="Start MCP server (for Claude Code, Cursor)")
99
+ mcp_p.add_argument("--transport", choices=["stdio", "sse"], default="stdio",
100
+ help="Transport (default: stdio)")
101
+ mcp_p.add_argument("--host", default="127.0.0.1", help="SSE bind host (default: 127.0.0.1; set to 0.0.0.0 for all interfaces)")
102
+ mcp_p.add_argument("--port", type=int, default=8090, help="SSE port")
103
+
104
+ # mcp-config
105
+ sub.add_parser("mcp-config", help="Output .mcp.json config for Claude Code / Cursor")
106
+
107
+ # ext (extensions)
108
+ ext_p = sub.add_parser("ext", help="Manage extensions (Canvas, ComfyUI, agents)")
109
+ ext_sub = ext_p.add_subparsers(dest="ext_command")
110
+ ext_sub.add_parser("list", help="List available extensions")
111
+ ext_install_p = ext_sub.add_parser("install", help="Install an extension")
112
+ ext_install_p.add_argument("extension_id", help="Extension ID (e.g., canvas, comfyui)")
113
+ ext_start_p = ext_sub.add_parser("start", help="Start an extension")
114
+ ext_start_p.add_argument("extension_id", help="Extension ID")
115
+ ext_stop_p = ext_sub.add_parser("stop", help="Stop an extension")
116
+ ext_stop_p.add_argument("extension_id", help="Extension ID")
117
+ ext_sub.add_parser("status", help="Check all extension status")
118
+
119
+ # deploy
120
+ deploy_p = sub.add_parser("deploy", help="Deploy a service via AitherComet")
121
+ deploy_p.add_argument("service", help="Service name to deploy")
122
+ deploy_p.add_argument("--target", default="docker", choices=["docker", "docker-compose", "kubernetes", "systemd", "podman", "cloud-gpu"])
123
+ deploy_p.add_argument("--strategy", default="rolling", choices=["rolling", "blue-green", "canary", "recreate"])
124
+
125
+ # service — persistent host service (Scheduled Task / systemd --user / launchd)
126
+ svc_p = sub.add_parser(
127
+ "service",
128
+ help="Install/manage AwNode as a persistent host service "
129
+ "(Windows Scheduled Task, systemd user unit, or launchd agent)",
130
+ )
131
+ svc_p.add_argument("action", choices=["install", "uninstall", "status", "start", "stop"])
132
+ svc_p.add_argument("--port", type=int, default=8090, help="Port the service binds (default: 8090)")
133
+ svc_p.add_argument(
134
+ "--service-host", dest="service_host", default=None,
135
+ help="Bind host for the installed service (default: 127.0.0.1 — loopback only; "
136
+ "set 0.0.0.0 to expose on LAN, which also requires AITHERNODE_PROXY_TOKEN "
137
+ "for the /proxy plane)",
138
+ )
139
+
140
+ # doctor
141
+ doctor_p = sub.add_parser("doctor", help="End-to-end health check (license, portal, backends)")
142
+ doctor_p.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output")
143
+ doctor_p.add_argument("--timeout", type=float, default=3.0, help="Per-check HTTP timeout (default: 3s)")
144
+
145
+ args = parser.parse_args()
146
+
147
+ if args.command == "start":
148
+ if args.cloud:
149
+ os.environ["AITHERNODE_MODE"] = "cloud"
150
+ elif args.local:
151
+ os.environ["AITHERNODE_MODE"] = "local"
152
+ if args.vllm_url:
153
+ os.environ["AITHER_VLLM_URL"] = args.vllm_url
154
+ if args.ollama_url:
155
+ os.environ["OLLAMA_HOST"] = args.ollama_url
156
+ if args.genesis_url:
157
+ os.environ["AITHER_URL"] = args.genesis_url
158
+ os.environ["AITHERNODE_PORT"] = str(args.port)
159
+
160
+ import uvicorn
161
+ uvicorn.run(
162
+ "awnode.server:app",
163
+ host=args.host,
164
+ port=args.port,
165
+ log_level="info",
166
+ )
167
+
168
+ elif args.command == "status":
169
+ asyncio.run(_status())
170
+
171
+ elif args.command == "connect":
172
+ api_key = args.api_key or os.environ.get("AITHER_API_KEY", "")
173
+ enroll_token = getattr(args, "enroll_token", None)
174
+ portal_url = getattr(args, "portal_url", None)
175
+
176
+ if not api_key and not enroll_token:
177
+ print("Error: provide API key or --enroll-token")
178
+ sys.exit(1)
179
+
180
+ asyncio.run(_connect(
181
+ api_key=api_key,
182
+ enroll_token=enroll_token,
183
+ portal_url=portal_url,
184
+ ))
185
+
186
+ elif args.command == "login":
187
+ sys.exit(_login(args))
188
+
189
+ elif args.command == "setup" and getattr(args, "target", None) == "comfyui":
190
+ from awnode.comfyui_setup import _setup_comfyui
191
+ sys.exit(asyncio.run(_setup_comfyui(
192
+ profile=args.profile,
193
+ workspace_id=args.workspace_id,
194
+ tenant=args.tenant,
195
+ mesh_url=args.mesh_url,
196
+ port=args.port,
197
+ skip_models=args.skip_models,
198
+ dry_run=args.dry_run,
199
+ )))
200
+
201
+ elif args.command == "setup":
202
+ if getattr(args, "target", None):
203
+ print(f"Unknown setup target: {args.target!r}. "
204
+ "Use 'comfyui' or omit for default backend setup.")
205
+ sys.exit(2)
206
+ asyncio.run(_setup(
207
+ cloud_only=args.cloud,
208
+ model=args.model,
209
+ skip_model=args.skip_model,
210
+ ))
211
+
212
+ elif args.command == "mcp":
213
+ from awnode.mcp_server import main as mcp_main
214
+ # Forward args to mcp_server's argparse
215
+ sys.argv = ["awnode-mcp",
216
+ "--transport", args.transport,
217
+ "--host", args.host,
218
+ "--port", str(args.port)]
219
+ mcp_main()
220
+
221
+ elif args.command == "mcp-config":
222
+ _mcp_config()
223
+
224
+ elif args.command == "ext":
225
+ asyncio.run(_ext(args))
226
+
227
+ elif args.command == "deploy":
228
+ asyncio.run(_deploy(args.service, args.target, args.strategy))
229
+
230
+ elif args.command == "doctor":
231
+ sys.exit(asyncio.run(_doctor(emit_json=args.json, timeout=args.timeout)))
232
+
233
+ elif args.command == "service":
234
+ from awnode.service_install import service_action
235
+ try:
236
+ result = service_action(args.action, port=args.port, host=args.service_host)
237
+ print(f"[service] {result}")
238
+ except (RuntimeError, ValueError, OSError) as e:
239
+ print(f"[service] ERROR: {e}")
240
+ sys.exit(1)
241
+
242
+ else:
243
+ parser.print_help()
244
+
245
+
246
+ async def _doctor(*, emit_json: bool = False, timeout: float = 3.0) -> int:
247
+ """End-to-end health check.
248
+
249
+ Returns exit code: 0 if everything healthy, 1 if any required surface
250
+ is degraded, 2 if hard failure (no license + no offline grace).
251
+ """
252
+ import httpx
253
+ from pathlib import Path
254
+
255
+ aither_home = Path(os.environ.get("AITHER_HOME", str(Path.home() / ".aither")))
256
+ license_cache = aither_home / "license.json"
257
+ portal_token = aither_home / "portal.token"
258
+
259
+ report: dict = {
260
+ "aither_home": str(aither_home),
261
+ "checks": {},
262
+ "summary": {"ok": 0, "warn": 0, "fail": 0},
263
+ }
264
+
265
+ def record(name: str, status: str, detail: str = "", **extra) -> None:
266
+ report["checks"][name] = {"status": status, "detail": detail, **extra}
267
+ report["summary"][status] = report["summary"].get(status, 0) + 1
268
+
269
+ # 1. AITHER_HOME directory
270
+ if aither_home.exists():
271
+ record("aither_home", "ok", f"{aither_home}")
272
+ else:
273
+ record("aither_home", "warn", "missing — will be created on first run")
274
+
275
+ # 2. License cache + entitlement
276
+ if license_cache.exists():
277
+ try:
278
+ ent = json.loads(license_cache.read_text())
279
+ plan = ent.get("plan", "?")
280
+ expires = ent.get("expires_at", "?")
281
+ record("license_cache", "ok", f"plan={plan} expires={expires}", plan=plan)
282
+ except Exception as e:
283
+ record("license_cache", "fail", f"unreadable: {e}")
284
+ else:
285
+ record("license_cache", "warn", "no cached entitlement — free tier in effect")
286
+
287
+ # 3. API key env
288
+ api_key = os.environ.get("AITHER_API_KEY", "")
289
+ if api_key:
290
+ masked = api_key[:6] + "…" + api_key[-4:] if len(api_key) > 12 else "set"
291
+ record("api_key", "ok", f"AITHER_API_KEY={masked}")
292
+ else:
293
+ record("api_key", "warn", "AITHER_API_KEY not set — paid features will be locked")
294
+
295
+ # 4. Portal token (from `aithershell login` / `aither login`)
296
+ if portal_token.exists():
297
+ try:
298
+ size = portal_token.stat().st_size
299
+ record("portal_token", "ok", f"{portal_token} ({size}B)")
300
+ except Exception as e:
301
+ record("portal_token", "warn", str(e))
302
+ else:
303
+ record("portal_token", "warn", "no portal token — run `aithershell login`")
304
+
305
+ # 5. Backend reachability
306
+ backends = {
307
+ "genesis": os.environ.get("AITHER_URL", "http://localhost:8001"),
308
+ "vllm": os.environ.get("AITHER_VLLM_URL", "http://localhost:8120"),
309
+ "ollama": os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
310
+ "gateway": os.environ.get("AITHER_CLOUD_URL", "https://gateway.aitherium.com"),
311
+ }
312
+ async with httpx.AsyncClient(timeout=timeout) as client:
313
+ for name, url in backends.items():
314
+ health_path = "/api/tags" if name == "ollama" else "/health"
315
+ try:
316
+ r = await client.get(url.rstrip("/") + health_path)
317
+ if r.status_code < 500:
318
+ record(name, "ok", f"{url} ({r.status_code})")
319
+ else:
320
+ record(name, "warn", f"{url} -> {r.status_code}")
321
+ except Exception as e:
322
+ # gateway/vllm/ollama unreachable is a warn (not everyone runs them)
323
+ record(name, "warn", f"{url} unreachable: {type(e).__name__}")
324
+
325
+ # Compute exit code
326
+ fail = report["summary"].get("fail", 0)
327
+ warn = report["summary"].get("warn", 0)
328
+ # Hard fail only if license cache is corrupt; otherwise warns are OK
329
+ exit_code = 2 if fail else (1 if warn > 4 else 0)
330
+
331
+ if emit_json:
332
+ print(json.dumps(report, indent=2, default=str))
333
+ else:
334
+ print("AwNode Doctor")
335
+ print("=" * 60)
336
+ print(f"AITHER_HOME: {aither_home}")
337
+ print()
338
+ glyph = {"ok": "[OK] ", "warn": "[WARN]", "fail": "[FAIL]"}
339
+ for name, info in report["checks"].items():
340
+ print(f" {glyph.get(info['status'], '[?] ')} {name:<16} {info['detail']}")
341
+ print()
342
+ s = report["summary"]
343
+ print(f"Summary: {s.get('ok',0)} ok, {s.get('warn',0)} warn, {s.get('fail',0)} fail")
344
+ if exit_code == 0:
345
+ print("Status: healthy")
346
+ elif exit_code == 1:
347
+ print("Status: degraded (paid features may be unavailable)")
348
+ else:
349
+ print("Status: FAIL — license cache or required backend broken")
350
+
351
+ return exit_code
352
+
353
+
354
+ async def _status():
355
+ """Check backend availability."""
356
+ import httpx
357
+
358
+ checks = {
359
+ "Genesis": os.environ.get("AITHER_URL", "http://localhost:8001"),
360
+ "vLLM": os.environ.get("AITHER_VLLM_URL", os.environ.get("VLLM_URL", "http://localhost:8120")),
361
+ "Ollama": os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
362
+ "Elysium": "https://mcp.aitherium.com",
363
+ }
364
+
365
+ print("AwNode Backend Status")
366
+ print("=" * 40)
367
+ for name, url in checks.items():
368
+ try:
369
+ async with httpx.AsyncClient(timeout=3.0) as c:
370
+ health_path = "/api/tags" if name == "Ollama" else "/health"
371
+ r = await c.get(f"{url.rstrip('/')}{health_path}")
372
+ status = "UP" if r.status_code == 200 else f"HTTP {r.status_code}"
373
+ except Exception:
374
+ status = "DOWN"
375
+ icon = "+" if status == "UP" else "-"
376
+ print(f" [{icon}] {name:12s} {url:40s} {status}")
377
+
378
+ # Check for running Node
379
+ try:
380
+ async with httpx.AsyncClient(timeout=2.0) as c:
381
+ r = await c.get("http://localhost:8090/status")
382
+ if r.status_code == 200:
383
+ data = r.json()
384
+ print(f"\nNode running on port {data.get('port', 8090)} — mode: {data.get('mode', '?')}")
385
+ except Exception:
386
+ print("\nNode not running. Start with: awnode start")
387
+
388
+
389
+ _DEFAULT_IDENTITY_URL = "https://idp.aitherium.com"
390
+
391
+
392
+ def _login(args) -> int:
393
+ """Authenticate — API key, or browser device flow (RFC 8628).
394
+
395
+ Primary path delegates to adk's richer ``cmd_login`` (device / email / key
396
+ + endpoint persistence). If adk isn't importable (e.g. a stripped standalone
397
+ binary), we fall back to a self-contained stdlib device flow so login ALWAYS
398
+ works with zero extra dependencies. Either way credentials land in the shared
399
+ ``~/.aither/auth.json`` that ``awnode connect`` and adk-backed tools read.
400
+ """
401
+ # Fast path: a directly-provided API key needs no network round-trip.
402
+ api_key = getattr(args, "api_key", None)
403
+ identity_url = (
404
+ getattr(args, "portal_url", None)
405
+ or os.environ.get("AITHER_PORTAL_URL")
406
+ or os.environ.get("AITHERIDENTITY_URL")
407
+ or _DEFAULT_IDENTITY_URL
408
+ ).rstrip("/")
409
+
410
+ # Primary: reuse adk's richer login if adk is installed. Dynamic import so a
411
+ # stripped standalone binary (adk not bundled) simply falls through to the
412
+ # self-contained flow below instead of dragging the whole adk monolith.
413
+ try:
414
+ import importlib
415
+ cmd_login = importlib.import_module("adk.cli").cmd_login
416
+ except Exception: # noqa: BLE001 — ImportError, AttributeError, or a broken adk env
417
+ cmd_login = None
418
+ if cmd_login is not None:
419
+ from argparse import Namespace
420
+ ns = Namespace(
421
+ portal_url=getattr(args, "portal_url", None),
422
+ api_key=api_key,
423
+ email=getattr(args, "email", None),
424
+ password=getattr(args, "password", None),
425
+ )
426
+ try:
427
+ return int(cmd_login(ns) or 0)
428
+ except SystemExit as e: # cmd_login may sys.exit on some paths
429
+ return int(e.code or 0)
430
+
431
+ # Fallback: self-contained.
432
+ if getattr(args, "email", None):
433
+ print(" Email/password login needs the adk package. Install: pip install awdk")
434
+ return 1
435
+ if api_key:
436
+ _save_auth_json(identity_url, {"access_token": api_key, "token_type": "api_key"})
437
+ print(" API key saved to ~/.aither/auth.json")
438
+ return 0
439
+ try:
440
+ token = _device_flow_standalone(identity_url)
441
+ except RuntimeError as exc:
442
+ print(f" Error: {exc}")
443
+ return 1
444
+ _save_auth_json(identity_url, token)
445
+ print(" Signed in. Credentials saved to ~/.aither/auth.json")
446
+ return 0
447
+
448
+
449
+ def _device_flow_standalone(identity_url: str) -> dict:
450
+ """Minimal RFC-8628 device-code flow using only the stdlib.
451
+
452
+ Hits the same AitherIdentity endpoints as adk (``/auth/device/code`` +
453
+ ``/auth/device/token``) so a standalone binary authenticates identically.
454
+ """
455
+ import json as _json
456
+ import time
457
+ import urllib.error
458
+ import urllib.request
459
+ import webbrowser
460
+
461
+ ua = "awnode/login"
462
+
463
+ def _post(path: str, body: dict) -> dict:
464
+ req = urllib.request.Request(
465
+ f"{identity_url}{path}",
466
+ data=_json.dumps(body).encode(),
467
+ headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": ua},
468
+ method="POST",
469
+ )
470
+ with urllib.request.urlopen(req, timeout=15) as resp:
471
+ return _json.loads(resp.read())
472
+
473
+ try:
474
+ data = _post("/auth/device/code", {"client_name": "awnode", "scopes": "full"})
475
+ except (urllib.error.URLError, OSError) as exc:
476
+ raise RuntimeError(f"Cannot reach {identity_url}: {exc}") from exc
477
+
478
+ user_code = data["user_code"]
479
+ device_code = data["device_code"]
480
+ verify = data.get("verification_uri_complete") or data.get("verification_uri", "")
481
+ interval = max(2, int(data.get("interval", 5)))
482
+ expires_in = int(data.get("expires_in", 900))
483
+
484
+ print()
485
+ print(f" Your code: {user_code}")
486
+ print(f" Opening browser to: {verify}")
487
+ print(" (If it doesn't open, visit the URL and enter the code.)")
488
+ try:
489
+ webbrowser.open(verify)
490
+ except OSError:
491
+ pass
492
+ print(" Waiting for approval", end="", flush=True)
493
+
494
+ deadline = time.time() + expires_in
495
+ while time.time() < deadline:
496
+ time.sleep(interval)
497
+ try:
498
+ result = _post("/auth/device/token", {"device_code": device_code})
499
+ except urllib.error.HTTPError as exc:
500
+ if exc.code == 400:
501
+ try:
502
+ detail = _json.loads(exc.read()).get("detail", "")
503
+ except (ValueError, OSError):
504
+ detail = ""
505
+ if detail in ("expired_token", "invalid_device_code"):
506
+ print()
507
+ raise RuntimeError(f"{detail}. Run `awnode login` again.") from exc
508
+ print(".", end="", flush=True)
509
+ continue
510
+ except (urllib.error.URLError, OSError):
511
+ print(".", end="", flush=True)
512
+ continue
513
+ if result.get("status") == "authorization_pending":
514
+ print(".", end="", flush=True)
515
+ continue
516
+ if result.get("access_token"):
517
+ print(" approved!")
518
+ return result
519
+ print(".", end="", flush=True)
520
+
521
+ print()
522
+ raise RuntimeError("Timed out waiting for approval. Run `awnode login` again.")
523
+
524
+
525
+ def _save_auth_json(endpoint: str, token: dict) -> None:
526
+ """Persist credentials to ~/.aither/auth.json in the shared adk/shell format."""
527
+ import stat
528
+ from pathlib import Path
529
+
530
+ path = Path(os.environ.get("AITHER_HOME", str(Path.home() / ".aither"))) / "auth.json"
531
+ path.parent.mkdir(parents=True, exist_ok=True)
532
+ profile = {
533
+ "endpoint": endpoint,
534
+ "token_type": token.get("token_type", "bearer"),
535
+ "access_token": token.get("access_token", ""),
536
+ "expires_at": token.get("expires_at", ""),
537
+ "user": token.get("user", {}),
538
+ }
539
+ store = {"version": 1, "active_profile": "portal", "profiles": {"portal": profile}}
540
+ path.write_text(json.dumps(store, indent=2, default=str), encoding="utf-8")
541
+ try:
542
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
543
+ except OSError:
544
+ pass
545
+
546
+
547
+ async def _connect(
548
+ api_key: str = "",
549
+ enroll_token: str = "",
550
+ portal_url: str = "",
551
+ ):
552
+ """Register with Elysium or enroll via token."""
553
+ import httpx
554
+ import platform
555
+ from pathlib import Path
556
+
557
+ # Resolve portal URL
558
+ if not portal_url:
559
+ portal_url = os.environ.get(
560
+ "AITHER_CLOUD_URL", "https://portal.aitherium.com"
561
+ )
562
+
563
+ aither_home = Path(os.environ.get("AITHER_HOME", str(Path.home() / ".aither")))
564
+ state_file = aither_home / "sync_state.json"
565
+
566
+ # Path 1: Exchange enrollment token for workspace-scoped token
567
+ if enroll_token:
568
+ print("Exchanging enrollment token for workspace token...")
569
+ try:
570
+ async with httpx.AsyncClient(timeout=15.0) as c:
571
+ resp = await c.post(
572
+ f"{portal_url}/v1/workspace/api-keys/enrollment-token/exchange",
573
+ json={"enrollment_token": enroll_token},
574
+ )
575
+ if resp.status_code == 200:
576
+ data = resp.json()
577
+ workspace_token = data.get("token", "")
578
+ node_id = data.get("node_id", "")
579
+ if workspace_token:
580
+ # Save token to local state
581
+ import json
582
+ aither_home.mkdir(parents=True, exist_ok=True)
583
+ state = {}
584
+ if state_file.exists():
585
+ try:
586
+ state = json.loads(state_file.read_text())
587
+ except Exception:
588
+ pass
589
+ state["tenant_scoped_token"] = workspace_token
590
+ state["node_id"] = node_id
591
+ state_file.write_text(json.dumps(state, indent=2))
592
+ print(
593
+ f"✓ Token saved for node {node_id} "
594
+ f"(expires {data.get('expires_at', '?')})"
595
+ )
596
+ print("Start sync with: awnode start")
597
+ return
598
+ elif resp.status_code == 401:
599
+ print("Error: Invalid or expired enrollment token")
600
+ else:
601
+ print(
602
+ f"Error: Token exchange failed "
603
+ f"({resp.status_code}): {resp.text[:200]}"
604
+ )
605
+ except Exception as e:
606
+ print(f"Error exchanging enrollment token: {e}")
607
+ sys.exit(1)
608
+
609
+ # Path 2: Traditional API key registration
610
+ if api_key:
611
+ from adk.client import GatewayClient
612
+
613
+ gw = GatewayClient(api_key=api_key)
614
+ hostname = platform.node() or "local"
615
+
616
+ try:
617
+ result = await gw.register_agent(
618
+ name=f"node-{hostname}",
619
+ capabilities=["chat", "code", "filesystem", "git", "vllm", "ollama"],
620
+ description=f"AwNode on {hostname} ({platform.system()})",
621
+ )
622
+ print(f"Connected! Agent ID: {result.get('agent_id', '?')}")
623
+ print(f"Save your API key: export AITHER_API_KEY={api_key}")
624
+
625
+ # Save to config
626
+ try:
627
+ from adk.shell.config import CONFIG_FILE
628
+ import yaml
629
+
630
+ cfg = {}
631
+ if CONFIG_FILE.exists():
632
+ with open(CONFIG_FILE) as f:
633
+ cfg = yaml.safe_load(f) or {}
634
+ cfg["api_key"] = api_key
635
+ with open(CONFIG_FILE, "w") as f:
636
+ yaml.dump(cfg, f)
637
+ print(f"Saved to {CONFIG_FILE}")
638
+ except Exception:
639
+ pass
640
+ except Exception as e:
641
+ print(f"Connection failed: {e}")
642
+ sys.exit(1)
643
+
644
+
645
+ async def _deploy(service: str, target: str, strategy: str):
646
+ """Deploy via AitherComet."""
647
+ import httpx
648
+
649
+ genesis_url = os.environ.get("AITHER_URL", "http://localhost:8001")
650
+ api_key = os.environ.get("AITHER_API_KEY", "")
651
+
652
+ print(f"Deploying {service} → {target} ({strategy})...")
653
+ try:
654
+ async with httpx.AsyncClient(timeout=60.0) as c:
655
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
656
+ r = await c.post(f"{genesis_url}/deploy/deploy", json={
657
+ "service": service,
658
+ "target": target,
659
+ "strategy": strategy,
660
+ }, headers=headers)
661
+ data = r.json()
662
+ if r.status_code == 200:
663
+ print(f"Deployment started: {data.get('deployment_id', '?')}")
664
+ print(f"Status: {data.get('status', '?')}")
665
+ else:
666
+ print(f"Error: {data}")
667
+ except Exception as e:
668
+ print(f"Deploy failed: {e}")
669
+ print("Is Genesis running? Start AitherOS or connect to cloud.")
670
+ sys.exit(1)
671
+
672
+
673
+ async def _setup(cloud_only: bool = False, model: str = None, skip_model: bool = False):
674
+ """Detect hardware, configure backends, optionally pull models."""
675
+ import shutil
676
+ import subprocess
677
+ from pathlib import Path
678
+
679
+ import yaml
680
+
681
+ config_dir = Path(os.environ.get("AITHER_HOME", str(Path.home() / ".aither")))
682
+ config_file = config_dir / "config.yaml"
683
+ config_dir.mkdir(parents=True, exist_ok=True)
684
+
685
+ cfg = {}
686
+ if config_file.exists():
687
+ with open(config_file) as f:
688
+ cfg = yaml.safe_load(f) or {}
689
+
690
+ print("AwNode Setup")
691
+ print("=" * 40)
692
+
693
+ # -- GPU Detection --
694
+ gpu_vram = 0
695
+ gpu_name = ""
696
+ if not cloud_only:
697
+ try:
698
+ result = subprocess.run(
699
+ ["nvidia-smi", "--query-gpu=name,memory.total",
700
+ "--format=csv,noheader,nounits"],
701
+ capture_output=True, text=True, timeout=5,
702
+ )
703
+ if result.returncode == 0:
704
+ line = result.stdout.strip().split("\n")[0]
705
+ parts = line.rsplit(",", 1)
706
+ gpu_name = parts[0].strip()
707
+ gpu_vram = int(parts[1].strip()) if len(parts) > 1 else 0
708
+ print(f" GPU: {gpu_name} ({gpu_vram} MB VRAM)")
709
+ else:
710
+ print(" GPU: Not detected (nvidia-smi failed)")
711
+ except FileNotFoundError:
712
+ print(" GPU: Not detected (nvidia-smi not found)")
713
+ except Exception as e:
714
+ print(f" GPU: Detection error ({e})")
715
+
716
+ cfg["gpu"] = {"name": gpu_name, "vram_mb": gpu_vram}
717
+
718
+ # -- Backend Selection --
719
+ if cloud_only:
720
+ cfg["mode"] = "cloud"
721
+ print(" Mode: cloud-only (Elysium)")
722
+ api_key = cfg.get("api_key") or os.environ.get("AITHER_API_KEY", "")
723
+ if not api_key:
724
+ print("\n Set your API key: awnode connect <your-api-key>")
725
+ elif gpu_vram >= 16384:
726
+ cfg["mode"] = "auto"
727
+ print(f" Recommended: vLLM (GPU has {gpu_vram}MB VRAM)")
728
+ print(" Local models will use your GPU for fast inference.")
729
+ elif gpu_vram >= 4096:
730
+ cfg["mode"] = "auto"
731
+ print(f" Recommended: Ollama with small models ({gpu_vram}MB VRAM)")
732
+ else:
733
+ cfg["mode"] = "auto"
734
+ print(" Recommended: Ollama CPU mode or cloud")
735
+
736
+ # -- Ollama Detection & Model Pull --
737
+ has_ollama = shutil.which("ollama") is not None
738
+ if has_ollama:
739
+ print(" Ollama: installed")
740
+ cfg["ollama"] = {"available": True}
741
+
742
+ if not skip_model and not cloud_only:
743
+ target_model = model
744
+ if not target_model:
745
+ if gpu_vram >= 16384:
746
+ target_model = "llama3.1:8b"
747
+ elif gpu_vram >= 8192:
748
+ target_model = "llama3.2:3b"
749
+ else:
750
+ target_model = "llama3.2:1b"
751
+
752
+ print(f"\n Pulling model: {target_model}...")
753
+ pull_result = subprocess.run(
754
+ ["ollama", "pull", target_model],
755
+ timeout=600,
756
+ )
757
+ if pull_result.returncode == 0:
758
+ cfg["ollama"]["default_model"] = target_model
759
+ print(f" Model ready: {target_model}")
760
+ else:
761
+ print(f" Model pull failed (exit {pull_result.returncode})")
762
+ else:
763
+ print(" Ollama: not installed")
764
+ if not cloud_only:
765
+ print(" Install: https://ollama.com/download")
766
+ cfg["ollama"] = {"available": False}
767
+
768
+ # -- vLLM Detection --
769
+ import httpx
770
+ vllm_url = os.environ.get("AITHER_VLLM_URL", "http://localhost:8120")
771
+ try:
772
+ async with httpx.AsyncClient(timeout=3.0) as c:
773
+ r = await c.get(f"{vllm_url}/health")
774
+ if r.status_code == 200:
775
+ print(f" vLLM: running at {vllm_url}")
776
+ cfg["vllm"] = {"available": True, "url": vllm_url}
777
+ else:
778
+ cfg["vllm"] = {"available": False}
779
+ except Exception:
780
+ cfg["vllm"] = {"available": False}
781
+
782
+ # -- Save config --
783
+ with open(config_file, "w") as f:
784
+ yaml.dump(cfg, f, default_flow_style=False)
785
+ print(f"\n Config saved: {config_file}")
786
+
787
+ print("\n Quick start:")
788
+ print(" awnode start # Start local gateway")
789
+ print(" awnode mcp # Start MCP server (for Claude Code)")
790
+ print(" awnode mcp-config # Get .mcp.json config")
791
+
792
+
793
+ def _mcp_config():
794
+ """Output MCP client configuration for Claude Code / Cursor."""
795
+ import shutil
796
+
797
+ awnode_path = shutil.which("awnode") or "awnode"
798
+
799
+ config = {
800
+ "mcpServers": {
801
+ "awnode": {
802
+ "command": awnode_path,
803
+ "args": ["mcp"],
804
+ "env": {},
805
+ }
806
+ }
807
+ }
808
+
809
+ api_key = os.environ.get("AITHER_API_KEY", "")
810
+ if api_key:
811
+ config["mcpServers"]["awnode"]["env"]["AITHER_API_KEY"] = api_key
812
+
813
+ print(json.dumps(config, indent=2))
814
+ print("\nAdd the above to your .mcp.json (Claude Code) or MCP settings (Cursor).")
815
+ print("Or copy just the 'awnode' block into an existing .mcp.json.")
816
+
817
+
818
+ async def _ext(args):
819
+ """Manage extensions: list, install, start, stop, status."""
820
+ from awnode.extensions import get_extension_manager
821
+ mgr = get_extension_manager()
822
+
823
+ if args.ext_command == "list":
824
+ catalog = mgr.list_catalog()
825
+ print(
826
+ f"{'ID':15s} {'Name':20s} {'Category':10s} "
827
+ f"{'Status':10s} {'GPU':5s} Description"
828
+ )
829
+ print("-" * 100)
830
+ for ext in catalog:
831
+ gpu = "Yes" if ext.get("gpu") else "No"
832
+ print(
833
+ f"{ext['id']:15s} {ext['name']:20s} "
834
+ f"{ext.get('category', ''):10s} "
835
+ f"{ext.get('status', 'available'):10s} "
836
+ f"{gpu:5s} {ext['description'][:50]}"
837
+ )
838
+
839
+ elif args.ext_command == "install":
840
+ result = await mgr.install(args.extension_id)
841
+ if "error" in result:
842
+ print(f"Error: {result['error']}")
843
+ if result.get("hint"):
844
+ print(f"Hint: {result['hint']}")
845
+ else:
846
+ print(f"Installed: {args.extension_id}")
847
+
848
+ elif args.ext_command == "start":
849
+ result = await mgr.start(args.extension_id)
850
+ if "error" in result:
851
+ print(f"Error: {result['error']}")
852
+ if result.get("hint"):
853
+ print(f"Hint: {result['hint']}")
854
+ else:
855
+ print(f"Started: {args.extension_id}")
856
+ print(f" URL: {result.get('url', '?')}")
857
+ if result.get("tools"):
858
+ print(f" Tools: {', '.join(result['tools'])}")
859
+
860
+ elif args.ext_command == "stop":
861
+ result = await mgr.stop(args.extension_id)
862
+ if "error" not in result:
863
+ print(f"Stopped: {args.extension_id}")
864
+ else:
865
+ print(f"Error: {result['error']}")
866
+
867
+ elif args.ext_command == "status":
868
+ results = await mgr.health_check_all()
869
+ if not results:
870
+ print("No extensions running.")
871
+ for ext_id, status in results.items():
872
+ icon = "+" if status.get("status") == "running" else "-"
873
+ print(
874
+ f" [{icon}] {ext_id:15s} "
875
+ f"{status.get('url', ''):30s} "
876
+ f"{status.get('status', '?')}"
877
+ )
878
+
879
+ else:
880
+ print("Usage: awnode ext [list|install|start|stop|status]")
881
+
882
+
883
+ if __name__ == "__main__":
884
+ main()