Skip to content

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

Open
nfebe wants to merge 6 commits into
mainfrom
feat/full-agent-coverage
Open

feat: Reach every agent endpoint from the CLI#4
nfebe wants to merge 6 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 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

  • Replaced 294 hand-written wrappers with a generated endpoint table.
  • Added OpenAPI-based field and query parameter validation.
  • Implemented automatic response rendering for lists, items, and messages.
  • Fixed a path traversal risk by escaping path arguments.

💡 Minor Suggestions

  • Deduplicate families in the root help listing by using the catalogue function.
  • Ensure wildcard parameters are correctly handled in the generator to avoid argument count mismatches.

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

if len(fields) > 0 {
return map[string]any(fields), nil
}
if e.writes() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This conditional block is redundant because both branches return nil, nil. It can be simplified while retaining the helpful explanatory comment.

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

Comment thread tools/gen_endpoints.py
used[name] += 1
plainest = {}
for name, route, rest in entries:
arg_count = sum(1 for s in rest if s.startswith(":"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Include wildcard parameters when counting arguments to ensure consistent command naming for endpoints that rely on paths.

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

@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 on lines +42 to +43
path := e.path
for i, name := range e.args {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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

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.

}
}
}
if len(missing) > 0 && len(sent) > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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

@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
}

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