axnwork-cli 0.2.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.
axon/cli.py ADDED
@@ -0,0 +1,595 @@
1
+ """Axon CLI entry point."""
2
+ import os
3
+ import httpx
4
+ import typer
5
+ from axon.api import api_get, api_post, api_patch
6
+ from axon.display import console, print_banner, _fmt_usdc
7
+ from axon.log import setup_logging
8
+
9
+ setup_logging()
10
+
11
+ app = typer.Typer(name="axon", help="Axon — USDC Bounty Mining CLI", add_completion=False)
12
+ DEFAULT_MINE_ROUNDS = 5
13
+ DEFAULT_MINE_TIMEOUT = 600
14
+
15
+
16
+ def _api(fn, *args, **kwargs):
17
+ """Call an API function with unified error handling."""
18
+ try:
19
+ return fn(*args, **kwargs)
20
+ except httpx.ConnectError:
21
+ console.print("[red]Cannot connect to server. Is the backend running?[/]")
22
+ raise typer.Exit(1)
23
+ except httpx.HTTPStatusError as e:
24
+ if e.response.status_code == 401:
25
+ console.print("[red]Not authenticated. Run: axon onboard[/]")
26
+ else:
27
+ detail = ""
28
+ try:
29
+ detail = e.response.json().get("detail", "")
30
+ except Exception:
31
+ pass
32
+ console.print(f"[red]Error {e.response.status_code}: {detail or e}[/]")
33
+ raise typer.Exit(1)
34
+
35
+
36
+ def _is_first_run() -> bool:
37
+ from axon.wallet import load_wallet
38
+ return load_wallet() is None
39
+
40
+
41
+ @app.callback(invoke_without_command=True)
42
+ def main(ctx: typer.Context):
43
+ """Axon CLI. Run 'axon onboard' for first-time setup."""
44
+ print_banner()
45
+ if ctx.invoked_subcommand is None:
46
+ if _is_first_run():
47
+ console.print("First time? Run [bold green]axon onboard[/] to get started.\n")
48
+ console.print(" [green]axon onboard[/] Generate wallet + setup")
49
+ console.print(" [green]axon tasks[/] List available tasks")
50
+ console.print(" [green]axon mine[/] Start mining a task")
51
+ console.print(" [green]axon --help[/] All commands")
52
+ else:
53
+ from axon.wallet import get_address
54
+ from axon.config import load_config
55
+ config = load_config()
56
+ addr = get_address()
57
+ model = config.get("default_model", "not set")
58
+ console.print(f" wallet: [cyan]{addr[:6]}...{addr[-4:]}[/] model: [cyan]{model}[/]\n")
59
+ console.print(" [green]axon tasks[/] List available tasks")
60
+ console.print(" [green]axon task <id>[/] View task details")
61
+ console.print(" [green]axon mine[/] Start mining a task")
62
+ console.print(" [green]axon balance[/] Check USDC balance")
63
+ console.print(" [green]axon wallet[/] Show wallet address")
64
+
65
+
66
+ # --- Onboard ---
67
+
68
+ def _select(title: str, options: list[str], cursor_index: int = 0) -> int | None:
69
+ """Arrow-key selection menu. Returns chosen index or None if cancelled."""
70
+ from simple_term_menu import TerminalMenu
71
+ menu = TerminalMenu(
72
+ options,
73
+ title=title,
74
+ cursor_index=cursor_index,
75
+ menu_cursor=" ❯ ",
76
+ menu_cursor_style=("fg_green", "bold"),
77
+ menu_highlight_style=("fg_green", "bold"),
78
+ )
79
+ return menu.show()
80
+
81
+
82
+ @app.command()
83
+ def onboard():
84
+ """First-time setup — generate wallet, configure model."""
85
+ from axon.config import save_config
86
+ from axon.wallet import load_wallet, generate_wallet, save_wallet
87
+
88
+ console.print("\n[bold gold1]Onboard[/]\n")
89
+
90
+ # Step 1: Wallet
91
+ existing_wallet = load_wallet()
92
+ if existing_wallet:
93
+ console.print(f" Wallet exists: [cyan]{existing_wallet['address']}[/]")
94
+ if not typer.confirm("Generate a new wallet? (this will replace the existing one)", default=False):
95
+ console.print(" [green]✓ Keeping existing wallet[/]")
96
+ else:
97
+ wallet = generate_wallet()
98
+ save_wallet(wallet)
99
+ console.print(f" [green]✓ New wallet: {wallet['address']}[/]")
100
+ else:
101
+ wallet = generate_wallet()
102
+ save_wallet(wallet)
103
+ console.print(f" [green]✓ Wallet generated: {wallet['address']}[/]")
104
+ console.print(f" [dim]Private key saved to ~/.axon/wallet.json[/]")
105
+
106
+ # Step 2: Server
107
+ console.print()
108
+ from axon.config import load_config
109
+ existing = load_config()
110
+ default_server = existing.get("server_url", "http://localhost:8000")
111
+ server = typer.prompt("Server URL", default=default_server)
112
+ try:
113
+ resp = httpx.get(f"{server}/health", timeout=5, transport=httpx.HTTPTransport(proxy=None))
114
+ if resp.status_code == 200:
115
+ console.print(f" [green]✓ Connected to {server}[/]")
116
+ else:
117
+ console.print(f" [red]✗ Server returned {resp.status_code}[/]")
118
+ except Exception:
119
+ console.print(f" [yellow]⚠ Cannot reach {server}[/]")
120
+ save_config({"server_url": server})
121
+
122
+ # Step 3: Auto-authenticate with server
123
+ console.print()
124
+ try:
125
+ from axon.api import _ensure_auth
126
+ _ensure_auth()
127
+ console.print(" [green]✓ Authenticated with server[/]")
128
+ except Exception:
129
+ console.print(" [yellow]⚠ Could not authenticate (server may be offline)[/]")
130
+
131
+ # Step 4: Mining backend
132
+ import shutil
133
+ console.print()
134
+ backend_list = [
135
+ ("litellm", "LiteLLM API (Anthropic/OpenAI/DeepSeek/Ollama)"),
136
+ ("claude-cli", "Claude Code CLI (agentic — tools, search, code exec)"),
137
+ ("codex-cli", "OpenAI Codex CLI (agentic — code exec, search)"),
138
+ ]
139
+ backend_labels = []
140
+ for bid, label in backend_list:
141
+ avail = ""
142
+ if bid == "claude-cli" and not shutil.which("claude"):
143
+ avail = " [red](not installed)[/]"
144
+ elif bid == "codex-cli" and not shutil.which("codex"):
145
+ avail = " [red](not installed)[/]"
146
+ backend_labels.append(f"{label}{avail}")
147
+
148
+ idx = _select(" Select mining backend:\n", backend_labels)
149
+ if idx is None:
150
+ idx = 0
151
+ chosen_backend = backend_list[idx][0]
152
+ save_config({"backend": chosen_backend})
153
+ console.print(f" [green]✓ Backend: {chosen_backend}[/]")
154
+ _check_cli_available(chosen_backend, shutil)
155
+
156
+ # CLI backends skip API key / model selection
157
+ if chosen_backend in ("claude-cli", "codex-cli"):
158
+ console.print(f"\n [dim]Using {chosen_backend} — API keys and model managed by the CLI tool.[/]")
159
+ else:
160
+ # Step 5: LLM Provider (arrow-key select)
161
+ console.print()
162
+ provider_list = [
163
+ ("anthropic", "Anthropic (Claude)"),
164
+ ("openai", "OpenAI (GPT / o-series)"),
165
+ ("deepseek", "DeepSeek (Chat / Reasoner)"),
166
+ ("ollama", "Ollama (local models)"),
167
+ ]
168
+ idx = _select(" Select LLM provider:\n", [label for _, label in provider_list])
169
+ if idx is None:
170
+ idx = 0
171
+ provider, provider_label = provider_list[idx]
172
+ console.print(f" [green]✓ {provider_label}[/]\n")
173
+
174
+ # Step 6: API Key (visible) + fetch models
175
+ from axon.providers import fetch_models
176
+
177
+ if provider != "ollama":
178
+ import os
179
+ env_names = {"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY", "deepseek": "DEEPSEEK_API_KEY"}
180
+ env_key = env_names.get(provider, "")
181
+ existing_key = os.environ.get(env_key, "") if env_key else ""
182
+
183
+ if existing_key:
184
+ preview = f"{existing_key[:8]}...{existing_key[-4:]}"
185
+ console.print(f" Found [cyan]{env_key}[/] in environment: [dim]{preview}[/]")
186
+ if typer.confirm("Use this key?", default=True):
187
+ api_key = existing_key
188
+ else:
189
+ api_key = typer.prompt(f"Enter {env_key}")
190
+ else:
191
+ api_key = typer.prompt(f"Enter {env_key or 'API key'}")
192
+
193
+ save_config({"api_keys": {provider: api_key}})
194
+ key_preview = f"{api_key[:8]}...{api_key[-4:]}" if len(api_key) > 12 else api_key
195
+ console.print(f" [green]✓ Key saved[/] [dim]({key_preview})[/]\n")
196
+
197
+ console.print(" Fetching models from API...")
198
+ try:
199
+ models = fetch_models(provider, api_key)
200
+ except Exception:
201
+ models = []
202
+
203
+ _pick_model(models, provider, save_config)
204
+ else:
205
+ api_base = typer.prompt("Ollama API base", default="http://localhost:11434")
206
+ save_config({"api_base": api_base})
207
+
208
+ console.print(" Fetching local models...")
209
+ try:
210
+ models = fetch_models("ollama", "", api_base)
211
+ except Exception:
212
+ models = []
213
+
214
+ _pick_model(models, "ollama", save_config)
215
+
216
+ # Done
217
+ from axon.wallet import get_address
218
+ console.print(f"\n[bold gold1]ψ Setup complete![/]")
219
+ console.print(f" Wallet: [cyan]{get_address()}[/]\n")
220
+ console.print(" Run [green]axon tasks[/] to see available tasks.")
221
+ console.print(" Run [green]axon mine[/] to start mining.\n")
222
+
223
+
224
+ def _pick_model(models: list[dict], provider: str, save_config):
225
+ """Arrow-key model picker. Falls back to manual input."""
226
+ if models:
227
+ menu_items = [m["label"] for m in models[:20]] + ["Enter model name manually"]
228
+ console.print(f" Found [bold]{len(models)}[/] models:\n")
229
+ idx = _select(" Select model:\n", menu_items)
230
+ if idx is None:
231
+ idx = 0
232
+ if idx == len(menu_items) - 1:
233
+ # manual entry
234
+ model_name = typer.prompt("Model name")
235
+ save_config({"default_model": f"{provider}/{model_name}"})
236
+ console.print(f" [green]✓ Model: {provider}/{model_name}[/]")
237
+ else:
238
+ save_config({"default_model": models[idx]["value"]})
239
+ console.print(f" [green]✓ Model: {models[idx]['value']}[/]")
240
+ else:
241
+ console.print(" [yellow]Could not fetch models — enter manually[/]")
242
+ model_name = typer.prompt("Model name")
243
+ save_config({"default_model": f"{provider}/{model_name}"})
244
+ console.print(f" [green]✓ Model: {provider}/{model_name}[/]")
245
+
246
+
247
+ # --- Wallet ---
248
+
249
+ @app.command()
250
+ def wallet():
251
+ """Show wallet address."""
252
+ from axon.wallet import load_wallet
253
+ w = load_wallet()
254
+ if not w:
255
+ console.print("[red]No wallet. Run: axon onboard[/]")
256
+ raise typer.Exit(1)
257
+ console.print(f" Address: [bold cyan]{w['address']}[/]")
258
+ console.print(f" [dim]Key file: ~/.axon/wallet.json[/]")
259
+
260
+
261
+ # --- Model ---
262
+
263
+ @app.command()
264
+ def model(name: str = ""):
265
+ """Show or switch LLM model."""
266
+ from axon.config import load_config, save_config
267
+ from axon.providers import fetch_models
268
+
269
+ config = load_config()
270
+ current = config.get("default_model", "not set")
271
+
272
+ if name:
273
+ save_config({"default_model": name})
274
+ console.print(f" [green]✓ Model: {name}[/]")
275
+ return
276
+
277
+ console.print(f"\n Current model: [bold cyan]{current}[/]\n")
278
+
279
+ # Step 1: Pick provider
280
+ providers = [
281
+ ("anthropic", "Anthropic (Claude)"),
282
+ ("openai", "OpenAI (GPT / o-series)"),
283
+ ("deepseek", "DeepSeek (Chat / Reasoner)"),
284
+ ("ollama", "Ollama (local models)"),
285
+ ("manual", "Enter model name manually"),
286
+ ]
287
+ current_provider = current.split("/")[0] if "/" in current else ""
288
+ cursor = next((i for i, (pid, _) in enumerate(providers) if pid == current_provider), 0)
289
+ idx = _select(" Select provider:\n", [label for _, label in providers], cursor_index=cursor)
290
+ if idx is None:
291
+ return
292
+ provider, _ = providers[idx]
293
+
294
+ if provider == "manual":
295
+ name = typer.prompt("Model name (e.g. anthropic/claude-sonnet-4-20250514)")
296
+ save_config({"default_model": name})
297
+ console.print(f" [green]✓ Model: {name}[/]")
298
+ return
299
+
300
+ # Step 2: Check API key
301
+ keys = config.get("api_keys", {})
302
+ api_key = keys.get(provider, "")
303
+ api_base = config.get("api_base", "")
304
+
305
+ if provider != "ollama" and not api_key:
306
+ import os
307
+ env_names = {"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY", "deepseek": "DEEPSEEK_API_KEY"}
308
+ env_var = env_names.get(provider, "")
309
+ env_key = os.environ.get(env_var, "")
310
+ if env_key:
311
+ api_key = env_key
312
+ console.print(f" Using [cyan]{env_var}[/] from environment")
313
+ else:
314
+ api_key = typer.prompt(f"Enter {env_var or 'API key'}")
315
+ save_config({"api_keys": {provider: api_key}})
316
+ console.print(f" [green]✓ Key saved[/]")
317
+
318
+ # Step 3: Fetch and pick model
319
+ console.print(" Fetching models...")
320
+ try:
321
+ models = fetch_models(provider, api_key, api_base)
322
+ except Exception:
323
+ models = []
324
+
325
+ if models:
326
+ menu_items = [m["label"] for m in models[:20]] + ["Enter model name manually"]
327
+ idx = _select(" Select model:\n", menu_items)
328
+ if idx is None:
329
+ return
330
+ if idx == len(menu_items) - 1:
331
+ name = typer.prompt("Model name")
332
+ save_config({"default_model": f"{provider}/{name}"})
333
+ console.print(f" [green]✓ Model: {provider}/{name}[/]")
334
+ else:
335
+ save_config({"default_model": models[idx]["value"]})
336
+ console.print(f" [green]✓ Model: {models[idx]['value']}[/]")
337
+ else:
338
+ console.print(" [yellow]Could not fetch models[/]")
339
+ name = typer.prompt("Model name")
340
+ save_config({"default_model": f"{provider}/{name}"})
341
+ console.print(f" [green]✓ Model: {provider}/{name}[/]")
342
+
343
+
344
+ # --- Backend ---
345
+
346
+ @app.command()
347
+ def backend(name: str = typer.Argument("", help="Backend name: auto, litellm, claude-cli, codex-cli")):
348
+ """Show or switch mining backend (auto, litellm, claude-cli, codex-cli)."""
349
+ import shutil
350
+ from axon.backends import auto_detect_backend
351
+ from axon.config import load_config, save_config
352
+
353
+ config = load_config()
354
+ current = config.get("backend", "auto")
355
+
356
+ backends = [
357
+ ("auto", "Auto (claude-cli > codex-cli > litellm)"),
358
+ ("claude-cli", "Claude Code CLI (agentic — tools, search, code exec)"),
359
+ ("codex-cli", "OpenAI Codex CLI (agentic — code exec, search)"),
360
+ ("litellm", "LiteLLM (API: Anthropic/OpenAI/DeepSeek/Ollama)"),
361
+ ]
362
+
363
+ if name:
364
+ valid = [b[0] for b in backends]
365
+ if name not in valid:
366
+ console.print(f"[red]Unknown backend '{name}'. Choose from: {', '.join(valid)}[/]")
367
+ raise typer.Exit(1)
368
+ if name != "auto":
369
+ _check_cli_available(name, shutil)
370
+ save_config({"backend": name})
371
+ resolved = auto_detect_backend() if name == "auto" else name
372
+ console.print(f" [green]✓ Backend: {name}[/]" + (f" [dim](→ {resolved})[/]" if name == "auto" else ""))
373
+ return
374
+
375
+ resolved = auto_detect_backend() if current == "auto" else current
376
+ console.print(f"\n Current backend: [bold cyan]{current}[/]" + (f" [dim](→ {resolved})[/]" if current == "auto" else "") + "\n")
377
+
378
+ # Mark availability
379
+ labels = []
380
+ for bid, label in backends:
381
+ avail = ""
382
+ if bid == "claude-cli" and not shutil.which("claude"):
383
+ avail = " [red](not installed)[/]"
384
+ elif bid == "codex-cli" and not shutil.which("codex"):
385
+ avail = " [red](not installed)[/]"
386
+ labels.append(f"{label}{avail}")
387
+
388
+ cursor = next((i for i, (bid, _) in enumerate(backends) if bid == current), 0)
389
+ idx = _select(" Select mining backend:\n", labels, cursor_index=cursor)
390
+ if idx is None:
391
+ return
392
+
393
+ chosen = backends[idx][0]
394
+ if chosen != "auto":
395
+ _check_cli_available(chosen, shutil)
396
+ save_config({"backend": chosen})
397
+ resolved = auto_detect_backend() if chosen == "auto" else chosen
398
+ console.print(f" [green]✓ Backend: {chosen}[/]" + (f" [dim](→ {resolved})[/]" if chosen == "auto" else ""))
399
+
400
+
401
+ def _check_cli_available(backend_name: str, shutil):
402
+ """Warn if CLI tool is not installed."""
403
+ if backend_name == "claude-cli" and not shutil.which("claude"):
404
+ console.print(" [yellow]⚠ 'claude' CLI not found in PATH. Install: npm install -g @anthropic-ai/claude-code[/]")
405
+ elif backend_name == "codex-cli" and not shutil.which("codex"):
406
+ console.print(" [yellow]⚠ 'codex' CLI not found in PATH. Install: npm install -g @openai/codex[/]")
407
+
408
+
409
+ # --- Tasks ---
410
+
411
+ @app.command()
412
+ def tasks(status_filter: str = "open"):
413
+ """List available tasks."""
414
+ from axon.display import print_task_list
415
+ data = _api(api_get, f"/api/tasks?status_filter={status_filter}", auth=False)
416
+ print_task_list(data)
417
+
418
+
419
+ @app.command()
420
+ def task(task_id: str):
421
+ """View details of a specific task by ID (or row number from 'axon tasks')."""
422
+ from axon.display import print_task_detail
423
+
424
+ # Allow row number shorthand: "axon task 3" → pick 3rd open task
425
+ if task_id.isdigit():
426
+ idx = int(task_id)
427
+ task_list = _api(api_get, "/api/tasks?status_filter=open", auth=False)
428
+ if not task_list:
429
+ console.print("[yellow]No open tasks.[/]")
430
+ raise typer.Exit(1)
431
+ if idx < 1 or idx > len(task_list):
432
+ console.print(f"[red]Row #{idx} out of range (1-{len(task_list)})[/]")
433
+ raise typer.Exit(1)
434
+ data = task_list[idx - 1]
435
+ else:
436
+ data = _api(api_get, f"/api/tasks/{task_id}", auth=False)
437
+
438
+ print_task_detail(data)
439
+
440
+
441
+ # --- Mine (task selection) ---
442
+
443
+ @app.command()
444
+ def mine(
445
+ max_rounds: int = typer.Option(
446
+ DEFAULT_MINE_ROUNDS,
447
+ "--max-rounds",
448
+ min=0,
449
+ help="Maximum mining rounds for this run. Use 0 for no round limit.",
450
+ ),
451
+ timeout: int = typer.Option(
452
+ DEFAULT_MINE_TIMEOUT,
453
+ "--timeout",
454
+ min=1,
455
+ help="Hard timeout in seconds for each CLI backend call during this run.",
456
+ ),
457
+ yolo: bool = typer.Option(
458
+ False,
459
+ "--yolo",
460
+ "-yolo",
461
+ help="Disable hard timeout and round limit for this run. Stop with Ctrl+C.",
462
+ ),
463
+ ):
464
+ """Start mining a task. Select from available tasks."""
465
+ from axon.mining import run_mining
466
+
467
+ if yolo and max_rounds != DEFAULT_MINE_ROUNDS:
468
+ console.print("[red]Cannot combine --yolo with --max-rounds.[/]")
469
+ raise typer.Exit(1)
470
+ if yolo and timeout != DEFAULT_MINE_TIMEOUT:
471
+ console.print("[red]Cannot combine --yolo with --timeout.[/]")
472
+ raise typer.Exit(1)
473
+
474
+ # Get open tasks
475
+ task_list = _api(api_get, "/api/tasks?status_filter=open", auth=False)
476
+ if not task_list:
477
+ console.print("[yellow]No open tasks available.[/]")
478
+ raise typer.Exit()
479
+
480
+ if len(task_list) == 1:
481
+ task = task_list[0]
482
+ console.print(f" Mining: [bold]{task['title']}[/] Pool: [green]{_fmt_usdc(task.get('pool_balance', 0))}[/]\n")
483
+ else:
484
+ # Task selection menu
485
+ options = []
486
+ for t in task_list:
487
+ pool = _fmt_usdc(t.get("pool_balance", 0))
488
+ options.append(f"{t['title']} ({pool})")
489
+ idx = _select(" Select task to mine:\n", options)
490
+ if idx is None:
491
+ return
492
+ task = task_list[idx]
493
+
494
+ effective_max_rounds = 0 if yolo else max_rounds
495
+ effective_timeout = None if yolo else timeout
496
+ os.system("clear")
497
+ print_banner()
498
+ run_mining(task, effective_max_rounds, cli_timeout_override=effective_timeout)
499
+
500
+
501
+ # --- Balance ---
502
+
503
+ @app.command()
504
+ def balance():
505
+ """Show USDC balance + on-chain assets (Base)."""
506
+ me = _api(api_get, "/api/auth/me")
507
+ addr = me["address"]
508
+ console.print(f"\n Wallet: [cyan]{addr}[/]")
509
+ console.print(f" USDC: [bold green]{_fmt_usdc(me['balance'])}[/] (platform)")
510
+
511
+ # On-chain balances (Base mainnet)
512
+ console.print(f"\n [bold]Base Chain[/]")
513
+ try:
514
+ on_chain = _fetch_base_balances(addr)
515
+ console.print(f" ETH: [bold]{on_chain['eth']:.6f}[/]")
516
+ console.print(f" USDC: [bold]{on_chain['usdc']:.2f}[/]")
517
+ console.print(f" USDT: [bold]{on_chain['usdt']:.2f}[/]")
518
+ except Exception:
519
+ console.print(f" [dim](could not fetch on-chain balances)[/]")
520
+ console.print()
521
+
522
+
523
+ def _fetch_base_balances(address: str) -> dict:
524
+ """Fetch ETH, USDC, USDT balances on Base mainnet via public RPC."""
525
+ import httpx
526
+
527
+ rpcs = [
528
+ "https://base.llamarpc.com",
529
+ "https://base-mainnet.public.blastapi.io",
530
+ "https://mainnet.base.org",
531
+ ]
532
+ usdc_contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
533
+ usdt_contract = "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"
534
+
535
+ padded_addr = "0" * 24 + address[2:].lower()
536
+ calldata = "0x70a08231" + padded_addr
537
+
538
+ def rpc_call(method, params):
539
+ for rpc in rpcs:
540
+ try:
541
+ resp = httpx.post(rpc, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
542
+ timeout=10, transport=httpx.HTTPTransport(proxy=None))
543
+ result = resp.json().get("result")
544
+ if result:
545
+ return result
546
+ except Exception:
547
+ continue
548
+ return "0x0"
549
+
550
+ eth_raw = rpc_call("eth_getBalance", [address, "latest"])
551
+ usdc_raw = rpc_call("eth_call", [{"to": usdc_contract, "data": calldata}, "latest"])
552
+ usdt_raw = rpc_call("eth_call", [{"to": usdt_contract, "data": calldata}, "latest"])
553
+
554
+ return {
555
+ "eth": int(eth_raw, 16) / 1e18,
556
+ "usdc": int(usdc_raw, 16) / 1e6,
557
+ "usdt": int(usdt_raw, 16) / 1e6,
558
+ }
559
+
560
+
561
+ # --- Network ---
562
+
563
+ @app.command()
564
+ def network():
565
+ """Show global network overview — active miners, pools, per-task competition."""
566
+ from axon.display import print_network
567
+ data = _api(api_get, "/api/network", auth=False)
568
+ print_network(data)
569
+
570
+
571
+ # --- Stats ---
572
+
573
+ @app.command()
574
+ def stats():
575
+ """Show mining statistics."""
576
+ from axon.display import print_stats
577
+
578
+ me = _api(api_get, "/api/auth/me")
579
+ txns = _api(api_get, "/api/transactions?limit=1000")
580
+
581
+ breakdown = {
582
+ "pool_reward": 0,
583
+ "completion_reward": 0,
584
+ }
585
+ for t in txns:
586
+ typ = t.get("type", "")
587
+ amt = t.get("amount", 0)
588
+ if typ in breakdown:
589
+ breakdown[typ] += amt
590
+ elif amt > 0:
591
+ breakdown.setdefault("other_in", 0)
592
+ breakdown["other_in"] = breakdown.get("other_in", 0) + amt
593
+
594
+ improvements = sum(1 for t in txns if t.get("type") == "pool_reward")
595
+ print_stats(me, breakdown, improvements)
axon/config.py ADDED
@@ -0,0 +1,55 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ AXON_HOME = Path(os.environ.get("AXON_HOME", str(Path.home() / ".axon")))
6
+ CONFIG_DIR = AXON_HOME
7
+ CONFIG_FILE = CONFIG_DIR / "config.json"
8
+
9
+ DEFAULT_CONFIG = {
10
+ "server_url": "http://localhost:8000",
11
+ "auth_token": "",
12
+ "default_model": "anthropic/claude-sonnet-4-20250514",
13
+ "api_base": "",
14
+ "api_keys": {},
15
+ "backend": "auto",
16
+ "cli_timeout": 600,
17
+ "claude_cli_model": "",
18
+ "codex_cli_model": "",
19
+ }
20
+
21
+
22
+ def resolve_cli_timeout(config: dict, default: int = 600) -> int | None:
23
+ """Return CLI backend timeout in seconds, or None when disabled."""
24
+ raw_timeout = config.get("cli_timeout", default)
25
+ if raw_timeout is None:
26
+ return None
27
+ try:
28
+ timeout = int(raw_timeout)
29
+ except (TypeError, ValueError):
30
+ return default
31
+ return None if timeout <= 0 else timeout
32
+
33
+
34
+ def load_config() -> dict:
35
+ if not CONFIG_FILE.exists():
36
+ return {**DEFAULT_CONFIG}
37
+ try:
38
+ data = json.loads(CONFIG_FILE.read_text())
39
+ return {**DEFAULT_CONFIG, **data, "api_keys": {**DEFAULT_CONFIG["api_keys"], **data.get("api_keys", {})}}
40
+ except Exception:
41
+ return {**DEFAULT_CONFIG}
42
+
43
+
44
+ def save_config(updates: dict):
45
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
46
+ current = load_config()
47
+ if "api_keys" in updates:
48
+ current["api_keys"] = {**current.get("api_keys", {}), **updates.pop("api_keys")}
49
+ current.update(updates)
50
+ CONFIG_FILE.write_text(json.dumps(current, indent=2) + "\n")
51
+
52
+
53
+ def get_token() -> str:
54
+ config = load_config()
55
+ return config.get("auth_token", "")