Skip to content

ENG-11018: Make the cloud CLI agent-usable (--json everywhere, non-interactive off a TTY, --follow opt-in) - #6917

Open
amsraman wants to merge 12 commits into
mainfrom
claude/eng-11018-a0edp7
Open

ENG-11018: Make the cloud CLI agent-usable (--json everywhere, non-interactive off a TTY, --follow opt-in)#6917
amsraman wants to merge 12 commits into
mainfrom
claude/eng-11018-a0edp7

Conversation

@amsraman

@amsraman amsraman commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Delegating to the reflex cloud CLI is the alternative to reimplementing the cloud surface inside the Reflex MCP (design spec §3.4). Today an agent that tries has three ways to get stuck: it gets human-formatted tables to regex, a prompt can block it forever, and apps logs follows by default and never returns.

What changed

1. --json on every command. It was on apps inspect, apps list, apps history and a handful of project / providers reads. It is now on all 33 commands under reflex cloud, including the action-shaped ones that previously only logged their outcome: rollback, describe, build-logs, status, stop, start, delete, logs, scale, secrets update / delete, project invite / select / selected, create-token, config and gcp-standalone.

Emitting JSON is only half of it, because log lines land on stdout beside it. So passing --json now reserves stdout for the document: it is written straight to stdout, while log records, tables, rules, spinners and progress bars render to stderr for the duration (new reserve_stdout() in reflex-base). The reservation is also read off argv in the hosting_cli group callback — that callback emits its reflex-version warning before click has parsed the subcommand's options, so waiting for the parsed flag would let the warning land in front of the document.

2. Non-interactive when stdout isn't a TTY. One shared interactive_option replaces ~30 copies of default=True, resolving to sys.stdout.isatty() when neither spelling is passed. An explicit --interactive / --no-interactive still wins.

3. apps logs --follow defaults to off. Following means prompting between pages, and a prompt nobody answers is a command that never exits. It is now opt-in, and refused outright in JSON mode, where paging belongs to the caller (the response carries the next cursor).

Behavior changes to be aware of

  • reflex cloud apps logs at a terminal prints one page and exits instead of paging; pass --follow true for the old behavior. Kept as type=bool rather than a --follow/--no-follow pair on purpose — a flag pair would make an existing --follow false silently bind false to the optional app_id argument.
  • Off a TTY, commands take the non-interactive path without being asked. In practice that turns a hang into an error: reflex cloud apps list in CI with no token now exits 1 with Token is required for non-interactive mode. instead of opening a browser nobody can see.

Also fixed along the way

  • delete --json reports "deleted": false with the server's message when the delete is refused, rather than reporting a refused delete as a success. stop / start do the same.
  • logs --json keeps "could not read them" distinguishable from "there are none", via an error field that is null on the normal path.
  • generate_config returns the path it wrote, so config no longer prints "Configuration file generated." when it declined to overwrite an existing cloud.yml.

Deliberately not in scope

reflex deploy gets the TTY-based --interactive but no data --json: it already carries one from @log_options meaning JSON log records, and a second flag of the same name would collide. Its progress is a stream rather than a result, so the log-record form is the better fit anyway.

Testing

  • 51 new tests across tests/units/reflex_cli/ and tests/units/reflex_base/utils/test_log.py, covering the JSON payload of each command, the TTY-derived interactive default, the --follow default, and that human-readable output stays off stdout while a document is on it.
  • Existing prompt-driven tests now pass --interactive explicitly, since CliRunner's stdout is not a terminal.
  • uv run pytest tests/units → 7651 passed, 18 skipped. ruff check / ruff format --check / pyright clean. Two failures left untouched because they reproduce on main: test_is_process_on_port_free_port (no IPv6 in my container) and a pyright error in tests/units/docgen/.
  • Also exercised by hand against the real CLI, both under a pipe and under a pty.

Linear: ENG-11018

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — two defaults change, see Behavior changes above
  • This change requires a documentation update — the CLI reference for --json, --interactive and apps logs --follow

New Feature Submission:

  • Does your submission pass the tests?
  • Have you linted your code locally prior to submission?

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

Review in cubic

🤖 Generated with Claude Code

