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.
- axnwork_cli-0.2.0.dist-info/METADATA +13 -0
- axnwork_cli-0.2.0.dist-info/RECORD +23 -0
- axnwork_cli-0.2.0.dist-info/WHEEL +5 -0
- axnwork_cli-0.2.0.dist-info/entry_points.txt +2 -0
- axnwork_cli-0.2.0.dist-info/top_level.txt +1 -0
- axon/__init__.py +0 -0
- axon/api.py +83 -0
- axon/backends/__init__.py +5 -0
- axon/backends/base.py +23 -0
- axon/backends/claude_cli.py +290 -0
- axon/backends/codex_cli.py +223 -0
- axon/backends/litellm_backend.py +51 -0
- axon/backends/registry.py +61 -0
- axon/cli.py +595 -0
- axon/config.py +55 -0
- axon/display.py +364 -0
- axon/history.py +133 -0
- axon/llm.py +214 -0
- axon/log.py +44 -0
- axon/mining.py +671 -0
- axon/providers.py +44 -0
- axon/session.py +26 -0
- axon/wallet.py +45 -0
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", "")
|