From 326d75d91a682c93dd2963ce7c8a60e776c11b64 Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 23 Apr 2026 15:25:09 -0700 Subject: [PATCH] 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 --- README.md | 30 +++++++++++++++++ cmd/mithril-go/json.go | 30 +++++++++++++++++ cmd/mithril-go/main.go | 75 +++++++++++++++++++++++++++++++++--------- 3 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 cmd/mithril-go/json.go diff --git a/README.md b/README.md index ab2933c..41099fd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/mithril-go/json.go b/cmd/mithril-go/json.go new file mode 100644 index 0000000..b647a47 --- /dev/null +++ b/cmd/mithril-go/json.go @@ -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}, + }) +} diff --git a/cmd/mithril-go/main.go b/cmd/mithril-go/main.go index 2872d42..aa78c30 100644 --- a/cmd/mithril-go/main.go +++ b/cmd/mithril-go/main.go @@ -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) {