https://claude.ai/code/session_01UqTVZN9bsCxt7hz5U3qJok

@linear-code

linear-code Bot commented Aug 19, 2026

Copy link
Copy Markdown

ENG-11018

@amsraman amsraman changed the title ENG-11018: Make reflex_cli usable by an agent (--json, non-interactive) ENG-11018: Make the cloud CLI agent-usable (--json everywhere, non-interactive off a TTY, --follow opt-in) Aug 19, 2026
@codspeed-hq

codspeed-hq Bot commented Aug 19, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 32 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing claude/eng-11018-a0edp7 (128ae63) with main (8853e14)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@greptile-apps

greptile-apps Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR makes the cloud CLI suitable for automated callers by adding consistent structured output, deriving interactivity from TTY state, and making log following opt-in.

  • Reserves stdout for JSON documents while routing human-readable output to stderr.
  • Adds shared JSON and interactive options across cloud commands.
  • Returns structured outcomes for application, project, provider, secret, scan, and GCP operations.
  • Adds regression coverage for output routing, option parsing, command payloads, and non-interactive behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/reflex-hosting-cli/src/reflex_cli/utils/output.py Centralizes JSON output, TTY-derived interactivity, argv-aware early reservation, and context-scoped reservation cleanup; both previously reported defects are fixed.
packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py Reserves stdout before group-level warnings can precede a subcommand’s JSON document.
packages/reflex-base/src/reflex_base/utils/log.py Adds the process-wide stdout reservation state and consistently redirects human and JSON log records to stderr while reserved.
packages/reflex-base/src/reflex_base/utils/console.py Routes human-readable console features through the reservation-aware console and suppresses progress decoration during structured output.
packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py Adds structured results throughout application commands, distinguishes log retrieval errors from empty pages, and makes log following opt-in.
packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py Adds structured GCP command results and redirects deployment-script output away from reserved stdout.
tests/units/reflex_cli/utils/test_output.py Covers JSON flag ordering, combined short options, reservation cleanup, and repeated CLI invocations.

Reviews (10): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread packages/reflex-hosting-cli/src/reflex_cli/utils/output.py Outdated
Comment thread packages/reflex-hosting-cli/src/reflex_cli/utils/output.py Outdated
amsraman pushed a commit that referenced this pull request Aug 19, 2026
…tion

Both from review on #6917.

`json_requested` read argv as a set, so it disagreed with click wherever
order mattered: click parses a boolean flag pair last-wins, and
`--no-json --json` enables JSON while the set test answered "a refusal is
present". Scanning back to front gives click's answer. The same disagreement
came from combined short flags -- `-ij` is `-i -j` to click and was nothing
to a membership test -- so the scan reads those too. Reading them means it
can also fire on a `-j` click would take as another option's value, which is
the direction to be wrong in: a message on stderr costs some context, one
inside the document costs the whole parse.

The reservation is process-global and nothing released it, so a `--json`
command left every later log line in the process writing to stderr. A CLI
process exits and never notices; an embedding one, or a second run in the
same interpreter, does. It now follows the click context's lifetime and
restores the previous value on close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqTVZN9bsCxt7hz5U3qJok
@amsraman
amsraman marked this pull request as ready for review August 19, 2026 22:58
@amsraman
amsraman requested a review from a team as a code owner August 19, 2026 22:58

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8f690bb068

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 23 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/reflex-hosting-cli/src/reflex_cli/v2/project.py Outdated
Comment thread packages/reflex-hosting-cli/src/reflex_cli/v2/project.py
Comment thread packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py Outdated
Comment thread packages/reflex-base/src/reflex_base/utils/log.py
Comment thread packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py
Comment thread packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py
Comment thread packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py
Comment thread packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py
Comment thread packages/reflex-hosting-cli/news/+eng-11018-agent-friendly-cli.feature.md Outdated
claude and others added 3 commits August 31, 2026 09:56
…e, --follow)

An agent delegating to `reflex cloud` had three ways to get stuck: it got
human-formatted tables to regex, a prompt could block it forever, and
`apps logs` followed by default and never returned.

