Skip to content

feat: Reach every agent endpoint from the CLI - #4

Merged
nfebe merged 10 commits into
mainfrom
feat/full-agent-coverage
Aug 15, 2026
Merged

feat: Reach every agent endpoint from the CLI#4
nfebe merged 10 commits into
mainfrom
feat/full-agent-coverage

Conversation

@nfebe

@nfebe nfebe commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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:

python3 tools/gen_endpoints.py ../agent > internal/command/endpoints_gen.go

Everything becomes flatrun FAMILY OPERATION [ARGS]. The commands that render a table or take
flags 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-sources works.

flatrun lists the families and flatrun FAMILY its commands. --json on either gives the same
list 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 DOMAIN renews
one, certificates renew-all renews every certificate.

Two bugs fixed along the way:

  • a path argument was not escaped, so a value containing a slash reshaped the request path
  • -url and -token swallowed the following argument, since only the double-dash spelling was
    registered 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.

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.
@sourceant

sourceant Bot commented Aug 11, 2026

Copy link
Copy Markdown

Code Review Summary

This 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

  • Full coverage of the agent API without the need for manual code updates per endpoint.
  • Local validation of required fields and query parameters using cached OpenAPI specs.
  • Safe path argument resolution using url.PathEscape and segment-aware wildcard escaping.
  • Intelligent output rendering that infers table layouts from JSON response shapes.
  • Fix for single-dash flags (-url, -token) swallowing subsequent arguments.

💡 Minor Suggestions

  • The explainEndpoint logic could be improved to provide detailed help even when positional arguments are partially provided before the help flag.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/command/endpoints.go Outdated
Comment thread tools/gen_endpoints.py Outdated
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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/command/endpoints.go
// marker that says the generated table is not the whole story for this one.
flags string
shaped bool
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}
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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/command/schema.go Outdated
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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

return 0
}

func families() []string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

`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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/spec/spec.go
func specPath(path string) string {
segments := strings.Split(path, "/")
for i, segment := range segments {
if strings.HasPrefix(segment, ":") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

@nfebe
nfebe merged commit 05abc3d into main Aug 15, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant