json output + stable exit codes (M2M / LLM contract)

- -json on info, list, show, cert (+ -chain): emit structured JSON ready
  for jq / agent consumption
- exit codes are now stable + documented: 0/1/2/3/4/5/130 with distinct
  meanings for network vs integrity vs signature failures
- help text enumerates the contract
- readme: machine-usage section explains both

MCP stdio server (for Claude Code / Cursor etc.) planned, not wired yet
This commit is contained in:
Kayos 2026-04-23 15:25:09 -07:00
parent 4ea5635bf6
commit 326d75d91a
3 changed files with 120 additions and 15 deletions

View file

@ -92,6 +92,36 @@ STM (Stake-based Threshold Multi-signature) aggregate proof over BLS12-381.
downloaded — 16836 entries for preprod as of epoch 284).
- Full immutables loop for the `download -immutables` path.
## Machine / LLM usage
Every query command accepts `-json` for structured output:
```
mithril-go list -network mainnet -json # snapshot array
mithril-go show -network mainnet latest -json
mithril-go cert -network mainnet head -json
mithril-go cert -network mainnet head -chain -json
mithril-go info -network mainnet -json
```
Exit codes are stable:
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | generic error |
| 2 | usage error |
| 3 | network / aggregator error |
| 4 | integrity failure (SHA, tar-slip, truncated archive) |
| 5 | signature verification failure (genesis or STM) |
| 130 | canceled (SIGINT) |
These are the contract — existing codes won't renumber.
Planned: `mithril-go mcp` stdio server (Model Context Protocol) so MCP-native
agents (Claude Code, Cursor, etc.) can discover + call commands without
shelling out. Not yet implemented.
## Dependencies
- `github.com/klauspost/compress/zstd` — pure Go zstd decoder

30
cmd/mithril-go/json.go Normal file
View file

@ -0,0 +1,30 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
)
// emitJSON writes v as pretty-printed JSON to stdout. Returns a process
// exit code: 0 on success, 1 on any marshal/write error.
func emitJSON(v any) int {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
fmt.Fprintln(os.Stderr, "json:", err)
return 1
}
return 0
}
// emitJSONErr writes a structured error envelope. Mirrors the shape
// Claude/MCP-friendly consumers want: {"error": {"code":..., "message":...}}.
func emitJSONErr(w io.Writer, code, msg string) {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]any{
"error": map[string]string{"code": code, "message": msg},
})
}

View file