Every command in `reflex cloud` now takes `--json`. The document is written
straight to stdout rather than through the console, and passing the flag
reserves stdout for it: log records, tables, rules, spinners and progress
bars render to stderr for the duration, so a warning from a helper deep in
the call stack cannot land in the middle of the output. The reservation is
also taken from argv in the group callback, because that callback speaks
(the reflex-version warning) before click has parsed the subcommand's
options.

`--interactive` is now one shared option defaulting to whether stdout is a
terminal, instead of thirty copies defaulting to True. A pipe, a CI job or
an agent gets the non-interactive path it would have had to ask for; a
person at a terminal sees no change.

`apps logs --follow` defaults to off. Following means prompting between
pages, and a prompt nobody answers is a command that never exits, so it is
opt-in and is refused outright in JSON mode, where paging belongs to the
caller.

Commands that only logged their outcome now report it: a refusal comes back
as `"deleted": false` with the server's message rather than as a success,
and logs that could not be read stay distinguishable from an app with none.
`generate_config` returns the path it wrote, so `config` no longer claims to
have generated a file it declined to overwrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqTVZN9bsCxt7hz5U3qJok
…tion

Both from review on #6917.

`json_requested` read argv as a set, so it disagreed with click wherever
order mattered: click parses a boolean flag pair last-wins, and
`--no-json --json` enables JSON while the set test answered "a refusal is
present". Scanning back to front gives click's answer. The same disagreement
came from combined short flags -- `-ij` is `-i -j` to click and was nothing
to a membership test -- so the scan reads those too. Reading them means it
can also fire on a `-j` click would take as another option's value, which is
the direction to be wrong in: a message on stderr costs some context, one
inside the document costs the whole parse.

The reservation is process-global and nothing released it, so a `--json`
command left every later log line in the process writing to stderr. A CLI
process exits and never notices; an embedding one, or a second run in the
same interpreter, does. It now follows the click context's lifetime and
restores the previous value on close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqTVZN9bsCxt7hz5U3qJok
The rebase landed this branch on top of the reflex-base log migration, which
brought a test pinning that every hosting-CLI module still imports against a
reflex-base predating its log API. `output.py` imported
`reflex_base.utils.log` directly, so seventeen of those imports broke.

`reflex_cli.utils.log` is the shim that exists for this, and it now re-exports
`reserve_stdout` and `is_stdout_reserved` alongside the rest, degrading to a
no-op reservation when the installed base is too old to have them. `--json`
then simply loses the guarantee that human output stays off stdout, which is
where it was before the feature existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amsraman
amsraman force-pushed the claude/eng-11018-a0edp7 branch from 8f690bb to 13c45b7 Compare August 31, 2026 16:58
amsraman and others added 3 commits August 31, 2026 10:32
…bed as having

The description claimed it, the code did not: deploy kept a hand-rolled
`--interactive/--no-interactive, default=True`, so it stayed the one command in
the family that prompts off a TTY. Everything the flag gates is already
threaded through -- browser auth, project selection, the GCP provider choice,
the requirements check -- so this is the shared option and nothing else.

`--json` still stays away: `@log_options` owns that name for JSON log records,
and a deploy's progress is a stream rather than a result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…owns stdout

Its flag spelling is unchanged -- the hand-rolled option was already
`--json/--no-json` / `-j` -- but it reserved nothing, so anything printed to the
human console while the command ran landed on stdout in front of the identity
and broke the parse. That is what the reservation exists to stop, and whoami
is the command an agent reads first.

The comment about bypassing the console still applies and is why `print_json`
is right here: it writes the document to stdout while the console renders to
stderr, so nothing is wrapped at the terminal width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adopting the shared option brought `-i` along with it, and the existing flag
surface test cannot see that: it compares parameter names, and the name did not
move. So a later edit to the shared option would change what `reflex deploy`
accepts on the command line with nothing failing, and the alias is public the
moment it ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@masenf masenf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this needs to remain compatible with older reflex versions, otherwise they will lose the ability to deploy.

anything that is imported from reflex_base particularly must be treated specially.

i would have your agent do compatibility testing with reflex 0.6.6.post1 and 0.7.7 to be double sure that no changes here break deploy from older versions.

i also couldn't directly test because this version appears "older" than the latest published version:

