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/__init__.py +20 -0
- awnode/__main__.py +6 -0
- awnode/cli.py +884 -0
- awnode/comfyui_setup.py +407 -0
- awnode/extensions.py +596 -0
- awnode/license.py +339 -0
- awnode/mcp_server.py +543 -0
- awnode/proxy.py +291 -0
- awnode/server.py +1824 -0
- awnode/service_install.py +296 -0
- awnode/sync.py +394 -0
- awnode/tools/__init__.py +17 -0
- awnode/tools/_http.py +297 -0
- awnode/tools/_scopes.py +211 -0
- awnode/tools/commands.py +134 -0
- awnode/tools/context.py +63 -0
- awnode/tools/documents.py +156 -0
- awnode/tools/embeddings.py +107 -0
- awnode/tools/extensions.py +80 -0
- awnode/tools/filesystem.py +242 -0
- awnode/tools/generation.py +187 -0
- awnode/tools/git_tools.py +375 -0
- awnode/tools/http_client.py +61 -0
- awnode/tools/knowledge.py +143 -0
- awnode/tools/memory.py +116 -0
- awnode/tools/mermaid.py +100 -0
- awnode/tools/notebooks.py +139 -0
- awnode/tools/ollama.py +129 -0
- awnode/tools/orchestrator.py +171 -0
- awnode/tools/search.py +330 -0
- awnode-0.3.0.dist-info/METADATA +98 -0
- awnode-0.3.0.dist-info/RECORD +35 -0
- awnode-0.3.0.dist-info/WHEEL +4 -0
- awnode-0.3.0.dist-info/entry_points.txt +3 -0
- awnode-0.3.0.dist-info/licenses/LICENSE +21 -0
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()
|