@ -27,7 +27,19 @@ import (
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
)
const version = "0.0.2-dev"
const version = "0.0.3-dev"
// Stable exit codes. Any addition goes at the end; existing values
// don't renumber. LLM/automation-friendly contract.
const (
exitOK = 0
exitGeneric = 1
exitUsage = 2
exitNetwork = 3 // aggregator unreachable / HTTP error
exitIntegrity = 4 // SHA mismatch, archive truncated, tar-slip
exitBadSig = 5 // genesis Ed25519 or STM BLS verification failed
exitCanceled = 130
)
func main() {
if len(os.Args) < 2 {
@ -80,7 +92,17 @@ Commands:
help Show this help
Common flags:
-network mainnet | preprod | preview (default: preprod)`)
-network mainnet | preprod | preview (default: preprod)
-json emit structured JSON (info, list, show, cert)
Exit codes:
0 success
1 generic error
2 usage error
3 network / aggregator error
4 integrity failure (SHA, archive, tar-slip)
5 signature verification failure (genesis or STM)
130 canceled (SIGINT)`)
}
func resolveNetwork(fs *flag.FlagSet, args []string) (networks.Network, []string, error) {
@ -97,6 +119,7 @@ func resolveNetwork(fs *flag.FlagSet, args []string) (networks.Network, []string
func cmdList(ctx context.Context, args []string) int {
fs := flag.NewFlagSet("list", flag.ExitOnError)
asJSON := fs.Bool("json", false, "emit structured JSON")
n, _, err := resolveNetwork(fs, args)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@ -106,7 +129,10 @@ func cmdList(ctx context.Context, args []string) int {
snaps, err := c.ListCardanoDBSnapshots(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "list:", err)
return 1
return exitNetwork
}
if *asJSON {
return emitJSON(map[string]any{"network": n.Name, "snapshots": snaps})
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "HASH\tEPOCH\tIMMUTABLE\tSIZE\tCREATED")
@ -124,10 +150,11 @@ func cmdList(ctx context.Context, args []string) int {
func cmdShow(ctx context.Context, args []string) int {
fs := flag.NewFlagSet("show", flag.ExitOnError)
asJSON := fs.Bool("json", false, "emit structured JSON")
n, rest, err := resolveNetwork(fs, args)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
return exitUsage
}
hash := "latest"
if len(rest) > 0 {
@ -137,7 +164,10 @@ func cmdShow(ctx context.Context, args []string) int {
snap, err := resolveSnapshot(ctx, c, hash)
if err != nil {
fmt.Fprintln(os.Stderr, "show:", err)
return 1
return exitNetwork
}
if *asJSON {
return emitJSON(snap)
}
fmt.Printf("hash: %s\n", snap.Hash)
fmt.Printf("network: %s\n", snap.Network)
@ -150,7 +180,7 @@ func cmdShow(ctx context.Context, args []string) int {
fmt.Printf("ancillary size: %s locations: %d\n", humanSize(snap.Ancillary.SizeUncompressed), len(snap.Ancillary.Locations))
fmt.Printf("immutable avg: %s files: %d locations: %d\n",
humanSize(snap.Immutables.AverageSizeUncompressed), snap.Beacon.ImmutableFileNumber, len(snap.Immutables.Locations))
return 0
return exitOK
}
func cmdDownload(ctx context.Context, args []string) int {
@ -242,14 +272,15 @@ func cmdCert(ctx context.Context, args []string) int {
fs := flag.NewFlagSet("cert", flag.ExitOnError)
chain := fs.Bool("chain", false, "walk previous_hash back to the genesis certificate")
maxDepth := fs.Int("max-depth", 1024, "chain walk safety cap")
asJSON := fs.Bool("json", false, "emit structured JSON")
n, rest, err := resolveNetwork(fs, args)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
return exitUsage
}
if len(rest) == 0 {
fmt.Fprintln(os.Stderr, "cert: hash required (or 'head' to use the latest snapshot's cert_hash)")
return 2
return exitUsage
}
head := rest[0]
c := aggregator.New(n.AggregatorURL)
@ -257,7 +288,7 @@ func cmdCert(ctx context.Context, args []string) int {
snap, err := resolveSnapshot(ctx, c, "latest")
if err != nil {
fmt.Fprintln(os.Stderr, "resolve head:", err)
return 1
return exitNetwork
}
head = snap.CertificateHash
}
@ -265,7 +296,10 @@ func cmdCert(ctx context.Context, args []string) int {
certs, err := c.CertChain(ctx, head, *maxDepth)
if err != nil {
fmt.Fprintln(os.Stderr, "chain:", err)
return 1
return exitNetwork
}
if *asJSON {
return emitJSON(map[string]any{"chain_length": len(certs), "certs": certs})
}
fmt.Printf("chain length: %d (head → genesis)\n\n", len(certs))
for i, ct := range certs {
@ -276,12 +310,15 @@ func cmdCert(ctx context.Context, args []string) int {
fmt.Printf("[%3d] %s epoch=%d prev=%.16s…%s\n",
i, ct.Hash, ct.Epoch, ct.PreviousHash, role)
}
return 0
return exitOK
}
cert, err := c.GetCertificate(ctx, head)
if err != nil {
fmt.Fprintln(os.Stderr, "cert:", err)
return 1
return exitNetwork
}
if *asJSON {
return emitJSON(cert)
}
fmt.Printf("hash: %s\n", cert.Hash)
fmt.Printf("previous_hash: %s\n", cert.PreviousHash)
@ -290,7 +327,7 @@ func cmdCert(ctx context.Context, args []string) int {
fmt.Printf("genesis sig: %s\n", yesNo(cert.GenesisSignature != ""))
fmt.Printf("multi_signature: %d bytes (raw)\n", len(cert.Multisignature))
fmt.Printf("protocol_message: %d bytes (raw)\n", len(cert.ProtocolMessage))
return 0
return exitOK
}
func yesNo(b bool) string {
@ -302,15 +339,23 @@ func yesNo(b bool) string {
func cmdInfo(args []string) int {
fs := flag.NewFlagSet("info", flag.ExitOnError)
asJSON := fs.Bool("json", false, "emit structured JSON")
n, _, err := resolveNetwork(fs, args)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
return exitUsage
}
if *asJSON {
return emitJSON(map[string]any{
"network": n.Name,
"aggregator": n.AggregatorURL,
"genesis_verify_key": n.GenesisVerifyKey,
})
}
fmt.Printf("network: %s\n", n.Name)
fmt.Printf("aggregator: %s\n", n.AggregatorURL)
fmt.Printf("genesis verify key: %s…\n", n.GenesisVerifyKey[:16])
return 0
return exitOK
}
func resolveSnapshot(ctx context.Context, c *aggregator.Client, hashOrLatest string) (*aggregator.CardanoDBSnapshot, error) {