ENG-11018: Make the cloud CLI agent-usable (--json everywhere, non-interactive off a TTY, --follow opt-in) - #6917
ENG-11018: Make the cloud CLI agent-usable (--json everywhere, non-interactive off a TTY, --follow opt-in)#6917amsraman wants to merge 12 commits into
Conversation
Merging this PR will not alter performance
Comparing Footnotes
|
Greptile SummaryThe 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.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| 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
…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
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
All reported issues were addressed across 23 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…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>
8f690bb to
13c45b7
Compare
…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
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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.
|
Ran the compatibility check. Three throwaway venvs, each exercised twice: once with the published 0.6.6.post1 and 0.7.7: no difference from 0.1.71, on any probeBoth versions, line for line identical between baseline and branch:
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, One real gap, and it is in the band you waved off
The split import itself does what Narrow (debug only, default and Pre-existing on both baselines, untouched hereFlagging so they don't read as fallout from this PR — each reproduces identically on 0.1.71:
Generated by Claude Code |
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>
Delegating to the
reflex cloudCLI 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, andapps logsfollows by default and never returns.What changed
1.
--jsonon every command. It was onapps inspect,apps list,apps historyand a handful ofproject/providersreads. It is now on all 33 commands underreflex 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,configandgcp-standalone.Emitting JSON is only half of it, because log lines land on stdout beside it. So passing
--jsonnow 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 (newreserve_stdout()inreflex-base). The reservation is also read off argv in thehosting_cligroup 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_optionreplaces ~30 copies ofdefault=True, resolving tosys.stdout.isatty()when neither spelling is passed. An explicit--interactive/--no-interactivestill wins.3.
apps logs --followdefaults 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 nextcursor).Behavior changes to be aware of
reflex cloud apps logsat a terminal prints one page and exits instead of paging; pass--follow truefor the old behavior. Kept astype=boolrather than a--follow/--no-followpair on purpose — a flag pair would make an existing--follow falsesilently bindfalseto the optionalapp_idargument.reflex cloud apps listin CI with no token now exits 1 withToken is required for non-interactive mode.instead of opening a browser nobody can see.Also fixed along the way
delete --jsonreports"deleted": falsewith the server's message when the delete is refused, rather than reporting a refused delete as a success.stop/startdo the same.logs --jsonkeeps "could not read them" distinguishable from "there are none", via anerrorfield that isnullon the normal path.generate_configreturns the path it wrote, soconfigno longer prints "Configuration file generated." when it declined to overwrite an existingcloud.yml.Deliberately not in scope
reflex deploygets the TTY-based--interactivebut no data--json: it already carries one from@log_optionsmeaning 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
tests/units/reflex_cli/andtests/units/reflex_base/utils/test_log.py, covering the JSON payload of each command, the TTY-derived interactive default, the--followdefault, and that human-readable output stays off stdout while a document is on it.--interactiveexplicitly, sinceCliRunner's stdout is not a terminal.uv run pytest tests/units→ 7651 passed, 18 skipped.ruff check/ruff format --check/pyrightclean. Two failures left untouched because they reproduce onmain:test_is_process_on_port_free_port(no IPv6 in my container) and a pyright error intests/units/docgen/.Linear: ENG-11018
All Submissions:
Type of change
--json,--interactiveandapps logs --followNew Feature Submission:
Changes To Core Features:
Review in cubic
🤖 Generated with Claude Code
https://claude.ai/code/session_01UqTVZN9bsCxt7hz5U3qJok