Files
zip/cli.go
T
zeekay 33ebd6abca Prose is filed under the package that declared it
The documentation map is one map for a whole process, keyed by an address —
and an address is unique only within the app that serves it. A node runs twelve
chains on one binary and the P-Chain and the X-Chain both answer GET /height,
GET /block, GET /tx, GET /utxos, GET /balance and POST /tx: six addresses where
whichever init() ran last published its prose for the other chain routes.

Nothing failed. The document and the MCP tool carried the wrong sentence for the
right route, which is why it survived every test until someone read the output —
and renaming a path to dodge it would have been the wrong repair twice over,
since the mount already tells the chains apart and every future chain would
inherit the dodge.

So the key carries the declaring package, read off the handler itself at
registration. It is the namespacing the schema registry already does for named
types: who declared a thing is a property of the code. An unqualified key is
still read, so a generated file written before this is still correct for the
single-app process it was written for.
2026-08-29 23:16:02 -07:00

1051 lines
34 KiB
Go

package zip
import (
"context"
"encoding/json"
"fmt"
"github.com/zap-proto/zip/internal/jsonenc"
"io"
"reflect"
"sort"
"strconv"
"strings"
"github.com/valyala/fasthttp"
)
// CLI — the FOURTH projection. The same typed-op registry (a.registry) that produces
// the REST routes, the OpenAPI document and the MCP tools produces a command
// line: an op becomes `<service> <operation>`, its In fields become flags, its
// doc comment becomes the help, and its Example becomes the example invocation.
//
// A command carries the op's OWN identity, not a second one: Command.OperationID
// is the token the document, the tool list and zip.Call all address it by, and
// an explicit WithOperationID renames the command with them.
//
// Nothing about a command is written anywhere. Registering a typed route adds
// the command, its flags, its help and its example — with no edit to any CLI
// source, which is the only test that proves a CLI is derived rather than
// merely generated once and maintained by hand afterwards. A command therefore
// cannot drift from the API: there is no second place for it to drift in.
//
// Two axes, kept apart:
//
// - DERIVATION. App.Commands reads the registry in THIS process.
// CommandsFromSpec reads it over the wire, off the OpenAPI document the
// owning service derives from that same registry. Same registry, one hop.
// - EXECUTION. LocalInvoke runs the handler here; Remote.Invoke sends it to a
// running service.
//
// They compose freely, which is what makes one command tree serve both a fused
// binary and a control-plane client. A client CLI does not link the service
// graph at all: it asks the service what it can do, and a route added this
// morning is a command this afternoon with nothing rebuilt.
//
// It also pairs with Plugin.Lazy: a lazily loaded service starts on the first
// request that reaches its prefix, so running one command starts exactly the
// one service that command touches, and a host composing dozens of them pays
// for the one you used.
// Command is one operation as a command line: the value both the help text and
// the argument parser read. It is the CLI's whole surface — a derivation fills
// it in, the runner consumes it, and neither knows how the other got there.
type Command struct {
// Service is the first non-version segment of the path ("billing"), and
// Name is the operation token under it ("invoices-list").
Service string
Name string
// OperationID is the op's identity — the SAME token the OpenAPI document's
// operationId, the MCP tool's name and zip.Call all address it by. A command
// is a projection of an op, not a second thing with a second name, and this
// is what says WHICH op it projects.
OperationID string
// Summary is the one-line help; Description is the full prose. Both come
// from the handler's doc comment when cmd/zipdoc has run.
Summary string
Description string
// Method and Path are the operation's identity — the route pattern in
// fiber's ":name" form, whatever the derivation read it from.
Method string
Path string
// Args are the path parameters, in path order, as positional arguments. The
// URL is the addressing authority (see bindPath), so what addresses the
// resource is positional and what modifies the request is a flag.
Args []Arg
// Flags are the remaining In fields.
Flags []Flag
// Example is the op's example input from the doc comment, rendered by the
// help as a runnable command line. It reaches a spec-derived command either
// from the request body or, for a bodyless method, rebuilt from the
// parameters the document had to split it across.
Example json.RawMessage
// op is set only by App.Commands: it is what LocalInvoke runs. A
// spec-derived command has none and must be executed remotely.
op *registeredOp
}
// Arg is one path parameter as a positional argument.
type Arg struct {
Name string // the param name, "app"
Help string
}
// Flag is one In field as a flag.
type Flag struct {
Name string // the flag, kebab-cased and without the dashes: "organization-id"
Field string // the JSON field it sets: "organizationId"
Type string // string | integer | number | boolean | json
Help string
Required bool
}
// Commands projects every registered typed op into a command. This is the whole
// derivation: no registration, no list of commands, no per-endpoint code.
func (a *App) Commands() []Command {
ops := a.Registry()
cmds := make([]Command, 0, len(ops))
for _, op := range ops {
doc, has := docFor(op.Pkg, op.Method, op.Path)
c := newCommand(op.Method, op.Path, opName(op), op.Summary, doc, has)
c.op = op
c.Args, c.Flags = bindIn(op.InType, pathParams(op.Path), docFields(has, doc), hasBody(op.Method))
cmds = append(cmds, c)
}
sortCommands(cmds)
return cmds
}
// newCommand fills in everything that comes from the op's identity and its doc,
// which is the half both derivations share.
func newCommand(method, path, id, summary string, doc Doc, has bool) Command {
svc, name := commandName(method, path, id)
c := Command{Service: svc, Name: name, OperationID: id, Summary: summary, Method: method, Path: path}
if has {
c.Description = doc.Description
c.Example = doc.Example
if c.Summary == "" {
c.Summary = firstSentence(doc.Description)
}
}
return c
}
// bindIn splits an In type into positional args and flags. A field whose name
// matches a path parameter is positional and is NOT also a flag: the URL
// addresses the resource, so offering a second way to set the same value would
// be two ways to say one thing (and bindPath would overrule one of them).
//
// body says the method carries one (hasBody). A bodyless op's input rides the
// URL, so its flags are exactly the URL-bindable fields — the SAME urlFields
// list the document reads. Offering the rest would offer flags the wire cannot
// carry: a `--tags '["a"]'` on a DELETE marshalled fine, went out as a query
// value, and was dropped by the binder, so the command silently did nothing.
func bindIn(in reflect.Type, params []pathParam, fieldDocs map[string]string, body bool) ([]Arg, []Flag) {
args := make([]Arg, 0, len(params))
for _, p := range params {
// The argument a person types is the DOCUMENT's name: a wildcard's router
// key is *1, which is not something to ask anyone to write.
args = append(args, Arg{Name: p.Name, Help: fieldDocs[typeName(in)+"."+p.Name]})
}
if in == nil {
return args, nil
}
t := in
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return args, nil
}
var flags []Flag
// url is the name the URL carries the field under, which is what decides
// whether a flag would be a SECOND way to say what the path already says. It
// is not always the field's JSON name: a body field tagged `url:"-"` shares
// its name with a path param on purpose (the worker's source vs its name), and
// offering no flag for it left the command unable to send the body at all.
add := func(name, url, kind string, required bool) {
if name == "-" || isParam(params, url) {
return
}
flags = append(flags, Flag{
Name: kebab(name),
Field: name,
Type: kind,
Help: fieldDocs[t.Name()+"."+name],
Required: required,
})
}
if body {
// wireFields, so an embedded body type's promoted fields get flags. Reading
// only the outer type left a command unable to send them at all.
for _, f := range wireFields(t) {
add(jsonFieldName(f), urlFieldName(f), flagType(f.Type), strings.Contains(f.Tag.Get("validate"), "required"))
}
} else {
for _, f := range urlFields(t) {
kind, _ := f.schema["type"].(string)
add(f.name, f.name, specType(kind), f.required)
}
}
// The args' help lives under the field's own json name, which is what the
// extraction keyed it by; look it up now that the type is in hand.
for i, a := range args {
if a.Help == "" {
args[i].Help = fieldDocs[t.Name()+"."+a.Name]
}
}
return args, flags
}
func isParam(params []pathParam, name string) bool {
for _, p := range params {
if strings.EqualFold(p.Name, name) || strings.EqualFold(p.Key, name) {
return true
}
}
return false
}
// flagType is a flag's value kind, read off the ONE schema derivation: what a
// field IS on the wire is what schemaOf says it is, and specType is the SAME
// schema-type → flag-kind rule the spec-derived tree uses. A flag spelled from a
// Go type and the same flag spelled from that type's published schema are then
// equal by construction, rather than by two lists of kinds someone has to keep
// in step.
//
// Anything compound lands on JSON: a struct, map or slice has no unambiguous
// flat spelling, and inventing one would be a second encoding of a value JSON
// already encodes. No registry is passed because none is needed — the kind of a
// struct is "object" whatever its fields are, and expanding them here would
// build a definition with nowhere to live.
func flagType(t reflect.Type) string {
kind, _ := schemaOf(t, nil, nil)["type"].(string)
return specType(kind)
}
// commandName derives `<service> <operation>` from an op's identity.
//
// The SERVICE is where the command lives in the tree: the first non-version
// static segment of the path, which is the same thing the router groups on.
//
// The OPERATION is the op's own name. An explicit id (WithOperationID) is that
// name — renaming an op has to rename it in EVERY projection or the command line
// addresses something the document has never heard of. Absent one, id is the
// method+path default and the words below spell it out long-hand:
//
// GET /v1/paas/apps → paas apps-list
// GET /v1/paas/apps/:app → paas apps-get <app>
// POST /v1/paas/apps/:app/deploy → paas apps-deploy <app>
// GET /v1/paas/apps/:app/deploy → paas apps-deploy-get <app>
// POST /v1/billing/charge → billing charge-create
// DELETE /v1/keys/:id → keys delete <id>
//
// The name is a function of that one op and nothing else. It cannot change
// because a sibling route was added — a rule that shortened names while they
// stayed unique would rename a command, and therefore break a script, every
// time an unrelated endpoint shipped.
//
// POST is the only method whose verb an action segment replaces, because POST
// on a path ending in a verb IS the REST spelling of "run this" — `apps-deploy`
// rather than `apps-deploy-create`. Every other method still appends its own
// verb, so no two ops on one path can collide.
func commandName(method, path, id string) (service, name string) {
// An id that IS the default says nothing the path does not; only an
// explicit one renames the command.
if id == ID(method, path) {
id = ""
}
segs := make([]string, 0, 8)
for _, s := range strings.Split(path, "/") {
if s != "" && !isVersion(s) {
segs = append(segs, s)
}
}
// The service is the first static segment; a leading parameter (rare) is
// addressing, not naming, and is picked up as an argument like any other.
svc := 0
for svc < len(segs) && strings.HasPrefix(segs[svc], ":") {
svc++
}
if svc == len(segs) {
return "root", opWord("root", id, methodVerb(method, len(segs) > 0))
}
service = segs[svc]
rest := segs[svc+1:]
first, last := -1, -1
for i, s := range rest {
if strings.HasPrefix(s, ":") {
if first < 0 {
first = i
}
last = i
}
}
var words []string
var tail []string
if first < 0 {
words = append(words, rest...)
} else {
words = append(words, rest[:first]...)
tail = rest[last+1:]
words = append(words, tail...)
}
if !(method == "POST" && len(tail) > 0) {
// One thing or many. A path ending in a parameter is always one. Past
// that the last word decides, because a route that continues after its
// parameter has already said which it is: /apps/:app/deploy addresses one
// deploy, /nodes/:node/jobs addresses many jobs.
item := last >= 0 && len(tail) == 0
if !item && len(words) > 0 {
item = !plural(words[len(words)-1])
}
words = append(words, methodVerb(method, item))
}
for i, w := range words {
words[i] = kebab(w)
}
service = kebab(service)
return service, opWord(service, id, strings.Join(words, "-"))
}
// opWord is the operation token: the explicit id when there is one, else the
// name spelled out of the route. An explicit id is kebab-cased like any other
// identifier and loses a leading service word it would otherwise repeat —
// `billing_charge_create` under `billing` is `charge-create`, because the
// command line has already said billing.
func opWord(service, id, derived string) string {
if id == "" {
return derived
}
name := kebab(id)
if p := service + "-"; strings.HasPrefix(name, p) && len(name) > len(p) {
name = name[len(p):]
}
return name
}
// plural is the collection test. English "ends in s" is crude, but it reads the
// same signal the route author already gave when they named the segment, so it
// tracks intent without a second place to keep in sync — and the endings that
// break it (-ss, -us, -is) are singular, which is the direction that matters:
// mistaking one thing for many is the error that produces a wrong command name.
func plural(w string) bool {
return strings.HasSuffix(w, "s") &&
!strings.HasSuffix(w, "ss") &&
!strings.HasSuffix(w, "us") &&
!strings.HasSuffix(w, "is")
}
// methodVerb is the verb a method contributes. item says the path addresses one
// thing rather than many, which is what separates "get one" from "list many".
func methodVerb(method string, item bool) string {
switch method {
case "GET":
if item {
return "get"
}
return "list"
case "POST":
return "create"
case "PUT", "PATCH":
return "update"
case "DELETE":
return "delete"
}
return strings.ToLower(method)
}
// isVersion reports whether a segment is an API version ("v1"), which names the
// contract rather than the resource and so is not part of any command's name.
func isVersion(s string) bool {
if len(s) < 2 || s[0] != 'v' {
return false
}
for _, r := range s[1:] {
if r < '0' || r > '9' {
return false
}
}
return true
}
// kebab renders a Go/JSON identifier as a flag or command word: organizationId
// → organization-id, ID → id, snake_case → snake-case.
func kebab(s string) string {
var b strings.Builder
b.Grow(len(s) + 4)
rs := []rune(s)
for i, r := range rs {
switch {
case r == '_' || r == ' ' || r == '.':
b.WriteByte('-')
continue
case r >= 'A' && r <= 'Z':
prevLower := i > 0 && (rs[i-1] >= 'a' && rs[i-1] <= 'z' || rs[i-1] >= '0' && rs[i-1] <= '9')
nextLower := i+1 < len(rs) && rs[i+1] >= 'a' && rs[i+1] <= 'z'
if i > 0 && (prevLower || nextLower) {
b.WriteByte('-')
}
b.WriteRune(r + ('a' - 'A'))
continue
}
b.WriteRune(r)
}
return strings.Trim(b.String(), "-")
}
func sortCommands(cmds []Command) {
sort.Slice(cmds, func(i, j int) bool {
if cmds[i].Service != cmds[j].Service {
return cmds[i].Service < cmds[j].Service
}
return cmds[i].Name < cmds[j].Name
})
}
// ---------------------------------------------------------------------------
// Execution
// ---------------------------------------------------------------------------
// Invoker performs one parsed command. path holds the positional arguments by
// parameter name and body is the JSON built from the flags — exactly what a
// typed op's invoke seam takes, so a command runs through the same decode,
// validate and authorize path as a REST request or an MCP tool call.
type Invoker func(ctx context.Context, c Command, path map[string]string, body []byte) (any, error)
// LocalInvoke runs the handler in this process, through the op's own invoke
// seam. It is the whole reason a fused binary needs no client: the command IS
// the handler call.
func LocalInvoke(ctx context.Context, c Command, path map[string]string, body []byte) (any, error) {
if c.op == nil || c.op.invoke == nil {
return nil, fmt.Errorf("%s %s is not registered in this process — give the CLI a Remote invoker", c.Service, c.Name)
}
// No query map and no headers: a command's arguments are named, so they bind
// as path values, and a CLI has neither a URL for the "?a=b" half nor a
// request to read headers from. A declared header field is simply supplied
// as an argument here — which is why the honest answer on a transport with
// no request is "nothing", not a panic.
return c.op.invoke(ctx, jsonenc.Unmarshal, body, nil, path, nil)
}
// Remote executes a command against a running zip service, and reads that
// service's registry off the document it derives from it. Base is an address in
// the same form Proxy takes — the scheme selects the transport, so a command
// runs over ZAP, HTTP or anything else RegisterTransport'd without the CLI
// knowing which.
type Remote struct {
Base string // "https://api.hanzo.ai", "http://:8080", a unix socket path…
Header map[string]string // sent on every request (Authorization, …)
}
// Spec fetches the OpenAPI document — the registry in wire form. Pair it with
// CommandsFromSpec to build a command tree for a service this binary does not
// link.
func (r Remote) Spec(ctx context.Context) ([]byte, error) {
return r.do(ctx, "GET", "/.well-known/openapi.json", nil)
}
// Invoke sends one command to the service. Its signature is Invoker's, so it
// drops into a CLI wherever LocalInvoke would.
func (r Remote) Invoke(ctx context.Context, c Command, path map[string]string, body []byte) (any, error) {
url := c.Path
for name, val := range path {
url = strings.ReplaceAll(url, ":"+name, urlEscape(val))
}
// A bodyless method's non-path inputs belong in the query string, which is
// where the route's own decoder looks for them. hasBody is THE rule, read
// here as well as by the document and by the route itself.
if !hasBody(c.Method) && len(body) > 0 {
q, err := queryOf(body)
if err != nil {
return nil, err
}
if q != "" {
url += "?" + q
}
body = nil
}
out, err := r.do(ctx, c.Method, url, body)
if err != nil {
return nil, err
}
if len(out) == 0 {
return nil, nil
}
return json.RawMessage(out), nil
}
// do performs one request over the transport Base names. ctx is honoured up to
// the point the request is handed to the transport, which owns its own
// deadlines from there.
func (r Remote) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
scheme, host, t, err := transportFor(r.Base)
if err != nil {
return nil, err
}
if t.Dial == nil {
return nil, fmt.Errorf("zip: transport %q cannot dial (serve-only)", scheme)
}
req, resp := fasthttp.AcquireRequest(), fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(path)
req.SetHost(host)
req.Header.SetMethod(method)
// THE ESCAPING MUST SURVIVE THE TRANSPORT. Invoke percent-encodes every path
// argument (urlEscape) precisely so an argument cannot become part of the
// path, and fasthttp then undid it: its default serialisation is
// appendQuotedPath(u.Path()), and Path() has already decoded %2F and resolved
// "..". So `Invoke(cmd, {"app": "../../../v1/iam/users"})` on
// GET /v1/platform/apps/:app left as GET /v1/iam/users — one op's argument
// addressing another op entirely, on the caller's own credential.
//
// Set here so RequestURI() serialises PathOriginal(), the bytes Invoke built.
// It is stated AGAIN on the http/https HostClients (transport.go), and that is
// not a belt on a brace: HostClient.doNonNilReqResp ASSIGNS this field from
// its own, so for those two transports the request's own wish is overwritten
// and only the client's setting is read. This line is what carries the
// property over every OTHER transport — ZAP, the default, and anything
// RegisterTransport adds — which serialise the request as it was built.
//
// A command addresses ONE operation. That is the whole basis on which any
// caller decides whether it may run, and a transport that re-reads the address
// makes that decision about a different request than the one it sends.
req.URI().DisablePathNormalizing = true
if scheme == "https" {
req.URI().SetScheme("https")
}
if len(body) > 0 {
req.SetBody(body)
req.Header.SetContentType("application/json")
}
for k, v := range r.Header {
req.Header.Set(k, v)
}
if err := t.Dial(host).Do(req, resp); err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
}
out := append([]byte(nil), resp.Body()...)
if code := resp.StatusCode(); code >= 400 {
return nil, fmt.Errorf("%s %s: %d %s", method, path, code, strings.TrimSpace(string(out)))
}
return out, nil
}
// Query is the query string that carries in to a bodyless op.
//
// It is the client half of the URL binder and its exact inverse: what this
// writes, [bindURL] reads. That matters because they are two halves of one
// rule and they had drifted — a list went out as `?ids=["a","b"]` and a record
// as `?startIndex={"utxo":...}`, neither of which the binder reads, so the
// argument arrived empty and the op answered 200 about nothing.
//
// The value is taken from the op's own In, so the spelling is derived rather
// than agreed: a caller does not have to know that a list is comma-joined or
// that a record's leaves are named through it.
func Query(in any) (string, error) {
body, err := json.Marshal(in)
if err != nil {
return "", err
}
return queryOf(body)
}
// queryOf renders a JSON object as a query string, sorted so a command line
// always produces the same URL.
func queryOf(body []byte) (string, error) {
var b strings.Builder
if err := writeQuery(&b, "", body); err != nil {
return "", err
}
return b.String(), nil
}
// writeQuery writes one JSON object's keys under prefix, descending into a
// nested object the way [bindRecord] descends into a nested record.
func writeQuery(b *strings.Builder, prefix string, body []byte) error {
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
return err
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
raw := m[k]
if len(raw) == 0 || string(raw) == "null" {
continue // an absent value is written by leaving the key out
}
name := prefix + k
if raw[0] == '{' {
if err := writeQuery(b, name+".", raw); err != nil {
return err
}
continue
}
val, err := queryValue(raw)
if err != nil {
return err
}
if b.Len() > 0 {
b.WriteByte('&')
}
b.WriteString(urlEscape(name))
b.WriteByte('=')
b.WriteString(urlEscape(val))
}
return nil
}
// queryValue is one JSON value as the characters a URL carries: a string
// unquoted, a list comma-joined, anything else as it was written.
func queryValue(raw json.RawMessage) (string, error) {
if raw[0] == '[' {
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return "", err
}
parts := make([]string, len(items))
for i, item := range items {
part, err := queryValue(item)
if err != nil {
return "", err
}
parts[i] = part
}
return strings.Join(parts, ","), nil
}
if s, err := strconv.Unquote(string(raw)); err == nil {
return s, nil
}
return string(raw), nil
}
// urlEscape percent-encodes everything outside the unreserved set. Small enough
// to not be worth a net/url import in a package this hot.
func urlEscape(s string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0xf])
}
return b.String()
}
// ---------------------------------------------------------------------------
// The runner
// ---------------------------------------------------------------------------
// CLI runs a derived command tree. Name is what the usage lines call the
// binary; Invoke defaults to LocalInvoke and Out to os.Stdout's stand-in the
// caller passes.
type CLI struct {
Name string
Commands []Command
Invoke Invoker
Out io.Writer
}
// CLI returns the command line for everything registered on this app, executed
// in-process. `app.CLI().Run(ctx, os.Args[1:])` is a complete CLI for a service.
func (a *App) CLI() *CLI {
name := a.cfg.AppName
if name == "" {
name = "zip"
}
return &CLI{Name: name, Commands: a.Commands(), Invoke: LocalInvoke}
}
// Run executes one command line. It is the only entry point: help, dispatch and
// errors all come out of here so there is one place a CLI behaves.
func (c *CLI) Run(ctx context.Context, args []string) error {
out := c.Out
if out == nil {
return fmt.Errorf("zip: CLI.Out is nil")
}
if c.Invoke == nil {
c.Invoke = LocalInvoke
}
args = trimHelp(args)
if len(args) == 0 {
c.usage(out)
return nil
}
if len(args) == 1 {
if !c.hasService(args[0]) {
c.usage(out)
return fmt.Errorf("unknown service %q", args[0])
}
c.serviceUsage(out, args[0])
return nil
}
matches := c.find(args[0], args[1])
switch len(matches) {
case 0:
if c.hasService(args[0]) {
c.serviceUsage(out, args[0])
return fmt.Errorf("unknown operation %q for %s", args[1], args[0])
}
c.usage(out)
return fmt.Errorf("unknown service %q", args[0])
case 1:
default:
// Two ops that name themselves identically. Never resolved by picking
// one: silently running the wrong route is worse than saying so.
var paths []string
for _, m := range matches {
paths = append(paths, m.Method+" "+m.Path)
}
return fmt.Errorf("%s %s is ambiguous: %s", args[0], args[1], strings.Join(paths, ", "))
}
cmd := matches[0]
rest := args[2:]
if wantsHelp(rest) {
cmd.help(out, c.Name)
return nil
}
path, body, err := cmd.parse(rest)
if err != nil {
cmd.help(out, c.Name)
return err
}
res, err := c.Invoke(ctx, cmd, path, body)
if err != nil {
return err
}
return writeResult(out, res)
}
// parse turns the remaining argv into the two values an op's invoke seam takes:
// the path parameters and the JSON body. Only flags that were actually given
// appear in the body — an absent flag must not become a zero that overwrites a
// server-side default.
func (c Command) parse(args []string) (map[string]string, []byte, error) {
byName := make(map[string]Flag, len(c.Flags))
for _, f := range c.Flags {
byName[f.Name] = f
}
fields := map[string]json.RawMessage{}
var positional []string
for i := 0; i < len(args); i++ {
a := args[i]
if !strings.HasPrefix(a, "--") {
positional = append(positional, a)
continue
}
name, val, hasVal := strings.Cut(strings.TrimPrefix(a, "--"), "=")
f, ok := byName[name]
if !ok {
return nil, nil, fmt.Errorf("unknown flag --%s for %s %s", name, c.Service, c.Name)
}
if !hasVal {
// A boolean stands alone; anything else takes the next argument.
if f.Type == "boolean" {
val = "true"
} else {
if i+1 >= len(args) {
return nil, nil, fmt.Errorf("--%s needs a value", name)
}
i++
val = args[i]
}
}
raw, err := encodeFlag(f, val)
if err != nil {
return nil, nil, err
}
fields[f.Field] = raw
}
if len(positional) != len(c.Args) {
return nil, nil, fmt.Errorf("%s %s takes %d argument(s): %s",
c.Service, c.Name, len(c.Args), argNames(c.Args))
}
path := make(map[string]string, len(c.Args))
for i, a := range c.Args {
path[a.Name] = positional[i]
}
for _, f := range c.Flags {
if f.Required {
if _, ok := fields[f.Field]; !ok {
return nil, nil, fmt.Errorf("--%s is required", f.Name)
}
}
}
if len(fields) == 0 {
return path, nil, nil
}
body, err := json.Marshal(fields)
return path, body, err
}
// encodeFlag turns one flag value into the JSON it stands for, refusing a value
// its type cannot hold rather than sending something the handler will reject
// with a worse message.
func encodeFlag(f Flag, val string) (json.RawMessage, error) {
switch f.Type {
case "string":
b, err := json.Marshal(val)
return b, err
case "integer":
if _, err := strconv.ParseInt(val, 10, 64); err != nil {
return nil, fmt.Errorf("--%s wants an integer, got %q", f.Name, val)
}
return json.RawMessage(val), nil
case "number":
if _, err := strconv.ParseFloat(val, 64); err != nil {
return nil, fmt.Errorf("--%s wants a number, got %q", f.Name, val)
}
return json.RawMessage(val), nil
case "boolean":
b, err := strconv.ParseBool(val)
if err != nil {
return nil, fmt.Errorf("--%s wants true or false, got %q", f.Name, val)
}
return json.RawMessage(strconv.FormatBool(b)), nil
default:
if !json.Valid([]byte(val)) {
return nil, fmt.Errorf("--%s wants JSON, got %q", f.Name, val)
}
return json.RawMessage(val), nil
}
}
func writeResult(w io.Writer, res any) error {
if res == nil {
return nil
}
b, err := json.MarshalIndent(res, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(b))
return err
}
func (c *CLI) find(service, name string) []Command {
var out []Command
for _, cmd := range c.Commands {
if cmd.Service == service && cmd.Name == name {
out = append(out, cmd)
}
}
return out
}
func (c *CLI) hasService(service string) bool {
for _, cmd := range c.Commands {
if cmd.Service == service {
return true
}
}
return false
}
// trimHelp drops a leading help token so `cli help billing` and `cli billing`
// reach the same place.
func trimHelp(args []string) []string {
if len(args) > 0 && (args[0] == "help" || args[0] == "-h" || args[0] == "--help") {
return args[1:]
}
return args
}
func wantsHelp(args []string) bool {
for _, a := range args {
if a == "-h" || a == "--help" || a == "help" {
return true
}
}
return false
}
func argNames(args []Arg) string {
if len(args) == 0 {
return "(none)"
}
parts := make([]string, len(args))
for i, a := range args {
parts[i] = "<" + a.Name + ">"
}
return strings.Join(parts, " ")
}
// ---------------------------------------------------------------------------
// Help
// ---------------------------------------------------------------------------
func (c *CLI) usage(w io.Writer) {
fmt.Fprintf(w, "%s — %d operations, derived from the service registry\n\n", c.Name, len(c.Commands))
fmt.Fprintf(w, "Usage:\n %s <service> <operation> [args] [flags]\n\n", c.Name)
if len(c.Commands) == 0 {
fmt.Fprintf(w, "No typed operations are registered, so there is nothing to derive.\n")
return
}
counts := map[string]int{}
var services []string
for _, cmd := range c.Commands {
if counts[cmd.Service] == 0 {
services = append(services, cmd.Service)
}
counts[cmd.Service]++
}
sort.Strings(services)
fmt.Fprintf(w, "Services:\n")
width := 0
for _, s := range services {
width = max(width, len(s))
}
for _, s := range services {
fmt.Fprintf(w, " %-*s %d operations\n", width, s, counts[s])
}
fmt.Fprintf(w, "\nRun `%s <service>` to list its operations.\n", c.Name)
}
func (c *CLI) serviceUsage(w io.Writer, service string) {
fmt.Fprintf(w, "%s %s\n\nUsage:\n %s %s <operation> [args] [flags]\n\nOperations:\n",
c.Name, service, c.Name, service)
width := 0
for _, cmd := range c.Commands {
if cmd.Service == service {
width = max(width, len(cmd.Name)+len(argNames(cmd.Args)))
}
}
for _, cmd := range c.Commands {
if cmd.Service != service {
continue
}
use := cmd.Name
if len(cmd.Args) > 0 {
use += " " + argNames(cmd.Args)
}
fmt.Fprintf(w, " %-*s %s\n", width+1, use, cmd.Summary)
}
}
// help renders one command: what it does, how to call it, every flag, and an
// example built from the doc comment's own request body — so the example a
// reader copies is the example the spec ships, not a second one to keep true.
func (c Command) help(w io.Writer, bin string) {
fmt.Fprintf(w, "%s %s %s", bin, c.Service, c.Name)
if len(c.Args) > 0 {
fmt.Fprintf(w, " %s", argNames(c.Args))
}
if len(c.Flags) > 0 {
fmt.Fprintf(w, " [flags]")
}
fmt.Fprintf(w, "\n\n")
if c.Description != "" {
fmt.Fprintf(w, "%s\n\n", strings.TrimSpace(c.Description))
} else if c.Summary != "" {
fmt.Fprintf(w, "%s\n\n", c.Summary)
}
fmt.Fprintf(w, "%s %s\n\n", c.Method, c.Path)
width := 0
for _, a := range c.Args {
width = max(width, len(a.Name)+2)
}
for _, f := range c.Flags {
width = max(width, len(f.Name)+len(f.Type)+3)
}
if len(c.Args) > 0 {
fmt.Fprintf(w, "Arguments:\n")
for _, a := range c.Args {
fmt.Fprintf(w, " %-*s %s\n", width, "<"+a.Name+">", a.Help)
}
fmt.Fprintln(w)
}
if len(c.Flags) > 0 {
fmt.Fprintf(w, "Flags:\n")
for _, f := range c.Flags {
help := f.Help
if f.Required {
help = strings.TrimSpace(help + " (required)")
}
fmt.Fprintf(w, " %-*s %s\n", width, "--"+f.Name+" "+f.Type, help)
}
fmt.Fprintln(w)
}
if ex := c.exampleLine(bin); ex != "" {
fmt.Fprintf(w, "Example:\n %s\n", ex)
}
}
// exampleLine renders the doc comment's Example body as the command line that
// sends it. The example in the reference and the example on the terminal are
// then the same value, projected twice.
func (c Command) exampleLine(bin string) string {
if len(c.Example) == 0 {
return ""
}
var m map[string]json.RawMessage
if json.Unmarshal(c.Example, &m) != nil {
return ""
}
flag := make(map[string]Flag, len(c.Flags))
for _, f := range c.Flags {
flag[f.Field] = f
}
var b strings.Builder
fmt.Fprintf(&b, "%s %s %s", bin, c.Service, c.Name)
for _, a := range c.Args {
val := "<" + a.Name + ">"
if raw, ok := m[a.Name]; ok {
if s, err := strconv.Unquote(string(raw)); err == nil {
val = s
}
}
fmt.Fprintf(&b, " %s", val)
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
f, ok := flag[k]
if !ok {
continue
}
val := string(m[k])
if s, err := strconv.Unquote(val); err == nil {
val = s
}
if strings.ContainsAny(val, " \t\"'") {
val = strconv.Quote(val)
}
if f.Type == "boolean" && val == "true" {
fmt.Fprintf(&b, " --%s", f.Name)
continue
}
fmt.Fprintf(&b, " --%s %s", f.Name, val)
}
return b.String()
}