Add a command
Declare an operation once and get a command, a route, an MCP tool, and a URI type.
Everything hf does is one kit.Handle registration in hf/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 hf package, one file per family.
Every one embeds Meta, which carries the URI, the live URL, the sources it was built from, and the Extra map:
type Widget struct {
Meta
ID string `json:"id"`
Title string `json:"title,omitempty"`
Owner string `json:"owner,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
}
func (w *Widget) UnmarshalJSON(b []byte) error {
type raw Widget
return decodeExtra(b, (*raw)(w), &w.Extra)
}
decodeExtra is the rule the whole tool is built on: decode what you model, keep what you do not.
Without it, a field the hub adds next month is a field nobody ever sees.
With it, the test suite fails and you go and model it.
2. The client method
func (c *Client) Widget(ctx context.Context, id string) (*Widget, error) {
resp, err := c.Get(ctx, c.api("widgets", id))
if err != nil {
return nil, err
}
var w Widget
if err := json.Unmarshal(resp.Body, &w); err != nil {
return nil, err
}
w.setMeta(KindWidget, w.ID, resp.URL)
return &w, nil
}
c.Get handles pacing, auth, retries, caching, and the status-to-error mapping, so a method that uses it inherits the whole policy.
setMeta fills the URI, the URL, and the source list.
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:"a widget id, a hub URL, or an hf:// URI"`
}
func getWidget(ctx context.Context, in widgetIn, emit func(*Widget) error) error {
id, err := ResolveRef(KindWidget, 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:
kit.Handle(app, kit.OpMeta{
Name: "widget", Group: "read", Single: true, URIType: KindWidget, Resolver: true,
Summary: "Read one widget",
Args: []kit.Arg{{Name: "ref", Help: "widget id, URL, or hf:// URI"}},
}, getWidget)
That is the whole change. The operation is now:
hf widget <id> # the command
curl 'localhost:7777/v1/widget/<id>' # the route, under serve
ant get hf://widget/<id> # the URI dereference, via a host
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=order-by" |
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.
Errors
Return the kinds from kit/errs and every surface reports the same outcome with the same exit code:
return errs.Usage("a post id is user/slug, got %q", id)
return errs.NotFound("no such widget: %s", id)
The client already maps hub status codes to those kinds, so most handlers just pass the error up.
Add it to the tests
hf/scenario_test.go has one table that every test drives.
Add a line:
{Name: "widget", Run: op(widgetIn{Ref: fixWidget}, getWidget)},
Then record the fixture and write the golden:
make fixtures
The offline run now covers the new command: it checks that it returns records, that nothing landed in Extra, and that the set of fields it produces does not shrink later.
See the repository README for how the fixtures work.
Commands that write bytes
An operation that produces a file rather than records is not a kit.Handle.
Use kit.Command and register it in cli/, the way cat, readme, rdf, export, and croissant 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.