{"timestamp": "2026-08-31T18:59:17.258885+00:00", "level": "error", "logger": "reflex_cli.v2.deployments", "message": "Warning: You are usingreflex-hosting-cli version 0.1.70.post9.dev0+8f690bb06. A newer version 0.1.71 is available. Upgrade using: pip install --upgrade reflex-hosting-cli", "location": "/Users/masen/code/reflex-dev/reflex/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py:168", "pid": 42782}

you just need to refresh from main

Both fragments carried an internal ticket id in their filename, which lands in
the changelog directory of two published packages and means nothing to anyone
reading it. The orphan `+slug` form is what this repo asks for before a PR
number exists; `reflex-release materialize` renames orphans after their PR at
release time, so the name only has to say what the change is.
The shim asked for five names in one try, and two of them -- reserve_stdout and
is_stdout_reserved -- are added by this branch and exist in no published
reflex-base. So the ImportError fired against every release there is, including
the newest: HAS_REFLEX_BASE went False and the whole fallback activated,
swapping out reflex-base's console, its parenting of the reflex_cli logger and
its is_json_mode. Measured against published reflex-base 0.9.9, this branch
answered False where published reflex-hosting-cli 0.1.71 answers True, so the
regression reached current installs rather than only old ones -- and it was
release-order dependent, since the hosting CLI publishes separately from reflex.

The reservation is now asked for in a try of its own, which is what the
fallback's own comment always claimed the behaviour was: an older base loses the
reservation and keeps everything else.

The fallback then has to honour the reservation itself rather than answer False
forever, or `--json` has no protection on exactly the installs that take this
path -- and it did not: the fallback handler wrote INFO records to stdout ahead
of the document, corrupting the output --json exists to produce, on old reflex
only, where no test would have caught it. It tracks the flag, and the fallback
console's writers move with it.

The runtime tests each run in a subprocess. The in-process version -- swapping
sys.modules for a stand-in module -- leaves the parent package's `log`
attribute pointing at it, which nothing here observes and which destabilized
async tests elsewhere in the suite.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/reflex-hosting-cli/src/reflex_cli/utils/log.py
amsraman and others added 2 commits August 31, 2026 13:27
A prompt is human output like any other, and the one that least belongs on a
reserved stdout: it blocks, so a caller parsing the document reads the question
as data and then waits forever for an answer nobody will type. Both consoles
reached Prompt.ask with no console of its own, so rich used its global one --
stdout -- while every print beside it had already moved.

Found on the login fall-through, where `--json` emitted a single non-JSON line:
"please hit 'Enter' or 'Return' after login on website complete: ". That
fall-through is a separate, pre-existing problem (get_authenticated_client
guards only the no-token case, so a token that exists but fails to validate
reaches a browser login regardless of --no-interactive); this is only the half
that corrupts the document.

Fixed in reflex-base, where the reservation lives, and in the hosting CLI's
fallback, so a reflex with no reflex-base at all gets it too.
@amsraman

amsraman commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Ran the compatibility check. Three throwaway venvs, each exercised twice: once with the published reflex-hosting-cli 0.1.71 as the baseline, then with this branch's build (0.1.71.post21.dev0+c4d13ae8) swapped in via --no-deps, same battery both times. (Branch is 18 behind main again as of today — happy to refresh before you look.)

0.6.6.post1 and 0.7.7: no difference from 0.1.71, on any probe

Both versions, line for line identical between baseline and branch:

  • every module old reflex reaches into reflex_cli for — constants.hosting.Hosting, constants.base.LogLevel, v2.deployments.{check_version,hosting_cli}, v2.cli.{login,logout,deploy}, utils.dependency, utils.hosting
  • hosting_cli is still a typer.main.Typer, so 0.6.6's cli.add_typer(hosting_cli, name="cloud") still binds
  • cli.deploy's keyword surface, including every name 0.6.6 passes positionally-by-keyword
  • console.set_log_level still accepting a foreign enum — 0.6.6 hands over reflex.constants.LogLevel.INFO, not the CLI's own, and set_log_level resolves it by value on both
  • reflex deploy --help, and deploy() driven exactly as reflex.reflex:deploy drives it, reaching get_authenticated_client and refusing with Exit(1) non-interactively

