feat: Reach every agent endpoint from the CLI - #4
Conversation
The CLI covered deployments, images and containers, which is a fraction of what the agent can do, and each new endpoint waited on someone writing a wrapper for it. Backups, certificates, databases, domains, the scheduler, security, object stores, users and keys were all out of reach except through the raw bridge. Every endpoint is now a command, from a table generated out of the agent's own routes, so the question of whether the CLI has caught up is answered by regenerating it rather than by reading both codebases. The commands that print something worth reading are still shaped by hand; the rest take their arguments from the path, a body from repeated fields or raw JSON, and query parameters as they come. Anything driving the CLI without a human can now ask what exists: one command prints every family, operation, method and path, as JSON if asked.
Code Review SummaryThis PR implements automated command generation for the FlatRun CLI by scraping the agent's route table. This ensures the CLI covers the entire API surface without manual maintenance of wrappers. It also adds local validation and response rendering based on OpenAPI specifications. 🚀 Key Improvements
💡 Minor Suggestions
|
Listing every command was its own noun while `flatrun` and `flatrun FAMILY` already listed things. The listings themselves now take --json, so there is one way to ask what exists rather than two.
| if len(fields) > 0 { | ||
| return map[string]any(fields), nil | ||
| } | ||
| if e.writes() { |
There was a problem hiding this comment.
This conditional block is redundant because both branches return nil, nil. It can be simplified while retaining the helpful explanatory comment.
| if e.writes() { | |
| // A write with no body (or a GET) is normal here: restarting a deployment or renewing a | |
| // certificate carries nothing. | |
| return nil, nil |
| used[name] += 1 | ||
| plainest = {} | ||
| for name, route, rest in entries: | ||
| arg_count = sum(1 for s in rest if s.startswith(":")) |
There was a problem hiding this comment.
Include wildcard parameters when counting arguments to ensure consistent command naming for endpoints that rely on paths.
| arg_count = sum(1 for s in rest if s.startswith(":")) | |
| arg_count = sum(1 for s in rest if s.startswith((":", "*"))) |
The hand-shaped commands and the generated ones were listed separately, so `deployment` showed seventeen commands, `deployments` showed sixty-three, and the machine-readable listing showed only the generated half. Anything reading that listing to decide what to call was working from a partial picture of what the CLI can do. Both now come from one catalogue, and the singular families reach everything their plural counterparts do.
| path := e.path | ||
| for i, name := range e.args { |
There was a problem hiding this comment.
Similar to the generator bug, the path resolver only attempts to substitute parameters prefixed with :. It should also substitute wildcard parameters prefixed with *. Note that for wildcards representing file paths, the user might expect slashes to be preserved, but for consistency with the PR's goal of preventing path reshaping, url.PathEscape is appropriate for now.
| path := e.path | |
| for i, name := range e.args { | |
| for i, name := range e.args { | |
| if args[i] == "" { | |
| return "", fmt.Errorf("%s cannot be empty: %s", strings.ToUpper(name), e.command()) | |
| } | |
| path = strings.Replace(path, ":"+name, url.PathEscape(args[i]), 1) | |
| path = strings.Replace(path, "*"+name, url.PathEscape(args[i]), 1) | |
| } |
| // marker that says the generated table is not the whole story for this one. | ||
| flags string | ||
| shaped bool | ||
| } |
There was a problem hiding this comment.
The JSON listing correctly uses catalogue() which includes hand-shaped commands, but the dispatch functions findEndpoint and knownFamily (lines 52 and 61) only check generatedEndpoints. While the main command loop handles existing families manually, any future family-agnostic logic or external tooling relying on runEndpoint for a hand-shaped family might fail. These should be updated to use catalogue() for consistency.
| } | |
| func findEndpoint(family, op string) (endpoint, bool) { | |
| for _, e := range catalogue() { | |
| if e.family == family && e.op == op { | |
| return e, true | |
| } | |
| } | |
| return endpoint{}, false | |
| } | |
| func knownFamily(family string) bool { | |
| for _, e := range catalogue() { | |
| if e.family == family { | |
| return true | |
| } | |
| } | |
| return false | |
| } |
Generated commands passed fields through without knowing what an endpoint accepted, so a typo came back as a 400 naming nothing, and every answer printed as raw JSON because nothing said which fields made a row. Where an agent describes itself, a mistyped field or query parameter now fails before the request and says what was probably meant, asking for help on a command lists the fields it takes and the permission it needs, and answers print as tables laid out from the types the agent returns rather than from a renderer written per endpoint. The description is read from the agent being talked to and cached per agent, so it matches the instance rather than whatever was true when the CLI was built. An agent too old to describe itself behaves exactly as before.
| } | ||
| } | ||
| } | ||
| if len(missing) > 0 && len(sent) > 0 { |
There was a problem hiding this comment.
Validation for required fields is currently skipped if the user provides no request body fields at all. Removing the len(sent) > 0 check ensures that the CLI identifies missing mandatory fields locally, providing a clear error message instead of allowing the request to fail with a generic error from the agent.
| if len(missing) > 0 && len(sent) > 0 { | |
| if len(missing) > 0 { | |
| return fmt.Errorf("missing required field %s", strings.Join(missing, ", ")) | |
| } |
The agent now says which shape an endpoint answers in, so a list of certificates and a list of backups take the same path through the CLI and an endpoint converted tomorrow prints properly with no change here. Columns come from the row's own type, capped so a wide one stays readable, with the whole answer still under --json.
Every collection was rendered as a table, so a list of domains came out as a column with a heading over it. A row that is a plain value, or a type with one column, prints one per line, which is also what pipes into the next command.
| return 0 | ||
| } | ||
|
|
||
| func families() []string { |
There was a problem hiding this comment.
The families() function should use catalogue() instead of generatedEndpoints. This ensures that hand-shaped families (which might not be in the generated table or might use singular names) are included in the family listing shown by flatrun help.
| func families() []string { | |
| func families() []string { | |
| seen := map[string]bool{} | |
| names := []string{} | |
| for _, e := range catalogue() { | |
| if !seen[e.family] { | |
| seen[e.family] = true | |
| names = append(names, e.family) | |
| } | |
| } | |
| sort.Strings(names) | |
| return names | |
| } |
The CLI covered 3 of the agent's 42 resource families. Backups, certificates, databases, the
scheduler, security, object stores, users and keys were only reachable through
flatrun api.294 hand-written wrappers would go stale on the next agent release, so the table is generated from
the agent's routes:
Everything becomes
flatrun FAMILY OPERATION [ARGS]. The commands that render a table or takeflags shaped for the task stay hand-written, since a route says nothing about what a body looks
like or how to print it. One catalogue lists both kinds, and the singular families reach
everything the plural ones do, so
deployment log-sourcesworks.flatrunlists the families andflatrun FAMILYits commands.--jsonon either gives the samelist with each command's method, path and arguments, for anything driving the CLI without a human.
Operation names keep the sweeping one out of reach of a typo:
certificates renew DOMAINrenewsone,
certificates renew-allrenews every certificate.Two bugs fixed along the way:
-urland-tokenswallowed the following argument, since only the double-dash spelling wasregistered as taking a value
VERSION and the changelog are both at 0.3.0, so pushing the v0.3.0 tag after merge is all the
release needs.