mycelium-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/PKG-INFO +1 -1
- mycelium_cli-0.2.0/mycelium_cli/commands/compile.py +137 -0
- mycelium_cli-0.2.0/mycelium_cli/commands/deal.py +186 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/deploy.py +16 -18
- mycelium_cli-0.2.0/mycelium_cli/commands/doctor.py +132 -0
- mycelium_cli-0.2.0/mycelium_cli/commands/init.py +73 -0
- mycelium_cli-0.2.0/mycelium_cli/commands/jobs.py +238 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/main.py +32 -4
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli.egg-info/PKG-INFO +1 -1
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli.egg-info/SOURCES.txt +2 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/pyproject.toml +1 -1
- mycelium_cli-0.1.0/mycelium_cli/commands/compile.py +0 -59
- mycelium_cli-0.1.0/mycelium_cli/commands/doctor.py +0 -106
- mycelium_cli-0.1.0/mycelium_cli/commands/init.py +0 -188
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/README.md +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/__init__.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/__init__.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/agent.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/call.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/check.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/discover.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/events.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/fund.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/newwallet.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/pay.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/register.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/resolve.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/run.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/status.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/commands/test.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli/config.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli.egg-info/dependency_links.txt +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli.egg-info/entry_points.txt +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli.egg-info/requires.txt +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/mycelium_cli.egg-info/top_level.txt +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/setup.cfg +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/tests/test_agent.py +0 -0
- {mycelium_cli-0.1.0 → mycelium_cli-0.2.0}/tests/test_cli.py +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`mycelium compile` — compile the project's contract to Soroban WASM.
|
|
3
|
+
|
|
4
|
+
Two paths:
|
|
5
|
+
- **remote** (default when no local toolchain is detected): POST the source to
|
|
6
|
+
the hosted `/compile` endpoint (constants.COMPILE_URL, runs the compiler in
|
|
7
|
+
Docker server-side) and write back the returned WASM. Needs zero local Rust
|
|
8
|
+
or stellar-cli install — the zero-toolchain default for new users.
|
|
9
|
+
- **local** (`--local`, or auto when a toolchain is present): run the compiler
|
|
10
|
+
pipeline (parse → validate → generate_wasm) on this machine via the pinned
|
|
11
|
+
stellar-cli 27.0.0 toolchain. For users who already have Rust + stellar-cli.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from mycelium_sdk.constants import COMPILE_URL
|
|
20
|
+
|
|
21
|
+
from mycelium_cli.config import get_value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _has_local_toolchain() -> bool:
|
|
25
|
+
"""True if both a Rust compiler and the stellar binary are on PATH."""
|
|
26
|
+
return shutil.which("rustc") is not None and shutil.which("stellar") is not None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _compile_local(source_code: str, optimize: bool) -> bytes:
|
|
30
|
+
"""Compile in-process via the bundled compiler (needs Rust + stellar-cli)."""
|
|
31
|
+
from mycelium_compiler.parser import parse_source
|
|
32
|
+
from mycelium_compiler.validator import validate_ast
|
|
33
|
+
from mycelium_compiler.codegen import generate_wasm
|
|
34
|
+
|
|
35
|
+
visitor = parse_source(source_code)
|
|
36
|
+
validate_ast(visitor)
|
|
37
|
+
return generate_wasm(visitor)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _compile_remote(filename: str, source_code: str) -> bytes:
|
|
41
|
+
"""Compile via the hosted `/compile` endpoint; returns the WASM bytes."""
|
|
42
|
+
import requests
|
|
43
|
+
|
|
44
|
+
print(f"[compile] No local toolchain in use — compiling remotely via {COMPILE_URL}...")
|
|
45
|
+
try:
|
|
46
|
+
res = requests.post(
|
|
47
|
+
COMPILE_URL,
|
|
48
|
+
json={"filename": filename, "source_code": source_code},
|
|
49
|
+
timeout=120,
|
|
50
|
+
)
|
|
51
|
+
except requests.RequestException as e:
|
|
52
|
+
print(f"❌ Remote compile request failed: {e}")
|
|
53
|
+
print(f" Set MYCELIUM_COMPILE_URL to point at a reachable backend, or "
|
|
54
|
+
f"install a local toolchain and use `--local`.")
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
if not res.ok:
|
|
58
|
+
print(f"❌ Remote compile failed: HTTP {res.status_code}\n{res.text}")
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
payload = res.json()
|
|
62
|
+
if not payload.get("success"):
|
|
63
|
+
print(f"❌ Compilation failed:\n{payload.get('logs', '(no logs returned)')}")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
wasm_b64 = payload.get("wasm_base64")
|
|
67
|
+
if not wasm_b64:
|
|
68
|
+
print("❌ Remote compile returned success but no WASM payload.")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
return base64.b64decode(wasm_b64)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_compile(
|
|
74
|
+
file_path: str | None = None,
|
|
75
|
+
output_path: str | None = None,
|
|
76
|
+
optimize: bool = False,
|
|
77
|
+
remote: bool | None = None,
|
|
78
|
+
local: bool = False,
|
|
79
|
+
) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Compile `file_path` to `output_path`. When omitted, both are read from
|
|
82
|
+
mycelium.toml ([onchain].source_contract / [onchain].target_wasm).
|
|
83
|
+
|
|
84
|
+
Path selection:
|
|
85
|
+
- `local=True` → force the local toolchain.
|
|
86
|
+
- `remote=True` → force the hosted endpoint.
|
|
87
|
+
- neither → local iff a toolchain is detected, else remote.
|
|
88
|
+
Returns the output path.
|
|
89
|
+
"""
|
|
90
|
+
file_path = file_path or get_value("onchain", "source_contract", "contract.py")
|
|
91
|
+
output_path = output_path or get_value("onchain", "target_wasm", "build/contract.wasm")
|
|
92
|
+
|
|
93
|
+
if not os.path.exists(file_path):
|
|
94
|
+
print(f"Error: File {file_path} not found.")
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
with open(file_path, "r") as f:
|
|
98
|
+
source_code = f.read()
|
|
99
|
+
|
|
100
|
+
# Decide the path. Explicit flags win; otherwise auto-detect.
|
|
101
|
+
if local and remote:
|
|
102
|
+
print("Error: pass at most one of --local / --remote.")
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
if local:
|
|
105
|
+
use_remote = False
|
|
106
|
+
elif remote:
|
|
107
|
+
use_remote = True
|
|
108
|
+
else:
|
|
109
|
+
use_remote = not _has_local_toolchain()
|
|
110
|
+
|
|
111
|
+
where = "remotely" if use_remote else "locally"
|
|
112
|
+
print(f"Compiling contract {file_path} -> {output_path} ({where})...")
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
if use_remote:
|
|
116
|
+
wasm_bytes = _compile_remote(os.path.basename(file_path), source_code)
|
|
117
|
+
else:
|
|
118
|
+
wasm_bytes = _compile_local(source_code, optimize)
|
|
119
|
+
except SystemExit:
|
|
120
|
+
raise
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(f"❌ Compilation failed: {e}")
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
out_dir = os.path.dirname(output_path)
|
|
126
|
+
if out_dir:
|
|
127
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
128
|
+
with open(output_path, "wb") as f_out:
|
|
129
|
+
f_out.write(wasm_bytes)
|
|
130
|
+
|
|
131
|
+
size = len(wasm_bytes)
|
|
132
|
+
print(f"✓ Compilation successful! Output: {output_path}")
|
|
133
|
+
print(f" WASM size: {size:,} bytes ({size / 1024:.2f} KiB)")
|
|
134
|
+
# The release profile already optimizes for size (opt-level "z", LTO).
|
|
135
|
+
if optimize and not use_remote:
|
|
136
|
+
print(" (--optimize: size-optimized release profile is always applied)")
|
|
137
|
+
return output_path
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`mycelium deal …` — wire two agents into an agent-to-agent (A2A) commerce deal.
|
|
3
|
+
|
|
4
|
+
Where `mycelium pay` is an *unconditional* transfer, a deal is *conditional*
|
|
5
|
+
x402 commerce between two agents: the payer locks funds into a fresh escrow
|
|
6
|
+
payable to a provider (resolved by Hive Registry unique name or raw address),
|
|
7
|
+
and the provider only collects once it publishes a proof of the agreed task.
|
|
8
|
+
If the provider never delivers, the payer reclaims the funds after the timeout.
|
|
9
|
+
|
|
10
|
+
This is the CLI front door to what `a2a_demo.py` / `EscrowPaymentRouter` do by
|
|
11
|
+
hand — two sovereign agents transacting purely through on-chain state:
|
|
12
|
+
|
|
13
|
+
open payer locks `amount` XLM to a provider against a task hash → escrow id
|
|
14
|
+
release provider (or payer) publishes the proof preimage → funds disburse
|
|
15
|
+
refund payer reclaims the locked funds after the deadline passes
|
|
16
|
+
status read the escrow's current state (amount, provider, deadline, settled)
|
|
17
|
+
|
|
18
|
+
The escrow contract enforces the proof (SHA-256(proof) == task_hash) and the
|
|
19
|
+
deadline on-chain, so neither side has to trust the other. The board/registry
|
|
20
|
+
default from `mycelium.toml`, mirroring `deploy` / `register` / `job`.
|
|
21
|
+
|
|
22
|
+
Commands: open, release, refund, status.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import os
|
|
27
|
+
from decimal import Decimal
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
import typer
|
|
31
|
+
|
|
32
|
+
from mycelium_cli.config import get_value
|
|
33
|
+
|
|
34
|
+
DEFAULT_WALLET_PATH = os.path.join(".mycelium", "wallet.json")
|
|
35
|
+
PASSPHRASE_ENV_VAR = "MYCELIUM_DECRYPT_KEY"
|
|
36
|
+
# Default escrow timeout (seconds) after which the payer may refund. Mirrors
|
|
37
|
+
# mycelium_sdk.x402.settlement.DEFAULT_ESCROW_TIMEOUT_SECONDS (24h).
|
|
38
|
+
DEFAULT_TIMEOUT_SECONDS = 24 * 60 * 60
|
|
39
|
+
|
|
40
|
+
deal_app = typer.Typer(help="Conditional agent-to-agent (A2A) commerce via x402 escrow.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_passphrase(label: str = "Wallet passphrase") -> str:
|
|
44
|
+
"""MYCELIUM_DECRYPT_KEY if set, else prompt — matches the rest of the CLI."""
|
|
45
|
+
env_value = os.environ.get(PASSPHRASE_ENV_VAR)
|
|
46
|
+
if env_value:
|
|
47
|
+
return env_value
|
|
48
|
+
return typer.prompt(label, hide_input=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _context(network: Optional[str], wallet: str, *, signing: bool):
|
|
52
|
+
"""Build an AgentContext. Read-only commands skip wallet + passphrase."""
|
|
53
|
+
from mycelium_sdk import AgentContext
|
|
54
|
+
|
|
55
|
+
network = network or get_value("onchain", "network", "testnet")
|
|
56
|
+
if signing:
|
|
57
|
+
if not os.path.exists(wallet):
|
|
58
|
+
typer.echo(f"Error: wallet {wallet} not found. Run `mycelium newwallet` first.")
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
return AgentContext(keypair_path=wallet, network_type=network, passphrase=_resolve_passphrase())
|
|
61
|
+
return AgentContext.read_only(network_type=network)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_agent_address(context, agent: str, registry: Optional[str]) -> str:
|
|
65
|
+
"""Pass through a G/C address; otherwise resolve a Hive Registry unique name."""
|
|
66
|
+
from stellar_sdk import StrKey
|
|
67
|
+
|
|
68
|
+
if StrKey.is_valid_ed25519_public_key(agent) or StrKey.is_valid_contract(agent):
|
|
69
|
+
return agent
|
|
70
|
+
from mycelium_sdk import HiveClient
|
|
71
|
+
|
|
72
|
+
entry = HiveClient(context, registry_address=registry).resolve_agent(agent)
|
|
73
|
+
addr = entry.get("public_key")
|
|
74
|
+
if not addr:
|
|
75
|
+
typer.echo(f"Error: could not resolve agent '{agent}' to an address.")
|
|
76
|
+
raise typer.Exit(code=1)
|
|
77
|
+
return addr
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _task_bytes(task: str) -> bytes:
|
|
81
|
+
"""A task file's raw bytes, or the UTF-8 bytes of a literal string."""
|
|
82
|
+
if os.path.isfile(task):
|
|
83
|
+
with open(task, "rb") as f:
|
|
84
|
+
return f.read()
|
|
85
|
+
return task.encode("utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@deal_app.command("open")
|
|
89
|
+
def open_deal(
|
|
90
|
+
to: str = typer.Option(..., "--to", help="Provider: Hive Registry unique name or G/C address to pay"),
|
|
91
|
+
amount: str = typer.Option(..., "--amount", help="Amount in XLM to lock for the provider"),
|
|
92
|
+
task: str = typer.Option(..., "--task", help="Task spec file or string; its SHA-256 is the release condition"),
|
|
93
|
+
token: str = typer.Option(None, "--token", help="Payment token contract (defaults to native XLM SAC)"),
|
|
94
|
+
timeout: int = typer.Option(DEFAULT_TIMEOUT_SECONDS, "--timeout", help="Refund deadline in seconds"),
|
|
95
|
+
network: str = typer.Option(None, help="testnet or mainnet (defaults to mycelium.toml)"),
|
|
96
|
+
wallet: str = typer.Option(DEFAULT_WALLET_PATH, help="Wallet path"),
|
|
97
|
+
registry: str = typer.Option(None, "--registry", help="Hive Registry id override (for name resolution)"),
|
|
98
|
+
):
|
|
99
|
+
"""Payer locks `amount` XLM to a provider against a task hash; prints the escrow id."""
|
|
100
|
+
from mycelium_sdk.x402.settlement import EscrowPaymentRouter
|
|
101
|
+
|
|
102
|
+
context = _context(network, wallet, signing=True)
|
|
103
|
+
provider = _resolve_agent_address(context, to, registry)
|
|
104
|
+
task_hash = hashlib.sha256(_task_bytes(task)).digest()
|
|
105
|
+
|
|
106
|
+
typer.echo(f"[deal] Locking {amount} XLM to provider {to} ({provider[:8]}…) for {timeout}s...")
|
|
107
|
+
try:
|
|
108
|
+
escrow_id = EscrowPaymentRouter(context).create_locked_escrow(
|
|
109
|
+
provider_id=provider,
|
|
110
|
+
amount_xlm=Decimal(amount),
|
|
111
|
+
task_hash=task_hash,
|
|
112
|
+
token=token,
|
|
113
|
+
timeout_seconds=timeout,
|
|
114
|
+
)
|
|
115
|
+
except Exception as e: # noqa: BLE001
|
|
116
|
+
typer.echo(f"❌ deal open failed: {e}")
|
|
117
|
+
raise typer.Exit(code=1)
|
|
118
|
+
typer.echo(f"✓ Deal opened. Escrow {escrow_id}")
|
|
119
|
+
typer.echo(f" Provider releases with: mycelium deal release {escrow_id} --proof <task>")
|
|
120
|
+
typer.echo(f" Payer refunds after {timeout}s with: mycelium deal refund {escrow_id}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@deal_app.command("release")
|
|
124
|
+
def release(
|
|
125
|
+
escrow_id: str = typer.Argument(..., help="Escrow contract id from `deal open`"),
|
|
126
|
+
proof: str = typer.Option(..., "--proof", help="Proof file or string (must SHA-256 to the task hash)"),
|
|
127
|
+
network: str = typer.Option(None, help="testnet or mainnet (defaults to mycelium.toml)"),
|
|
128
|
+
wallet: str = typer.Option(DEFAULT_WALLET_PATH, help="Wallet path"),
|
|
129
|
+
):
|
|
130
|
+
"""Disburse the locked funds to the provider by publishing the task proof."""
|
|
131
|
+
from mycelium_sdk.x402.settlement import EscrowPaymentRouter
|
|
132
|
+
|
|
133
|
+
context = _context(network, wallet, signing=True)
|
|
134
|
+
try:
|
|
135
|
+
EscrowPaymentRouter(context).release_funds(escrow_id, _task_bytes(proof))
|
|
136
|
+
except Exception as e: # noqa: BLE001
|
|
137
|
+
typer.echo(f"❌ deal release failed: {e}")
|
|
138
|
+
raise typer.Exit(code=1)
|
|
139
|
+
typer.echo(f"✓ Released escrow {escrow_id} — funds disbursed to the provider.")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@deal_app.command("refund")
|
|
143
|
+
def refund(
|
|
144
|
+
escrow_id: str = typer.Argument(..., help="Escrow contract id from `deal open`"),
|
|
145
|
+
network: str = typer.Option(None, help="testnet or mainnet (defaults to mycelium.toml)"),
|
|
146
|
+
wallet: str = typer.Option(DEFAULT_WALLET_PATH, help="Wallet path"),
|
|
147
|
+
):
|
|
148
|
+
"""Payer reclaims the locked funds after the deadline passes."""
|
|
149
|
+
from mycelium_sdk.x402.settlement import EscrowPaymentRouter
|
|
150
|
+
|
|
151
|
+
context = _context(network, wallet, signing=True)
|
|
152
|
+
try:
|
|
153
|
+
EscrowPaymentRouter(context).refund(escrow_id)
|
|
154
|
+
except Exception as e: # noqa: BLE001
|
|
155
|
+
typer.echo(f"❌ deal refund failed: {e}")
|
|
156
|
+
raise typer.Exit(code=1)
|
|
157
|
+
typer.echo(f"✓ Refunded escrow {escrow_id} — funds returned to the payer.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@deal_app.command("status")
|
|
161
|
+
def status(
|
|
162
|
+
escrow_id: str = typer.Argument(..., help="Escrow contract id from `deal open`"),
|
|
163
|
+
network: str = typer.Option(None, help="testnet or mainnet (defaults to mycelium.toml)"),
|
|
164
|
+
):
|
|
165
|
+
"""Show an escrow deal's current state (read-only, no wallet)."""
|
|
166
|
+
context = _context(network, DEFAULT_WALLET_PATH, signing=False)
|
|
167
|
+
try:
|
|
168
|
+
details = context.call_contract(escrow_id, "get_details", [], read_only=True)
|
|
169
|
+
except Exception as e: # noqa: BLE001
|
|
170
|
+
typer.echo(f"❌ deal status failed: {e}")
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
|
|
173
|
+
def _get(key):
|
|
174
|
+
if isinstance(details, dict):
|
|
175
|
+
return details.get(key)
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
amount = _get("amount")
|
|
179
|
+
provider = _get("provider")
|
|
180
|
+
deadline = _get("deadline")
|
|
181
|
+
settled = _get("settled")
|
|
182
|
+
typer.echo(f"Deal escrow {escrow_id}")
|
|
183
|
+
typer.echo(f" amount : {int(amount) / 10_000_000:.7f} XLM" if amount is not None else " amount : —")
|
|
184
|
+
typer.echo(f" provider : {getattr(provider, 'address', provider)}")
|
|
185
|
+
typer.echo(f" deadline : {int(deadline)} (unix)" if deadline is not None else " deadline : —")
|
|
186
|
+
typer.echo(f" settled : {bool(settled)}")
|
|
@@ -4,23 +4,19 @@
|
|
|
4
4
|
Mirrors the proven flow in the IDE backend's /api/deploy:
|
|
5
5
|
- testnet: if the wallet has 0 XLM, fund it via Friendbot and poll.
|
|
6
6
|
- mainnet: refuse to deploy unless the wallet holds >= 5 XLM (no Friendbot).
|
|
7
|
-
- deploy via
|
|
7
|
+
- deploy via pure-Python signed Soroban transactions (no stellar-cli / Rust).
|
|
8
8
|
- write the resulting contract_id + wallet public key back to mycelium.toml.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import os
|
|
12
|
-
import subprocess
|
|
13
12
|
import sys
|
|
14
13
|
import time
|
|
15
14
|
|
|
16
|
-
from mycelium_compiler.codegen import ensure_stellar_cli
|
|
17
15
|
from mycelium_sdk import crypto
|
|
18
16
|
from mycelium_sdk.constants import (
|
|
19
17
|
FRIENDBOT_URL,
|
|
20
18
|
HORIZON_URLS,
|
|
21
19
|
MAINNET_MIN_XLM,
|
|
22
|
-
NETWORK_PASSPHRASES,
|
|
23
|
-
SOROBAN_RPC_URLS,
|
|
24
20
|
normalize_network,
|
|
25
21
|
)
|
|
26
22
|
|
|
@@ -97,7 +93,7 @@ def run_deploy(
|
|
|
97
93
|
print(f"Error: wallet {wallet_path} not found. Run `mycelium newwallet` first.")
|
|
98
94
|
sys.exit(1)
|
|
99
95
|
|
|
100
|
-
|
|
96
|
+
_, public_key = _load_secret(wallet_path, passphrase)
|
|
101
97
|
print(f"[deploy] Deploying {wasm_path} to {network} as {public_key}...")
|
|
102
98
|
|
|
103
99
|
balance = _native_balance(network, public_key)
|
|
@@ -113,20 +109,22 @@ def run_deploy(
|
|
|
113
109
|
)
|
|
114
110
|
sys.exit(1)
|
|
115
111
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
112
|
+
with open(wasm_path, "rb") as f:
|
|
113
|
+
wasm_bytes = f.read()
|
|
114
|
+
|
|
115
|
+
from mycelium_sdk.context import AgentContext
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
ctx = AgentContext(
|
|
119
|
+
keypair_path=wallet_path,
|
|
120
|
+
network_type=network,
|
|
121
|
+
passphrase=passphrase,
|
|
122
|
+
)
|
|
123
|
+
contract_id = ctx.deploy_contract(wasm_bytes)
|
|
124
|
+
except Exception as e: # noqa: BLE001 - surface a clean CLI error
|
|
125
|
+
print(f"❌ Deployment failed: {e}")
|
|
127
126
|
sys.exit(1)
|
|
128
127
|
|
|
129
|
-
contract_id = res.stdout.strip().splitlines()[-1].strip()
|
|
130
128
|
print(f"✓ Deployment successful! Contract ID: {contract_id}")
|
|
131
129
|
|
|
132
130
|
if write_config:
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`mycelium doctor` — verify connectivity and print fixes.
|
|
3
|
+
|
|
4
|
+
Mycelium's default happy path is **zero-toolchain**: compile runs on the hosted
|
|
5
|
+
backend and deploy is pure-Python signed transactions, so neither Rust nor
|
|
6
|
+
stellar-cli is required. doctor's hard checks reflect that:
|
|
7
|
+
- the hosted compile endpoint is reachable,
|
|
8
|
+
- the Soroban RPC for the configured network is reachable.
|
|
9
|
+
|
|
10
|
+
stellar-cli and a local Rust/wasm32 toolchain are reported as **optional**
|
|
11
|
+
"local compile" capabilities — their absence is informational, never a failure.
|
|
12
|
+
|
|
13
|
+
Each failed check prints the exact command that fixes it. Exit code is non-zero
|
|
14
|
+
only if a *required* check fails, so it can gate CI.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from mycelium_sdk.constants import COMPILE_URL, SOROBAN_RPC_URLS, normalize_network
|
|
24
|
+
|
|
25
|
+
from mycelium_cli.config import get_value
|
|
26
|
+
|
|
27
|
+
# Must track compiler/.../core.py::ensure_stellar_cli (the version we bundle).
|
|
28
|
+
PINNED_STELLAR_VERSION = "27.0.0"
|
|
29
|
+
WASM_TARGET = "wasm32-unknown-unknown"
|
|
30
|
+
|
|
31
|
+
_OK = "✓"
|
|
32
|
+
_NO = "✗"
|
|
33
|
+
_WARN = "⚠"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _run(cmd: list[str]) -> Optional[str]:
|
|
37
|
+
"""Run `cmd`, returning stripped stdout, or None if it isn't runnable."""
|
|
38
|
+
try:
|
|
39
|
+
res = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
|
40
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
41
|
+
return None
|
|
42
|
+
if res.returncode != 0:
|
|
43
|
+
return None
|
|
44
|
+
return (res.stdout or res.stderr).strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _check_stellar_cli() -> None:
|
|
48
|
+
"""Optional: report local stellar-cli (only needed for `compile --local`)."""
|
|
49
|
+
binary = shutil.which("stellar")
|
|
50
|
+
if not binary:
|
|
51
|
+
print(f" {_WARN} stellar-cli not installed (optional — only for `compile --local`)")
|
|
52
|
+
print(f" install: cargo install --locked stellar-cli@{PINNED_STELLAR_VERSION}")
|
|
53
|
+
return
|
|
54
|
+
out = _run(["stellar", "--version"]) or ""
|
|
55
|
+
m = re.search(r"(\d+)\.(\d+)\.(\d+)", out)
|
|
56
|
+
version = m.group(0) if m else "unknown"
|
|
57
|
+
major = int(m.group(1)) if m else 0
|
|
58
|
+
pinned_major = int(PINNED_STELLAR_VERSION.split(".")[0])
|
|
59
|
+
if major == pinned_major:
|
|
60
|
+
print(f" {_OK} stellar-cli {version} ({binary}) — local compile available")
|
|
61
|
+
else:
|
|
62
|
+
print(f" {_WARN} stellar-cli {version} — Mycelium pins v{PINNED_STELLAR_VERSION} for local compile")
|
|
63
|
+
print(f" fix: cargo install --locked stellar-cli@{PINNED_STELLAR_VERSION}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _check_rust() -> None:
|
|
67
|
+
"""Optional: report local Rust + wasm32 target (only for `compile --local`)."""
|
|
68
|
+
rustc = _run(["rustc", "--version"])
|
|
69
|
+
if not rustc:
|
|
70
|
+
print(f" {_WARN} rust not installed (optional — only for `compile --local`)")
|
|
71
|
+
print(" install: curl https://sh.rustup.rs -sSf | sh")
|
|
72
|
+
return
|
|
73
|
+
print(f" {_OK} rust {rustc}")
|
|
74
|
+
|
|
75
|
+
installed = _run(["rustup", "target", "list", "--installed"]) or ""
|
|
76
|
+
if WASM_TARGET in installed:
|
|
77
|
+
print(f" {_OK} wasm target {WASM_TARGET} installed — local compile available")
|
|
78
|
+
else:
|
|
79
|
+
print(f" {_WARN} wasm target {WASM_TARGET} missing (optional)")
|
|
80
|
+
print(f" fix: rustup target add {WASM_TARGET}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _check_compile_endpoint() -> bool:
|
|
84
|
+
"""Required: the hosted compile endpoint must be reachable (default path)."""
|
|
85
|
+
import requests
|
|
86
|
+
|
|
87
|
+
# GET the backend root — the /compile route only accepts POST, but a
|
|
88
|
+
# reachable host returning any HTTP response proves connectivity.
|
|
89
|
+
base = COMPILE_URL.rsplit("/compile", 1)[0] or COMPILE_URL
|
|
90
|
+
try:
|
|
91
|
+
requests.get(base, timeout=15)
|
|
92
|
+
print(f" {_OK} compile hosted endpoint reachable ({COMPILE_URL})")
|
|
93
|
+
return True
|
|
94
|
+
except Exception as e:
|
|
95
|
+
print(f" {_NO} compile hosted endpoint unreachable: {e}")
|
|
96
|
+
print(f" fix: check connectivity to {COMPILE_URL}, set MYCELIUM_COMPILE_URL, "
|
|
97
|
+
f"or install a local toolchain and use `compile --local`")
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _check_rpc(network: str) -> bool:
|
|
102
|
+
url = SOROBAN_RPC_URLS[network]
|
|
103
|
+
try:
|
|
104
|
+
from stellar_sdk import SorobanServer
|
|
105
|
+
|
|
106
|
+
seq = SorobanServer(url).get_latest_ledger().sequence
|
|
107
|
+
print(f" {_OK} rpc {network} reachable (ledger {seq})")
|
|
108
|
+
return True
|
|
109
|
+
except Exception as e:
|
|
110
|
+
print(f" {_NO} rpc {network} unreachable: {e}")
|
|
111
|
+
print(f" fix: check connectivity to {url}")
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_doctor(network: Optional[str] = None) -> bool:
|
|
116
|
+
network = normalize_network(network or get_value("onchain", "network", "testnet"))
|
|
117
|
+
print("\nMycelium doctor — connectivity check\n")
|
|
118
|
+
|
|
119
|
+
# Required checks gate the exit code; optional ones are informational only.
|
|
120
|
+
required = [
|
|
121
|
+
_check_compile_endpoint(),
|
|
122
|
+
_check_rpc(network),
|
|
123
|
+
]
|
|
124
|
+
print("\n optional — local compile (not needed for the default workflow):")
|
|
125
|
+
_check_stellar_cli()
|
|
126
|
+
_check_rust()
|
|
127
|
+
|
|
128
|
+
ok = all(required)
|
|
129
|
+
print(f"\n{'✓ All required checks passed.' if ok else '✗ Some required checks failed — see fixes above.'}\n")
|
|
130
|
+
if not ok:
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
return ok
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`mycelium init` — scaffold a new agent project per sdk.md section 1.1:
|
|
3
|
+
|
|
4
|
+
<project>/
|
|
5
|
+
├── mycelium.toml # project / agent / onchain / registry config
|
|
6
|
+
├── agent.py # outer LLM-orchestration logic
|
|
7
|
+
├── contract.py # inner Soroban contract (Mycelium DSL)
|
|
8
|
+
└── .mycelium/ # protected dir for the encrypted wallet (gitignored)
|
|
9
|
+
|
|
10
|
+
The file templates live in `mycelium_sdk.scaffold` (shared with the IDE backend's
|
|
11
|
+
in-IDE agent creation so the two scaffolders never drift).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
import tomli_w
|
|
17
|
+
|
|
18
|
+
from mycelium_sdk.scaffold import (
|
|
19
|
+
CONTRACT_TEMPLATE,
|
|
20
|
+
GITIGNORE,
|
|
21
|
+
VALID_FRAMEWORKS,
|
|
22
|
+
API_KEY_ENV as _API_KEY_ENV,
|
|
23
|
+
agent_template,
|
|
24
|
+
build_config,
|
|
25
|
+
validate_unique_name,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_init(
|
|
30
|
+
project_name: str,
|
|
31
|
+
framework: str = "custom",
|
|
32
|
+
model: str = "custom",
|
|
33
|
+
unique_name: str | None = None,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""
|
|
37
|
+
Scaffold a project directory. Prompting for framework/model/unique_name is
|
|
38
|
+
handled by the CLI layer; this function takes resolved values so it can be
|
|
39
|
+
driven non-interactively (tests/CI). When `api_key` is given it is written
|
|
40
|
+
to a gitignored `.env` so the agent can authenticate without re-prompting.
|
|
41
|
+
Returns the project path.
|
|
42
|
+
"""
|
|
43
|
+
if framework not in VALID_FRAMEWORKS:
|
|
44
|
+
raise ValueError(f"framework must be one of {VALID_FRAMEWORKS}, got {framework!r}")
|
|
45
|
+
unique_name = unique_name or project_name
|
|
46
|
+
if not validate_unique_name(unique_name):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"unique_name {unique_name!r} must match ^[a-zA-Z0-9_]{{3,30}}$"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
os.makedirs(project_name, exist_ok=True)
|
|
52
|
+
os.makedirs(os.path.join(project_name, ".mycelium"), exist_ok=True)
|
|
53
|
+
|
|
54
|
+
with open(os.path.join(project_name, "mycelium.toml"), "wb") as f:
|
|
55
|
+
tomli_w.dump(build_config(project_name, framework, model, unique_name), f)
|
|
56
|
+
with open(os.path.join(project_name, "contract.py"), "w") as f:
|
|
57
|
+
f.write(CONTRACT_TEMPLATE)
|
|
58
|
+
with open(os.path.join(project_name, "agent.py"), "w") as f:
|
|
59
|
+
f.write(agent_template(framework, model, unique_name))
|
|
60
|
+
with open(os.path.join(project_name, ".gitignore"), "w") as f:
|
|
61
|
+
f.write(GITIGNORE)
|
|
62
|
+
|
|
63
|
+
if api_key:
|
|
64
|
+
env_var = _API_KEY_ENV.get(framework, "API_KEY")
|
|
65
|
+
env_path = os.path.join(project_name, ".env")
|
|
66
|
+
with open(env_path, "w") as f:
|
|
67
|
+
f.write(f"{env_var}={api_key}\n")
|
|
68
|
+
try:
|
|
69
|
+
os.chmod(env_path, 0o600)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
return project_name
|