I stopped at the auth boundary rather than running a live deploy against real infrastructure; everything past that point is network, not this diff.

One thing that gets better on the old versions: on 0.6.6, reflex cloud apps list --json under 0.1.71 writes stdout that does not parse (rich wraps and styles it — Invalid control character at line 20). On this branch the same command is valid JSON on stdout with the "Support for Reflex version 0.6.6.post1 … is deprecated" warning on stderr.

One real gap, and it is in the band you waved off

reflex_base writing to stderr by default holds for warnings and errors, but not for DEBUG/INFO — its console handler sends those to stdout, and a reflex_base without reserve_stdout cannot be told otherwise. So against published reflex-base:

reflex reflex_base --json (default / --loglevel info) --json --loglevel debug
0.6.6.post1 absent, CLI fallback owns the sinks clean clean (stderr)
0.7.7 absent clean clean (stderr)
0.9.10.post1 published, no reservation clean stdout not parseable
0.9.10.post1 this branch's clean clean (stderr)
$ reflex cloud project list --json --loglevel debug     # reflex 0.9.10.post1 + branch CLI
stdout: Debug: Fetching token from existing config...
        Debug: Fetching token from existing config...
        [{"id": ...

The split import itself does what 144e398d says: HAS_REFLEX_BASE stays True against 0.9.10.post1, set_log_level and is_json_mode are still reflex-base's, and only the reservation falls back to the shim. It is just that the shim's reservation reaches only the shim's own sinks, so the band this affects is "hosting CLI newer than reflex-base" — which is every currently-published reflex until a release carries the reservation, and permanently anyone who upgrades the CLI on its own.

Narrow (debug only, default and info are clean), and not a regression — 0.1.71 has no reservation to keep either. But it is a real limit on the "stdout stays parseable" promise, so it should be either closed or written down. Closing it means the CLI attaching its own stderr-routed handler for reflex_cli and cutting propagation while a document owns stdout, in JSON mode against a reflex-base that lacks the reservation — the fallback handler for that already exists in the shim. Say the word and I'll push it; otherwise I'll note the limitation in the news fragment.

Pre-existing on both baselines, untouched here

Flagging so they don't read as fallout from this PR — each reproduces identically on 0.1.71:

  • reflex_cli.v2.utils.hosting does not exist. 0.6.6 imports it at prerequisites.py:1411, 0.7.7 at prerequisites.py:1786 and :2080.
  • reflex cloud --help and reflex cloud apps --help on 0.6.6 and 0.7.7 exit 1 with an uncaught click.exceptions.Exit: 0 traceback after printing the help — typer/click interop, not this diff.

Generated by Claude Code

masenf
masenf previously approved these changes Sep 3, 2026
amsraman and others added 2 commits September 3, 2026 16:38
Three documents this branch added assert an outcome they did not check.

`apps status --json` classified the deployment with `"failed" not in
status`, while `--watch` calls "build error" and every other `error`
status a failure -- so the one command an agent polls to learn whether
its deploy worked answered `"success": true` for the commonest way it
does not. Both paths now read `deployment_status_failed`, built from
the markers the watch loop itself branches on, so a marker spelled
twice cannot become two answers.

`logs --json` promised to keep "we could not read them" apart from
"there are none", and could not: `get_app_logs` returned `[]` for a
request error and for a body it could not decode, which is the same
value an empty page has. Those paths return None, which the command
already reports as unreadable, and a refusal the server explained is
now passed on in its own words rather than replaced by ours.

`project selected --json` swallowed a failed lookup and exited 0 with
nothing on stdout -- indistinguishable from the document that says
nothing is selected. It reports the error and exits 1, and the two
settled documents carry a null `error` so the shape is one shape.

`apps delete --json` had one exit that was zero with no document behind
it, so nothing said the app had not been deleted. The branches that
exit non-zero answer through their status and are left alone.

The changelog fragment is split in two. Both defaults this changes --
interactivity following the terminal, and `--follow` becoming opt-in --
are breaking for an existing script, and towncrier files a `.feature`
where a reader looking for migration impact will not find them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants