mycelium-cli 0.1.0__tar.gz → 0.3.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.
Files changed (39) hide show
  1. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/PKG-INFO +1 -1
  2. mycelium_cli-0.3.0/mycelium_cli/commands/compile.py +137 -0
  3. mycelium_cli-0.3.0/mycelium_cli/commands/deal.py +186 -0
  4. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/deploy.py +16 -18
  5. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/discover.py +11 -2
  6. mycelium_cli-0.3.0/mycelium_cli/commands/doctor.py +132 -0
  7. mycelium_cli-0.3.0/mycelium_cli/commands/init.py +73 -0
  8. mycelium_cli-0.3.0/mycelium_cli/commands/jobs.py +241 -0
  9. mycelium_cli-0.3.0/mycelium_cli/commands/memory.py +206 -0
  10. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/main.py +36 -4
  11. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli.egg-info/PKG-INFO +1 -1
  12. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli.egg-info/SOURCES.txt +3 -0
  13. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/pyproject.toml +1 -1
  14. mycelium_cli-0.1.0/mycelium_cli/commands/compile.py +0 -59
  15. mycelium_cli-0.1.0/mycelium_cli/commands/doctor.py +0 -106
  16. mycelium_cli-0.1.0/mycelium_cli/commands/init.py +0 -188
  17. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/README.md +0 -0
  18. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/__init__.py +0 -0
  19. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/__init__.py +0 -0
  20. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/agent.py +0 -0
  21. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/call.py +0 -0
  22. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/check.py +0 -0
  23. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/events.py +0 -0
  24. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/fund.py +0 -0
  25. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/newwallet.py +0 -0
  26. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/pay.py +0 -0
  27. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/register.py +0 -0
  28. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/resolve.py +0 -0
  29. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/run.py +0 -0
  30. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/status.py +0 -0
  31. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/commands/test.py +0 -0
  32. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli/config.py +0 -0
  33. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli.egg-info/dependency_links.txt +0 -0
  34. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli.egg-info/entry_points.txt +0 -0
  35. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli.egg-info/requires.txt +0 -0
  36. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/mycelium_cli.egg-info/top_level.txt +0 -0
  37. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/setup.cfg +0 -0
  38. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/tests/test_agent.py +0 -0
  39. {mycelium_cli-0.1.0 → mycelium_cli-0.3.0}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycelium-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Mycelium CLI — init, newwallet, compile, deploy, register
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -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 the pinned stellar-cli 27.0.0 binary (ensure_stellar_cli()).
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
- secret, public_key = _load_secret(wallet_path, passphrase)
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
- stellar_bin = ensure_stellar_cli()
117
- cmd = [
118
- stellar_bin, "contract", "deploy",
119
- "--wasm", wasm_path,
120
- "--source-account", secret,
121
- "--rpc-url", SOROBAN_RPC_URLS[network],
122
- "--network-passphrase", NETWORK_PASSPHRASES[network],
123
- ]
124
- res = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
125
- if res.returncode != 0:
126
- print(f"❌ Deployment failed:\n{res.stdout}\n{res.stderr}")
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:
@@ -31,9 +31,18 @@ def run_discover(
31
31
  context = AgentContext.read_only(network_type=network)
32
32
  hive = HiveClient(context, registry_address=registry)
33
33
 
34
- print(f"[discover] Scanning Hive Registry {registry} on {network}...")
34
+ # Hosted indexer first (instant, full history); falls back to the on-chain
35
+ # event-scan automatically when unreachable. An explicit --start-ledger means
36
+ # the caller wants the chain scan, so skip the indexer in that case.
37
+ prefer_indexer = start_ledger is None
38
+ if prefer_indexer:
39
+ print(f"[discover] Querying indexer (falls back to chain scan of {registry})...")
40
+ else:
41
+ print(f"[discover] Scanning Hive Registry {registry} on {network}...")
35
42
  try:
36
- agents = hive.discover_agents(start_ledger=start_ledger, resolve=resolve)
43
+ agents = hive.discover_agents(
44
+ start_ledger=start_ledger, resolve=resolve, prefer_indexer=prefer_indexer
45
+ )
37
46
  except Exception as e:
38
47
  print(f"❌ Discovery failed: {e}")
39
48
  sys.exit(1)
@@ -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