Add a command
Declare an operation once and get a command, a route, an MCP tool, and a URI type.
Everything github does is one kit.Handle registration in gh/ops.go, backed by a client method and a record type.
Add those three pieces and every surface updates itself.
1. The record
Records live in the gh package, one file per family.
Every one embeds Base, which carries the kind, the id, the two addresses, the sources it was read from, the tier each field came from, and Extra:
type Widget struct {
Base
Name string `json:"name" table:"name"`
Owner string `json:"owner,omitempty" table:"owner"`
Stars *int `json:"stars,omitempty" table:"stars,num"`
}
Two habits matter here.
A count is a pointer, because zero and absent are different answers and a table that shows 0 for "GitHub did not say" is lying.
And every decoder ends with decodeExtra, which returns the keys the struct did not claim:
w.setIdentity(KindWidget, id)
w.addSource(resp.URL)
w.addExtra("widgetPayload", decodeExtra(raw, &w))
That is the rule the whole tool is built on: decode what you model, keep what you do not.
Without it, a field GitHub adds next month is a field nobody ever sees.
With it, Extra names the block it came from and the field shows up the first time anyone looks.
2. The client method
func (c *Client) Widget(ctx context.Context, id string) (*Widget, error) {
var w Widget
res, err := c.GetJSON(ctx, BaseURL+"/"+id+"/widget", SurfaceRouteJSON, &w)
if err != nil {
return nil, err
}
w.setIdentity(KindWidget, id)
w.addSource(res.URL)
return &w, nil
}
Get, GetJSON, GetHTML, Page, Stream, and Poll are the whole client surface, and every one of them handles pacing, retries, the response cache, and the status-to-error mapping, so a method that goes through them inherits the whole policy.
Poll is the one for a route that answers 202 while GitHub computes, which is how the contributor graph works.
Which surface you reach for is the real decision, and github routes is the record of every one already made.
See the page plane for what each surface is.
3. The operation
An input struct declares the arguments and flags by reflection, and the handler emits records:
type widgetIn struct {
C *Client `kit:"inject"`
Ref string `kit:"arg" help:"owner/name, or any URL from the repository"`
}
func getWidget(ctx context.Context, in widgetIn, emit func(*Widget) error) error {
id, err := ResolveRepo(in.Ref)
if err != nil {
return err
}
w, err := in.C.Widget(ctx, id)
if err != nil {
return err
}
return emit(w)
}
Then register it in the right register*Ops function:
kit.Handle(app, kit.OpMeta{
Name: "widget", Group: "discover", URIType: KindWidget, Single: true, Resolver: true,
Summary: "Read one widget",
Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}},
}, getWidget)
That is the whole change. The operation is now:
github widget golang/go # the command
curl 'localhost:7777/v1/widget?ref=golang/go' # the route, under serve
ant get github://widget/golang/go # the URI dereference, via a host
It is also an MCP tool under github mcp, with the summary as its description and the input struct as its schema.
Input struct tags
| Tag | Meaning |
|---|---|
kit:"arg" |
a positional argument |
kit:"arg,variadic" |
the rest of the positionals |
kit:"flag" |
a flag, named from the field in snake case |
kit:"flag,name=min-trust" |
a flag with the name spelled out |
kit:"flag,short=r" |
a flag with a short form |
kit:"flag,inherit" |
a flag the app already defines globally, like Limit |
kit:"inject" |
filled in by the app, which is how the client arrives |
Alongside them, help:"...", default:"...", and enum:"a,b,c" do what they look like.
Resolver ops and list ops
Two fields shape how a host treats an operation:
Single: truewithResolver: truemarks the canonical one-record fetch for aURIType. It answersant get.List: truemarks a member-lister for a parent resource. It answersant ls, and should emit records that are themselves addressable so every member is a URI a host can follow.
Reference resolution
Handlers do not parse references themselves.
ResolveRepo takes anything that names a repository, including a file URL or an issue URL, and gives back owner/name.
ResolveRef(kind, input) does the same for any other kind.
Both are strict about explicit input and forgiving about guesses.
github org golang works because a bare word is a guess that yields to the command.
github org https://github.com/golang/go fails, because that URL states its kind and the kind is wrong.
Errors
Return the kinds from kit/errs and every surface reports the same outcome with the same exit code:
return errs.Usage("not a widget id: %q", id)
return errs.NotFound("no widget in %s", repo)
Start the message with a plain lowercase word.
The renderer capitalises the first token, so a message that leads with its argument comes back as "Golang/Go" is a repo and reads like the tool mangled the input.
gh/messages_test.go parses every source file and fails on messages that lead with a quote, a format verb, a URL, or a capital, because the mistake is invisible in review and obvious on screen.
Add it to the tests
The offline suite has no network in it.
What guards a new command is a live test in gh/live_test.go, which runs only under GITHUB_LIVE=1:
func TestLiveWidget(t *testing.T) {
c := liveClient(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
w, err := c.Widget(ctx, "gohugoio/hugo")
if err != nil {
t.Fatal(err)
}
if w.Name == "" {
t.Error("name missing, the widget payload did not decode")
}
if len(w.Extra) > 0 {
t.Errorf("unmodelled keys: %s", w.Extra)
}
}
Assert shape, not values.
A star count changes hourly and pinning one turns a test into a clock.
The Extra check is the one that earns its keep: it is what tells you GitHub added a field.
make test # offline, the default
make live # against github.com
If a new command touches the page plane, add a check to github doctor too, so a reader whose command came back empty can find out why without reading the source.
Commands that write bytes
An operation that produces a file or a stream rather than records is not a kit.Handle.
Use kit.Command and register it in cli/, the way cat, readme, diff, archive, rdf, export, and page do.
Those are the escape hatch, and there are only a handful of them for a reason: a record is almost always the better answer.