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:
parent
4ea5635bf6
commit
326d75d91a
3 changed files with 120 additions and 15 deletions
30
README.md
30
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
|
||||
|
|
|
|||
30
cmd/mithril-go/json.go
Normal file
30
cmd/mithril-go/json.go
Normal 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},
|
||||
})
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue