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 pull request expands the CLI to cover all 294 agent endpoints by generating a command table from the agent's OpenAPI specification. This replaces manual wrappers with a generic bridge that handles path parameter substitution, body field collection, and query parameters. It also introduces local validation against the agent's API description and improves security through proper path escaping. 🚀 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.
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.
| // 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.
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 read the agent's source itself to build its table, which is a second reading that can disagree with the first. It now reads what the agent publishes, which brought in thirteen endpoints it had never reached: nine proxy DNS ones registered in a function of their own, and four more registered under an empty path. An endpoint whose last segment holds a whole file path was unreachable in a different way: that segment was treated as a literal, so the command was named after it and took no argument for it. Those now take the path as an argument, and its separators survive the request while everything between them is still escaped. A required field is checked whatever carries the body, so --data is held to the same requirement as -f, and an endpoint that requires something no longer accepts an empty body.
`containers list` printed a wall of JSON where `container list` printed a table, for the same data, because only the hand-shaped name had a renderer. Both names now reach the same command. An answer nothing has described is laid out from the answer itself: the field holding the rows becomes the table, its scalar fields become the columns, in the order they were written. So an endpoint the agent has not typed yet, or an agent too old to describe itself at all, reads the same as one that has. The API description was also being listed as a resource family of the API.
| func specPath(path string) string { | ||
| segments := strings.Split(path, "/") | ||
| for i, segment := range segments { | ||
| if strings.HasPrefix(segment, ":") { |
There was a problem hiding this comment.
The specPath function only handles parameters prefixed with :, but the code generator also uses * for wildcard parameters that capture the remainder of a path. Without handling *, operations for routes like /config/*key will fail to resolve in the OpenAPI spec, leading the CLI to skip local field validation and help descriptions for those endpoints. This is the OpenAPI equivalent of the fix already applied to resolvePath in endpoints.go.
| if strings.HasPrefix(segment, ":") { | |
| + if strings.HasPrefix(segment, ":") || strings.HasPrefix(segment, "*") { | |
| + segments[i] = "{" + segment[1:] + "}" | |
| + } |
Help had two lists with two shapes: a Commands block naming three resources in the singular, and a separate line naming forty in the plural. Which list a resource appeared in, and which spelling it answered to, depended only on whether someone had hand-written its commands. There is now one list of resources with the incidental commands below it, and every resource answers to either spelling, so nobody has to remember whether the API said backup or backups.
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.