From fbe0bdfbc162327a27df9f849d18905bfca845f9 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 01:08:14 -0400 Subject: [PATCH 01/19] refactor: align cli command boundaries on CommandContext --- ...refactor-command-context-alignment-plan.md | 835 ++++++++++++++++++ lib/crates/fabro-cli/src/command_context.rs | 281 ++++-- .../fabro-cli/src/commands/artifact/cp.rs | 20 +- .../fabro-cli/src/commands/artifact/list.rs | 18 +- .../fabro-cli/src/commands/artifact/mod.rs | 19 +- .../fabro-cli/src/commands/auth/login.rs | 18 +- .../fabro-cli/src/commands/auth/logout.rs | 17 +- lib/crates/fabro-cli/src/commands/auth/mod.rs | 22 +- .../fabro-cli/src/commands/auth/status.rs | 13 +- .../fabro-cli/src/commands/config/mod.rs | 20 +- lib/crates/fabro-cli/src/commands/doctor.rs | 15 +- lib/crates/fabro-cli/src/commands/graph.rs | 21 +- lib/crates/fabro-cli/src/commands/install.rs | 4 +- lib/crates/fabro-cli/src/commands/model.rs | 17 +- lib/crates/fabro-cli/src/commands/pr/close.rs | 21 +- .../fabro-cli/src/commands/pr/create.rs | 20 +- lib/crates/fabro-cli/src/commands/pr/list.rs | 20 +- lib/crates/fabro-cli/src/commands/pr/merge.rs | 21 +- lib/crates/fabro-cli/src/commands/pr/mod.rs | 38 +- lib/crates/fabro-cli/src/commands/pr/view.rs | 21 +- .../fabro-cli/src/commands/preflight.rs | 16 +- .../fabro-cli/src/commands/provider/login.rs | 13 +- .../fabro-cli/src/commands/provider/mod.rs | 14 +- .../fabro-cli/src/commands/repo/deinit.rs | 11 +- .../fabro-cli/src/commands/repo/init.rs | 30 +- lib/crates/fabro-cli/src/commands/repo/mod.rs | 20 +- .../fabro-cli/src/commands/run/command.rs | 19 +- lib/crates/fabro-cli/src/commands/run/cp.rs | 24 +- lib/crates/fabro-cli/src/commands/run/diff.rs | 15 +- lib/crates/fabro-cli/src/commands/run/fork.rs | 19 +- lib/crates/fabro-cli/src/commands/run/logs.rs | 16 +- lib/crates/fabro-cli/src/commands/run/mod.rs | 46 +- .../fabro-cli/src/commands/run/preview.rs | 19 +- .../fabro-cli/src/commands/run/resume.rs | 13 +- .../fabro-cli/src/commands/run/rewind.rs | 14 +- lib/crates/fabro-cli/src/commands/run/ssh.rs | 23 +- lib/crates/fabro-cli/src/commands/run/wait.rs | 16 +- .../fabro-cli/src/commands/runs/archive.rs | 29 +- .../fabro-cli/src/commands/runs/inspect.rs | 7 +- .../fabro-cli/src/commands/runs/list.rs | 13 +- lib/crates/fabro-cli/src/commands/runs/mod.rs | 25 +- lib/crates/fabro-cli/src/commands/runs/rm.rs | 26 +- .../fabro-cli/src/commands/sandbox/mod.rs | 22 +- .../fabro-cli/src/commands/secret/list.rs | 6 +- .../fabro-cli/src/commands/secret/mod.rs | 21 +- .../fabro-cli/src/commands/secret/rm.rs | 6 +- .../fabro-cli/src/commands/secret/set.rs | 6 +- .../fabro-cli/src/commands/store/dump.rs | 16 +- .../fabro-cli/src/commands/store/mod.rs | 13 +- .../fabro-cli/src/commands/system/df.rs | 15 +- .../fabro-cli/src/commands/system/events.rs | 12 +- .../fabro-cli/src/commands/system/info.rs | 15 +- .../fabro-cli/src/commands/system/mod.rs | 19 +- .../fabro-cli/src/commands/system/prune.rs | 19 +- lib/crates/fabro-cli/src/commands/validate.rs | 14 +- lib/crates/fabro-cli/src/commands/version.rs | 15 +- lib/crates/fabro-cli/src/main.rs | 103 +-- 57 files changed, 1445 insertions(+), 746 deletions(-) create mode 100644 docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md diff --git a/docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md b/docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md new file mode 100644 index 000000000..348d5cfd6 --- /dev/null +++ b/docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md @@ -0,0 +1,835 @@ +--- +title: "refactor: CommandContext alignment at CLI command boundaries" +type: refactor +status: completed +date: 2026-04-23 +deepened: 2026-04-23 +--- + +# CommandContext Alignment At CLI Command Boundaries + +## Overview + +Finish the partially landed `CommandContext` refactor in `fabro-cli` by +making `CommandContext` the shared command-boundary abstraction for +invocation plumbing in the CLI command families that already depend on +it or immediately reconstruct it. The goal is to stop threading +`&CliNamespace`, `&CliLayer`, `Printer`, and `process_local_json` +through command entrypoints when that data is already part of the same +invocation context. + +This is a follow-on plan to the earlier server-access refactor. The +current codebase already centralizes `cwd`, merged settings, and server +access in `CommandContext`, but command entrypoints still receive raw +plumbing and then rebuild a context locally. This plan aligns the +surface area around the existing struct instead of introducing a second +context or service wrapper. + +## Problem Frame + +`CommandContext` exists today in +`lib/crates/fabro-cli/src/command_context.rs`, and many commands +already depend on it for `cwd`, `machine_settings`, `user_settings`, and +`server()` access. The refactor is only half-finished: + +- the `printer` field and `printer()` accessor are both marked + `#[allow(dead_code, reason = "...still being wired through")]` +- `main.rs` still passes raw plumbing into most command families +- many command entrypoints still accept some combination of + `&CliNamespace`, `&CliLayer`, `Printer`, and `process_local_json` +- those same commands often construct `CommandContext` immediately + inside the handler + +That leaves the CLI with two overlapping models: + +- `CommandContext` as the intended abstraction for shared invocation + state +- raw parameter threading as the de facto command API + +The result is more churn on command signatures, more repeated output +format and JSON-gating branches, and a dead-code-marked `printer` field +that signals the abstraction boundary is unfinished. + +The refactor needs to finish in a way that preserves current CLI +behavior and avoids turning `CommandContext` into a new god object. + +## Requirements Trace + +- **R1.** In-scope CLI command entrypoints use `CommandContext` as the + shared invocation-plumbing abstraction instead of separately receiving + `&CliNamespace`, `&CliLayer`, `Printer`, and `process_local_json`. +- **R2.** `CommandContext` remains narrow: it carries invocation-scoped + plumbing and shared derived state, not command-specific args, styles, + or workflow-specific data. +- **R3.** JSON output, verbosity behavior, printer routing, and global + `--json` restrictions remain behaviorally unchanged for existing + commands, including `auth status` remaining explicit-global-`--json` + only rather than switching to persisted `cli.output.format = "json"`. +- **R4.** Target-based and storage-dir-based server resolution semantics + remain unchanged, including `CommandContext::server()` behavior and + `ServerSummaryLookup::from_client(...)` usage. +- **R5.** `main.rs` keeps its current pre-tracing bootstrap ordering for + logging and upgrade checks; `CommandContext` begins after that phase. +- **R6.** The current dead-code allowance on the `printer` field/accessor + is removed by making printer access a real part of the abstraction, or + by deleting redundant surface area if a narrower accessor is better. +- **R7.** Representative unit and integration coverage exists for the + context API, JSON/text output behavior, global `--json` gating, and + target/connection-based command families touched by the refactor. + +## Scope Boundaries + +- **In scope:** command families in `fabro-cli` that already use + `CommandContext` directly or immediately construct one from raw + plumbing: + `run`, `runs`, `artifact`, `store dump`, `preflight`, `validate`, + `graph`, `model`, `secret`, `pr`, `repo`, `auth`, `provider`, + `config`, `version`, `doctor`, `system`, and the public + `sandbox preview` / `sandbox ssh` command boundary that currently + forwards into `commands/run/*` leaf modules. +- **In scope:** top-level dispatch cleanup in + `lib/crates/fabro-cli/src/main.rs` and family dispatch modules where + the only reason raw CLI plumbing is still passed down is command + signature inertia. +- **Out of scope:** `exec`, `server`, `install`, `upgrade`, `workflow`, + `parse`, `render_graph`, hidden analytics/panic upload commands, + hidden `run worker`, and `sandbox cp`, except for thin compile-only + adapters if needed. +- **Out of scope:** changing CLI text, response payloads, exit-code + semantics, or server connection behavior beyond what is required to + move the plumbing boundary. +- **Out of scope:** moving `Styles` ownership into `CommandContext` or + making low-level pure rendering helpers context-aware when explicit + `Printer` or `Styles` parameters remain clearer. +- **Out of scope:** redesigning `server_client.rs` connection semantics. + This plan consumes the current `CommandContext::server()` model rather + than reopening the earlier server-access design. + +## Context & Research + +### Relevant Code and Patterns + +- `lib/crates/fabro-cli/src/command_context.rs` — current + `CommandContext`, constructors, `user_settings()` / `machine_settings()` + accessors, and dead-code-marked printer storage. +- `lib/crates/fabro-cli/src/main.rs` — top-level command dispatch still + passing raw plumbing into most families after bootstrap. +- `lib/crates/fabro-cli/src/server_client.rs` — current target and + storage-dir connection behavior that must stay unchanged. +- `lib/crates/fabro-cli/src/commands/run/create.rs` — existing example + of a helper that already takes `&CommandContext`. +- `lib/crates/fabro-cli/src/commands/run/mod.rs` — current mixed model: + some subcommands build a `CommandContext`, some still read raw + `cli.output.format`, and `process_local_json` is already vestigial at + this boundary. +- `lib/crates/fabro-cli/src/commands/secret/mod.rs` — good family-level + pattern where a single derived server/client can be shared across + subcommands. +- `lib/crates/fabro-cli/src/commands/pr/mod.rs` — representative mixed + local/server family using both `CommandContext::base(...)` and + `CommandContext::for_target(...)`. +- `lib/crates/fabro-cli/src/commands/auth/mod.rs` and + `lib/crates/fabro-cli/src/commands/provider/mod.rs` — the clearest + examples of raw `process_local_json` still being threaded despite the + rest of the state already belonging to the invocation. +- `files-internal/testing-strategy.md` — CLI integration tests should + stay command-driven and black-box, with implementation-facing behavior + covered by unit tests near the code. +- `lib/crates/fabro-cli/src/commands/sandbox/mod.rs` — the real public + boundary for preview/ssh command dispatch; leaf implementation lives in + `commands/run/preview.rs` and `commands/run/ssh.rs`, but the command + family boundary is `sandbox`. +- `lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs` and + `lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs` — existing + command-owned coverage for those public entrypoints. + +### Current-State Inventory + +- `CommandContext::{base, for_target, for_connection}` currently has + **42** call sites across **37** command files under + `lib/crates/fabro-cli/src/commands`. +- The command tree contains **one** existing helper that already accepts + `&CommandContext` directly: + `lib/crates/fabro-cli/src/commands/run/create.rs`. +- The `printer()` accessor currently has no call sites, which is why the + dead-code allowance still exists. +- For in-scope command files, the dominant remaining use of raw + `&CliNamespace` is reading `cli.output.format` or + `cli.output.verbosity`; that is a signal that the data belongs on the + shared invocation context rather than each command signature. +- `process_local_json` is now concentrated in `auth`, `provider`, + `graph`, `sandbox preview`, and `sandbox ssh`. That makes it a good + candidate for a dedicated invocation-context field/helper instead of + continued parameter threading. + +### Related Context + +- `docs/plans/2026-04-08-cli-services-command-context-refactor-plan.md` + — earlier plan that introduced the current `CommandContext` and server + access model. This plan finishes the command-boundary alignment that + document did not fully land. +- `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md` + — recent owner-first context plan that reinforces the repo preference + for dense, owner-scoped context objects instead of repeated free-form + plumbing. +- `git log -- lib/crates/fabro-cli/src/command_context.rs` shows recent + follow-on commits including `simplify: drop duplicate settings plumbing + from cli/server refactor`, which is consistent with the current goal of + collapsing overlapping command-boundary APIs. + +### Institutional Learnings + +- No relevant `docs/solutions/` entries currently cover this seam. + +### External References + +- No external research used. The repo already has sufficient local + context, existing partial implementation, and tests for this refactor. + +## Key Technical Decisions + +- **Use `CommandContext` as the command-boundary API for in-scope + commands.** + The abstraction already owns the hard parts: working directory, + merged settings, and server access. The remaining refactor should move + entrypoint APIs onto that abstraction instead of continuing to thread + raw plumbing beside it. + +- **Keep `CommandContext` narrow and invocation-scoped, not god-shaped.** + It should own: + `printer`, merged CLI-derived settings (`machine_settings`, + `user_settings`), `cwd`, config-path context, server-derivation state, + and the invocation-only global `--json` flag. + It should not own command args, `Styles`, render-only helpers, or + workflow/build-specific state. + +- **Do not store or expose the full `CliNamespace` publicly.** + Commands in scope only need a small subset of CLI plumbing: + output format, output verbosity, and global `--json` restrictions. + Output format and verbosity should come from + `ctx.user_settings().cli.output`, which already reflects CLI override + precedence through merged settings. The only truly extra invocation + field is the global `--json` switch, which is not part of persisted + settings and therefore belongs on the context explicitly. + +- **Preserve the current error-timing split.** + `CommandContext` construction should keep doing what it does today: + capture cwd, load local settings, and build merged invocation state. + Server-target resolution and server-connection failures should remain + deferred to `ctx.server().await?` or existing explicit + `resolve_server_target(...)` calls. The refactor should not make + malformed target/server resolution errors eager at context-construction + time. + +- **Keep `auth status` as an explicit-global-JSON command.** + Most in-scope commands should read output mode from + `ctx.user_settings().cli.output`, but `auth status` is a special case: + it currently emits JSON only for the explicit invocation-wide global + `--json` path, not merely because resolved CLI settings say + `output.format = json`. That distinction should remain intact, so the + context needs both resolved output settings and a separate helper for + the explicit global JSON flag. + +- **Create a base invocation context once, then derive target/connection + variants from it.** + `main.rs` should keep using raw settings during pre-tracing bootstrap. + After that, it should build a base `CommandContext` once for each + in-scope dispatch path. Command families then derive + target-based or connection-based contexts from that base without + re-supplying `Printer` and `CliLayer`. + +- **Allow private derivation state inside `CommandContext` if that is the + simplest way to avoid raw parameter threading.** + Storing a private `CliLayer` or equivalent internal builder state is + acceptable if it enables methods like `with_target(...)` or + `with_connection(...)` and keeps the raw plumbing hidden behind the + abstraction boundary. + +- **Keep render helpers explicit.** + Command entrypoints and family dispatchers should align on + `CommandContext`, but low-level helpers such as table rendering, + summary formatting, and browser-opening routines may keep explicit + `Printer` / `Styles` / boolean parameters where that stays simpler than + threading the full context downward. + +- **Keep workflow/manifest layer assembly command-local.** + Commands such as `run`, `preflight`, `validate`, and `graph` should + continue to build their workflow/project/manifests with the existing + command-owned helpers. `CommandContext` can provide `cwd`, merged user + settings for output behavior, and server access, but it should not + absorb manifest-building policy or workflow-layer composition. + +- **Migrate family-by-family with temporary compatibility wrappers if + needed.** + This is a cross-cutting refactor with wide signature churn. It is + better to allow short-lived constructor/adapter overlap during the + migration than to force a single giant all-or-nothing patch that is + harder to validate. + +## Open Questions + +### Resolved During Planning + +- **Should this refactor introduce a second wrapper type such as + `Services` or `InvocationContext`?** + No. The repo already has a partially landed `CommandContext`, and the + simplest aligned design is to finish that abstraction rather than + splitting responsibilities across two overlapping context types. + +- **Should `CommandContext` absorb the entire `CliNamespace`?** + No. Public command consumers should read output mode and verbosity from + `ctx.user_settings().cli.output`, while command-specific configuration + stays local to the commands that own it. + +- **Should `process_local_json` become part of `CommandContext`?** + Yes. It is invocation-scoped, currently leaks across multiple command + signatures, and is the one remaining piece of global command plumbing + that is not already represented by merged settings. + +- **Should preview/ssh be treated as `run` work or `sandbox` work in + this plan?** + Treat them as `sandbox` work at the public command boundary. The leaf + implementation modules remain under `commands/run/*`, but the raw + plumbing boundary in the current CLI is `commands/sandbox/mod.rs` and + the plan should align to that boundary and its tests. + +- **Should `auth status` remain “explicit global JSON only”?** + Yes. R3 for this plan is behavioral preservation, and the current + contract is that `auth status` emits JSON only when the explicit + invocation-global `--json` switch is active. + +- **Should `Styles` move into `CommandContext` in the same pass?** + No. That would enlarge the abstraction without addressing the actual + duplicated plumbing problem. + +- **Should out-of-scope local commands be forced onto `CommandContext` + just for uniformity?** + No. This pass should target the families where `CommandContext` already + provides real value or is already partially adopted. + +### Deferred to Implementation + +- **Exact API names for derived contexts.** + The implementation may settle on `with_target(...)`, + `for_target_from(...)`, or similar naming. The important contract is + that callers no longer pass raw `Printer` and `CliLayer` repeatedly. + +- **Whether static constructors remain temporarily during migration.** + If temporary wrappers reduce compile churn while family-by-family + patches land, they are acceptable. Final cleanup should remove the + now-redundant raw-plumbing entrypoints from in-scope call sites. + +- **Whether `CommandContext` should be cheaply cloneable or should build + derived variants from private state on demand.** + Either is acceptable if it preserves the abstraction boundary and does + not change runtime behavior. + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +| Boundary | Current shape | Target shape | +|---|---|---| +| `main.rs` -> command family | `dispatch(args, &cli_settings, &cli_layer, process_local_json, printer)` | `dispatch(args, &base_ctx)` | +| family dispatch -> leaf command | raw CLI plumbing plus `CommandContext::for_target(...)` inside the leaf | derive `target_ctx` / `connection_ctx` once from `base_ctx`, then pass `&CommandContext` or already-resolved client/output helpers | +| output mode lookup | `cli.output.format` / `cli.output.verbosity` | `ctx.user_settings().cli.output.*` | +| global `--json` guard | separate `process_local_json` parameter | `ctx` accessor/helper | +| printing | raw `printer` parameter | `ctx.printer()` or a narrower printer extracted from `ctx` | + +Directional flow: + +```text +bootstrap raw globals/settings for tracing + upgrade check + -> build base CommandContext once for the in-scope command dispatch + -> family dispatch receives &base_ctx + -> family derives: + target_ctx from target args + connection_ctx from storage-dir-aware args + base/local ctx for settings-only commands + -> leaf command reads: + ctx.cwd() + ctx.machine_settings() + ctx.user_settings().cli.output.* + ctx.require_no_json_override() or equivalent + ctx.printer() + ctx.server().await? + -> render-only helpers stay explicit over Printer / Styles where simpler +``` + +## Implementation Units + +- [x] **Unit 1: Reframe `CommandContext` as the invocation-boundary object** + +**Goal:** Make `CommandContext` capable of carrying the invocation +plumbing that is still leaking through command signatures, while keeping +the type narrowly scoped. + +**Requirements:** R1, R2, R3, R4, R6 + +**Dependencies:** None + +**Files:** +- Modify: `lib/crates/fabro-cli/src/command_context.rs` +- Modify: `lib/crates/fabro-cli/src/main.rs` +- Test: `lib/crates/fabro-cli/src/command_context.rs` + +**Approach:** +- Add the remaining invocation-only plumbing that does not already exist + on the context, specifically the global `--json` switch used today via + `process_local_json`. +- Make printer access part of the live API so the dead-code allowance on + the `printer` field/accessor can be removed. +- Add context-derivation methods that let callers obtain target-based or + connection-based variants from a base invocation context without + re-supplying raw `Printer` and `CliLayer`. +- Keep output format and verbosity sourced from + `ctx.user_settings().cli.output` rather than storing a second public + output-format copy on the side, while still exposing the explicit + invocation-global JSON flag separately for commands like `auth status` + whose contract is not identical to resolved output format. + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/command_context.rs` +- `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md` + for owner-first context boundaries + +**Test scenarios:** +- Happy path: a base context exposes the same output format and verbosity + that commands currently read from merged CLI settings. +- Happy path: a base context preserves both resolved output settings and + the explicit invocation-global JSON flag so commands can distinguish + between “resolved output format is JSON” and “user passed global + `--json`”. +- Happy path: deriving a target-based context preserves printer/global + JSON state and resolves server access through the existing + `server_client::connect_server_with_settings(...)` path. +- Edge case: deriving a connection-based context with a storage-dir + override changes only the storage-backed settings path and preserves + other invocation-scoped data. +- Error path: settings-load failures tied to context construction still + surface during context construction, while malformed target resolution + remains deferred until `ctx.server().await?` or explicit + `resolve_server_target(...)` calls. + +**Verification:** +- The `printer` field/accessor is no longer dead code. +- There is one clear way to obtain a base context and derive + target/connection variants without raw plumbing at the call site. +- The context API makes the distinction between resolved output format + and explicit global JSON invocation state unambiguous. + +- [x] **Unit 2: Move `main.rs` and the run/preflight/graph/sandbox boundary to context-first dispatch** + +**Goal:** Eliminate raw plumbing from the top-level dispatch path and the +run-oriented command family plus the public `sandbox preview` / +`sandbox ssh` boundary that already rely heavily on `CommandContext`. + +**Requirements:** R1, R3, R4, R5, R6, R7 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/main.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/command.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/create.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/preview.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/ssh.rs` +- Modify: `lib/crates/fabro-cli/src/commands/sandbox/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/resume.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/rewind.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/fork.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/wait.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/diff.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/logs.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/cp.rs` +- Modify: `lib/crates/fabro-cli/src/commands/preflight.rs` +- Modify: `lib/crates/fabro-cli/src/commands/validate.rs` +- Modify: `lib/crates/fabro-cli/src/commands/graph.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/run.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/create.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/start.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/diff.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/logs.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/preflight.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/validate.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/graph.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs` +- Test: `lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs` +- Test: `lib/crates/fabro-cli/tests/it/scenario/recovery.rs` + +**Approach:** +- Build a base `CommandContext` in `main.rs` only after the existing + bootstrap phase completes. +- Change the run-family and run-adjacent entrypoints to accept context + instead of raw `cli` / `cli_layer` / `printer` bundles. +- Replace direct reads of `cli.output.format` and + `cli.output.verbosity` with `ctx.user_settings().cli.output.*`. +- Replace direct `process_local_json` threading with a context helper + for the small number of commands that still need it (`graph`, + `commands/sandbox/mod.rs` for the public preview/ssh boundary). +- Move the public `sandbox preview` / `sandbox ssh` dispatch boundary to + the same context-first shape as the run-family boundary, while leaving + the leaf implementation modules in `commands/run/*` if that remains + the cleanest internal organization. +- Keep `Styles` local to the leaf commands and keep `attach` / `start` + client helpers narrow if broadening them adds no value. + +**Execution note:** Start by preserving or expanding characterization +coverage for JSON/text output and global `--json` gating before removing +the old raw-plumbing signatures from these entrypoints. + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/commands/run/create.rs` +- `lib/crates/fabro-cli/src/commands/preflight.rs` +- `lib/crates/fabro-cli/src/commands/graph.rs` +- `lib/crates/fabro-cli/src/commands/sandbox/mod.rs` + +**Test scenarios:** +- Happy path: `fabro run --detach` still prints a bare run ID in text + mode and the same JSON payload in JSON mode after output format moves + behind `CommandContext`. +- Happy path: `fabro run` / `resume` / `attach` still inherit verbose + rendering from CLI output verbosity and preserve idle-sleep behavior. +- Edge case: `fabro graph --json` without an explicit output file still + rejects the global JSON override exactly as it does today. +- Edge case: `fabro sandbox preview --open` still opens the browser only + when global JSON mode is not active. +- Edge case: `fabro sandbox ssh` still allows `--print` under global + JSON mode but continues to reject the unsupported interactive + combination. +- Error path: `preflight` and `validate` continue to fail on validation + errors with the same text-vs-JSON contract and exit behavior. +- Integration: create -> start -> attach and resume/recovery flows still + resolve targets, stream output, and summarize results through the same + black-box CLI contracts. + +**Verification:** +- `main.rs` no longer passes the raw plumbing bundle into the run / + preflight / validate / graph / sandbox preview-ssh boundary. +- Those command boundaries obtain shared invocation data exclusively via + `CommandContext`. + +- [x] **Unit 3: Migrate runs/artifact/store families to context-first family dispatch** + +**Goal:** Remove repeated target-context reconstruction from the command +families that already build a target-based `CommandContext` immediately +and mostly use raw CLI state only for output mode. + +**Requirements:** R1, R3, R4, R6, R7 + +**Dependencies:** Units 1-2 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/runs/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/runs/list.rs` +- Modify: `lib/crates/fabro-cli/src/commands/runs/archive.rs` +- Modify: `lib/crates/fabro-cli/src/commands/runs/rm.rs` +- Modify: `lib/crates/fabro-cli/src/commands/runs/inspect.rs` +- Modify: `lib/crates/fabro-cli/src/commands/artifact/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/artifact/list.rs` +- Modify: `lib/crates/fabro-cli/src/commands/artifact/cp.rs` +- Modify: `lib/crates/fabro-cli/src/commands/store/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/store/dump.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/archive.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/inspect.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/rm.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/store_dump.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/store.rs` + +**Approach:** +- Have each family receive a context-first boundary and derive its + target-based command context once, close to the namespace/selector + boundary. +- Switch output-mode branches to + `ctx.user_settings().cli.output.format`. +- Preserve existing client-sharing patterns such as + `ServerSummaryLookup::from_client(ctx.server().await?)`. +- Keep leaf helpers narrow where they already only need a resolved + client, resolved run ID, or printer. + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/commands/secret/mod.rs` +- `lib/crates/fabro-cli/src/commands/artifact/mod.rs` + +**Test scenarios:** +- Happy path: `runs list`, `archive`, `unarchive`, and `rm` still render + the same JSON and text shapes after output-mode decisions move behind + the context. +- Happy path: `runs inspect` remains a JSON-only projection of server + state and still resolves the selected run before fetching the state. +- Edge case: “no runs found” and “no artifacts found” text-mode messages + remain unchanged. +- Error path: ambiguous or missing run selectors still fail through the + existing server/client resolution path. +- Integration: artifact listing/copy and store dump continue to hit the + same server-backed data path and respect output-format selection. + +**Verification:** +- These family dispatchers no longer need separate `cli_layer` and + `printer` arguments merely to reconstruct a target context. + +- [x] **Unit 4: Migrate PR, secret, and system command families** + +**Goal:** Align the families that mix local settings, target-based +server access, and storage-dir-aware server access onto the same +context-first boundary. + +**Requirements:** R1, R3, R4, R6, R7 + +**Dependencies:** Units 1-3 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/pr/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/list.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/create.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/view.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/merge.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/close.rs` +- Modify: `lib/crates/fabro-cli/src/commands/secret/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/secret/list.rs` +- Modify: `lib/crates/fabro-cli/src/commands/secret/set.rs` +- Modify: `lib/crates/fabro-cli/src/commands/secret/rm.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/info.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/df.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/events.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/prune.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_list.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_create.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_view.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_close.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/secret.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/secret_list.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/secret_set.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/secret_rm.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/system.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/system_info.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/system_df.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/system_events.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/system_prune.rs` + +**Approach:** +- Keep `pr`’s split between base-settings work and target-based run + lookup, but move both sides onto a common context-first boundary so + raw `cli_layer` / `printer` threading disappears from the family API. +- Preserve `secret`’s existing pattern of resolving the server once at + the family boundary and passing the client into subcommands. +- For `system`, derive connection-aware contexts from the base + invocation context so storage-dir override behavior remains explicit + and unchanged. + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/commands/pr/mod.rs` +- `lib/crates/fabro-cli/src/commands/secret/mod.rs` +- `lib/crates/fabro-cli/src/commands/system/mod.rs` + +**Test scenarios:** +- Happy path: PR list/view/create/merge/close continue to render the same + JSON/text contracts and still resolve GitHub credentials from merged + local settings. +- Happy path: secret list/set/rm continue to resolve one server client at + the namespace boundary and honor JSON mode. +- Happy path: system info/df/events/prune continue to use + storage-dir-aware connection mode where appropriate. +- Edge case: missing secret / no matching runs-to-prune / empty PR list + still produce the same user-visible text-mode outcomes. +- Error path: invalid storage-dir or connection resolution still fails + before the command attempts remote work. +- Integration: the `system` family continues to respect explicit + `--storage-dir` overrides and local daemon resolution semantics. + +**Verification:** +- These family boundaries no longer accept raw plumbing bundles when the + only reason was to build a `CommandContext` or inspect output mode. + +- [x] **Unit 5: Finish remaining base/target context users** + +**Goal:** Complete the context-first migration for the remaining in-scope +command surfaces that already use `CommandContext` but still expose raw +plumbing at their entrypoints. + +**Requirements:** R1, R2, R3, R5, R6, R7 + +**Dependencies:** Units 1-4 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/model.rs` +- Modify: `lib/crates/fabro-cli/src/commands/repo/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/repo/init.rs` +- Modify: `lib/crates/fabro-cli/src/commands/repo/deinit.rs` +- Modify: `lib/crates/fabro-cli/src/commands/auth/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/auth/login.rs` +- Modify: `lib/crates/fabro-cli/src/commands/auth/logout.rs` +- Modify: `lib/crates/fabro-cli/src/commands/auth/status.rs` +- Modify: `lib/crates/fabro-cli/src/commands/provider/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/provider/login.rs` +- Modify: `lib/crates/fabro-cli/src/commands/config/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/version.rs` +- Modify: `lib/crates/fabro-cli/src/commands/doctor.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/model.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/model_list.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/model_test.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/repo.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/repo_init.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/repo_deinit.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/auth.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/provider.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/provider_login.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/config.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/version.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/doctor.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs` + +**Approach:** +- Move base-context-only command families (`auth`, `provider`, parts of + `repo`) onto `CommandContext` so JSON gating and printer usage come + from the shared invocation object. +- Use `ctx.machine_settings()` / `ctx.user_settings()` consistently for + local settings lookups rather than parallel raw-CLI arguments. +- Preserve the special-case JSON contract for `auth status`: explicit + invocation-global `--json` remains the only JSON trigger, even if + resolved CLI settings say `output.format = json`. +- Keep `repo deinit` and similar pure-local leaf helpers narrow if a + derived `json_output` boolean or `Printer` extracted from the context + keeps the internal helper clearer than passing the full context. + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/commands/auth/login.rs` +- `lib/crates/fabro-cli/src/commands/auth/status.rs` +- `lib/crates/fabro-cli/src/commands/config/mod.rs` +- `lib/crates/fabro-cli/src/commands/version.rs` + +**Test scenarios:** +- Happy path: `auth status` and `provider login` preserve global `--json` + restrictions and still resolve the intended server target from merged + settings. +- Happy path: `config`, `version`, `model`, and `doctor` continue to + honor text-vs-JSON output without a direct `CliNamespace` parameter. +- Edge case: persisted `cli.output.format = "json"` without explicit + global `--json` still leaves `auth status` on its current text-mode + path. +- Edge case: explicit global `--json` still forces `auth status` onto + its JSON output path even though the implementation no longer receives + a raw `process_local_json` parameter. +- Edge case: `repo init` non-JSON progress output and JSON result + payloads stay unchanged. +- Edge case: `repo deinit` still stays local and does not accidentally + require server access just because the family boundary now uses + `CommandContext`. +- Error path: missing auth sessions, invalid server targets, and missing + GitHub access continue to fail with the same user-facing contract. + +**Verification:** +- Remaining in-scope command entrypoints no longer expose the raw + plumbing bundle as part of their public internal API. + +- [x] **Unit 6: Remove migration scaffolding and prove the boundary is clean** + +**Goal:** Delete transitional surface area and confirm the in-scope +command tree is consistently aligned on `CommandContext`. + +**Requirements:** R1, R2, R6, R7 + +**Dependencies:** Units 1-5 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/command_context.rs` +- Modify: `lib/crates/fabro-cli/src/main.rs` +- Modify: in-scope command modules touched by transitional wrappers +- Test: `lib/crates/fabro-cli/tests/it/cmd/top_level.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs` + +**Approach:** +- Remove temporary wrappers or compatibility constructors that were only + present to get through the migration. +- Delete stale comments and “still being wired through” dead-code + annotations once the boundary is real. +- Do a final sweep to ensure in-scope command boundaries are not still + taking `&CliNamespace`, `&CliLayer`, `Printer`, and + `process_local_json` together out of habit. + +**Patterns to follow:** +- Keep cleanup limited to true scaffolding removal; do not reopen command + semantics or formatting behavior in the cleanup pass. + +**Test scenarios:** +- Test expectation: none -- cleanup-only unit. Behavioral coverage should + already exist from Units 1-5. + +**Verification:** +- The command-boundary API is visibly simpler and consistent across the + in-scope families. +- No in-scope command still looks half-migrated. + +## System-Wide Impact + +- **Interaction graph:** `main.rs` bootstrap -> base `CommandContext` -> + family dispatchers -> derived target/connection contexts -> + `server_client.rs` / `user_config.rs`. This touches nearly every + user-facing CLI family that already talks to settings or server + resolution. +- **Error propagation:** context-construction failures remain early and + synchronous at the command boundary; server access errors still flow + through `ctx.server().await?` and should not move deeper into render + helpers. +- **State lifecycle risks:** the biggest correctness risk is accidental + reuse of the wrong derived context, especially storage-dir-aware + contexts in the `system` family and target-based contexts in run/PR + flows. +- **API surface parity:** although this is an internal refactor, it + touches external CLI contracts indirectly through JSON/text output, + verbosity, global `--json` restrictions, and server-target resolution. +- **Integration coverage:** black-box command tests and scenario tests + are the main safety net. Unit tests should only cover context + construction/derivation and not replace CLI integration coverage. +- **Unchanged invariants:** tracing and upgrade bootstrap ordering stays + in `main.rs`; `exec` keeps its distinct direct-provider path; server + connection semantics stay in `server_client.rs`; `Styles` remain + command-local. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Output-mode drift when replacing `cli.output.format` reads with `ctx.user_settings().cli.output.format` | Keep characterization coverage in existing `cmd/*` tests for both text and JSON paths before deleting the raw parameters | +| Global `--json` behavior changes while moving `process_local_json` into the context | Add targeted coverage in `json_global.rs`, `auth.rs`, `provider_login.rs`, `graph.rs`, and sandbox preview/ssh command tests | +| Storage-dir-aware system commands accidentally derive a target-mode context instead of a connection-mode context | Keep connection-specific derivation explicit and cover `system_info`, `system_df`, `system_events`, and `system_prune` with CLI integration tests | +| `CommandContext` grows into a second god object | Keep explicit scope rules: invocation plumbing only, no command args, no `Styles`, no feature-specific render state | +| The refactor becomes a giant compile-fix patch with poor reviewability | Land the work family-by-family with temporary compatibility wrappers where needed, and verify each family with its existing tests before cleanup | + +## Documentation / Operational Notes + +- No user-facing documentation changes are expected. +- Internal comments in `command_context.rs` and nearby command modules + should be updated to describe the final boundary, not the transitional + “still being wired through” state. +- The earlier April 8 plan should remain as historical context; this plan + supersedes it for the command-boundary alignment work. + +## Sources & References + +- Prior plan: `docs/plans/2026-04-08-cli-services-command-context-refactor-plan.md` +- Related plan: `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md` +- Related code: + `lib/crates/fabro-cli/src/command_context.rs` + `lib/crates/fabro-cli/src/main.rs` + `lib/crates/fabro-cli/src/server_client.rs` + `lib/crates/fabro-cli/src/commands/run/mod.rs` + `lib/crates/fabro-cli/src/commands/pr/mod.rs` + `lib/crates/fabro-cli/src/commands/secret/mod.rs` + `lib/crates/fabro-cli/src/commands/auth/mod.rs` + `lib/crates/fabro-cli/src/commands/provider/mod.rs` + `lib/crates/fabro-cli/src/commands/system/mod.rs` +- Testing guidance: `files-internal/testing-strategy.md` +- Related history: + `93b6577cd simplify: drop duplicate settings plumbing from cli/server refactor` + `367fd9302 refactor(cli): centralize command settings and server access` + `4b30a5f16 refactor(cli): route command output through Printer` diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index b5351d640..ce1741bb1 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -26,73 +26,55 @@ pub(crate) enum ServerMode { } pub(crate) struct CommandContext { - #[allow( - dead_code, - reason = "This item is kept for command plumbing that is still being wired through." - )] - printer: Printer, - cwd: PathBuf, - base_config_path: PathBuf, - machine_settings: SettingsLayer, - user_settings: UserSettings, - server_mode: ServerMode, - server: OnceCell>, + printer: Printer, + process_local_json: bool, + cwd: PathBuf, + base_config_path: PathBuf, + cli_layer: CliLayer, + machine_settings: SettingsLayer, + user_settings: UserSettings, + server_mode: ServerMode, + server: OnceCell>, } impl CommandContext { - pub(crate) fn base(printer: Printer, cli_layer: &CliLayer) -> Result { - Self::new(printer, ServerMode::None, cli_layer) - } - - pub(crate) fn for_target( - args: &ServerTargetArgs, + pub(crate) fn base( printer: Printer, cli_layer: &CliLayer, + process_local_json: bool, ) -> Result { - Self::new( - printer, - ServerMode::ByTarget { - target_override: args.server.clone(), - }, - cli_layer, - ) + Self::new(printer, process_local_json, ServerMode::None, cli_layer) } - pub(crate) fn for_connection( - args: &ServerConnectionArgs, + pub(crate) fn with_target(&self, args: &ServerTargetArgs) -> Result { + self.with_server_mode(ServerMode::ByTarget { + target_override: args.server.clone(), + }) + } + + pub(crate) fn with_connection(&self, args: &ServerConnectionArgs) -> Result { + self.with_server_mode(ServerMode::ByStorageDir { + target_override: args.target.server.clone(), + storage_dir_override: args.storage_dir.clone_path(), + }) + } + + fn new( printer: Printer, + process_local_json: bool, + server_mode: ServerMode, cli_layer: &CliLayer, ) -> Result { - Self::new( - printer, - ServerMode::ByStorageDir { - target_override: args.target.server.clone(), - storage_dir_override: args.storage_dir.clone_path(), - }, - cli_layer, - ) - } - - fn new(printer: Printer, server_mode: ServerMode, cli_layer: &CliLayer) -> Result { let cwd = std::env::current_dir().context("Failed to get current directory")?; let base_config_path = user_config::active_settings_path(None); - let disk_settings = match &server_mode { - ServerMode::None | ServerMode::ByTarget { .. } => user_config::load_settings()?, - ServerMode::ByStorageDir { - storage_dir_override, - .. - } => user_config::load_settings_with_storage_dir(storage_dir_override.as_deref())?, - }; - let machine_settings = combine_files(disk_settings, SettingsLayer { - cli: Some(cli_layer.clone()), - ..SettingsLayer::default() - }); - let user_settings = fabro_config::UserSettings::from_layer(&machine_settings)?; + let (machine_settings, user_settings) = load_merged_settings(cli_layer, &server_mode)?; Ok(Self { printer, + process_local_json, cwd, base_config_path, + cli_layer: cli_layer.clone(), machine_settings, user_settings, server_mode, @@ -100,14 +82,18 @@ impl CommandContext { }) } - #[allow( - dead_code, - reason = "This item is kept for command plumbing that is still being wired through." - )] pub(crate) fn printer(&self) -> Printer { self.printer } + pub(crate) fn explicit_json_requested(&self) -> bool { + self.process_local_json + } + + pub(crate) fn require_no_json_override(&self) -> Result<()> { + crate::args::require_no_json_override(self.process_local_json) + } + pub(crate) fn cwd(&self) -> &Path { &self.cwd } @@ -149,4 +135,193 @@ impl CommandContext { Ok(Arc::clone(client)) } + + fn with_server_mode(&self, server_mode: ServerMode) -> Result { + let (machine_settings, user_settings) = match &server_mode { + ServerMode::ByStorageDir { .. } => load_merged_settings(&self.cli_layer, &server_mode)?, + ServerMode::None | ServerMode::ByTarget { .. } => { + (self.machine_settings.clone(), self.user_settings.clone()) + } + }; + + Ok(Self { + printer: self.printer, + process_local_json: self.process_local_json, + cwd: self.cwd.clone(), + base_config_path: self.base_config_path.clone(), + cli_layer: self.cli_layer.clone(), + machine_settings, + user_settings, + server_mode, + server: OnceCell::new(), + }) + } +} + +fn load_merged_settings( + cli_layer: &CliLayer, + server_mode: &ServerMode, +) -> Result<(SettingsLayer, UserSettings)> { + let disk_settings = match server_mode { + ServerMode::None | ServerMode::ByTarget { .. } => user_config::load_settings()?, + ServerMode::ByStorageDir { + storage_dir_override, + .. + } => user_config::load_settings_with_storage_dir(storage_dir_override.as_deref())?, + }; + merge_settings_layer(disk_settings, cli_layer) +} + +fn merge_settings_layer( + disk_settings: SettingsLayer, + cli_layer: &CliLayer, +) -> Result<(SettingsLayer, UserSettings)> { + let machine_settings = combine_files(disk_settings, SettingsLayer { + cli: Some(cli_layer.clone()), + ..SettingsLayer::default() + }); + let user_settings = UserSettings::from_layer(&machine_settings)?; + Ok((machine_settings, user_settings)) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use fabro_config::parse_settings_layer; + use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity}; + use fabro_util::printer::Printer; + use tokio::sync::OnceCell; + + use super::{CommandContext, ServerMode, merge_settings_layer}; + use crate::args::ServerTargetArgs; + + fn cli_layer_with_json_and_verbose() -> CliLayer { + CliLayer { + output: Some(CliOutputLayer { + format: Some(OutputFormat::Json), + verbosity: Some(OutputVerbosity::Verbose), + }), + ..CliLayer::default() + } + } + + fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext { + let cli_layer = cli_layer_with_json_and_verbose(); + let (machine_settings, user_settings) = + merge_settings_layer(parse_settings_layer("_version = 1\n").unwrap(), &cli_layer) + .expect("settings should merge"); + + CommandContext { + printer, + process_local_json, + cwd: PathBuf::from("/tmp/workspace"), + base_config_path: PathBuf::from("/tmp/settings.toml"), + cli_layer, + machine_settings, + user_settings, + server_mode: ServerMode::None, + server: OnceCell::new(), + } + } + + #[test] + fn context_exposes_resolved_output_and_explicit_json_state() { + let ctx = synthetic_context(true, Printer::Default); + + assert_eq!(ctx.user_settings().cli.output.format, OutputFormat::Json); + assert_eq!( + ctx.user_settings().cli.output.verbosity, + OutputVerbosity::Verbose + ); + assert!(ctx.explicit_json_requested()); + assert_eq!(ctx.printer(), Printer::Default); + } + + #[test] + fn deriving_target_context_preserves_invocation_state() { + let base = synthetic_context(true, Printer::Verbose); + let derived = base + .with_target(&ServerTargetArgs { + server: Some("https://fabro.example.com".to_string()), + }) + .expect("target context should derive"); + + assert_eq!(derived.printer(), Printer::Verbose); + assert!(derived.explicit_json_requested()); + assert_eq!(derived.user_settings(), base.user_settings()); + assert_eq!(derived.machine_settings(), base.machine_settings()); + assert_eq!(derived.cwd(), base.cwd()); + assert!(matches!( + derived.server_mode, + ServerMode::ByTarget { target_override: Some(ref target) } + if target == "https://fabro.example.com" + )); + } + + #[test] + fn storage_dir_override_only_changes_storage_root_in_merged_settings() { + let cli_layer = cli_layer_with_json_and_verbose(); + let base_disk_settings = parse_settings_layer( + r#" +_version = 1 + +[server.storage] +root = "/srv/fabro/default" +"#, + ) + .expect("settings fixture should parse"); + let override_disk_settings = fabro_config::user::apply_storage_dir_override( + base_disk_settings.clone(), + Some(std::path::Path::new("/srv/fabro/override")), + ); + + let (base_settings, base_user_settings) = + merge_settings_layer(base_disk_settings, &cli_layer) + .expect("base settings should merge"); + let (connection_settings, connection_user_settings) = + merge_settings_layer(override_disk_settings, &cli_layer) + .expect("connection settings should merge"); + + assert_eq!(base_user_settings, connection_user_settings); + assert_eq!(base_user_settings.cli.output.format, OutputFormat::Json); + assert_eq!( + base_settings + .server + .as_ref() + .and_then(|server| server.storage.as_ref()) + .and_then(|storage| storage.root.as_ref()) + .map(|root| root.as_source()), + Some("/srv/fabro/default".to_string()) + ); + assert_eq!( + connection_settings + .server + .as_ref() + .and_then(|server| server.storage.as_ref()) + .and_then(|storage| storage.root.as_ref()) + .map(|root| root.as_source()), + Some("/srv/fabro/override".to_string()) + ); + } + + #[test] + fn explicit_json_guard_uses_invocation_flag_not_resolved_output_format() { + let json_ctx = synthetic_context(true, Printer::Default); + let text_ctx = synthetic_context(false, Printer::Default); + + assert!(json_ctx.require_no_json_override().is_err()); + assert!(text_ctx.require_no_json_override().is_ok()); + } + + #[test] + fn target_resolution_errors_remain_deferred() { + let ctx = synthetic_context(false, Printer::Default) + .with_target(&ServerTargetArgs { + server: Some("not-a-valid-target".to_string()), + }) + .expect("target derivation should not resolve the target eagerly"); + + assert!(matches!(ctx.server_mode, ServerMode::ByTarget { .. })); + } } diff --git a/lib/crates/fabro-cli/src/commands/artifact/cp.rs b/lib/crates/fabro-cli/src/commands/artifact/cp.rs index 2756912c1..5f6652326 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/cp.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/cp.rs @@ -6,28 +6,22 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use crate::args::ArtifactCpArgs; +use crate::command_context::CommandContext; use crate::server_client::Client; use crate::shared::{print_json_pretty, split_run_path}; -pub(super) async fn cp_command( - args: &ArtifactCpArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(super) async fn cp_command(args: &ArtifactCpArgs, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); let (run_id_selector, asset_path) = parse_source(&args.source); let (run_id, client, entries) = super::resolve_artifacts( + base_ctx, &args.server, run_id_selector, args.node.as_deref(), args.retry, - cli_layer, - printer, ) .await?; @@ -64,7 +58,7 @@ pub(super) async fn cp_command( .unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)), ); write_artifact_file(&client, &run_id, entry, &dest_file).await?; - if cli.output.format == OutputFormat::Json { + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "copied": [{ "relative_path": entry.relative_path, @@ -125,7 +119,7 @@ pub(super) async fn cp_command( } } - if cli.output.format == OutputFormat::Json { + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "copied": copied }))?; } else { fabro_util::printout!( diff --git a/lib/crates/fabro-cli/src/commands/artifact/list.rs b/lib/crates/fabro-cli/src/commands/artifact/list.rs index e7de1d4a4..e5375b659 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/list.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/list.rs @@ -1,30 +1,24 @@ use anyhow::Result; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use crate::args::ArtifactListArgs; +use crate::command_context::CommandContext; -pub(super) async fn list_command( - args: &ArtifactListArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(super) async fn list_command(args: &ArtifactListArgs, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); let (_run_id, _client, entries) = super::resolve_artifacts( + base_ctx, &args.server, &args.run_id, args.node.as_deref(), args.retry, - cli_layer, - printer, ) .await?; - if cli.output.format == OutputFormat::Json { + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { fabro_util::printout!(printer, "{}", serde_json::to_string_pretty(&entries)?); return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/artifact/mod.rs b/lib/crates/fabro-cli/src/commands/artifact/mod.rs index 605d1beec..b9a5ffcf7 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/mod.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/mod.rs @@ -2,10 +2,7 @@ mod cp; mod list; use anyhow::{Context, Result}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; use fabro_types::{RunId, StageId}; -use fabro_util::printer::Printer; use crate::args::{ArtifactCommand, ArtifactNamespace, ServerTargetArgs}; use crate::command_context::CommandContext; @@ -22,14 +19,13 @@ pub(super) struct ArtifactEntry { } pub(super) async fn resolve_artifacts( + base_ctx: &CommandContext, server: &ServerTargetArgs, run_selector: &str, node: Option<&str>, retry: Option, - cli_layer: &CliLayer, - printer: Printer, ) -> Result<(RunId, Client, Vec)> { - let ctx = CommandContext::for_target(server, printer, cli_layer)?; + let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; let run_id = client.resolve_run(run_selector).await?.run_id; let mut entries = Vec::new(); @@ -62,14 +58,9 @@ pub(super) async fn resolve_artifacts( Ok((run_id, client.clone_for_reuse(), entries)) } -pub(crate) async fn dispatch( - ns: ArtifactNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: ArtifactNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - ArtifactCommand::List(args) => list::list_command(&args, cli, cli_layer, printer).await, - ArtifactCommand::Cp(args) => cp::cp_command(&args, cli, cli_layer, printer).await, + ArtifactCommand::List(args) => list::list_command(&args, base_ctx).await, + ArtifactCommand::Cp(args) => cp::cp_command(&args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/auth/login.rs b/lib/crates/fabro-cli/src/commands/auth/login.rs index 7968fb78e..926dd23a9 100644 --- a/lib/crates/fabro-cli/src/commands/auth/login.rs +++ b/lib/crates/fabro-cli/src/commands/auth/login.rs @@ -4,13 +4,12 @@ use anyhow::{Context as _, Result, bail}; use chrono::{DateTime, Utc}; use fabro_client::{AuthEntry, AuthStore, StoredSubject}; use fabro_http::header::CONTENT_TYPE; -use fabro_types::settings::cli::CliLayer; use fabro_util::browser; use fabro_util::printer::Printer; use serde::Deserialize; use tokio::time::timeout; -use crate::args::{AuthLoginArgs, require_no_json_override}; +use crate::args::AuthLoginArgs; use crate::command_context::CommandContext; use crate::user_config; use crate::user_config::ServerTarget; @@ -33,17 +32,13 @@ struct CliTokenSubject { email: String, } -pub(super) async fn login_command( - args: AuthLoginArgs, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - require_no_json_override(process_local_json)?; +pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext) -> Result<()> { + base_ctx.require_no_json_override()?; + let printer = base_ctx.printer(); #[cfg(not(unix))] { - let _ = (args, cli_layer, printer); + let _ = (args, printer); bail!( "CLI OAuth login is not supported on Windows in this release. Use WSL, or use a dev-token server." ); @@ -51,8 +46,7 @@ pub(super) async fn login_command( #[cfg(unix)] { - let ctx = CommandContext::base(printer, cli_layer)?; - let target = user_config::resolve_server_target(&args.server, ctx.machine_settings())?; + let target = user_config::resolve_server_target(&args.server, base_ctx.machine_settings())?; let web_url = browser_origin(&target)?; let pkce = fabro_oauth::generate_pkce(); let state = fabro_oauth::generate_state(); diff --git a/lib/crates/fabro-cli/src/commands/auth/logout.rs b/lib/crates/fabro-cli/src/commands/auth/logout.rs index 6acf20275..f1ad4b806 100644 --- a/lib/crates/fabro-cli/src/commands/auth/logout.rs +++ b/lib/crates/fabro-cli/src/commands/auth/logout.rs @@ -1,23 +1,16 @@ use anyhow::{Result, bail}; use fabro_client::{AuthEntry, AuthStore}; use fabro_http::header::AUTHORIZATION; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; -use crate::args::{AuthLogoutArgs, require_no_json_override}; +use crate::args::AuthLogoutArgs; use crate::command_context::CommandContext; use crate::user_config; use crate::user_config::ServerTarget; -pub(super) async fn logout_command( - args: AuthLogoutArgs, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - require_no_json_override(process_local_json)?; +pub(super) async fn logout_command(args: AuthLogoutArgs, base_ctx: &CommandContext) -> Result<()> { + base_ctx.require_no_json_override()?; + let printer = base_ctx.printer(); - let ctx = CommandContext::base(printer, cli_layer)?; let store = AuthStore::default(); if args.all { let entries = store.list()?; @@ -41,7 +34,7 @@ pub(super) async fn logout_command( return Ok(()); } - let target = user_config::resolve_server_target(&args.server, ctx.machine_settings())?; + let target = user_config::resolve_server_target(&args.server, base_ctx.machine_settings())?; let Some(entry) = store.get(&target)? else { fabro_util::printerr!(printer, "Not logged in to {}.", target); return Ok(()); diff --git a/lib/crates/fabro-cli/src/commands/auth/mod.rs b/lib/crates/fabro-cli/src/commands/auth/mod.rs index dd4ca8a83..0802ac73d 100644 --- a/lib/crates/fabro-cli/src/commands/auth/mod.rs +++ b/lib/crates/fabro-cli/src/commands/auth/mod.rs @@ -3,26 +3,14 @@ mod logout; mod status; use anyhow::Result; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use crate::args::{AuthCommand, AuthNamespace}; +use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - ns: AuthNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: AuthNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - AuthCommand::Login(args) => { - login::login_command(args, cli_layer, process_local_json, printer).await - } - AuthCommand::Logout(args) => { - logout::logout_command(args, cli_layer, process_local_json, printer).await - } - AuthCommand::Status(args) => { - status::status_command(&args, cli_layer, process_local_json, printer) - } + AuthCommand::Login(args) => login::login_command(args, base_ctx).await, + AuthCommand::Logout(args) => logout::logout_command(args, base_ctx).await, + AuthCommand::Status(args) => status::status_command(&args, base_ctx), } } diff --git a/lib/crates/fabro-cli/src/commands/auth/status.rs b/lib/crates/fabro-cli/src/commands/auth/status.rs index aa82b6f7e..808745e7f 100644 --- a/lib/crates/fabro-cli/src/commands/auth/status.rs +++ b/lib/crates/fabro-cli/src/commands/auth/status.rs @@ -1,9 +1,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use fabro_client::{AuthEntry, AuthStore}; -use fabro_types::settings::cli::CliLayer; use fabro_util::dev_token::{read_dev_token_file, validate_dev_token_format}; -use fabro_util::printer::Printer; use serde::Serialize; use crate::args::AuthStatusArgs; @@ -40,13 +38,8 @@ struct StatusOutput { dev_token: &'static str, } -pub(super) fn status_command( - args: &AuthStatusArgs, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::base(printer, cli_layer)?; +pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Result<()> { + let printer = ctx.printer(); let store = AuthStore::default(); let now = Utc::now(); let rows = if args.server.as_deref().is_some() { @@ -61,7 +54,7 @@ pub(super) fn status_command( "not_set" }; - if process_local_json { + if ctx.explicit_json_requested() { print_json_pretty(&StatusOutput { servers: rows, dev_token, diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 1535bfa0e..34ced43a3 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -11,9 +11,7 @@ use std::io::Write; use fabro_api::types::ServerSettings; use fabro_config::UserSettings; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use serde::Serialize; use crate::args::SettingsArgs; @@ -28,10 +26,9 @@ struct RenderedConfig { async fn rendered_config( args: &SettingsArgs, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> anyhow::Result { - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.target)?; let user = fabro_config::UserSettings::resolve()?; let server = ctx .server() @@ -41,14 +38,9 @@ async fn rendered_config( serde_json::to_value(RenderedConfig { user, server }).map_err(Into::into) } -pub(crate) async fn execute( - args: &SettingsArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> anyhow::Result<()> { - let config = Box::pin(rendered_config(args, cli_layer, printer)).await?; - if cli.output.format == OutputFormat::Json { +pub(crate) async fn execute(args: &SettingsArgs, base_ctx: &CommandContext) -> anyhow::Result<()> { + let config = Box::pin(rendered_config(args, base_ctx)).await?; + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&config)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index 85c8517c6..e88731564 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -3,8 +3,7 @@ use std::path::PathBuf; use anyhow::Result; use fabro_api::types as api_types; use fabro_config::user::active_settings_path; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; pub(crate) use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, }; @@ -142,13 +141,13 @@ fn render_report(report: &CheckReport, styles: &Styles, verbose: bool, printer: pub(crate) async fn run_doctor( args: &DoctorArgs, - verbose: bool, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result { + let verbose = + args.verbose || base_ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose; + let printer = base_ctx.printer(); let styles = Styles::detect_stdout(); - let json = cli.output.format == OutputFormat::Json; + let json = base_ctx.user_settings().cli.output.format == OutputFormat::Json; let spinner = if json { None } else { @@ -179,7 +178,7 @@ pub(crate) async fn run_doctor( }], }; - let ctx = match CommandContext::for_target(&args.target, printer, cli_layer) { + let ctx = match base_ctx.with_target(&args.target) { Ok(ctx) => ctx, Err(err) => { report.sections.push(CheckSection { diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index 71ffa9f68..066d41cad 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -13,13 +13,12 @@ use anyhow::{Context, bail}; use fabro_api::types; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_types::settings::{CliNamespace, SettingsLayer}; -use fabro_util::printer::Printer; +use fabro_types::settings::SettingsLayer; +use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use tracing::debug; -use crate::args::{GraphArgs, GraphDirection, require_no_json_override}; +use crate::args::{GraphArgs, GraphDirection}; use crate::command_context::CommandContext; use crate::commands::run::output::api_diagnostics_to_local; use crate::manifest_builder::{ManifestBuildInput, build_run_manifest}; @@ -28,16 +27,14 @@ use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, r pub(crate) async fn run( args: &GraphArgs, styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, + base_ctx: &CommandContext, ) -> anyhow::Result<()> { - if process_local_json && args.output.is_none() { - require_no_json_override(process_local_json)?; + if base_ctx.explicit_json_requested() && args.output.is_none() { + base_ctx.require_no_json_override()?; } - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.target)?; let built = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), cwd: ctx.cwd().to_path_buf(), @@ -73,7 +70,7 @@ pub(crate) async fn run( if let Some(ref output_path) = args.output { std::fs::write(output_path, &rendered) .with_context(|| format!("writing rendered graph to {}", output_path.display()))?; - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "path": absolute_or_current(output_path), "format": args.format.to_string(), diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 3e0753af4..07a7574f9 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -52,6 +52,7 @@ use crate::args::{ DoctorArgs, InstallArgs, InstallCommand, InstallGitHubStrategyArg, InstallGithubArgs, InstallNonInteractiveArgs, ServerTargetArgs, }; +use crate::command_context::CommandContext; use crate::commands::server::{start, stop}; use crate::gh::GhCli; use crate::shared::provider_auth::{ @@ -1941,7 +1942,8 @@ async fn run_install_inner( target: ServerTargetArgs::default(), verbose: false, }; - doctor::run_doctor(&doctor_args, false, cli, cli_layer, printer).await + let base_ctx = CommandContext::base(printer, cli_layer, false)?; + doctor::run_doctor(&doctor_args, &base_ctx).await }, ) .await? diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index d7c26c2a5..e336696b1 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -3,9 +3,7 @@ use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_api::types as api_types; use fabro_model::{Catalog, Model, Provider}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use serde::Serialize; @@ -42,19 +40,22 @@ struct ModelTestOutput { pub(crate) async fn execute( command: Option, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { let command = command.unwrap_or_default(); let target_args = match &command { ModelsCommand::List(args) => &args.target, ModelsCommand::Test(args) => &args.target, }; - let ctx = CommandContext::for_target(target_args, printer, cli_layer)?; + let ctx = base_ctx.with_target(target_args)?; let server = ctx.server().await?; - run_models(command, &server, cli.output.format == OutputFormat::Json).await + run_models( + command, + &server, + ctx.user_settings().cli.output.format == OutputFormat::Json, + ) + .await } fn format_context_window(tokens: i64) -> String { diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index c48132ba0..cdc9bcba7 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -1,22 +1,17 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use tracing::info; use crate::args::PrCloseArgs; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(super) async fn close_command( - args: PrCloseArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let (record, _run_id) = - super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?; +pub(super) async fn close_command(args: PrCloseArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); + let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; - let creds = super::load_github_credentials_required(cli_layer, printer)?; + let creds = super::load_github_credentials_required(base_ctx)?; fabro_github::close_pull_request( &creds, @@ -29,7 +24,7 @@ pub(super) async fn close_command( .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request"); - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "number": record.number, "html_url": record.html_url, diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index e42c633e2..907ccef08 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -5,9 +5,7 @@ use fabro_auth::configured_providers_from_process_env; use fabro_config::Storage; use fabro_model::Catalog; use fabro_sandbox::daytona::detect_repo_info; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use fabro_vault::Vault; use fabro_workflow::outcome::StageStatus; use fabro_workflow::pull_request::maybe_open_pull_request; @@ -25,13 +23,9 @@ use crate::user_config; deprecated, reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side" )] -pub(super) async fn create_command( - args: PrCreateArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(super) async fn create_command(args: PrCreateArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let client = ctx.server().await?; let run_id = client.resolve_run(&args.run_id).await?.run_id; let events = client.list_run_events(&run_id, None, None).await?; @@ -86,7 +80,7 @@ pub(super) async fn create_command( let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url) .map_err(|err| anyhow::anyhow!("{err}"))?; - let creds = super::load_github_credentials_required(cli_layer, printer)?; + let creds = super::load_github_credentials_required(base_ctx)?; let branch_found = fabro_github::branch_exists( &creds, @@ -136,14 +130,14 @@ pub(super) async fn create_command( match pull_request { Some(record) => { info!(pr_url = %record.html_url, "Pull request created"); - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&record)?; } else { fabro_util::printout!(printer, "{}", record.html_url); } } None => { - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::Value::Null)?; } else { fabro_util::printout!(printer, "No pull request created (empty diff)."); diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs index bf86031eb..c38c59b61 100644 --- a/lib/crates/fabro-cli/src/commands/pr/list.rs +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -1,9 +1,7 @@ use anyhow::Result; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use futures::future::join_all; use serde::Serialize; @@ -23,13 +21,9 @@ struct PrRow { url: String, } -pub(super) async fn list_command( - args: PrListArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(super) async fn list_command(args: PrListArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; let mut entries = Vec::new(); @@ -42,7 +36,7 @@ pub(super) async fn list_command( } if entries.is_empty() { - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&Vec::::new())?; return Ok(()); } @@ -50,7 +44,7 @@ pub(super) async fn list_command( return Ok(()); } - let creds = super::load_github_credentials_required(cli_layer, printer)?; + let creds = super::load_github_credentials_required(base_ctx)?; let futures: Vec<_> = entries .iter() @@ -104,7 +98,7 @@ pub(super) async fn list_command( .collect() }; - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&rows)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index 3ee3f3338..ac043b654 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -1,22 +1,17 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use tracing::info; use crate::args::PrMergeArgs; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(super) async fn merge_command( - args: PrMergeArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let (record, _run_id) = - super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?; +pub(super) async fn merge_command(args: PrMergeArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); + let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; - let creds = super::load_github_credentials_required(cli_layer, printer)?; + let creds = super::load_github_credentials_required(base_ctx)?; fabro_github::merge_pull_request( &creds, @@ -30,7 +25,7 @@ pub(super) async fn merge_command( .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request"); - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "number": record.number, "html_url": record.html_url, diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index dd70c83f7..a0c83c21b 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -8,9 +8,7 @@ use anyhow::{Context, Result, anyhow}; use fabro_config::Storage; use fabro_github::GitHubCredentials; use fabro_types::PullRequestRecord; -use fabro_types::settings::cli::CliLayer; -use fabro_types::settings::{CliNamespace, InterpString}; -use fabro_util::printer::Printer; +use fabro_types::settings::InterpString; use crate::args::{PrCommand, PrNamespace, ServerTargetArgs}; use crate::command_context::CommandContext; @@ -20,20 +18,13 @@ use crate::user_config; const GITHUB_CREDENTIALS_REQUIRED: &str = "GitHub credentials required — run `fabro install` or set GITHUB_TOKEN"; -pub(crate) async fn dispatch( - ns: PrNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - PrCommand::Create(args) => { - Box::pin(create::create_command(args, cli, cli_layer, printer)).await - } - PrCommand::List(args) => list::list_command(args, cli, cli_layer, printer).await, - PrCommand::View(args) => view::view_command(args, cli, cli_layer, printer).await, - PrCommand::Merge(args) => merge::merge_command(args, cli, cli_layer, printer).await, - PrCommand::Close(args) => close::close_command(args, cli, cli_layer, printer).await, + PrCommand::Create(args) => Box::pin(create::create_command(args, base_ctx)).await, + PrCommand::List(args) => list::list_command(args, base_ctx).await, + PrCommand::View(args) => view::view_command(args, base_ctx).await, + PrCommand::Merge(args) => merge::merge_command(args, base_ctx).await, + PrCommand::Close(args) => close::close_command(args, base_ctx).await, } } @@ -41,14 +32,10 @@ pub(crate) async fn dispatch( deprecated, reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side" )] -fn load_github_credentials_required( - cli_layer: &CliLayer, - printer: Printer, -) -> Result { - let ctx = CommandContext::base(printer, cli_layer)?; - let server_settings = fabro_config::ServerSettings::from_layer(ctx.machine_settings()) +fn load_github_credentials_required(base_ctx: &CommandContext) -> Result { + let server_settings = fabro_config::ServerSettings::from_layer(base_ctx.machine_settings()) .map_err(anyhow::Error::from)?; - let vault = user_config::storage_dir(ctx.machine_settings()) + let vault = user_config::storage_dir(base_ctx.machine_settings()) .ok() .and_then(|dir| fabro_vault::Vault::load(Storage::new(&dir).secrets_path()).ok()); let creds = build_github_credentials( @@ -70,10 +57,9 @@ fn load_github_credentials_required( pub(crate) async fn load_pr_record( server: &ServerTargetArgs, run_id: &str, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<(PullRequestRecord, fabro_types::RunId)> { - let ctx = CommandContext::for_target(server, printer, cli_layer)?; + let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; let run_id = client.resolve_run(run_id).await?.run_id; let state = client.get_run_state(&run_id).await?; diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index d766e7dd4..e0f934d15 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -1,22 +1,17 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use tracing::info; use crate::args::PrViewArgs; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(super) async fn view_command( - args: PrViewArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let (record, _run_id) = - super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?; +pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); + let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; - let creds = super::load_github_credentials_required(cli_layer, printer)?; + let creds = super::load_github_credentials_required(base_ctx)?; let detail = fabro_github::get_pull_request( &creds, @@ -30,7 +25,7 @@ pub(super) async fn view_command( info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request"); - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&detail)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 1a49e8cb5..a3fb819bb 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,9 +1,7 @@ use anyhow::bail; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_util::terminal::Styles; use crate::args::PreflightArgs; @@ -17,13 +15,13 @@ use crate::shared::print_json_pretty; pub(crate) async fn execute( mut args: PreflightArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> anyhow::Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; - args.verbose = args.verbose || cli.output.verbosity == OutputVerbosity::Verbose; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.target)?; + args.verbose = + args.verbose || ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose; let manifest = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), @@ -38,7 +36,7 @@ pub(crate) async fn execute( let response = client.run_preflight(manifest.manifest).await?; let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&response)?; } else { print_preflight_workflow_summary( diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs index 86a393a09..32babd47d 100644 --- a/lib/crates/fabro-cli/src/commands/provider/login.rs +++ b/lib/crates/fabro-cli/src/commands/provider/login.rs @@ -1,23 +1,20 @@ use anyhow::Result; use fabro_api::types; use fabro_auth::credential_id_for; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use crate::args::{ProviderLoginArgs, require_no_json_override}; +use crate::args::ProviderLoginArgs; use crate::command_context::CommandContext; use crate::shared::provider_auth; pub(super) async fn login_command( args: ProviderLoginArgs, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { - require_no_json_override(process_local_json)?; + base_ctx.require_no_json_override()?; + let printer = base_ctx.printer(); let s = Styles::detect_stderr(); - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.target)?; let server = ctx.server().await?; let credential = if args.api_key_stdin { provider_auth::authenticate_provider_with_api_key_source( diff --git a/lib/crates/fabro-cli/src/commands/provider/mod.rs b/lib/crates/fabro-cli/src/commands/provider/mod.rs index 07e3d9adf..8dd8f0080 100644 --- a/lib/crates/fabro-cli/src/commands/provider/mod.rs +++ b/lib/crates/fabro-cli/src/commands/provider/mod.rs @@ -1,20 +1,12 @@ mod login; use anyhow::Result; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use crate::args::{ProviderCommand, ProviderNamespace}; +use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - ns: ProviderNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: ProviderNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - ProviderCommand::Login(args) => { - login::login_command(args, cli_layer, process_local_json, printer).await - } + ProviderCommand::Login(args) => login::login_command(args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/repo/deinit.rs b/lib/crates/fabro-cli/src/commands/repo/deinit.rs index 8ed8ae707..ff0e0d1f7 100644 --- a/lib/crates/fabro-cli/src/commands/repo/deinit.rs +++ b/lib/crates/fabro-cli/src/commands/repo/deinit.rs @@ -1,9 +1,10 @@ use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::OutputFormat; -use fabro_util::printer::Printer; -pub(crate) fn run_deinit(cli: &CliNamespace, printer: Printer) -> Result> { +use crate::command_context::CommandContext; + +pub(crate) fn run_deinit(base_ctx: &CommandContext) -> Result> { + let printer = base_ctx.printer(); let repo_root = super::init::git_repo_root()?; let mut removed = Vec::new(); @@ -20,7 +21,7 @@ pub(crate) fn run_deinit(cli: &CliNamespace, printer: Printer) -> Result Result Result { pub(crate) async fn run_init( args: &RepoInitArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result> { + let printer = base_ctx.printer(); let repo_root = spawn_blocking(git_repo_root) .await .context("git repo root task panicked")??; @@ -79,7 +76,7 @@ draft = true let green = console::Style::new().green(); let bold = console::Style::new().bold(); let dim = console::Style::new().dim(); - if cli.output.format != OutputFormat::Json { + if base_ctx.user_settings().cli.output.format != OutputFormat::Json { fabro_util::printerr!( printer, " {} {}", @@ -112,7 +109,7 @@ draft = true ) .with_context(|| format!("failed to write {}", dot_path.display()))?; created.push(".fabro/workflows/hello/workflow.fabro".to_string()); - if cli.output.format != OutputFormat::Json { + if base_ctx.user_settings().cli.output.format != OutputFormat::Json { fabro_util::printerr!( printer, " {} {}", @@ -129,7 +126,7 @@ draft = true ) .with_context(|| format!("failed to write {}", toml_path.display()))?; created.push(".fabro/workflows/hello/workflow.toml".to_string()); - if cli.output.format != OutputFormat::Json { + if base_ctx.user_settings().cli.output.format != OutputFormat::Json { fabro_util::printerr!( printer, " {} {}", @@ -138,7 +135,7 @@ draft = true ); } - if cli.output.format != OutputFormat::Json { + if base_ctx.user_settings().cli.output.format != OutputFormat::Json { fabro_util::printerr!( printer, "\n{} Run a workflow with:\n\n {}", @@ -150,18 +147,15 @@ draft = true ); } - if cli.output.format != OutputFormat::Json { - check_github_app_installation(&args.target, cli_layer, printer).await; + if base_ctx.user_settings().cli.output.format != OutputFormat::Json { + check_github_app_installation(&args.target, base_ctx).await; } Ok(created) } -async fn check_github_app_installation( - target: &ServerTargetArgs, - cli_layer: &CliLayer, - printer: Printer, -) { +async fn check_github_app_installation(target: &ServerTargetArgs, base_ctx: &CommandContext) { + let printer = base_ctx.printer(); // Get the git remote origin URL let output = match TokioCommand::new("git") .args(["remote", "get-url", "origin"]) @@ -199,7 +193,7 @@ async fn check_github_app_installation( return; // Not a GitHub repo — skip silently }; - let ctx = match CommandContext::for_target(target, printer, cli_layer) { + let ctx = match base_ctx.with_target(target) { Ok(ctx) => ctx, Err(err) => { fabro_util::printerr!( diff --git a/lib/crates/fabro-cli/src/commands/repo/mod.rs b/lib/crates/fabro-cli/src/commands/repo/mod.rs index 5ddeaa95b..8c768e576 100644 --- a/lib/crates/fabro-cli/src/commands/repo/mod.rs +++ b/lib/crates/fabro-cli/src/commands/repo/mod.rs @@ -2,30 +2,24 @@ pub(crate) mod deinit; pub(crate) mod init; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use crate::args::{RepoCommand, RepoNamespace}; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(crate) async fn dispatch( - ns: RepoNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: RepoNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { RepoCommand::Init(args) => { - let created = init::run_init(&args, cli, cli_layer, printer).await?; - if cli.output.format == OutputFormat::Json { + let created = init::run_init(&args, base_ctx).await?; + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "created": created }))?; } Ok(()) } RepoCommand::Deinit => { - let removed = deinit::run_deinit(cli, printer)?; - if cli.output.format == OutputFormat::Json { + let removed = deinit::run_deinit(base_ctx)?; + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "removed": removed }))?; } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index 20572cf2b..663cad207 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -1,7 +1,5 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_util::terminal::Styles; use crate::args::RunArgs; @@ -9,16 +7,13 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; use crate::user_config::load_settings_with_storage_dir; -pub(crate) async fn execute( - mut args: RunArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.target)?; let cli_defaults = load_settings_with_storage_dir(None)?; - args.verbose = args.verbose || cli.output.verbosity == OutputVerbosity::Verbose; + args.verbose = + args.verbose || ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose; let quiet = args.detach; let prevent_idle_sleep = ctx.user_settings().cli.exec.prevent_idle_sleep; @@ -50,7 +45,7 @@ pub(crate) async fn execute( let client = ctx.server().await?; super::start::start_run_with_client(&client, &created_run.run_id, false).await?; - let json = cli.output.format == OutputFormat::Json; + let json = ctx.user_settings().cli.output.format == OutputFormat::Json; if args.detach { if json { print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?; diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index 84c05258e..170ea62e1 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -1,9 +1,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use tokio::fs; use tracing::{debug, info}; @@ -26,12 +24,7 @@ enum CopyDirection { }, } -pub(crate) async fn cp_command( - args: CpArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn cp_command(args: CpArgs, base_ctx: &CommandContext) -> Result<()> { let direction = parse_direction(&args.src, &args.dst)?; match direction { @@ -41,7 +34,7 @@ pub(crate) async fn cp_command( local_path, } => { let (client, run_id) = - resolve_client_and_run_id(&args.server, &run_prefix, cli_layer, printer).await?; + resolve_client_and_run_id(base_ctx, &args.server, &run_prefix).await?; let file_count = if args.recursive { Some(download_recursive(&client, &run_id, &remote_path, &local_path).await?) @@ -51,7 +44,7 @@ pub(crate) async fn cp_command( None }; - if cli.output.format == OutputFormat::Json { + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { let mut value = serde_json::json!({ "direction": "download", "recursive": args.recursive, @@ -72,7 +65,7 @@ pub(crate) async fn cp_command( remote_path, } => { let (client, run_id) = - resolve_client_and_run_id(&args.server, &run_prefix, cli_layer, printer).await?; + resolve_client_and_run_id(base_ctx, &args.server, &run_prefix).await?; let file_count = if args.recursive { Some(upload_recursive(&client, &run_id, &local_path, &remote_path).await?) @@ -82,7 +75,7 @@ pub(crate) async fn cp_command( None }; - if cli.output.format == OutputFormat::Json { + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { let mut value = serde_json::json!({ "direction": "upload", "recursive": args.recursive, @@ -124,12 +117,11 @@ fn parse_direction(src: &str, dst: &str) -> Result { } async fn resolve_client_and_run_id( + base_ctx: &CommandContext, server: &ServerTargetArgs, run_prefix: &str, - cli_layer: &CliLayer, - printer: Printer, ) -> Result<(Client, fabro_types::RunId)> { - let ctx = CommandContext::for_target(server, printer, cli_layer)?; + let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; let run_id = client.resolve_run(run_prefix).await?.run_id; Ok((client.clone_for_reuse(), run_id)) diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/crates/fabro-cli/src/commands/run/diff.rs index 51da824cc..2bebdda23 100644 --- a/lib/crates/fabro-cli/src/commands/run/diff.rs +++ b/lib/crates/fabro-cli/src/commands/run/diff.rs @@ -10,9 +10,7 @@ use std::io::{self, IsTerminal, Write}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use tracing::{debug, info}; use crate::args::DiffArgs; @@ -20,21 +18,16 @@ use crate::command_context::CommandContext; use crate::server_client::RunProjection; use crate::shared::print_json_pretty; -pub(crate) async fn run( - args: DiffArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn run(args: DiffArgs, base_ctx: &CommandContext) -> Result<()> { info!(run_id = %args.run, "Showing diff"); - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let state = client.get_run_state(&run_id).await?; let patch = resolve_diff(&state, &args)?; - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { let value = serde_json::json!({ "run_id": run_id, "node": args.node, diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index 70d5ebba6..23ba16eda 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -1,8 +1,6 @@ use anyhow::{Context, Result}; use fabro_checkpoint::git::Store; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use fabro_workflow::operations::{ForkRunInput, RewindTarget, build_timeline_or_rebuild, fork}; use git2::Repository; @@ -13,15 +11,10 @@ use crate::commands::store::rebuild::rebuild_run_store; use crate::shared::print_json_pretty; use crate::shared::repo::ensure_matching_repo_origin; -pub(crate) async fn run( - args: &ForkArgs, - styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn run(args: &ForkArgs, styles: &Styles, base_ctx: &CommandContext) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run_id).await?.run_id; let state = client.get_run_state(&run_id).await?; @@ -34,7 +27,7 @@ pub(crate) async fn run( let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?; if args.list { - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&super::rewind::timeline_entries_json(&timeline))?; return Ok(()); } @@ -56,7 +49,7 @@ pub(crate) async fn run( let run_id_string = run_id.to_string(); let new_run_id_string = new_run_id.to_string(); - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { let target = args.target.clone().unwrap_or_else(|| "latest".to_string()); print_json_pretty(&serde_json::json!({ "source_run_id": run_id_string, diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index 879d3f68b..2592c43cd 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -13,10 +13,8 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::cli::OutputFormat; use fabro_util::json::normalize_json_value; -use fabro_util::printer::Printer; use fabro_util::redact::redact_jsonl_line; use fabro_util::terminal::Styles; use tokio::time; @@ -29,14 +27,8 @@ use crate::shared::format_usd_micros; const FOLLOW_TERMINAL_GRACE: Duration = Duration::from_millis(500); -pub(crate) async fn run( - args: &LogsArgs, - styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(crate) async fn run(args: &LogsArgs, styles: &Styles, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; info!(run_id = %run_id, "Showing logs"); @@ -60,7 +52,7 @@ pub(crate) async fn run( let stdout = io::stdout(); let is_tty = stdout.is_terminal(); let mut out = stdout.lock(); - let pretty = args.pretty && cli.output.format != OutputFormat::Json; + let pretty = args.pretty && ctx.user_settings().cli.output.format != OutputFormat::Json; for line in &filtered { if pretty { diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 7acd35e3e..0256ca566 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -1,7 +1,5 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_util::terminal::Styles; use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs}; @@ -27,19 +25,15 @@ pub(crate) mod ssh; pub(crate) mod start; pub(crate) mod wait; -pub(crate) async fn dispatch( - cmd: RunCommands, - cli: &CliNamespace, - cli_layer: &CliLayer, - _process_local_json: bool, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(cmd: RunCommands, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); + match cmd { - RunCommands::Run(args) => Box::pin(command::execute(args, cli, cli_layer, printer)).await, + RunCommands::Run(args) => Box::pin(command::execute(args, base_ctx)).await, RunCommands::Create(args) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let cli_defaults = load_settings_with_storage_dir(None)?; - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.target)?; let created_run = Box::pin(create::create_run( &ctx, &args, @@ -49,7 +43,7 @@ pub(crate) async fn dispatch( printer, )) .await?; - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?; } else { fabro_util::printout!(printer, "{}", created_run.run_id); @@ -57,26 +51,27 @@ pub(crate) async fn dispatch( Ok(()) } RunCommands::Start(StartArgs { server, run }) => { - let ctx = CommandContext::for_target(&server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&run).await?.run_id; start::start_run_with_client(client.as_ref(), &run_id, false).await?; - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; } Ok(()) } RunCommands::Attach(AttachArgs { server, run }) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let ctx = CommandContext::for_target(&server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&run).await?.run_id; + let json = ctx.user_settings().cli.output.format == OutputFormat::Json; let exit_code = Box::pin(attach::attach_run_with_client( client.as_ref(), &run_id, false, styles, - cli.output.format == OutputFormat::Json, + json, ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose, printer, )) @@ -104,34 +99,31 @@ pub(crate) async fn dispatch( ) .await } - RunCommands::Diff(args) => diff::run(args, cli, cli_layer, printer).await, + RunCommands::Diff(args) => diff::run(args, base_ctx).await, RunCommands::Logs(args) => { let styles = Styles::detect_stdout(); - logs::run(&args, &styles, cli, cli_layer, printer).await + logs::run(&args, &styles, base_ctx).await } RunCommands::Resume(args) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.server)?; crate::sleep_inhibitor::guard(ctx.user_settings().cli.exec.prevent_idle_sleep) }; - Box::pin(resume::resume_command( - args, styles, cli, cli_layer, printer, - )) - .await + Box::pin(resume::resume_command(args, styles, base_ctx)).await } RunCommands::Rewind(args) => { let styles = Styles::detect_stderr(); - Box::pin(rewind::run(&args, &styles, cli, cli_layer, printer)).await + Box::pin(rewind::run(&args, &styles, base_ctx)).await } RunCommands::Fork(args) => { let styles = Styles::detect_stderr(); - Box::pin(fork::run(&args, &styles, cli, cli_layer, printer)).await + Box::pin(fork::run(&args, &styles, base_ctx)).await } RunCommands::Wait(args) => { let styles = Styles::detect_stderr(); - wait::run(&args, &styles, cli, cli_layer, printer).await + wait::run(&args, &styles, base_ctx).await } } } diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index f344ffd76..bf916c27e 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -1,21 +1,14 @@ use anyhow::{Context, Result}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use tracing::info; use crate::args::PreviewArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(crate) async fn run( - args: PreviewArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(crate) async fn run(args: PreviewArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let expires_in_secs = @@ -31,7 +24,7 @@ pub(crate) async fn run( info!(run_id = %args.run, port = args.port, "Generating preview URL"); - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { match response.token { Some(token) => { print_json_pretty(&serde_json::json!({ "url": response.url, "token": token }))?; @@ -56,7 +49,7 @@ pub(crate) async fn run( } } - if args.open && !process_local_json { + if args.open && !ctx.explicit_json_requested() { #[expect( clippy::disallowed_methods, reason = "Preview URL opening is a fire-and-forget OS integration, not a Tokio-managed child process." diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index 9c892ea14..ca05661f9 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -1,6 +1,4 @@ -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_util::terminal::Styles; use crate::args::ResumeArgs; @@ -15,17 +13,16 @@ use crate::shared::print_json_pretty; pub(crate) async fn resume_command( args: ResumeArgs, styles: &'static Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> anyhow::Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; super::start::start_run_with_client(client.as_ref(), &run_id, true).await?; - let json = cli.output.format == OutputFormat::Json; + let json = ctx.user_settings().cli.output.format == OutputFormat::Json; if args.detach { if json { print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index a57e0f9d0..96e7c450b 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -3,8 +3,7 @@ use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_checkpoint::git::Store; use fabro_types::run_event::{CheckpointCompletedProps, RunRewoundProps, RunSubmittedProps}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::cli::OutputFormat; use fabro_types::{EventBody, RunEvent}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -33,12 +32,11 @@ pub(crate) struct TimelineEntryJson { pub(crate) async fn run( args: &RewindArgs, styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run_id).await?.run_id; let state = client.get_run_state(&run_id).await?; @@ -54,7 +52,7 @@ pub(crate) async fn run( let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?; if args.list || args.target.is_none() { - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&timeline_entries_json(&timeline))?; return Ok(()); } @@ -79,7 +77,7 @@ pub(crate) async fn run( let run_id_string = run_id.to_string(); - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "run_id": run_id_string, "target": target_arg, diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index d3ce4f9f8..537c781e9 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -1,25 +1,18 @@ use anyhow::{Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use tracing::info; -use crate::args::{SshArgs, require_no_json_override}; +use crate::args::SshArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(crate) async fn run( - args: SshArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - if process_local_json && !args.print { - require_no_json_override(process_local_json)?; +pub(crate) async fn run(args: SshArgs, base_ctx: &CommandContext) -> Result<()> { + if base_ctx.explicit_json_requested() && !args.print { + base_ctx.require_no_json_override()?; } - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let ssh = client.create_run_ssh_access(&run_id, args.ttl).await?; @@ -27,7 +20,7 @@ pub(crate) async fn run( info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access"); if args.print { - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "command": ssh.command }))?; } else { { diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index 26bf2d181..f43e33510 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -11,8 +11,7 @@ use std::io::Write; use anyhow::{Result, bail}; use fabro_types::RunId; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_workflow::records::Conclusion; @@ -24,14 +23,9 @@ use crate::args::WaitArgs; use crate::command_context::CommandContext; use crate::shared::{format_duration_ms, format_usd_micros, run_status_kind}; -pub(crate) async fn run( - args: &WaitArgs, - styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(crate) async fn run(args: &WaitArgs, styles: &Styles, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; info!(run_id = %run_id, "Waiting for run to complete"); @@ -63,7 +57,7 @@ pub(crate) async fn run( let conclusion = client.get_run_state(&run_id).await?.conclusion; - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { let json_value = build_json_output(final_status, &run_id, conclusion.as_ref()); let mut out = std::io::stdout().lock(); serde_json::to_writer_pretty(&mut out, &json_value)?; diff --git a/lib/crates/fabro-cli/src/commands/runs/archive.rs b/lib/crates/fabro-cli/src/commands/runs/archive.rs index dcc996fc9..ac4b2e39c 100644 --- a/lib/crates/fabro-cli/src/commands/runs/archive.rs +++ b/lib/crates/fabro-cli/src/commands/runs/archive.rs @@ -1,7 +1,5 @@ use anyhow::{Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use super::short_run_id; use crate::args::{RunsArchiveArgs, RunsUnarchiveArgs}; @@ -11,34 +9,30 @@ use crate::shared::print_json_pretty; pub(crate) async fn archive_command( args: &RunsArchiveArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.server)?; run_bulk( Action::Archive, &args.runs, ctx.server().await?.as_ref(), - cli, - printer, + ctx.user_settings().cli.output.format == OutputFormat::Json, + ctx.printer(), ) .await } pub(crate) async fn unarchive_command( args: &RunsUnarchiveArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.server)?; run_bulk( Action::Unarchive, &args.runs, ctx.server().await?.as_ref(), - cli, - printer, + ctx.user_settings().cli.output.format == OutputFormat::Json, + ctx.printer(), ) .await } @@ -66,10 +60,9 @@ async fn run_bulk( action: Action, identifiers: &[String], client: &server_client::Client, - cli: &CliNamespace, - printer: Printer, + json: bool, + printer: fabro_util::printer::Printer, ) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; let mut had_errors = false; let mut changed = Vec::new(); let mut errors = Vec::new(); diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index dfde883ae..80f7b2015 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -1,6 +1,4 @@ use anyhow::Result; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use fabro_workflow::run_status::RunStatus; use serde::Serialize; @@ -20,8 +18,9 @@ pub(crate) struct InspectOutput { pub sandbox: Option, } -pub(crate) async fn run(args: &InspectArgs, cli_layer: &CliLayer, printer: Printer) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(crate) async fn run(args: &InspectArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let client = ctx.server().await?; let run = ServerRunSummaryInfo::from_summary(client.resolve_run(&args.run).await?); let run_id = run.run_id(); diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index c26f4c73e..25c6daab3 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -4,9 +4,7 @@ use anyhow::Result; use chrono::Utc; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use fabro_util::text::strip_goal_decoration; use fabro_workflow::run_status::RunStatus; @@ -20,11 +18,10 @@ use crate::shared::{color_if, format_duration_ms, run_status_kind, tilde_path}; pub(crate) async fn list_command( args: &RunsListArgs, styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; let label_filters = parse_label_filters(&args.filter.label); let filtered = filter_server_runs( @@ -35,7 +32,7 @@ pub(crate) async fn list_command( !args.all, ); - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { let json_rows: Vec<_> = filtered .iter() .map(|run| { diff --git a/lib/crates/fabro-cli/src/commands/runs/mod.rs b/lib/crates/fabro-cli/src/commands/runs/mod.rs index 3804d4052..49ea0561c 100644 --- a/lib/crates/fabro-cli/src/commands/runs/mod.rs +++ b/lib/crates/fabro-cli/src/commands/runs/mod.rs @@ -1,35 +1,24 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::RunsCommands; +use crate::command_context::CommandContext; pub(crate) mod archive; pub(crate) mod inspect; pub(crate) mod list; pub(crate) mod rm; -pub(crate) async fn dispatch( - cmd: RunsCommands, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(cmd: RunsCommands, base_ctx: &CommandContext) -> Result<()> { match cmd { RunsCommands::Ps(args) => { let styles = Styles::detect_stdout(); - list::list_command(&args, &styles, cli, cli_layer, printer).await - } - RunsCommands::Rm(args) => rm::remove_command(&args, cli, cli_layer, printer).await, - RunsCommands::Inspect(args) => inspect::run(&args, cli_layer, printer).await, - RunsCommands::Archive(args) => { - archive::archive_command(&args, cli, cli_layer, printer).await - } - RunsCommands::Unarchive(args) => { - archive::unarchive_command(&args, cli, cli_layer, printer).await + list::list_command(&args, &styles, base_ctx).await } + RunsCommands::Rm(args) => rm::remove_command(&args, base_ctx).await, + RunsCommands::Inspect(args) => inspect::run(&args, base_ctx).await, + RunsCommands::Archive(args) => archive::archive_command(&args, base_ctx).await, + RunsCommands::Unarchive(args) => archive::unarchive_command(&args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index 8990d4efc..1fcff5364 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -1,7 +1,5 @@ use anyhow::{Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use super::short_run_id; use crate::args::RunsRemoveArgs; @@ -9,23 +7,23 @@ use crate::command_context::CommandContext; use crate::server_client; use crate::shared::print_json_pretty; -pub(crate) async fn remove_command( - args: &RunsRemoveArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; - remove_from(args, ctx.server().await?.as_ref(), cli, printer).await +pub(crate) async fn remove_command(args: &RunsRemoveArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + remove_from( + args, + ctx.server().await?.as_ref(), + ctx.user_settings().cli.output.format == OutputFormat::Json, + ctx.printer(), + ) + .await } async fn remove_from( args: &RunsRemoveArgs, client: &server_client::Client, - cli: &CliNamespace, - printer: Printer, + json: bool, + printer: fabro_util::printer::Printer, ) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; let mut had_errors = false; let mut removed = Vec::new(); let mut errors = Vec::new(); diff --git a/lib/crates/fabro-cli/src/commands/sandbox/mod.rs b/lib/crates/fabro-cli/src/commands/sandbox/mod.rs index 3adc7bd13..96c41bb50 100644 --- a/lib/crates/fabro-cli/src/commands/sandbox/mod.rs +++ b/lib/crates/fabro-cli/src/commands/sandbox/mod.rs @@ -1,24 +1,12 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use crate::args::SandboxCommand; +use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - command: SandboxCommand, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(command: SandboxCommand, base_ctx: &CommandContext) -> Result<()> { match command { - SandboxCommand::Cp(args) => super::run::cp::cp_command(args, cli, cli_layer, printer).await, - SandboxCommand::Preview(args) => { - super::run::preview::run(args, cli, cli_layer, process_local_json, printer).await - } - SandboxCommand::Ssh(args) => { - super::run::ssh::run(args, cli, cli_layer, process_local_json, printer).await - } + SandboxCommand::Cp(args) => super::run::cp::cp_command(args, base_ctx).await, + SandboxCommand::Preview(args) => super::run::preview::run(args, base_ctx).await, + SandboxCommand::Ssh(args) => super::run::ssh::run(args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/secret/list.rs b/lib/crates/fabro-cli/src/commands/secret/list.rs index 25d7f7680..fb664124d 100644 --- a/lib/crates/fabro-cli/src/commands/secret/list.rs +++ b/lib/crates/fabro-cli/src/commands/secret/list.rs @@ -2,8 +2,6 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -25,11 +23,11 @@ fn format_age(dt: DateTime, now: DateTime) -> String { pub(super) async fn list_command( client: &Client, _args: &SecretListArgs, - cli: &CliNamespace, + json_output: bool, printer: Printer, ) -> Result<()> { let secrets = client.list_secrets().await?; - if cli.output.format == OutputFormat::Json { + if json_output { print_json_pretty(&secrets)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/secret/mod.rs b/lib/crates/fabro-cli/src/commands/secret/mod.rs index 19fdeef73..458136baf 100644 --- a/lib/crates/fabro-cli/src/commands/secret/mod.rs +++ b/lib/crates/fabro-cli/src/commands/secret/mod.rs @@ -3,24 +3,19 @@ mod rm; mod set; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use crate::args::{SecretCommand, SecretNamespace}; use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - ns: SecretNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&ns.target, printer, cli_layer)?; +pub(crate) async fn dispatch(ns: SecretNamespace, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&ns.target)?; let server = ctx.server().await?; + let json_output = + ctx.user_settings().cli.output.format == fabro_types::settings::cli::OutputFormat::Json; + let printer = ctx.printer(); match ns.command { - SecretCommand::List(args) => list::list_command(&server, &args, cli, printer).await, - SecretCommand::Rm(args) => rm::rm_command(&server, &args, cli, printer).await, - SecretCommand::Set(args) => set::set_command(&server, &args, cli, printer).await, + SecretCommand::List(args) => list::list_command(&server, &args, json_output, printer).await, + SecretCommand::Rm(args) => rm::rm_command(&server, &args, json_output, printer).await, + SecretCommand::Set(args) => set::set_command(&server, &args, json_output, printer).await, } } diff --git a/lib/crates/fabro-cli/src/commands/secret/rm.rs b/lib/crates/fabro-cli/src/commands/secret/rm.rs index 253ca09d0..ec3a4922b 100644 --- a/lib/crates/fabro-cli/src/commands/secret/rm.rs +++ b/lib/crates/fabro-cli/src/commands/secret/rm.rs @@ -1,6 +1,4 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use crate::args::SecretRmArgs; @@ -10,11 +8,11 @@ use crate::shared::print_json_pretty; pub(super) async fn rm_command( client: &Client, args: &SecretRmArgs, - cli: &CliNamespace, + json_output: bool, printer: Printer, ) -> Result<()> { client.delete_secret_by_name(&args.key).await?; - if cli.output.format == OutputFormat::Json { + if json_output { print_json_pretty(&serde_json::json!({ "key": args.key }))?; } else { fabro_util::printerr!(printer, "Removed {}", args.key); diff --git a/lib/crates/fabro-cli/src/commands/secret/set.rs b/lib/crates/fabro-cli/src/commands/secret/set.rs index 1ec66418e..1ad6b4ff4 100644 --- a/lib/crates/fabro-cli/src/commands/secret/set.rs +++ b/lib/crates/fabro-cli/src/commands/secret/set.rs @@ -11,8 +11,6 @@ use std::io::{IsTerminal, Read as _}; use anyhow::{Context as _, Result, bail}; use fabro_api::types; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use tokio::task::spawn_blocking; @@ -60,7 +58,7 @@ async fn resolve_value(args: &SecretSetArgs) -> Result { pub(super) async fn set_command( client: &Client, args: &SecretSetArgs, - cli: &CliNamespace, + json_output: bool, printer: Printer, ) -> Result<()> { let value = resolve_value(args).await?; @@ -72,7 +70,7 @@ pub(super) async fn set_command( description: args.description.clone(), }) .await?; - if cli.output.format == OutputFormat::Json { + if json_output { print_json_pretty(&meta)?; } else { fabro_util::printerr!(printer, "Set {}", meta.name); diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index df1cf1f6a..b12422a35 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -11,10 +11,8 @@ use bytes::Bytes; #[cfg(test)] use fabro_store::{ArtifactStore, RunDatabase}; use fabro_store::{EventEnvelope, RunProjection, StageId}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::cli::OutputFormat; use fabro_types::{RunBlobId, RunId}; -use fabro_util::printer::Printer; use futures::future::BoxFuture; #[cfg(test)] use serde::de::DeserializeOwned; @@ -26,19 +24,15 @@ use crate::command_context::CommandContext; use crate::server_client::Client; use crate::shared::{absolute_or_current, print_json_pretty}; -pub(crate) async fn dump_command( - args: &StoreDumpArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(crate) async fn dump_command(args: &StoreDumpArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let state = client.get_run_state(&run_id).await?; let source = ServerDumpSource::new(client.as_ref(), &run_id); let file_count = export_run_from_source(&source, &state, &args.output).await?; - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "run_id": run_id, "output_dir": absolute_or_current(&args.output), diff --git a/lib/crates/fabro-cli/src/commands/store/mod.rs b/lib/crates/fabro-cli/src/commands/store/mod.rs index 2c885231b..07089c243 100644 --- a/lib/crates/fabro-cli/src/commands/store/mod.rs +++ b/lib/crates/fabro-cli/src/commands/store/mod.rs @@ -3,19 +3,12 @@ pub(crate) mod rebuild; mod run_export; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use crate::args::{StoreCommand, StoreNamespace}; +use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - ns: StoreNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: StoreNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - StoreCommand::Dump(args) => dump::dump_command(&args, cli, cli_layer, printer).await, + StoreCommand::Dump(args) => dump::dump_command(&args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index 67a099a2e..2504dbf09 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -3,23 +3,16 @@ use chrono::{DateTime, Utc}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; use fabro_api::types; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use crate::args::DfArgs; use crate::command_context::CommandContext; use crate::shared::{format_size, print_json_pretty}; -pub(super) async fn df_command( - args: &DfArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; +pub(super) async fn df_command(args: &DfArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_connection(&args.connection)?; let server = ctx.server().await?; - let json = cli.output.format == OutputFormat::Json; + let json = ctx.user_settings().cli.output.format == OutputFormat::Json; let (output, storage_dir) = if json { (server.get_system_disk_usage(args.verbose).await?, None) diff --git a/lib/crates/fabro-cli/src/commands/system/events.rs b/lib/crates/fabro-cli/src/commands/system/events.rs index 1e8d69346..48165e774 100644 --- a/lib/crates/fabro-cli/src/commands/system/events.rs +++ b/lib/crates/fabro-cli/src/commands/system/events.rs @@ -1,8 +1,6 @@ use anyhow::Result; use fabro_client::sse; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use futures::StreamExt; use crate::args::SystemEventsArgs; @@ -10,16 +8,14 @@ use crate::command_context::CommandContext; pub(super) async fn events_command( args: &SystemEventsArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; + let ctx = base_ctx.with_connection(&args.connection)?; let server = ctx.server().await?; let mut stream = server.attach_events(&args.run_ids).await?; let mut pending = Vec::new(); - let json = cli.output.format == OutputFormat::Json; + let json = ctx.user_settings().cli.output.format == OutputFormat::Json; while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|err| anyhow::anyhow!("{err}"))?; pending.extend_from_slice(&chunk); diff --git a/lib/crates/fabro-cli/src/commands/system/info.rs b/lib/crates/fabro-cli/src/commands/system/info.rs index 314b3f347..7d0efaf55 100644 --- a/lib/crates/fabro-cli/src/commands/system/info.rs +++ b/lib/crates/fabro-cli/src/commands/system/info.rs @@ -1,23 +1,16 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; +use fabro_types::settings::cli::OutputFormat; use crate::args::SystemInfoArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(super) async fn info_command( - args: &SystemInfoArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; +pub(super) async fn info_command(args: &SystemInfoArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_connection(&args.connection)?; let server = ctx.server().await?; let response = server.get_system_info().await?; - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&response)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/system/mod.rs b/lib/crates/fabro-cli/src/commands/system/mod.rs index 45f35dee6..d70cae9c6 100644 --- a/lib/crates/fabro-cli/src/commands/system/mod.rs +++ b/lib/crates/fabro-cli/src/commands/system/mod.rs @@ -4,23 +4,16 @@ mod info; mod prune; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; pub(crate) use prune::parse_duration; use crate::args::{SystemCommand, SystemNamespace}; +use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - ns: SystemNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: SystemNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - SystemCommand::Info(args) => info::info_command(&args, cli, cli_layer, printer).await, - SystemCommand::Prune(args) => prune::prune_command(&args, cli, cli_layer, printer).await, - SystemCommand::Df(args) => df::df_command(&args, cli, cli_layer, printer).await, - SystemCommand::Events(args) => events::events_command(&args, cli, cli_layer, printer).await, + SystemCommand::Info(args) => info::info_command(&args, base_ctx).await, + SystemCommand::Prune(args) => prune::prune_command(&args, base_ctx).await, + SystemCommand::Df(args) => df::df_command(&args, base_ctx).await, + SystemCommand::Events(args) => events::events_command(&args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index 376ae2003..b0956389c 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -2,8 +2,7 @@ use std::collections::HashMap; use anyhow::{Context, Result, bail}; use fabro_api::types; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use tracing::{debug, info}; @@ -11,13 +10,9 @@ use crate::args::RunsPruneArgs; use crate::command_context::CommandContext; use crate::shared::{format_size, print_json_pretty}; -pub(super) async fn prune_command( - args: &RunsPruneArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; +pub(super) async fn prune_command(args: &RunsPruneArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_connection(&args.connection)?; + let printer = ctx.printer(); let server = ctx.server().await?; let response = server .prune_runs(types::PruneRunsRequest { @@ -29,7 +24,11 @@ pub(super) async fn prune_command( workflow: args.filter.workflow.clone(), }) .await?; - prune_from(&response, cli.output.format == OutputFormat::Json, printer) + prune_from( + &response, + ctx.user_settings().cli.output.format == OutputFormat::Json, + printer, + ) } pub(crate) fn parse_duration(s: &str) -> Result { diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index 74bba2d11..0c0a5b460 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -1,9 +1,8 @@ use anyhow::bail; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_types::settings::{CliNamespace, SettingsLayer}; -use fabro_util::printer::Printer; +use fabro_types::settings::SettingsLayer; +use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use crate::args::ValidateArgs; @@ -15,11 +14,10 @@ use crate::shared::{print_diagnostics, print_json_pretty, relative_path}; pub(crate) async fn run( args: &ValidateArgs, styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> anyhow::Result<()> { - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.target)?; let built = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), cwd: ctx.cwd().to_path_buf(), @@ -33,7 +31,7 @@ pub(crate) async fn run( let response = client.run_preflight(built.manifest).await?; let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "workflow_name": response.workflow.name, "nodes": response.workflow.nodes, diff --git a/lib/crates/fabro-cli/src/commands/version.rs b/lib/crates/fabro-cli/src/commands/version.rs index 993557834..73c4709c3 100644 --- a/lib/crates/fabro-cli/src/commands/version.rs +++ b/lib/crates/fabro-cli/src/commands/version.rs @@ -6,8 +6,7 @@ use std::io::IsTerminal; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use serde_json::{Map, Value, json}; @@ -16,14 +15,10 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; use crate::user_config::{self, ServerTarget}; -pub(crate) async fn version_command( - args: &VersionArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn version_command(args: &VersionArgs, base_ctx: &CommandContext) -> Result<()> { let client = client_info(); - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.target)?; let server_target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?; let server_address = format_server_target(&server_target); let server_info = match ctx.server().await { @@ -49,7 +44,7 @@ pub(crate) async fn version_command( }, }; - if cli.output.format == OutputFormat::Json { + if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&json_output(&client, &server_info))?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 6e54808ff..754e38243 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -30,7 +30,6 @@ use clap::{CommandFactory, Parser}; use fabro_config::merge::combine_files; use fabro_telemetry::{git, panic as tel_panic, sanitize, sender}; use fabro_types::settings::SettingsLayer; -use fabro_types::settings::cli::OutputVerbosity; use fabro_util::exit::ExitClass; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -38,6 +37,8 @@ use fabro_util::{browser, exit}; use rustls::crypto::ring::default_provider; use tracing::debug; +use crate::command_context::CommandContext; + #[derive(Parser)] #[command(name = "fabro", version, long_version = LONG_VERSION)] struct Cli { @@ -210,51 +211,46 @@ async fn main_inner() -> (String, Result<()>) { }; let result = Box::pin(async move { + let build_base_ctx = || CommandContext::base(printer, &cli_layer, process_local_json); + match *command { Commands::Exec(args) => commands::exec::execute(args, &cli_settings, printer).await?, Commands::RunCmd(cmd) => { - Box::pin(commands::run::dispatch( - cmd, - &cli_settings, - &cli_layer, - process_local_json, - printer, - )) - .await?; + let base_ctx = build_base_ctx()?; + Box::pin(commands::run::dispatch(cmd, &base_ctx)).await?; } Commands::Preflight(args) => { - commands::preflight::execute(args, &cli_settings, &cli_layer, printer).await?; + let base_ctx = build_base_ctx()?; + commands::preflight::execute(args, &base_ctx).await?; } Commands::Validate(args) => { let styles = Styles::detect_stderr(); - commands::validate::run(&args, &styles, &cli_settings, &cli_layer, printer).await?; + let base_ctx = build_base_ctx()?; + commands::validate::run(&args, &styles, &base_ctx).await?; } Commands::Graph(args) => { let styles = Styles::detect_stderr(); - commands::graph::run( - &args, - &styles, - &cli_settings, - &cli_layer, - process_local_json, - printer, - ) - .await?; + let base_ctx = build_base_ctx()?; + commands::graph::run(&args, &styles, &base_ctx).await?; } Commands::Parse(args) => { commands::parse::run(&args, &cli_settings, printer)?; } Commands::Artifact(ns) => { - commands::artifact::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + let base_ctx = build_base_ctx()?; + commands::artifact::dispatch(ns, &base_ctx).await?; } Commands::Store(ns) => { - commands::store::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + let base_ctx = build_base_ctx()?; + commands::store::dispatch(ns, &base_ctx).await?; } Commands::RunsCmd(cmd) => { - commands::runs::dispatch(cmd, &cli_settings, &cli_layer, printer).await?; + let base_ctx = build_base_ctx()?; + commands::runs::dispatch(cmd, &base_ctx).await?; } Commands::Model { command } => { - commands::model::execute(command, &cli_settings, &cli_layer, printer).await?; + let base_ctx = build_base_ctx()?; + commands::model::execute(command, &base_ctx).await?; } Commands::Server(ns) => { Box::pin(commands::server::dispatch( @@ -266,21 +262,13 @@ async fn main_inner() -> (String, Result<()>) { .await?; } Commands::Doctor(args) => { - let verbose = - args.verbose || cli_settings.output.verbosity == OutputVerbosity::Verbose; - let exit_code = Box::pin(commands::doctor::run_doctor( - &args, - verbose, - &cli_settings, - &cli_layer, - printer, - )) - .await?; + let base_ctx = build_base_ctx()?; + let exit_code = Box::pin(commands::doctor::run_doctor(&args, &base_ctx)).await?; std::process::exit(exit_code); } Commands::Version(args) => { - commands::version::version_command(&args, &cli_settings, &cli_layer, printer) - .await?; + let base_ctx = build_base_ctx()?; + commands::version::version_command(&args, &base_ctx).await?; } Commands::Discord => { if process_local_json { @@ -301,7 +289,8 @@ async fn main_inner() -> (String, Result<()>) { } } Commands::Repo(ns) => { - commands::repo::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + let base_ctx = build_base_ctx()?; + commands::repo::dispatch(ns, &base_ctx).await?; } Commands::Install { args, command } => { Box::pin(commands::install::execute( @@ -318,48 +307,36 @@ async fn main_inner() -> (String, Result<()>) { commands::uninstall::run_uninstall(&args, &cli_settings, printer).await?; } Commands::Auth(ns) => { - commands::auth::dispatch(ns, &cli_layer, process_local_json, printer).await?; + let base_ctx = build_base_ctx()?; + commands::auth::dispatch(ns, &base_ctx).await?; } Commands::Pr(ns) => { - Box::pin(commands::pr::dispatch( - ns, - &cli_settings, - &cli_layer, - printer, - )) - .await?; + let base_ctx = build_base_ctx()?; + Box::pin(commands::pr::dispatch(ns, &base_ctx)).await?; } Commands::Secret(ns) => { - commands::secret::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + let base_ctx = build_base_ctx()?; + commands::secret::dispatch(ns, &base_ctx).await?; } Commands::Settings(args) => { - Box::pin(commands::config::execute( - &args, - &cli_settings, - &cli_layer, - printer, - )) - .await?; + let base_ctx = build_base_ctx()?; + Box::pin(commands::config::execute(&args, &base_ctx)).await?; } Commands::Workflow(ns) => commands::workflow::dispatch(ns, &cli_settings, printer)?, Commands::Upgrade(args) => { commands::upgrade::run_upgrade(args, &cli_settings, printer).await?; } Commands::Provider(ns) => { - commands::provider::dispatch(ns, &cli_layer, process_local_json, printer).await?; + let base_ctx = build_base_ctx()?; + commands::provider::dispatch(ns, &base_ctx).await?; } Commands::Sandbox { command } => { - commands::sandbox::dispatch( - command, - &cli_settings, - &cli_layer, - process_local_json, - printer, - ) - .await?; + let base_ctx = build_base_ctx()?; + commands::sandbox::dispatch(command, &base_ctx).await?; } Commands::System(ns) => { - commands::system::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + let base_ctx = build_base_ctx()?; + commands::system::dispatch(ns, &base_ctx).await?; } Commands::Completion(args) => { require_no_json_override(process_local_json)?; From 2b933597b273b50301d3a94e2fcaa266382925a2 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 07:14:32 -0400 Subject: [PATCH 02/19] refactor(cli): finish command context cleanup --- lib/crates/fabro-cli/src/command_context.rs | 193 ++++++++++++++---- .../fabro-cli/src/commands/run/command.rs | 12 +- .../fabro-cli/src/commands/run/create.rs | 5 +- lib/crates/fabro-cli/src/commands/run/mod.rs | 12 +- lib/crates/fabro-cli/src/main.rs | 19 +- .../fabro-cli/tests/it/cmd/json_global.rs | 24 +++ .../fabro-cli/tests/it/cmd/system_events.rs | 85 ++++++++ .../fabro-cli/tests/it/cmd/system_info.rs | 38 ++++ 8 files changed, 312 insertions(+), 76 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index ce1741bb1..60348841d 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -43,7 +43,37 @@ impl CommandContext { cli_layer: &CliLayer, process_local_json: bool, ) -> Result { - Self::new(printer, process_local_json, ServerMode::None, cli_layer) + let (machine_settings, user_settings) = load_merged_settings(cli_layer, &ServerMode::None)?; + Self::base_with_settings( + printer, + cli_layer, + process_local_json, + machine_settings, + user_settings, + ) + } + + pub(crate) fn base_with_settings( + printer: Printer, + cli_layer: &CliLayer, + process_local_json: bool, + machine_settings: SettingsLayer, + user_settings: UserSettings, + ) -> Result { + let cwd = std::env::current_dir().context("Failed to get current directory")?; + let base_config_path = user_config::active_settings_path(None); + + Ok(Self { + printer, + process_local_json, + cwd, + base_config_path, + cli_layer: cli_layer.clone(), + machine_settings, + user_settings, + server_mode: ServerMode::None, + server: OnceCell::new(), + }) } pub(crate) fn with_target(&self, args: &ServerTargetArgs) -> Result { @@ -59,29 +89,6 @@ impl CommandContext { }) } - fn new( - printer: Printer, - process_local_json: bool, - server_mode: ServerMode, - cli_layer: &CliLayer, - ) -> Result { - let cwd = std::env::current_dir().context("Failed to get current directory")?; - let base_config_path = user_config::active_settings_path(None); - let (machine_settings, user_settings) = load_merged_settings(cli_layer, &server_mode)?; - - Ok(Self { - printer, - process_local_json, - cwd, - base_config_path, - cli_layer: cli_layer.clone(), - machine_settings, - user_settings, - server_mode, - server: OnceCell::new(), - }) - } - pub(crate) fn printer(&self) -> Printer { self.printer } @@ -137,14 +144,28 @@ impl CommandContext { } fn with_server_mode(&self, server_mode: ServerMode) -> Result { - let (machine_settings, user_settings) = match &server_mode { - ServerMode::ByStorageDir { .. } => load_merged_settings(&self.cli_layer, &server_mode)?, - ServerMode::None | ServerMode::ByTarget { .. } => { - (self.machine_settings.clone(), self.user_settings.clone()) - } - }; + // Always reload settings for the requested derivation mode so the result + // depends only on the requested mode, not on whichever derived context + // happened to call into this helper. + let (machine_settings, user_settings) = + load_merged_settings(&self.cli_layer, &server_mode)?; - Ok(Self { + Ok( + self.with_server_mode_from_loaded_settings( + server_mode, + machine_settings, + user_settings, + ), + ) + } + + fn with_server_mode_from_loaded_settings( + &self, + server_mode: ServerMode, + machine_settings: SettingsLayer, + user_settings: UserSettings, + ) -> Self { + Self { printer: self.printer, process_local_json: self.process_local_json, cwd: self.cwd.clone(), @@ -154,7 +175,7 @@ impl CommandContext { user_settings, server_mode, server: OnceCell::new(), - }) + } } } @@ -188,13 +209,14 @@ fn merge_settings_layer( mod tests { use std::path::PathBuf; - use fabro_config::parse_settings_layer; + use fabro_config::user::apply_storage_dir_override; + use fabro_config::{UserSettings, parse_settings_layer}; + use fabro_types::settings::SettingsLayer; use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; use super::{CommandContext, ServerMode, merge_settings_layer}; - use crate::args::ServerTargetArgs; fn cli_layer_with_json_and_verbose() -> CliLayer { CliLayer { @@ -225,6 +247,27 @@ mod tests { } } + fn synthetic_context_with_settings( + process_local_json: bool, + printer: Printer, + cli_layer: CliLayer, + machine_settings: SettingsLayer, + user_settings: UserSettings, + server_mode: ServerMode, + ) -> CommandContext { + CommandContext { + printer, + process_local_json, + cwd: PathBuf::from("/tmp/workspace"), + base_config_path: PathBuf::from("/tmp/settings.toml"), + cli_layer, + machine_settings, + user_settings, + server_mode, + server: OnceCell::new(), + } + } + #[test] fn context_exposes_resolved_output_and_explicit_json_state() { let ctx = synthetic_context(true, Printer::Default); @@ -241,11 +284,13 @@ mod tests { #[test] fn deriving_target_context_preserves_invocation_state() { let base = synthetic_context(true, Printer::Verbose); - let derived = base - .with_target(&ServerTargetArgs { - server: Some("https://fabro.example.com".to_string()), - }) - .expect("target context should derive"); + let derived = base.with_server_mode_from_loaded_settings( + ServerMode::ByTarget { + target_override: Some("https://fabro.example.com".to_string()), + }, + base.machine_settings().clone(), + base.user_settings().clone(), + ); assert_eq!(derived.printer(), Printer::Verbose); assert!(derived.explicit_json_requested()); @@ -271,7 +316,7 @@ root = "/srv/fabro/default" "#, ) .expect("settings fixture should parse"); - let override_disk_settings = fabro_config::user::apply_storage_dir_override( + let override_disk_settings = apply_storage_dir_override( base_disk_settings.clone(), Some(std::path::Path::new("/srv/fabro/override")), ); @@ -305,6 +350,61 @@ root = "/srv/fabro/default" ); } + #[test] + fn deriving_target_from_connection_context_discards_storage_override() { + let cli_layer = cli_layer_with_json_and_verbose(); + let base_disk_settings = parse_settings_layer( + r#" +_version = 1 + +[server.storage] +root = "/srv/fabro/default" +"#, + ) + .expect("settings fixture should parse"); + let override_disk_settings = apply_storage_dir_override( + base_disk_settings.clone(), + Some(std::path::Path::new("/srv/fabro/override")), + ); + + let (base_settings, base_user_settings) = + merge_settings_layer(base_disk_settings, &cli_layer) + .expect("base settings should merge"); + let (connection_settings, connection_user_settings) = + merge_settings_layer(override_disk_settings, &cli_layer) + .expect("connection settings should merge"); + let connection_ctx = synthetic_context_with_settings( + false, + Printer::Default, + cli_layer, + connection_settings, + connection_user_settings, + ServerMode::ByStorageDir { + target_override: None, + storage_dir_override: Some(PathBuf::from("/srv/fabro/override")), + }, + ); + + let derived = connection_ctx.with_server_mode_from_loaded_settings( + ServerMode::ByTarget { + target_override: Some("https://fabro.example.com".to_string()), + }, + base_settings, + base_user_settings, + ); + + assert_eq!( + derived + .machine_settings() + .server + .as_ref() + .and_then(|server| server.storage.as_ref()) + .and_then(|storage| storage.root.as_ref()) + .map(|root| root.as_source()), + Some("/srv/fabro/default".to_string()) + ); + } + #[test] fn explicit_json_guard_uses_invocation_flag_not_resolved_output_format() { let json_ctx = synthetic_context(true, Printer::Default); @@ -316,11 +416,14 @@ root = "/srv/fabro/default" #[test] fn target_resolution_errors_remain_deferred() { - let ctx = synthetic_context(false, Printer::Default) - .with_target(&ServerTargetArgs { - server: Some("not-a-valid-target".to_string()), - }) - .expect("target derivation should not resolve the target eagerly"); + let base = synthetic_context(false, Printer::Default); + let ctx = base.with_server_mode_from_loaded_settings( + ServerMode::ByTarget { + target_override: Some("not-a-valid-target".to_string()), + }, + base.machine_settings().clone(), + base.user_settings().clone(), + ); assert!(matches!(ctx.server_mode, ServerMode::ByTarget { .. })); } diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index 663cad207..471edc8e7 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -5,27 +5,17 @@ use fabro_util::terminal::Styles; use crate::args::RunArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let printer = base_ctx.printer(); let ctx = base_ctx.with_target(&args.target)?; - let cli_defaults = load_settings_with_storage_dir(None)?; args.verbose = args.verbose || ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose; let quiet = args.detach; let prevent_idle_sleep = ctx.user_settings().cli.exec.prevent_idle_sleep; - let created_run = Box::pin(super::create::create_run( - &ctx, - &args, - cli_defaults, - styles, - quiet, - printer, - )) - .await?; + let created_run = Box::pin(super::create::create_run(&ctx, &args, styles, quiet)).await?; if !quiet { fabro_util::printerr!( diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 4c34ef861..9f1e5a105 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -1,8 +1,6 @@ use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; use fabro_types::RunId; -use fabro_types::settings::SettingsLayer; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary}; @@ -22,10 +20,8 @@ pub(crate) struct CreatedRun { pub(crate) async fn create_run( ctx: &CommandContext, args: &RunArgs, - _cli_defaults: SettingsLayer, styles: &Styles, quiet: bool, - printer: Printer, ) -> anyhow::Result { let workflow_path = args .workflow @@ -51,6 +47,7 @@ pub(crate) async fn create_run( })?; let client = ctx.server().await?; if !quiet { + let printer = ctx.printer(); let preflight = client.run_preflight(built.manifest.clone()).await?; let diagnostics = api_diagnostics_to_local(&preflight.workflow.diagnostics); if !diagnostics diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 0256ca566..000e8fc3d 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -5,7 +5,6 @@ use fabro_util::terminal::Styles; use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs}; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; pub(crate) mod attach; pub(crate) mod command; @@ -32,17 +31,8 @@ pub(crate) async fn dispatch(cmd: RunCommands, base_ctx: &CommandContext) -> Res RunCommands::Run(args) => Box::pin(command::execute(args, base_ctx)).await, RunCommands::Create(args) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli_defaults = load_settings_with_storage_dir(None)?; let ctx = base_ctx.with_target(&args.target)?; - let created_run = Box::pin(create::create_run( - &ctx, - &args, - cli_defaults, - styles, - true, - printer, - )) - .await?; + let created_run = Box::pin(create::create_run(&ctx, &args, styles, true)).await?; if ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?; } else { diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 754e38243..a163ea8b1 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -166,18 +166,19 @@ async fn main_inner() -> (String, Result<()>) { Err(err) => return (command_name, Err(err)), }; - let user_settings = match user_config::load_settings() { + let disk_settings = match user_config::load_settings() { Ok(settings) => settings, Err(err) => return (command_name, Err(err)), }; - let combined_settings = combine_files(user_settings, SettingsLayer { + let machine_settings = combine_files(disk_settings, SettingsLayer { cli: Some(cli_layer.clone()), ..SettingsLayer::default() }); - let cli_settings = match fabro_config::UserSettings::from_layer(&combined_settings) { - Ok(settings) => settings.cli, + let user_settings = match fabro_config::UserSettings::from_layer(&machine_settings) { + Ok(settings) => settings, Err(err) => return (command_name, Err(err.into())), }; + let cli_settings = user_settings.cli.clone(); let printer = printer_from_verbosity(cli_settings.output.verbosity); let config_log_level = match &pre_tracing_bootstrap.sink { @@ -211,7 +212,15 @@ async fn main_inner() -> (String, Result<()>) { }; let result = Box::pin(async move { - let build_base_ctx = || CommandContext::base(printer, &cli_layer, process_local_json); + let build_base_ctx = || { + CommandContext::base_with_settings( + printer, + &cli_layer, + process_local_json, + machine_settings.clone(), + user_settings.clone(), + ) + }; match *command { Commands::Exec(args) => commands::exec::execute(args, &cli_settings, printer).await?, diff --git a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs index ac22067bd..32e7d4dd7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs @@ -48,6 +48,30 @@ fn settings_uses_json_output_format_from_home_config() { assert!(value.is_object()); } +#[test] +fn auth_status_ignores_json_output_format_from_home_config() { + let context = test_context!(); + context.write_home( + ".fabro/settings.toml", + "_version = 1\n\n[cli.output]\nformat = \"json\"\n", + ); + + let output = context + .command() + .args(["auth", "status"]) + .output() + .expect("command should run"); + + assert!(output.status.success()); + assert!( + output.stdout.is_empty(), + "stdout should stay empty in text mode" + ); + let stderr = output_stderr(&output); + assert!(stderr.contains("Not logged in to any servers.")); + assert!(stderr.contains("Dev token:")); +} + #[test] fn secret_list_uses_json_output_format_from_home_config() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_events.rs b/lib/crates/fabro-cli/tests/it/cmd/system_events.rs index 26c793e96..2950aeaae 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/system_events.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/system_events.rs @@ -1,4 +1,5 @@ use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; #[test] fn help() { @@ -26,3 +27,87 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn system_events_renders_text_lines_from_sse_payloads() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = crate::support::unique_run_id(); + let payload = serde_json::json!({ + "payload": { + "ts": "2026-04-05T12:00:00Z", + "run_id": run_id, + "event": "run.completed", + } + }); + let attach_mock = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/attach") + .query_param("run_id", run_id.as_str()); + then.status(200) + .header("Content-Type", "text/event-stream") + .body(format!("data: {payload}\n\n")); + }); + + let output = context + .command() + .args([ + "system", + "events", + "--server", + &format!("{}/api/v1", server.base_url()), + "--run-id", + &run_id, + ]) + .output() + .expect("command should run"); + + assert!(output.status.success(), "system events failed"); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert_eq!( + stdout.trim(), + format!("2026-04-05T12:00:00Z {} run.completed", &run_id[..12]) + ); + attach_mock.assert(); +} + +#[test] +fn system_events_json_emits_raw_sse_payloads() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = crate::support::unique_run_id(); + let payload = serde_json::json!({ + "payload": { + "ts": "2026-04-05T12:00:00Z", + "run_id": run_id, + "event": "run.completed", + } + }); + let attach_mock = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/attach") + .query_param("run_id", run_id.as_str()); + then.status(200) + .header("Content-Type", "text/event-stream") + .body(format!("data: {payload}\n\n")); + }); + + let output = context + .command() + .args([ + "--json", + "system", + "events", + "--server", + &format!("{}/api/v1", server.base_url()), + "--run-id", + &run_id, + ]) + .output() + .expect("command should run"); + + assert!(output.status.success(), "system events failed"); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert_eq!(stdout.trim(), payload.to_string()); + attach_mock.assert(); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_info.rs b/lib/crates/fabro-cli/tests/it/cmd/system_info.rs index b7cd24027..ce218247f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/system_info.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/system_info.rs @@ -48,3 +48,41 @@ fn system_info_json_reports_runtime_fields() { assert!(value["uptime_secs"].is_number()); assert!(value["runs"]["total"].is_number()); } + +#[test] +fn system_info_uses_explicit_storage_dir_override() { + let mut context = test_context!(); + let storage_dir = context.temp_dir.join("alternate-storage"); + std::fs::create_dir_all(&storage_dir).unwrap(); + context.write_home( + ".fabro/settings.toml", + format!( + "_version = 1\n\n[server.storage]\nroot = {:?}\n", + context.storage_dir.display().to_string() + ), + ); + context.ensure_home_server_auth_methods(); + context.manage_storage_dir(&storage_dir); + + let output = context + .command() + .args([ + "--json", + "system", + "info", + "--storage-dir", + storage_dir.to_str().unwrap(), + ]) + .output() + .expect("command should run"); + + assert!( + output.status.success(), + "system info failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let value: Value = + serde_json::from_slice(&output.stdout).expect("system info JSON should parse"); + assert_eq!(value["storage_dir"], storage_dir.display().to_string()); +} From 07d4aee67ce1411e286753ae81f65c7df1580418 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 07:28:54 -0400 Subject: [PATCH 03/19] simplify: drop redundant CommandContext derivations and guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ssh/graph: remove `explicit_json_requested() &&` guard before `require_no_json_override()` (the call already no-ops without --json). - pr close/merge/view: drop the outer `with_target` derivation that was used only for `printer()` and the output format — both match base_ctx, so the derivation was an unused disk-read + settings re-merge. - command_context tests: collapse `synthetic_context` to delegate to `synthetic_context_with_settings`, removing duplicated struct literals. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/command_context.rs | 34 ++++++++----------- lib/crates/fabro-cli/src/commands/graph.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/close.rs | 5 ++- lib/crates/fabro-cli/src/commands/pr/merge.rs | 5 ++- lib/crates/fabro-cli/src/commands/pr/view.rs | 5 ++- lib/crates/fabro-cli/src/commands/run/ssh.rs | 2 +- 6 files changed, 23 insertions(+), 30 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 60348841d..6e3c48dfa 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -228,25 +228,6 @@ mod tests { } } - fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext { - let cli_layer = cli_layer_with_json_and_verbose(); - let (machine_settings, user_settings) = - merge_settings_layer(parse_settings_layer("_version = 1\n").unwrap(), &cli_layer) - .expect("settings should merge"); - - CommandContext { - printer, - process_local_json, - cwd: PathBuf::from("/tmp/workspace"), - base_config_path: PathBuf::from("/tmp/settings.toml"), - cli_layer, - machine_settings, - user_settings, - server_mode: ServerMode::None, - server: OnceCell::new(), - } - } - fn synthetic_context_with_settings( process_local_json: bool, printer: Printer, @@ -268,6 +249,21 @@ mod tests { } } + fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext { + let cli_layer = cli_layer_with_json_and_verbose(); + let (machine_settings, user_settings) = + merge_settings_layer(parse_settings_layer("_version = 1\n").unwrap(), &cli_layer) + .expect("settings should merge"); + synthetic_context_with_settings( + process_local_json, + printer, + cli_layer, + machine_settings, + user_settings, + ServerMode::None, + ) + } + #[test] fn context_exposes_resolved_output_and_explicit_json_state() { let ctx = synthetic_context(true, Printer::Default); diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index 066d41cad..5848cb845 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -29,7 +29,7 @@ pub(crate) async fn run( styles: &Styles, base_ctx: &CommandContext, ) -> anyhow::Result<()> { - if base_ctx.explicit_json_requested() && args.output.is_none() { + if args.output.is_none() { base_ctx.require_no_json_override()?; } diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index cdc9bcba7..2d9975709 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -7,8 +7,7 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; pub(super) async fn close_command(args: PrCloseArgs, base_ctx: &CommandContext) -> Result<()> { - let ctx = base_ctx.with_target(&args.server)?; - let printer = ctx.printer(); + let printer = base_ctx.printer(); let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; let creds = super::load_github_credentials_required(base_ctx)?; @@ -24,7 +23,7 @@ pub(super) async fn close_command(args: PrCloseArgs, base_ctx: &CommandContext) .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request"); - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "number": record.number, "html_url": record.html_url, diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index ac043b654..9966e3169 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -7,8 +7,7 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; pub(super) async fn merge_command(args: PrMergeArgs, base_ctx: &CommandContext) -> Result<()> { - let ctx = base_ctx.with_target(&args.server)?; - let printer = ctx.printer(); + let printer = base_ctx.printer(); let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; let creds = super::load_github_credentials_required(base_ctx)?; @@ -25,7 +24,7 @@ pub(super) async fn merge_command(args: PrMergeArgs, base_ctx: &CommandContext) .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request"); - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "number": record.number, "html_url": record.html_url, diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index e0f934d15..8e96729e7 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -7,8 +7,7 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> Result<()> { - let ctx = base_ctx.with_target(&args.server)?; - let printer = ctx.printer(); + let printer = base_ctx.printer(); let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; let creds = super::load_github_credentials_required(base_ctx)?; @@ -25,7 +24,7 @@ pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request"); - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.user_settings().cli.output.format == OutputFormat::Json { print_json_pretty(&detail)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index 537c781e9..8ed85380b 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -7,7 +7,7 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; pub(crate) async fn run(args: SshArgs, base_ctx: &CommandContext) -> Result<()> { - if base_ctx.explicit_json_requested() && !args.print { + if !args.print { base_ctx.require_no_json_override()?; } From b794ec534368204ee4ae748c206de58edfb92c89 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 07:29:37 -0400 Subject: [PATCH 04/19] fix(cli): suppress preview open for JSON output --- .../fabro-cli/src/commands/run/preview.rs | 25 +++++- .../fabro-cli/tests/it/cmd/sandbox_preview.rs | 77 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index bf916c27e..58561b24a 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -24,7 +24,8 @@ pub(crate) async fn run(args: PreviewArgs, base_ctx: &CommandContext) -> Result< info!(run_id = %args.run, port = args.port, "Generating preview URL"); - if ctx.user_settings().cli.output.format == OutputFormat::Json { + let json = ctx.user_settings().cli.output.format == OutputFormat::Json; + if json { match response.token { Some(token) => { print_json_pretty(&serde_json::json!({ "url": response.url, "token": token }))?; @@ -49,7 +50,7 @@ pub(crate) async fn run(args: PreviewArgs, base_ctx: &CommandContext) -> Result< } } - if args.open && !ctx.explicit_json_requested() { + if should_open_browser(args.open, json) { #[expect( clippy::disallowed_methods, reason = "Preview URL opening is a fire-and-forget OS integration, not a Tokio-managed child process." @@ -76,3 +77,23 @@ fn format_standard_output(url: &str, token: &str) -> String { fn format_signed_output(url: &str) -> String { format!("{url}\n") } + +fn should_open_browser(open_requested: bool, json: bool) -> bool { + open_requested && !json +} + +#[cfg(test)] +mod tests { + use super::should_open_browser; + + #[test] + fn json_output_suppresses_browser_opening() { + assert!(!should_open_browser(true, true)); + } + + #[test] + fn text_output_honors_browser_opening() { + assert!(should_open_browser(true, false)); + assert!(!should_open_browser(false, false)); + } +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs index 6b86ca10f..dc6d6a0c5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs @@ -1,6 +1,31 @@ use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; +use serde_json::{Value, json}; use super::support::setup_local_sandbox_run; +use crate::support::unique_run_id; + +fn remote_run_summary(run_id: &str) -> serde_json::Value { + json!({ + "run_id": run_id, + "workflow_name": "Preview Test", + "workflow_slug": "preview-test", + "goal": "Preview test", + "title": "Preview test", + "labels": {}, + "host_repo_path": "/srv/repo", + "repository": { "name": "repo" }, + "start_time": "2026-04-19T12:00:00Z", + "created_at": "2026-04-19T12:00:00Z", + "status": { + "kind": "running" + }, + "pending_control": null, + "duration_ms": null, + "elapsed_secs": null, + "total_usd_micros": null + }) +} #[test] fn help() { @@ -49,3 +74,55 @@ fn sandbox_preview_rejects_non_daytona_run() { error: Sandbox provider does not support this capability. "); } + +#[test] +fn sandbox_preview_open_is_suppressed_by_json_output_format_from_home_config() { + let context = test_context!(); + context.write_home( + ".fabro/settings.toml", + "_version = 1\n\n[cli.output]\nformat = \"json\"\n", + ); + let server = MockServer::start(); + let run_id = unique_run_id(); + let resolve_run = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/runs/resolve") + .query_param("selector", run_id.as_str()); + then.status(200) + .header("content-type", "application/json") + .body(remote_run_summary(&run_id).to_string()); + }); + let preview = server.mock(|when, then| { + when.method("POST") + .path(format!("/api/v1/runs/{run_id}/preview")) + .json_body(json!({ + "port": 3000, + "expires_in_secs": 3600, + "signed": true, + })); + then.status(201) + .header("content-type", "application/json") + .body(json!({ "url": "https://preview.example.test/app" }).to_string()); + }); + + let mut cmd = context.preview(); + cmd.args([ + "--server", + &format!("{}/api/v1", server.base_url()), + "--open", + run_id.as_str(), + "3000", + ]); + let output = cmd.output().expect("command should run"); + + assert!(output.status.success(), "sandbox preview should succeed"); + let value: Value = serde_json::from_slice(&output.stdout).expect("preview JSON should parse"); + assert_eq!( + value, + json!({ + "url": "https://preview.example.test/app" + }) + ); + resolve_run.assert(); + preview.assert(); +} From 4fa5896d787a2f7d9fc0198d31005b63e520a5f9 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 07:33:21 -0400 Subject: [PATCH 05/19] refactor(cli): narrow base context construction --- lib/crates/fabro-cli/src/command_context.rs | 43 ++++++++++++++++++++- lib/crates/fabro-cli/src/main.rs | 32 ++++----------- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 6e3c48dfa..5dcbe533c 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -37,6 +37,47 @@ pub(crate) struct CommandContext { server: OnceCell>, } +pub(crate) struct ResolvedBaseContext { + printer: Printer, + process_local_json: bool, + cli_layer: CliLayer, + machine_settings: SettingsLayer, + user_settings: UserSettings, +} + +impl ResolvedBaseContext { + pub(crate) fn from_disk(cli_layer: &CliLayer, process_local_json: bool) -> Result { + let (machine_settings, user_settings) = load_merged_settings(cli_layer, &ServerMode::None)?; + let printer = crate::args::printer_from_verbosity(user_settings.cli.output.verbosity); + + Ok(Self { + printer, + process_local_json, + cli_layer: cli_layer.clone(), + machine_settings, + user_settings, + }) + } + + pub(crate) fn printer(&self) -> Printer { + self.printer + } + + pub(crate) fn user_settings(&self) -> &UserSettings { + &self.user_settings + } + + pub(crate) fn to_context(&self) -> Result { + CommandContext::base_with_settings( + self.printer, + &self.cli_layer, + self.process_local_json, + self.machine_settings.clone(), + self.user_settings.clone(), + ) + } +} + impl CommandContext { pub(crate) fn base( printer: Printer, @@ -53,7 +94,7 @@ impl CommandContext { ) } - pub(crate) fn base_with_settings( + fn base_with_settings( printer: Printer, cli_layer: &CliLayer, process_local_json: bool, diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index a163ea8b1..d041040f0 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -24,12 +24,10 @@ use std::ffi::OsString; use anyhow::Result; use args::{ Commands, GlobalArgs, LONG_VERSION, RunCommands, ServerCommand, ServerNamespace, - global_args_cli_layer, printer_from_verbosity, require_no_json_override, + global_args_cli_layer, require_no_json_override, }; use clap::{CommandFactory, Parser}; -use fabro_config::merge::combine_files; use fabro_telemetry::{git, panic as tel_panic, sanitize, sender}; -use fabro_types::settings::SettingsLayer; use fabro_util::exit::ExitClass; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -37,7 +35,7 @@ use fabro_util::{browser, exit}; use rustls::crypto::ring::default_provider; use tracing::debug; -use crate::command_context::CommandContext; +use crate::command_context::ResolvedBaseContext; #[derive(Parser)] #[command(name = "fabro", version, long_version = LONG_VERSION)] @@ -166,20 +164,12 @@ async fn main_inner() -> (String, Result<()>) { Err(err) => return (command_name, Err(err)), }; - let disk_settings = match user_config::load_settings() { - Ok(settings) => settings, + let resolved_base = match ResolvedBaseContext::from_disk(&cli_layer, process_local_json) { + Ok(resolved) => resolved, Err(err) => return (command_name, Err(err)), }; - let machine_settings = combine_files(disk_settings, SettingsLayer { - cli: Some(cli_layer.clone()), - ..SettingsLayer::default() - }); - let user_settings = match fabro_config::UserSettings::from_layer(&machine_settings) { - Ok(settings) => settings, - Err(err) => return (command_name, Err(err.into())), - }; - let cli_settings = user_settings.cli.clone(); - let printer = printer_from_verbosity(cli_settings.output.verbosity); + let cli_settings = resolved_base.user_settings().cli.clone(); + let printer = resolved_base.printer(); let config_log_level = match &pre_tracing_bootstrap.sink { logging::InternalLogSink::Cli => cli_settings.logging.level.clone(), @@ -212,15 +202,7 @@ async fn main_inner() -> (String, Result<()>) { }; let result = Box::pin(async move { - let build_base_ctx = || { - CommandContext::base_with_settings( - printer, - &cli_layer, - process_local_json, - machine_settings.clone(), - user_settings.clone(), - ) - }; + let build_base_ctx = || resolved_base.to_context(); match *command { Commands::Exec(args) => commands::exec::execute(args, &cli_settings, printer).await?, From 897b818a9e0533a9294b6200c05ee7022aea4e26 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 07:34:11 -0400 Subject: [PATCH 06/19] simplify: introduce CommandContext::json_output() accessor The check `ctx.user_settings().cli.output.format == OutputFormat::Json` (and its `!=` variant) was repeated 51 times across 36 files. Add a `json_output()` method on CommandContext and replace every call site. `cargo fix` handles the now-unused `OutputFormat` imports. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/command_context.rs | 6 +++++- lib/crates/fabro-cli/src/commands/artifact/cp.rs | 5 ++--- lib/crates/fabro-cli/src/commands/artifact/list.rs | 3 +-- lib/crates/fabro-cli/src/commands/config/mod.rs | 3 +-- lib/crates/fabro-cli/src/commands/doctor.rs | 4 ++-- lib/crates/fabro-cli/src/commands/graph.rs | 3 +-- lib/crates/fabro-cli/src/commands/model.rs | 8 +------- lib/crates/fabro-cli/src/commands/pr/close.rs | 3 +-- lib/crates/fabro-cli/src/commands/pr/create.rs | 5 ++--- lib/crates/fabro-cli/src/commands/pr/list.rs | 5 ++--- lib/crates/fabro-cli/src/commands/pr/merge.rs | 3 +-- lib/crates/fabro-cli/src/commands/pr/view.rs | 3 +-- lib/crates/fabro-cli/src/commands/preflight.rs | 4 ++-- lib/crates/fabro-cli/src/commands/repo/deinit.rs | 5 ++--- lib/crates/fabro-cli/src/commands/repo/init.rs | 11 +++++------ lib/crates/fabro-cli/src/commands/repo/mod.rs | 5 ++--- lib/crates/fabro-cli/src/commands/run/command.rs | 4 ++-- lib/crates/fabro-cli/src/commands/run/cp.rs | 5 ++--- lib/crates/fabro-cli/src/commands/run/diff.rs | 3 +-- lib/crates/fabro-cli/src/commands/run/fork.rs | 5 ++--- lib/crates/fabro-cli/src/commands/run/logs.rs | 3 +-- lib/crates/fabro-cli/src/commands/run/mod.rs | 8 ++++---- lib/crates/fabro-cli/src/commands/run/preview.rs | 3 +-- lib/crates/fabro-cli/src/commands/run/resume.rs | 4 ++-- lib/crates/fabro-cli/src/commands/run/rewind.rs | 5 ++--- lib/crates/fabro-cli/src/commands/run/ssh.rs | 3 +-- lib/crates/fabro-cli/src/commands/run/wait.rs | 3 +-- lib/crates/fabro-cli/src/commands/runs/archive.rs | 5 ++--- lib/crates/fabro-cli/src/commands/runs/list.rs | 3 +-- lib/crates/fabro-cli/src/commands/runs/rm.rs | 3 +-- lib/crates/fabro-cli/src/commands/store/dump.rs | 3 +-- lib/crates/fabro-cli/src/commands/system/df.rs | 3 +-- lib/crates/fabro-cli/src/commands/system/events.rs | 3 +-- lib/crates/fabro-cli/src/commands/system/info.rs | 3 +-- lib/crates/fabro-cli/src/commands/system/prune.rs | 7 +------ lib/crates/fabro-cli/src/commands/validate.rs | 3 +-- lib/crates/fabro-cli/src/commands/version.rs | 3 +-- 37 files changed, 61 insertions(+), 97 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 5dcbe533c..196f3cc06 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -5,7 +5,7 @@ use anyhow::{Context as _, Result, bail}; use fabro_config::UserSettings; use fabro_config::merge::combine_files; use fabro_types::settings::SettingsLayer; -use fabro_types::settings::cli::CliLayer; +use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -154,6 +154,10 @@ impl CommandContext { &self.user_settings } + pub(crate) fn json_output(&self) -> bool { + self.user_settings.cli.output.format == OutputFormat::Json + } + pub(crate) async fn server(&self) -> Result> { let server_mode = self.server_mode.clone(); let base_config_path = self.base_config_path.clone(); diff --git a/lib/crates/fabro-cli/src/commands/artifact/cp.rs b/lib/crates/fabro-cli/src/commands/artifact/cp.rs index 5f6652326..bf989aac2 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/cp.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/cp.rs @@ -6,7 +6,6 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::cli::OutputFormat; use crate::args::ArtifactCpArgs; use crate::command_context::CommandContext; @@ -58,7 +57,7 @@ pub(super) async fn cp_command(args: &ArtifactCpArgs, base_ctx: &CommandContext) .unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)), ); write_artifact_file(&client, &run_id, entry, &dest_file).await?; - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&serde_json::json!({ "copied": [{ "relative_path": entry.relative_path, @@ -119,7 +118,7 @@ pub(super) async fn cp_command(args: &ArtifactCpArgs, base_ctx: &CommandContext) } } - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&serde_json::json!({ "copied": copied }))?; } else { fabro_util::printout!( diff --git a/lib/crates/fabro-cli/src/commands/artifact/list.rs b/lib/crates/fabro-cli/src/commands/artifact/list.rs index e5375b659..782e323c9 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/list.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/list.rs @@ -1,7 +1,6 @@ use anyhow::Result; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; -use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use crate::args::ArtifactListArgs; @@ -18,7 +17,7 @@ pub(super) async fn list_command(args: &ArtifactListArgs, base_ctx: &CommandCont ) .await?; - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { fabro_util::printout!(printer, "{}", serde_json::to_string_pretty(&entries)?); return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 34ced43a3..b0d7b34c7 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -11,7 +11,6 @@ use std::io::Write; use fabro_api::types::ServerSettings; use fabro_config::UserSettings; -use fabro_types::settings::cli::OutputFormat; use serde::Serialize; use crate::args::SettingsArgs; @@ -40,7 +39,7 @@ async fn rendered_config( pub(crate) async fn execute(args: &SettingsArgs, base_ctx: &CommandContext) -> anyhow::Result<()> { let config = Box::pin(rendered_config(args, base_ctx)).await?; - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&config)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index e88731564..052ed191c 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use anyhow::Result; use fabro_api::types as api_types; use fabro_config::user::active_settings_path; -use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; +use fabro_types::settings::cli::OutputVerbosity; pub(crate) use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, }; @@ -147,7 +147,7 @@ pub(crate) async fn run_doctor( args.verbose || base_ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose; let printer = base_ctx.printer(); let styles = Styles::detect_stdout(); - let json = base_ctx.user_settings().cli.output.format == OutputFormat::Json; + let json = base_ctx.json_output(); let spinner = if json { None } else { diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index 5848cb845..b8416a6c3 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -14,7 +14,6 @@ use fabro_api::types; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; use fabro_types::settings::SettingsLayer; -use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use tracing::debug; @@ -70,7 +69,7 @@ pub(crate) async fn run( if let Some(ref output_path) = args.output { std::fs::write(output_path, &rendered) .with_context(|| format!("writing rendered graph to {}", output_path.display()))?; - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "path": absolute_or_current(output_path), "format": args.format.to_string(), diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index e336696b1..2c33be0b8 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -3,7 +3,6 @@ use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_api::types as api_types; use fabro_model::{Catalog, Model, Provider}; -use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use serde::Serialize; @@ -50,12 +49,7 @@ pub(crate) async fn execute( let ctx = base_ctx.with_target(target_args)?; let server = ctx.server().await?; - run_models( - command, - &server, - ctx.user_settings().cli.output.format == OutputFormat::Json, - ) - .await + run_models(command, &server, ctx.json_output()).await } fn format_context_window(tokens: i64) -> String { diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index 2d9975709..bf584b45c 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use fabro_types::settings::cli::OutputFormat; use tracing::info; use crate::args::PrCloseArgs; @@ -23,7 +22,7 @@ pub(super) async fn close_command(args: PrCloseArgs, base_ctx: &CommandContext) .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request"); - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&serde_json::json!({ "number": record.number, "html_url": record.html_url, diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index 907ccef08..2844cd503 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -5,7 +5,6 @@ use fabro_auth::configured_providers_from_process_env; use fabro_config::Storage; use fabro_model::Catalog; use fabro_sandbox::daytona::detect_repo_info; -use fabro_types::settings::cli::OutputFormat; use fabro_vault::Vault; use fabro_workflow::outcome::StageStatus; use fabro_workflow::pull_request::maybe_open_pull_request; @@ -130,14 +129,14 @@ pub(super) async fn create_command(args: PrCreateArgs, base_ctx: &CommandContext match pull_request { Some(record) => { info!(pr_url = %record.html_url, "Pull request created"); - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&record)?; } else { fabro_util::printout!(printer, "{}", record.html_url); } } None => { - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::Value::Null)?; } else { fabro_util::printout!(printer, "No pull request created (empty diff)."); diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs index c38c59b61..f1ce2f230 100644 --- a/lib/crates/fabro-cli/src/commands/pr/list.rs +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -1,7 +1,6 @@ use anyhow::Result; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; -use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use futures::future::join_all; use serde::Serialize; @@ -36,7 +35,7 @@ pub(super) async fn list_command(args: PrListArgs, base_ctx: &CommandContext) -> } if entries.is_empty() { - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&Vec::::new())?; return Ok(()); } @@ -98,7 +97,7 @@ pub(super) async fn list_command(args: PrListArgs, base_ctx: &CommandContext) -> .collect() }; - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&rows)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index 9966e3169..2c7de4a0a 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use fabro_types::settings::cli::OutputFormat; use tracing::info; use crate::args::PrMergeArgs; @@ -24,7 +23,7 @@ pub(super) async fn merge_command(args: PrMergeArgs, base_ctx: &CommandContext) .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request"); - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&serde_json::json!({ "number": record.number, "html_url": record.html_url, diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index 8e96729e7..e861e19d1 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use fabro_types::settings::cli::OutputFormat; use tracing::info; use crate::args::PrViewArgs; @@ -24,7 +23,7 @@ pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request"); - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&detail)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index a3fb819bb..46144d8da 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,7 +1,7 @@ use anyhow::bail; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; +use fabro_types::settings::cli::OutputVerbosity; use fabro_util::terminal::Styles; use crate::args::PreflightArgs; @@ -36,7 +36,7 @@ pub(crate) async fn execute( let response = client.run_preflight(manifest.manifest).await?; let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&response)?; } else { print_preflight_workflow_summary( diff --git a/lib/crates/fabro-cli/src/commands/repo/deinit.rs b/lib/crates/fabro-cli/src/commands/repo/deinit.rs index ff0e0d1f7..4376c06a4 100644 --- a/lib/crates/fabro-cli/src/commands/repo/deinit.rs +++ b/lib/crates/fabro-cli/src/commands/repo/deinit.rs @@ -1,5 +1,4 @@ use anyhow::{Context, Result, bail}; -use fabro_types::settings::cli::OutputFormat; use crate::command_context::CommandContext; @@ -21,7 +20,7 @@ pub(crate) fn run_deinit(base_ctx: &CommandContext) -> Result> { std::fs::remove_dir_all(&fabro_dir) .with_context(|| format!("failed to remove {}", fabro_dir.display()))?; removed.push(".fabro/".to_string()); - if base_ctx.user_settings().cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { fabro_util::printerr!( printer, " {} {}", @@ -30,7 +29,7 @@ pub(crate) fn run_deinit(base_ctx: &CommandContext) -> Result> { ); } - if base_ctx.user_settings().cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { fabro_util::printerr!( printer, "\n{}", diff --git a/lib/crates/fabro-cli/src/commands/repo/init.rs b/lib/crates/fabro-cli/src/commands/repo/init.rs index db086cba0..1fe8e51a5 100644 --- a/lib/crates/fabro-cli/src/commands/repo/init.rs +++ b/lib/crates/fabro-cli/src/commands/repo/init.rs @@ -6,7 +6,6 @@ use std::path::PathBuf; use anyhow::{Context, Result, bail}; -use fabro_types::settings::cli::OutputFormat; use tokio::process::Command as TokioCommand; use tokio::task::spawn_blocking; @@ -76,7 +75,7 @@ draft = true let green = console::Style::new().green(); let bold = console::Style::new().bold(); let dim = console::Style::new().dim(); - if base_ctx.user_settings().cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { fabro_util::printerr!( printer, " {} {}", @@ -109,7 +108,7 @@ draft = true ) .with_context(|| format!("failed to write {}", dot_path.display()))?; created.push(".fabro/workflows/hello/workflow.fabro".to_string()); - if base_ctx.user_settings().cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { fabro_util::printerr!( printer, " {} {}", @@ -126,7 +125,7 @@ draft = true ) .with_context(|| format!("failed to write {}", toml_path.display()))?; created.push(".fabro/workflows/hello/workflow.toml".to_string()); - if base_ctx.user_settings().cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { fabro_util::printerr!( printer, " {} {}", @@ -135,7 +134,7 @@ draft = true ); } - if base_ctx.user_settings().cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { fabro_util::printerr!( printer, "\n{} Run a workflow with:\n\n {}", @@ -147,7 +146,7 @@ draft = true ); } - if base_ctx.user_settings().cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { check_github_app_installation(&args.target, base_ctx).await; } diff --git a/lib/crates/fabro-cli/src/commands/repo/mod.rs b/lib/crates/fabro-cli/src/commands/repo/mod.rs index 8c768e576..8ed44d046 100644 --- a/lib/crates/fabro-cli/src/commands/repo/mod.rs +++ b/lib/crates/fabro-cli/src/commands/repo/mod.rs @@ -2,7 +2,6 @@ pub(crate) mod deinit; pub(crate) mod init; use anyhow::Result; -use fabro_types::settings::cli::OutputFormat; use crate::args::{RepoCommand, RepoNamespace}; use crate::command_context::CommandContext; @@ -12,14 +11,14 @@ pub(crate) async fn dispatch(ns: RepoNamespace, base_ctx: &CommandContext) -> Re match ns.command { RepoCommand::Init(args) => { let created = init::run_init(&args, base_ctx).await?; - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&serde_json::json!({ "created": created }))?; } Ok(()) } RepoCommand::Deinit => { let removed = deinit::run_deinit(base_ctx)?; - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&serde_json::json!({ "removed": removed }))?; } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index 471edc8e7..90386bc3b 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; +use fabro_types::settings::cli::OutputVerbosity; use fabro_util::terminal::Styles; use crate::args::RunArgs; @@ -35,7 +35,7 @@ pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Res let client = ctx.server().await?; super::start::start_run_with_client(&client, &created_run.run_id, false).await?; - let json = ctx.user_settings().cli.output.format == OutputFormat::Json; + let json = ctx.json_output(); if args.detach { if json { print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?; diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index 170ea62e1..159354a8c 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -1,7 +1,6 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::cli::OutputFormat; use tokio::fs; use tracing::{debug, info}; @@ -44,7 +43,7 @@ pub(crate) async fn cp_command(args: CpArgs, base_ctx: &CommandContext) -> Resul None }; - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { let mut value = serde_json::json!({ "direction": "download", "recursive": args.recursive, @@ -75,7 +74,7 @@ pub(crate) async fn cp_command(args: CpArgs, base_ctx: &CommandContext) -> Resul None }; - if base_ctx.user_settings().cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { let mut value = serde_json::json!({ "direction": "upload", "recursive": args.recursive, diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/crates/fabro-cli/src/commands/run/diff.rs index 2bebdda23..e4ee319fb 100644 --- a/lib/crates/fabro-cli/src/commands/run/diff.rs +++ b/lib/crates/fabro-cli/src/commands/run/diff.rs @@ -10,7 +10,6 @@ use std::io::{self, IsTerminal, Write}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::cli::OutputFormat; use tracing::{debug, info}; use crate::args::DiffArgs; @@ -27,7 +26,7 @@ pub(crate) async fn run(args: DiffArgs, base_ctx: &CommandContext) -> Result<()> let patch = resolve_diff(&state, &args)?; - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { let value = serde_json::json!({ "run_id": run_id, "node": args.node, diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index 23ba16eda..9a2a4a5cf 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -1,6 +1,5 @@ use anyhow::{Context, Result}; use fabro_checkpoint::git::Store; -use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use fabro_workflow::operations::{ForkRunInput, RewindTarget, build_timeline_or_rebuild, fork}; use git2::Repository; @@ -27,7 +26,7 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, base_ctx: &CommandCont let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?; if args.list { - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&super::rewind::timeline_entries_json(&timeline))?; return Ok(()); } @@ -49,7 +48,7 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, base_ctx: &CommandCont let run_id_string = run_id.to_string(); let new_run_id_string = new_run_id.to_string(); - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { let target = args.target.clone().unwrap_or_else(|| "latest".to_string()); print_json_pretty(&serde_json::json!({ "source_run_id": run_id_string, diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index 2592c43cd..11def61ff 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -13,7 +13,6 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; -use fabro_types::settings::cli::OutputFormat; use fabro_util::json::normalize_json_value; use fabro_util::redact::redact_jsonl_line; use fabro_util::terminal::Styles; @@ -52,7 +51,7 @@ pub(crate) async fn run(args: &LogsArgs, styles: &Styles, base_ctx: &CommandCont let stdout = io::stdout(); let is_tty = stdout.is_terminal(); let mut out = stdout.lock(); - let pretty = args.pretty && ctx.user_settings().cli.output.format != OutputFormat::Json; + let pretty = args.pretty && !ctx.json_output(); for line in &filtered { if pretty { diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 000e8fc3d..979b95528 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; +use fabro_types::settings::cli::OutputVerbosity; use fabro_util::terminal::Styles; use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs}; @@ -33,7 +33,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, base_ctx: &CommandContext) -> Res let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let ctx = base_ctx.with_target(&args.target)?; let created_run = Box::pin(create::create_run(&ctx, &args, styles, true)).await?; - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?; } else { fabro_util::printout!(printer, "{}", created_run.run_id); @@ -45,7 +45,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, base_ctx: &CommandContext) -> Res let client = ctx.server().await?; let run_id = client.resolve_run(&run).await?.run_id; start::start_run_with_client(client.as_ref(), &run_id, false).await?; - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; } Ok(()) @@ -55,7 +55,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, base_ctx: &CommandContext) -> Res let ctx = base_ctx.with_target(&server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&run).await?.run_id; - let json = ctx.user_settings().cli.output.format == OutputFormat::Json; + let json = ctx.json_output(); let exit_code = Box::pin(attach::attach_run_with_client( client.as_ref(), &run_id, diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index 58561b24a..c4c79753e 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -1,5 +1,4 @@ use anyhow::{Context, Result}; -use fabro_types::settings::cli::OutputFormat; use tracing::info; use crate::args::PreviewArgs; @@ -24,7 +23,7 @@ pub(crate) async fn run(args: PreviewArgs, base_ctx: &CommandContext) -> Result< info!(run_id = %args.run, port = args.port, "Generating preview URL"); - let json = ctx.user_settings().cli.output.format == OutputFormat::Json; + let json = ctx.json_output(); if json { match response.token { Some(token) => { diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index ca05661f9..18afc4e3f 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -1,4 +1,4 @@ -use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; +use fabro_types::settings::cli::OutputVerbosity; use fabro_util::terminal::Styles; use crate::args::ResumeArgs; @@ -22,7 +22,7 @@ pub(crate) async fn resume_command( super::start::start_run_with_client(client.as_ref(), &run_id, true).await?; - let json = ctx.user_settings().cli.output.format == OutputFormat::Json; + let json = ctx.json_output(); if args.detach { if json { print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index 96e7c450b..e3539c1f0 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -3,7 +3,6 @@ use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_checkpoint::git::Store; use fabro_types::run_event::{CheckpointCompletedProps, RunRewoundProps, RunSubmittedProps}; -use fabro_types::settings::cli::OutputFormat; use fabro_types::{EventBody, RunEvent}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -52,7 +51,7 @@ pub(crate) async fn run( let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?; if args.list || args.target.is_none() { - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&timeline_entries_json(&timeline))?; return Ok(()); } @@ -77,7 +76,7 @@ pub(crate) async fn run( let run_id_string = run_id.to_string(); - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "run_id": run_id_string, "target": target_arg, diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index 8ed85380b..37ceabc0f 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -1,5 +1,4 @@ use anyhow::{Result, bail}; -use fabro_types::settings::cli::OutputFormat; use tracing::info; use crate::args::SshArgs; @@ -20,7 +19,7 @@ pub(crate) async fn run(args: SshArgs, base_ctx: &CommandContext) -> Result<()> info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access"); if args.print { - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "command": ssh.command }))?; } else { { diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index f43e33510..fbd2a1e96 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -11,7 +11,6 @@ use std::io::Write; use anyhow::{Result, bail}; use fabro_types::RunId; -use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_workflow::records::Conclusion; @@ -57,7 +56,7 @@ pub(crate) async fn run(args: &WaitArgs, styles: &Styles, base_ctx: &CommandCont let conclusion = client.get_run_state(&run_id).await?.conclusion; - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { let json_value = build_json_output(final_status, &run_id, conclusion.as_ref()); let mut out = std::io::stdout().lock(); serde_json::to_writer_pretty(&mut out, &json_value)?; diff --git a/lib/crates/fabro-cli/src/commands/runs/archive.rs b/lib/crates/fabro-cli/src/commands/runs/archive.rs index ac4b2e39c..7d1f2239a 100644 --- a/lib/crates/fabro-cli/src/commands/runs/archive.rs +++ b/lib/crates/fabro-cli/src/commands/runs/archive.rs @@ -1,5 +1,4 @@ use anyhow::{Result, bail}; -use fabro_types::settings::cli::OutputFormat; use super::short_run_id; use crate::args::{RunsArchiveArgs, RunsUnarchiveArgs}; @@ -16,7 +15,7 @@ pub(crate) async fn archive_command( Action::Archive, &args.runs, ctx.server().await?.as_ref(), - ctx.user_settings().cli.output.format == OutputFormat::Json, + ctx.json_output(), ctx.printer(), ) .await @@ -31,7 +30,7 @@ pub(crate) async fn unarchive_command( Action::Unarchive, &args.runs, ctx.server().await?.as_ref(), - ctx.user_settings().cli.output.format == OutputFormat::Json, + ctx.json_output(), ctx.printer(), ) .await diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 25c6daab3..1eec81398 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -4,7 +4,6 @@ use anyhow::Result; use chrono::Utc; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; -use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use fabro_util::text::strip_goal_decoration; use fabro_workflow::run_status::RunStatus; @@ -32,7 +31,7 @@ pub(crate) async fn list_command( !args.all, ); - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { let json_rows: Vec<_> = filtered .iter() .map(|run| { diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index 1fcff5364..362d1a5c1 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -1,5 +1,4 @@ use anyhow::{Result, bail}; -use fabro_types::settings::cli::OutputFormat; use super::short_run_id; use crate::args::RunsRemoveArgs; @@ -12,7 +11,7 @@ pub(crate) async fn remove_command(args: &RunsRemoveArgs, base_ctx: &CommandCont remove_from( args, ctx.server().await?.as_ref(), - ctx.user_settings().cli.output.format == OutputFormat::Json, + ctx.json_output(), ctx.printer(), ) .await diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index b12422a35..cdb9aa82d 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -11,7 +11,6 @@ use bytes::Bytes; #[cfg(test)] use fabro_store::{ArtifactStore, RunDatabase}; use fabro_store::{EventEnvelope, RunProjection, StageId}; -use fabro_types::settings::cli::OutputFormat; use fabro_types::{RunBlobId, RunId}; use futures::future::BoxFuture; #[cfg(test)] @@ -32,7 +31,7 @@ pub(crate) async fn dump_command(args: &StoreDumpArgs, base_ctx: &CommandContext let state = client.get_run_state(&run_id).await?; let source = ServerDumpSource::new(client.as_ref(), &run_id); let file_count = export_run_from_source(&source, &state, &args.output).await?; - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "run_id": run_id, "output_dir": absolute_or_current(&args.output), diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index 2504dbf09..fd4e43eab 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -3,7 +3,6 @@ use chrono::{DateTime, Utc}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; use fabro_api::types; -use fabro_types::settings::cli::OutputFormat; use crate::args::DfArgs; use crate::command_context::CommandContext; @@ -12,7 +11,7 @@ use crate::shared::{format_size, print_json_pretty}; pub(super) async fn df_command(args: &DfArgs, base_ctx: &CommandContext) -> Result<()> { let ctx = base_ctx.with_connection(&args.connection)?; let server = ctx.server().await?; - let json = ctx.user_settings().cli.output.format == OutputFormat::Json; + let json = ctx.json_output(); let (output, storage_dir) = if json { (server.get_system_disk_usage(args.verbose).await?, None) diff --git a/lib/crates/fabro-cli/src/commands/system/events.rs b/lib/crates/fabro-cli/src/commands/system/events.rs index 48165e774..8e063ca7d 100644 --- a/lib/crates/fabro-cli/src/commands/system/events.rs +++ b/lib/crates/fabro-cli/src/commands/system/events.rs @@ -1,6 +1,5 @@ use anyhow::Result; use fabro_client::sse; -use fabro_types::settings::cli::OutputFormat; use futures::StreamExt; use crate::args::SystemEventsArgs; @@ -15,7 +14,7 @@ pub(super) async fn events_command( let mut stream = server.attach_events(&args.run_ids).await?; let mut pending = Vec::new(); - let json = ctx.user_settings().cli.output.format == OutputFormat::Json; + let json = ctx.json_output(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|err| anyhow::anyhow!("{err}"))?; pending.extend_from_slice(&chunk); diff --git a/lib/crates/fabro-cli/src/commands/system/info.rs b/lib/crates/fabro-cli/src/commands/system/info.rs index 7d0efaf55..c81024215 100644 --- a/lib/crates/fabro-cli/src/commands/system/info.rs +++ b/lib/crates/fabro-cli/src/commands/system/info.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use fabro_types::settings::cli::OutputFormat; use crate::args::SystemInfoArgs; use crate::command_context::CommandContext; @@ -10,7 +9,7 @@ pub(super) async fn info_command(args: &SystemInfoArgs, base_ctx: &CommandContex let server = ctx.server().await?; let response = server.get_system_info().await?; - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&response)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index b0956389c..3fef61b9c 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use anyhow::{Context, Result, bail}; use fabro_api::types; -use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use tracing::{debug, info}; @@ -24,11 +23,7 @@ pub(super) async fn prune_command(args: &RunsPruneArgs, base_ctx: &CommandContex workflow: args.filter.workflow.clone(), }) .await?; - prune_from( - &response, - ctx.user_settings().cli.output.format == OutputFormat::Json, - printer, - ) + prune_from(&response, ctx.json_output(), printer) } pub(crate) fn parse_duration(s: &str) -> Result { diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index 0c0a5b460..162a980c7 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -2,7 +2,6 @@ use anyhow::bail; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; use fabro_types::settings::SettingsLayer; -use fabro_types::settings::cli::OutputFormat; use fabro_util::terminal::Styles; use crate::args::ValidateArgs; @@ -31,7 +30,7 @@ pub(crate) async fn run( let response = client.run_preflight(built.manifest).await?; let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "workflow_name": response.workflow.name, "nodes": response.workflow.nodes, diff --git a/lib/crates/fabro-cli/src/commands/version.rs b/lib/crates/fabro-cli/src/commands/version.rs index 73c4709c3..8b4304b23 100644 --- a/lib/crates/fabro-cli/src/commands/version.rs +++ b/lib/crates/fabro-cli/src/commands/version.rs @@ -6,7 +6,6 @@ use std::io::IsTerminal; use anyhow::Result; -use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use serde_json::{Map, Value, json}; @@ -44,7 +43,7 @@ pub(crate) async fn version_command(args: &VersionArgs, base_ctx: &CommandContex }, }; - if ctx.user_settings().cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&json_output(&client, &server_info))?; return Ok(()); } From c87e282f8c72b250d03c828d8d3915a217af31ad Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 07:48:04 -0400 Subject: [PATCH 07/19] simplify: gate with_server_mode_from_loaded_settings behind cfg(test) The helper only exists to let tests inject pre-loaded settings. Inline the struct-literal into the sole production caller and mark the helper `#[cfg(test)]` so the test-seam intent is explicit. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/command_context.rs | 35 ++++++++++++--------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 196f3cc06..9bcbed257 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -9,7 +9,9 @@ use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; -use crate::args::{ServerConnectionArgs, ServerTargetArgs}; +use crate::args::{ + ServerConnectionArgs, ServerTargetArgs, printer_from_verbosity, require_no_json_override, +}; use crate::server_client::Client; use crate::{server_client, user_config}; @@ -48,7 +50,7 @@ pub(crate) struct ResolvedBaseContext { impl ResolvedBaseContext { pub(crate) fn from_disk(cli_layer: &CliLayer, process_local_json: bool) -> Result { let (machine_settings, user_settings) = load_merged_settings(cli_layer, &ServerMode::None)?; - let printer = crate::args::printer_from_verbosity(user_settings.cli.output.verbosity); + let printer = printer_from_verbosity(user_settings.cli.output.verbosity); Ok(Self { printer, @@ -139,7 +141,7 @@ impl CommandContext { } pub(crate) fn require_no_json_override(&self) -> Result<()> { - crate::args::require_no_json_override(self.process_local_json) + require_no_json_override(self.process_local_json) } pub(crate) fn cwd(&self) -> &Path { @@ -195,15 +197,20 @@ impl CommandContext { let (machine_settings, user_settings) = load_merged_settings(&self.cli_layer, &server_mode)?; - Ok( - self.with_server_mode_from_loaded_settings( - server_mode, - machine_settings, - user_settings, - ), - ) + Ok(Self { + printer: self.printer, + process_local_json: self.process_local_json, + cwd: self.cwd.clone(), + base_config_path: self.base_config_path.clone(), + cli_layer: self.cli_layer.clone(), + machine_settings, + user_settings, + server_mode, + server: OnceCell::new(), + }) } + #[cfg(test)] fn with_server_mode_from_loaded_settings( &self, server_mode: ServerMode, @@ -256,8 +263,8 @@ mod tests { use fabro_config::user::apply_storage_dir_override; use fabro_config::{UserSettings, parse_settings_layer}; - use fabro_types::settings::SettingsLayer; use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity}; + use fabro_types::settings::{InterpString, SettingsLayer}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -377,7 +384,7 @@ root = "/srv/fabro/default" .as_ref() .and_then(|server| server.storage.as_ref()) .and_then(|storage| storage.root.as_ref()) - .map(|root| root.as_source()), + .map(InterpString::as_source), Some("/srv/fabro/default".to_string()) ); assert_eq!( @@ -386,7 +393,7 @@ root = "/srv/fabro/default" .as_ref() .and_then(|server| server.storage.as_ref()) .and_then(|storage| storage.root.as_ref()) - .map(|root| root.as_source()), + .map(InterpString::as_source), Some("/srv/fabro/override".to_string()) ); } @@ -441,7 +448,7 @@ root = "/srv/fabro/default" .as_ref() .and_then(|server| server.storage.as_ref()) .and_then(|storage| storage.root.as_ref()) - .map(|root| root.as_source()), + .map(InterpString::as_source), Some("/srv/fabro/default".to_string()) ); } From 80a36d1fca023ed57e2d7aee745e36e067ad1f92 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 07:49:07 -0400 Subject: [PATCH 08/19] simplify: pass CommandContext to runs archive/rm helpers `run_bulk` and `remove_from` took separate `json: bool` + `printer` parameters. Thread the context through instead and pull json/printer out of it inside the helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fabro-cli/src/commands/runs/archive.rs | 31 +++++-------------- lib/crates/fabro-cli/src/commands/runs/rm.rs | 19 ++++-------- 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/runs/archive.rs b/lib/crates/fabro-cli/src/commands/runs/archive.rs index 7d1f2239a..bf9543392 100644 --- a/lib/crates/fabro-cli/src/commands/runs/archive.rs +++ b/lib/crates/fabro-cli/src/commands/runs/archive.rs @@ -3,7 +3,6 @@ use anyhow::{Result, bail}; use super::short_run_id; use crate::args::{RunsArchiveArgs, RunsUnarchiveArgs}; use crate::command_context::CommandContext; -use crate::server_client; use crate::shared::print_json_pretty; pub(crate) async fn archive_command( @@ -11,14 +10,7 @@ pub(crate) async fn archive_command( base_ctx: &CommandContext, ) -> Result<()> { let ctx = base_ctx.with_target(&args.server)?; - run_bulk( - Action::Archive, - &args.runs, - ctx.server().await?.as_ref(), - ctx.json_output(), - ctx.printer(), - ) - .await + run_bulk(Action::Archive, &args.runs, &ctx).await } pub(crate) async fn unarchive_command( @@ -26,14 +18,7 @@ pub(crate) async fn unarchive_command( base_ctx: &CommandContext, ) -> Result<()> { let ctx = base_ctx.with_target(&args.server)?; - run_bulk( - Action::Unarchive, - &args.runs, - ctx.server().await?.as_ref(), - ctx.json_output(), - ctx.printer(), - ) - .await + run_bulk(Action::Unarchive, &args.runs, &ctx).await } #[derive(Clone, Copy)] @@ -55,13 +40,11 @@ impl Action { } } -async fn run_bulk( - action: Action, - identifiers: &[String], - client: &server_client::Client, - json: bool, - printer: fabro_util::printer::Printer, -) -> Result<()> { +async fn run_bulk(action: Action, identifiers: &[String], ctx: &CommandContext) -> Result<()> { + let client = ctx.server().await?; + let client = client.as_ref(); + let json = ctx.json_output(); + let printer = ctx.printer(); let mut had_errors = false; let mut changed = Vec::new(); let mut errors = Vec::new(); diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index 362d1a5c1..31cacfd99 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -8,21 +8,14 @@ use crate::shared::print_json_pretty; pub(crate) async fn remove_command(args: &RunsRemoveArgs, base_ctx: &CommandContext) -> Result<()> { let ctx = base_ctx.with_target(&args.server)?; - remove_from( - args, - ctx.server().await?.as_ref(), - ctx.json_output(), - ctx.printer(), - ) - .await + remove_from(args, &ctx).await } -async fn remove_from( - args: &RunsRemoveArgs, - client: &server_client::Client, - json: bool, - printer: fabro_util::printer::Printer, -) -> Result<()> { +async fn remove_from(args: &RunsRemoveArgs, ctx: &CommandContext) -> Result<()> { + let client = ctx.server().await?; + let client = client.as_ref(); + let json = ctx.json_output(); + let printer = ctx.printer(); let mut had_errors = false; let mut removed = Vec::new(); let mut errors = Vec::new(); From b08cb7631ba2ef75f8821583b34e497c1a723988 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 07:50:41 -0400 Subject: [PATCH 09/19] simplify: pass CommandContext to secret subcommands The secret dispatcher pre-computed json/printer and threaded them into every subcommand. Pass the context directly so each subcommand pulls what it needs, dropping the fabro_util::printer::Printer import from three files along the way. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/commands/secret/list.rs | 14 +++++--------- lib/crates/fabro-cli/src/commands/secret/mod.rs | 10 +++------- lib/crates/fabro-cli/src/commands/secret/rm.rs | 15 +++++---------- lib/crates/fabro-cli/src/commands/secret/set.rs | 15 +++++---------- 4 files changed, 18 insertions(+), 36 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/secret/list.rs b/lib/crates/fabro-cli/src/commands/secret/list.rs index fb664124d..0ceb23871 100644 --- a/lib/crates/fabro-cli/src/commands/secret/list.rs +++ b/lib/crates/fabro-cli/src/commands/secret/list.rs @@ -2,11 +2,10 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::SecretListArgs; -use crate::server_client::Client; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; fn format_age(dt: DateTime, now: DateTime) -> String { @@ -20,14 +19,11 @@ fn format_age(dt: DateTime, now: DateTime) -> String { } } -pub(super) async fn list_command( - client: &Client, - _args: &SecretListArgs, - json_output: bool, - printer: Printer, -) -> Result<()> { +pub(super) async fn list_command(_args: &SecretListArgs, ctx: &CommandContext) -> Result<()> { + let client = ctx.server().await?; + let printer = ctx.printer(); let secrets = client.list_secrets().await?; - if json_output { + if ctx.json_output() { print_json_pretty(&secrets)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/secret/mod.rs b/lib/crates/fabro-cli/src/commands/secret/mod.rs index 458136baf..6b748f445 100644 --- a/lib/crates/fabro-cli/src/commands/secret/mod.rs +++ b/lib/crates/fabro-cli/src/commands/secret/mod.rs @@ -9,13 +9,9 @@ use crate::command_context::CommandContext; pub(crate) async fn dispatch(ns: SecretNamespace, base_ctx: &CommandContext) -> Result<()> { let ctx = base_ctx.with_target(&ns.target)?; - let server = ctx.server().await?; - let json_output = - ctx.user_settings().cli.output.format == fabro_types::settings::cli::OutputFormat::Json; - let printer = ctx.printer(); match ns.command { - SecretCommand::List(args) => list::list_command(&server, &args, json_output, printer).await, - SecretCommand::Rm(args) => rm::rm_command(&server, &args, json_output, printer).await, - SecretCommand::Set(args) => set::set_command(&server, &args, json_output, printer).await, + SecretCommand::List(args) => list::list_command(&args, &ctx).await, + SecretCommand::Rm(args) => rm::rm_command(&args, &ctx).await, + SecretCommand::Set(args) => set::set_command(&args, &ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/secret/rm.rs b/lib/crates/fabro-cli/src/commands/secret/rm.rs index ec3a4922b..1821f2823 100644 --- a/lib/crates/fabro-cli/src/commands/secret/rm.rs +++ b/lib/crates/fabro-cli/src/commands/secret/rm.rs @@ -1,21 +1,16 @@ use anyhow::Result; -use fabro_util::printer::Printer; use crate::args::SecretRmArgs; -use crate::server_client::Client; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(super) async fn rm_command( - client: &Client, - args: &SecretRmArgs, - json_output: bool, - printer: Printer, -) -> Result<()> { +pub(super) async fn rm_command(args: &SecretRmArgs, ctx: &CommandContext) -> Result<()> { + let client = ctx.server().await?; client.delete_secret_by_name(&args.key).await?; - if json_output { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "key": args.key }))?; } else { - fabro_util::printerr!(printer, "Removed {}", args.key); + fabro_util::printerr!(ctx.printer(), "Removed {}", args.key); } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/secret/set.rs b/lib/crates/fabro-cli/src/commands/secret/set.rs index 1ad6b4ff4..2a361b875 100644 --- a/lib/crates/fabro-cli/src/commands/secret/set.rs +++ b/lib/crates/fabro-cli/src/commands/secret/set.rs @@ -11,11 +11,10 @@ use std::io::{IsTerminal, Read as _}; use anyhow::{Context as _, Result, bail}; use fabro_api::types; -use fabro_util::printer::Printer; use tokio::task::spawn_blocking; use crate::args::{SecretSetArgs, SecretTypeArg}; -use crate::server_client::Client; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; use crate::shared::provider_auth::prompt_password; @@ -55,13 +54,9 @@ async fn resolve_value(args: &SecretSetArgs) -> Result { bail!("secret value required: pass , use --value-stdin, or run interactively") } -pub(super) async fn set_command( - client: &Client, - args: &SecretSetArgs, - json_output: bool, - printer: Printer, -) -> Result<()> { +pub(super) async fn set_command(args: &SecretSetArgs, ctx: &CommandContext) -> Result<()> { let value = resolve_value(args).await?; + let client = ctx.server().await?; let meta = client .create_secret(types::CreateSecretRequest { name: args.key.clone(), @@ -70,10 +65,10 @@ pub(super) async fn set_command( description: args.description.clone(), }) .await?; - if json_output { + if ctx.json_output() { print_json_pretty(&meta)?; } else { - fabro_util::printerr!(printer, "Set {}", meta.name); + fabro_util::printerr!(ctx.printer(), "Set {}", meta.name); } Ok(()) } From 2e07cf10a1d9665f7eb96a99b0a42c566f72d07f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 07:52:06 -0400 Subject: [PATCH 10/19] simplify: borrow cli_settings from ResolvedBaseContext instead of cloning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `cli_settings` local was a standalone clone of `user_settings.cli`. Drop the clone and rebind it as `&resolved_base.user_settings().cli` inside the async block — all callers already borrowed it anyway. Pre-async uses inline `resolved_base.user_settings().cli.` directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/main.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index d041040f0..51b203439 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -168,11 +168,10 @@ async fn main_inner() -> (String, Result<()>) { Ok(resolved) => resolved, Err(err) => return (command_name, Err(err)), }; - let cli_settings = resolved_base.user_settings().cli.clone(); let printer = resolved_base.printer(); let config_log_level = match &pre_tracing_bootstrap.sink { - logging::InternalLogSink::Cli => cli_settings.logging.level.clone(), + logging::InternalLogSink::Cli => resolved_base.user_settings().cli.logging.level.clone(), logging::InternalLogSink::Server { .. } => pre_tracing_bootstrap.config_log_level.clone(), }; if let Err(err) = logging::init_tracing( @@ -196,16 +195,20 @@ async fn main_inner() -> (String, Result<()>) { | Commands::Repo(_) | Commands::Install { .. } ) { - commands::upgrade::spawn_upgrade_check(cli_settings.updates.check, printer) + commands::upgrade::spawn_upgrade_check( + resolved_base.user_settings().cli.updates.check, + printer, + ) } else { None }; let result = Box::pin(async move { + let cli_settings = &resolved_base.user_settings().cli; let build_base_ctx = || resolved_base.to_context(); match *command { - Commands::Exec(args) => commands::exec::execute(args, &cli_settings, printer).await?, + Commands::Exec(args) => commands::exec::execute(args, cli_settings, printer).await?, Commands::RunCmd(cmd) => { let base_ctx = build_base_ctx()?; Box::pin(commands::run::dispatch(cmd, &base_ctx)).await?; @@ -225,7 +228,7 @@ async fn main_inner() -> (String, Result<()>) { commands::graph::run(&args, &styles, &base_ctx).await?; } Commands::Parse(args) => { - commands::parse::run(&args, &cli_settings, printer)?; + commands::parse::run(&args, cli_settings, printer)?; } Commands::Artifact(ns) => { let base_ctx = build_base_ctx()?; @@ -287,7 +290,7 @@ async fn main_inner() -> (String, Result<()>) { Box::pin(commands::install::execute( &args, command, - &cli_settings, + cli_settings, &cli_layer, process_local_json, printer, @@ -295,7 +298,7 @@ async fn main_inner() -> (String, Result<()>) { .await?; } Commands::Uninstall(args) => { - commands::uninstall::run_uninstall(&args, &cli_settings, printer).await?; + commands::uninstall::run_uninstall(&args, cli_settings, printer).await?; } Commands::Auth(ns) => { let base_ctx = build_base_ctx()?; @@ -313,9 +316,9 @@ async fn main_inner() -> (String, Result<()>) { let base_ctx = build_base_ctx()?; Box::pin(commands::config::execute(&args, &base_ctx)).await?; } - Commands::Workflow(ns) => commands::workflow::dispatch(ns, &cli_settings, printer)?, + Commands::Workflow(ns) => commands::workflow::dispatch(ns, cli_settings, printer)?, Commands::Upgrade(args) => { - commands::upgrade::run_upgrade(args, &cli_settings, printer).await?; + commands::upgrade::run_upgrade(args, cli_settings, printer).await?; } Commands::Provider(ns) => { let base_ctx = build_base_ctx()?; From 72894b3c46dd16973be60cabb0d783c2451ef5d5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 07:54:53 -0400 Subject: [PATCH 11/19] simplify: drop CommandContext::base in favor of ResolvedBaseContext `CommandContext::base` had a single caller (install.rs) and duplicated the disk-load path that `ResolvedBaseContext::from_disk` already provides. Route install through `ResolvedBaseContext::from_disk(...).to_context()` and drop the standalone constructor. `base_with_settings` stays as the private shared helper behind both `to_context` entry points. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/command_context.rs | 15 --------------- lib/crates/fabro-cli/src/commands/install.rs | 4 ++-- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 9bcbed257..d98098ed6 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -81,21 +81,6 @@ impl ResolvedBaseContext { } impl CommandContext { - pub(crate) fn base( - printer: Printer, - cli_layer: &CliLayer, - process_local_json: bool, - ) -> Result { - let (machine_settings, user_settings) = load_merged_settings(cli_layer, &ServerMode::None)?; - Self::base_with_settings( - printer, - cli_layer, - process_local_json, - machine_settings, - user_settings, - ) - } - fn base_with_settings( printer: Printer, cli_layer: &CliLayer, diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 07a7574f9..f1d876bfe 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -52,7 +52,7 @@ use crate::args::{ DoctorArgs, InstallArgs, InstallCommand, InstallGitHubStrategyArg, InstallGithubArgs, InstallNonInteractiveArgs, ServerTargetArgs, }; -use crate::command_context::CommandContext; +use crate::command_context::ResolvedBaseContext; use crate::commands::server::{start, stop}; use crate::gh::GhCli; use crate::shared::provider_auth::{ @@ -1942,7 +1942,7 @@ async fn run_install_inner( target: ServerTargetArgs::default(), verbose: false, }; - let base_ctx = CommandContext::base(printer, cli_layer, false)?; + let base_ctx = ResolvedBaseContext::from_disk(cli_layer, false)?.to_context()?; doctor::run_doctor(&doctor_args, &base_ctx).await }, ) From b98513875a2da2042a6b1d6546f78c27a49ffd30 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 08:00:02 -0400 Subject: [PATCH 12/19] simplify: return derived context from load_pr_record `load_pr_record` built a `with_target`-derived context internally and threw it away, forcing callers to re-derive or fall back to `base_ctx`. Return the context alongside the record and let close/merge/view use it directly for printer/json access and github-credentials lookup. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/commands/pr/close.rs | 15 ++++++++++----- lib/crates/fabro-cli/src/commands/pr/merge.rs | 15 ++++++++++----- lib/crates/fabro-cli/src/commands/pr/mod.rs | 4 ++-- lib/crates/fabro-cli/src/commands/pr/view.rs | 9 +++++---- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index bf584b45c..034467526 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -6,10 +6,10 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; pub(super) async fn close_command(args: PrCloseArgs, base_ctx: &CommandContext) -> Result<()> { - let printer = base_ctx.printer(); - let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; + let (ctx, record, _run_id) = + super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; - let creds = super::load_github_credentials_required(base_ctx)?; + let creds = super::load_github_credentials_required(&ctx)?; fabro_github::close_pull_request( &creds, @@ -22,13 +22,18 @@ pub(super) async fn close_command(args: PrCloseArgs, base_ctx: &CommandContext) .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request"); - if base_ctx.json_output() { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "number": record.number, "html_url": record.html_url, }))?; } else { - fabro_util::printout!(printer, "Closed #{} ({})", record.number, record.html_url); + fabro_util::printout!( + ctx.printer(), + "Closed #{} ({})", + record.number, + record.html_url + ); } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index 2c7de4a0a..c34d5b4fc 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -6,10 +6,10 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; pub(super) async fn merge_command(args: PrMergeArgs, base_ctx: &CommandContext) -> Result<()> { - let printer = base_ctx.printer(); - let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; + let (ctx, record, _run_id) = + super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; - let creds = super::load_github_credentials_required(base_ctx)?; + let creds = super::load_github_credentials_required(&ctx)?; fabro_github::merge_pull_request( &creds, @@ -23,14 +23,19 @@ pub(super) async fn merge_command(args: PrMergeArgs, base_ctx: &CommandContext) .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request"); - if base_ctx.json_output() { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "number": record.number, "html_url": record.html_url, "method": args.method, }))?; } else { - fabro_util::printout!(printer, "Merged #{} ({})", record.number, record.html_url); + fabro_util::printout!( + ctx.printer(), + "Merged #{} ({})", + record.number, + record.html_url + ); } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index a0c83c21b..d5fd6c7e7 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -58,7 +58,7 @@ pub(crate) async fn load_pr_record( server: &ServerTargetArgs, run_id: &str, base_ctx: &CommandContext, -) -> Result<(PullRequestRecord, fabro_types::RunId)> { +) -> Result<(CommandContext, PullRequestRecord, fabro_types::RunId)> { let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; let run_id = client.resolve_run(run_id).await?.run_id; @@ -66,5 +66,5 @@ pub(crate) async fn load_pr_record( let record = state.pull_request.with_context(|| { format!("No pull request found in store. Create one first with: fabro pr create {run_id}") })?; - Ok((record, run_id)) + Ok((ctx, record, run_id)) } diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index e861e19d1..0fcf343a6 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -6,10 +6,10 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> Result<()> { - let printer = base_ctx.printer(); - let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; + let (ctx, record, _run_id) = + super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; - let creds = super::load_github_credentials_required(base_ctx)?; + let creds = super::load_github_credentials_required(&ctx)?; let detail = fabro_github::get_pull_request( &creds, @@ -23,11 +23,12 @@ pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request"); - if base_ctx.json_output() { + if ctx.json_output() { print_json_pretty(&detail)?; return Ok(()); } + let printer = ctx.printer(); fabro_util::printout!(printer, "#{} {}", detail.number, detail.title); let state_display = if detail.draft { "draft" } else { &detail.state }; fabro_util::printout!(printer, "State: {state_display}"); From 4a696e070659739becedb3df9af9eae696be33d9 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 08:00:43 -0400 Subject: [PATCH 13/19] simplify: inline rendered_config into execute The helper was a 2-line indirection with a single caller. Inline its body so the whole command fits in one function. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/commands/config/mod.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index b0d7b34c7..7dfe3c383 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -23,10 +23,7 @@ struct RenderedConfig { server: ServerSettings, } -async fn rendered_config( - args: &SettingsArgs, - base_ctx: &CommandContext, -) -> anyhow::Result { +pub(crate) async fn execute(args: &SettingsArgs, base_ctx: &CommandContext) -> anyhow::Result<()> { let ctx = base_ctx.with_target(&args.target)?; let user = fabro_config::UserSettings::resolve()?; let server = ctx @@ -34,11 +31,8 @@ async fn rendered_config( .await? .retrieve_resolved_server_settings() .await?; - serde_json::to_value(RenderedConfig { user, server }).map_err(Into::into) -} + let config = serde_json::to_value(RenderedConfig { user, server })?; -pub(crate) async fn execute(args: &SettingsArgs, base_ctx: &CommandContext) -> anyhow::Result<()> { - let config = Box::pin(rendered_config(args, base_ctx)).await?; if base_ctx.json_output() { print_json_pretty(&config)?; return Ok(()); From d51c6dfb76c9be17f95a3652a1ea2e79f153fe6c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 08:06:46 -0400 Subject: [PATCH 14/19] simplify: migrate exec/parse/workflow/upgrade/uninstall to CommandContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These top-level commands still took `&CliNamespace` + `Printer` separately. Thread `&CommandContext` through the public entry points and pull what's needed (`user_settings().cli`, `printer()`, `json_output()`) from the context: - parse: both args were unused — drop entirely. - workflow list/create: use `ctx.json_output()` / `ctx.printer()`. - exec: bind `cli = &ctx.user_settings().cli` at the top; drop unused printer param. - upgrade: extract cli/printer inside run_upgrade; leave the private run_upgrade_brew helper with its existing signature (unit tests use `CliNamespace::default()` directly). - uninstall: use `ctx.json_output()` / `ctx.printer()`. Install remains on the old signature — its nested callback structure makes a larger refactor than this simplification pass warrants. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/commands/exec.rs | 11 ++++------- lib/crates/fabro-cli/src/commands/parse.rs | 4 +--- lib/crates/fabro-cli/src/commands/uninstall.rs | 12 ++++-------- lib/crates/fabro-cli/src/commands/upgrade.rs | 9 ++++----- .../fabro-cli/src/commands/workflow/create.rs | 13 ++++--------- .../fabro-cli/src/commands/workflow/list.rs | 12 ++++-------- .../fabro-cli/src/commands/workflow/mod.rs | 9 ++++----- lib/crates/fabro-cli/src/main.rs | 18 +++++++++++++----- 8 files changed, 38 insertions(+), 50 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 4048915e5..c8b218daf 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -13,15 +13,15 @@ use fabro_llm::types::{ FinishReason, Message, Request, Response as LlmResponse, StreamEvent, TokenCounts, }; use fabro_mcp::config::{McpServerSettings, McpTransport}; +use fabro_types::settings::InterpString; use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat; use fabro_types::settings::run::McpEntryLayer; -use fabro_types::settings::{CliNamespace, InterpString}; use fabro_util::exit::{ErrorExt, ExitClass}; -use fabro_util::printer::Printer; use futures::stream; use serde::Deserialize; use crate::args::ExecArgs; +use crate::command_context::CommandContext; use crate::{server_client, user_config}; fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> McpServerSettings { @@ -356,14 +356,11 @@ impl ProviderAdapter for AuthenticatedFabroServerAdapter { } } -pub(crate) async fn execute( - mut args: ExecArgs, - cli: &CliNamespace, - _printer: Printer, -) -> AnyResult<()> { +pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResult<()> { use fabro_agent::cli::PermissionLevel as AgentPermissionLevel; use fabro_types::settings::run::AgentPermissions; + let cli = &ctx.user_settings().cli; let raw_settings = user_config::load_settings()?; #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = crate::sleep_inhibitor::guard(cli.exec.prevent_idle_sleep); diff --git a/lib/crates/fabro-cli/src/commands/parse.rs b/lib/crates/fabro-cli/src/commands/parse.rs index e14d970ce..12088ca04 100644 --- a/lib/crates/fabro-cli/src/commands/parse.rs +++ b/lib/crates/fabro-cli/src/commands/parse.rs @@ -11,13 +11,11 @@ use std::io::Write; use fabro_config::project::resolve_workflow; use fabro_graphviz::parser::parse_ast; -use fabro_types::settings::CliNamespace; -use fabro_util::printer::Printer; use crate::args::ParseArgs; use crate::shared::read_workflow_file; -pub(crate) fn run(args: &ParseArgs, _cli: &CliNamespace, _printer: Printer) -> anyhow::Result<()> { +pub(crate) fn run(args: &ParseArgs) -> anyhow::Result<()> { let stdout = std::io::stdout(); run_to(args, stdout.lock()) } diff --git a/lib/crates/fabro-cli/src/commands/uninstall.rs b/lib/crates/fabro-cli/src/commands/uninstall.rs index 5d19aa459..63b5fd67d 100644 --- a/lib/crates/fabro-cli/src/commands/uninstall.rs +++ b/lib/crates/fabro-cli/src/commands/uninstall.rs @@ -15,14 +15,13 @@ use std::time::Duration; use anyhow::{Context, Result}; use fabro_config::Storage; use fabro_config::daemon::ServerDaemon; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; use fabro_util::Home; use fabro_util::printer::Printer; use serde::Serialize; use tracing::warn; use crate::args::UninstallArgs; +use crate::command_context::CommandContext; use crate::commands::server::stop; use crate::shared::{format_size, print_json_pretty, tilde_path}; use crate::{local_server, user_config}; @@ -43,12 +42,9 @@ struct Inventory { clippy::unused_async, reason = "The shared command dispatch path expects an async handler." )] -pub(crate) async fn run_uninstall( - args: &UninstallArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; +pub(crate) async fn run_uninstall(args: &UninstallArgs, ctx: &CommandContext) -> Result<()> { + let json = ctx.json_output(); + let printer = ctx.printer(); let home = Home::from_env(); let home_root = home.root().to_path_buf(); diff --git a/lib/crates/fabro-cli/src/commands/upgrade.rs b/lib/crates/fabro-cli/src/commands/upgrade.rs index 53390cfd5..694ac7838 100644 --- a/lib/crates/fabro-cli/src/commands/upgrade.rs +++ b/lib/crates/fabro-cli/src/commands/upgrade.rs @@ -23,6 +23,7 @@ use tokio::task::JoinHandle; use tracing::debug; use crate::args::UpgradeArgs; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; // ── Download backend abstraction ─────────────────────────────────────────── @@ -434,11 +435,9 @@ impl UpgradeCheckState { // ── Main upgrade command ─────────────────────────────────────────────────── -pub(crate) async fn run_upgrade( - args: UpgradeArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { +pub(crate) async fn run_upgrade(args: UpgradeArgs, ctx: &CommandContext) -> Result<()> { + let cli = &ctx.user_settings().cli; + let printer = ctx.printer(); let current_exe = std::env::current_exe() .context("resolving current fabro executable path")? .canonicalize() diff --git a/lib/crates/fabro-cli/src/commands/workflow/create.rs b/lib/crates/fabro-cli/src/commands/workflow/create.rs index 6a4f7b5f8..742c3cad5 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/create.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/create.rs @@ -7,18 +7,13 @@ use std::path::Path; use anyhow::{Context, Result, bail}; use fabro_config::project::{discover_project_config, resolve_fabro_root}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; -use fabro_util::printer::Printer; use crate::args::WorkflowCreateArgs; +use crate::command_context::CommandContext; use crate::shared::{print_json_pretty, relative_path}; -pub(super) fn create_command( - args: &WorkflowCreateArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { +pub(super) fn create_command(args: &WorkflowCreateArgs, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); let cwd = std::env::current_dir()?; let Some((config_path, config)) = discover_project_config(&cwd)? else { @@ -31,7 +26,7 @@ pub(super) fn create_command( let fabro_root = resolve_fabro_root(&config_path, &config); let created = write_workflow_scaffold(args, &fabro_root)?; - if cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { let created: Vec<_> = created.iter().map(|path| relative_path(path)).collect(); print_json_pretty(&serde_json::json!({ "name": args.name, diff --git a/lib/crates/fabro-cli/src/commands/workflow/list.rs b/lib/crates/fabro-cli/src/commands/workflow/list.rs index 40da91203..96553af73 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/list.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/list.rs @@ -5,21 +5,17 @@ use fabro_config::project::{ WorkflowInfo, WorkflowSource, discover_project_config, list_workflows_detailed, resolve_fabro_root, }; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::WorkflowListArgs; +use crate::command_context::CommandContext; use crate::shared::{color_if, print_json_pretty, relative_path}; const GOAL_MAX_LEN: usize = 60; -pub(super) fn list_command( - _args: &WorkflowListArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { +pub(super) fn list_command(_args: &WorkflowListArgs, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); let styles = Styles::detect_stderr(); let cwd = std::env::current_dir()?; @@ -36,7 +32,7 @@ pub(super) fn list_command( let workflows = list_workflows_detailed(Some(&project_wf_dir), user_wf_dir.as_deref()); - if cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&workflows)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/workflow/mod.rs b/lib/crates/fabro-cli/src/commands/workflow/mod.rs index 44de6cf4e..528dc8a35 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/mod.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/mod.rs @@ -2,14 +2,13 @@ mod create; mod list; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_util::printer::Printer; use crate::args::{WorkflowCommand, WorkflowNamespace}; +use crate::command_context::CommandContext; -pub(crate) fn dispatch(ns: WorkflowNamespace, cli: &CliNamespace, printer: Printer) -> Result<()> { +pub(crate) fn dispatch(ns: WorkflowNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - WorkflowCommand::List(args) => list::list_command(&args, cli, printer), - WorkflowCommand::Create(args) => create::create_command(&args, cli, printer), + WorkflowCommand::List(args) => list::list_command(&args, base_ctx), + WorkflowCommand::Create(args) => create::create_command(&args, base_ctx), } } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 51b203439..3f559e7b3 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -208,7 +208,10 @@ async fn main_inner() -> (String, Result<()>) { let build_base_ctx = || resolved_base.to_context(); match *command { - Commands::Exec(args) => commands::exec::execute(args, cli_settings, printer).await?, + Commands::Exec(args) => { + let base_ctx = build_base_ctx()?; + commands::exec::execute(args, &base_ctx).await?; + } Commands::RunCmd(cmd) => { let base_ctx = build_base_ctx()?; Box::pin(commands::run::dispatch(cmd, &base_ctx)).await?; @@ -228,7 +231,7 @@ async fn main_inner() -> (String, Result<()>) { commands::graph::run(&args, &styles, &base_ctx).await?; } Commands::Parse(args) => { - commands::parse::run(&args, cli_settings, printer)?; + commands::parse::run(&args)?; } Commands::Artifact(ns) => { let base_ctx = build_base_ctx()?; @@ -298,7 +301,8 @@ async fn main_inner() -> (String, Result<()>) { .await?; } Commands::Uninstall(args) => { - commands::uninstall::run_uninstall(&args, cli_settings, printer).await?; + let base_ctx = build_base_ctx()?; + commands::uninstall::run_uninstall(&args, &base_ctx).await?; } Commands::Auth(ns) => { let base_ctx = build_base_ctx()?; @@ -316,9 +320,13 @@ async fn main_inner() -> (String, Result<()>) { let base_ctx = build_base_ctx()?; Box::pin(commands::config::execute(&args, &base_ctx)).await?; } - Commands::Workflow(ns) => commands::workflow::dispatch(ns, cli_settings, printer)?, + Commands::Workflow(ns) => { + let base_ctx = build_base_ctx()?; + commands::workflow::dispatch(ns, &base_ctx)?; + } Commands::Upgrade(args) => { - commands::upgrade::run_upgrade(args, cli_settings, printer).await?; + let base_ctx = build_base_ctx()?; + commands::upgrade::run_upgrade(args, &base_ctx).await?; } Commands::Provider(ns) => { let base_ctx = build_base_ctx()?; From 967b1efe767396825eb82386cc61b79b82e86343 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 08:12:21 -0400 Subject: [PATCH 15/19] simplify: migrate install to CommandContext and delete cli_settings local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public entry points (`execute`, `run_install`, `run_install_github_command`, `run_install_inner`, `run_install_github_inner`) now take `&CommandContext` instead of a 4-tuple of `(cli, cli_layer, process_local_json, printer)`. Extract cli/printer/json from the context once at the top of each. The nested doctor invocation inside run_install_inner previously built a fresh `ResolvedBaseContext::from_disk(...).to_context()` with `process_local_json = false`. Doctor only reads the resolved output format (`base_ctx.json_output()`), not the invocation flag, so passing the parent ctx through is behaviorally equivalent and avoids a second disk load. main.rs drops the `cli_settings` local entirely — no remaining command needs it. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/commands/install.rs | 58 ++++++++------------ lib/crates/fabro-cli/src/main.rs | 12 +--- 2 files changed, 26 insertions(+), 44 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index f1d876bfe..9072860b4 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -32,9 +32,8 @@ use fabro_install::{ use fabro_model::Provider; use fabro_server::serve; use fabro_store::ArtifactStore; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_types::settings::server::ServerAuthMethod; -use fabro_types::settings::{CliNamespace, SettingsLayer}; +use fabro_types::settings::SettingsLayer; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_util::version::FABRO_VERSION; @@ -52,7 +51,7 @@ use crate::args::{ DoctorArgs, InstallArgs, InstallCommand, InstallGitHubStrategyArg, InstallGithubArgs, InstallNonInteractiveArgs, ServerTargetArgs, }; -use crate::command_context::ResolvedBaseContext; +use crate::command_context::CommandContext; use crate::commands::server::{start, stop}; use crate::gh::GhCli; use crate::shared::provider_auth::{ @@ -1416,15 +1415,12 @@ where pub(crate) async fn execute( args: &InstallArgs, command: Option, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, + ctx: &CommandContext, ) -> Result<()> { match command { - None => run_install(args, cli, cli_layer, process_local_json, printer).await, + None => run_install(args, ctx).await, Some(InstallCommand::Github(github_args)) => { - run_install_github_command(args, &github_args, cli, process_local_json, printer).await + run_install_github_command(args, &github_args, ctx).await } } } @@ -1432,16 +1428,20 @@ pub(crate) async fn execute( async fn run_install_github_command( args: &InstallArgs, github_args: &InstallGithubArgs, - cli: &CliNamespace, - process_local_json: bool, - printer: Printer, + ctx: &CommandContext, ) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; - if process_local_json && !args.non_interactive { + let json = ctx.json_output(); + if ctx.explicit_json_requested() && !args.non_interactive { bail!("--json is only supported for install with --non-interactive"); } - let result = Box::pin(run_install_github_inner(args, github_args, json, printer)).await; + let result = Box::pin(run_install_github_inner( + args, + github_args, + json, + ctx.printer(), + )) + .await; if json { let emit_result = match &result { Ok(()) => emit_install_json_event(&install_complete_event()), @@ -1593,19 +1593,13 @@ async fn run_install_github_inner( Ok(()) } -pub(crate) async fn run_install( - args: &InstallArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; - if process_local_json && !args.non_interactive { +pub(crate) async fn run_install(args: &InstallArgs, ctx: &CommandContext) -> Result<()> { + let json = ctx.json_output(); + if ctx.explicit_json_requested() && !args.non_interactive { bail!("--json is only supported for install with --non-interactive"); } - let result = Box::pin(run_install_inner(args, cli, cli_layer, printer)).await; + let result = Box::pin(run_install_inner(args, ctx)).await; if json { let emit_result = match &result { Ok(()) => emit_install_json_event(&install_complete_event()), @@ -1619,13 +1613,10 @@ pub(crate) async fn run_install( result } -async fn run_install_inner( - args: &InstallArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; +async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<()> { + let _cli = &ctx.user_settings().cli; + let printer = ctx.printer(); + let json = ctx.json_output(); let web_url = &args.web_url; let s = Styles::detect_stderr(); let emoji = console::Emoji("⚒️ ", ""); @@ -1942,8 +1933,7 @@ async fn run_install_inner( target: ServerTargetArgs::default(), verbose: false, }; - let base_ctx = ResolvedBaseContext::from_disk(cli_layer, false)?.to_context()?; - doctor::run_doctor(&doctor_args, &base_ctx).await + doctor::run_doctor(&doctor_args, ctx).await }, ) .await? diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 3f559e7b3..4ebec92ec 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -204,7 +204,6 @@ async fn main_inner() -> (String, Result<()>) { }; let result = Box::pin(async move { - let cli_settings = &resolved_base.user_settings().cli; let build_base_ctx = || resolved_base.to_context(); match *command { @@ -290,15 +289,8 @@ async fn main_inner() -> (String, Result<()>) { commands::repo::dispatch(ns, &base_ctx).await?; } Commands::Install { args, command } => { - Box::pin(commands::install::execute( - &args, - command, - cli_settings, - &cli_layer, - process_local_json, - printer, - )) - .await?; + let base_ctx = build_base_ctx()?; + Box::pin(commands::install::execute(&args, command, &base_ctx)).await?; } Commands::Uninstall(args) => { let base_ctx = build_base_ctx()?; From c52e89438bc7fdbafc85d3780a8c2de37921b872 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 08:17:27 -0400 Subject: [PATCH 16/19] simplify: fold ResolvedBaseContext into CommandContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ResolvedBaseContext` was a staging struct that held pre-context settings so main.rs could read `user_settings.cli` before committing to a full `CommandContext`. Now that install no longer needs the indirection, the staging step doesn't earn its weight. Give `CommandContext` a direct `from_disk(cli_layer, process_local_json)` constructor that does the load + printer derivation + struct build in one shot. main.rs builds one `base_ctx` before the dispatch match and every arm borrows it — the `build_base_ctx` closure and ~20 duplicate `let base_ctx = build_base_ctx()?;` lines disappear. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/command_context.rs | 47 +------------------- lib/crates/fabro-cli/src/commands/install.rs | 2 +- lib/crates/fabro-cli/src/main.rs | 40 +++-------------- 3 files changed, 8 insertions(+), 81 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index d98098ed6..bca31e481 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -39,55 +39,10 @@ pub(crate) struct CommandContext { server: OnceCell>, } -pub(crate) struct ResolvedBaseContext { - printer: Printer, - process_local_json: bool, - cli_layer: CliLayer, - machine_settings: SettingsLayer, - user_settings: UserSettings, -} - -impl ResolvedBaseContext { +impl CommandContext { pub(crate) fn from_disk(cli_layer: &CliLayer, process_local_json: bool) -> Result { let (machine_settings, user_settings) = load_merged_settings(cli_layer, &ServerMode::None)?; let printer = printer_from_verbosity(user_settings.cli.output.verbosity); - - Ok(Self { - printer, - process_local_json, - cli_layer: cli_layer.clone(), - machine_settings, - user_settings, - }) - } - - pub(crate) fn printer(&self) -> Printer { - self.printer - } - - pub(crate) fn user_settings(&self) -> &UserSettings { - &self.user_settings - } - - pub(crate) fn to_context(&self) -> Result { - CommandContext::base_with_settings( - self.printer, - &self.cli_layer, - self.process_local_json, - self.machine_settings.clone(), - self.user_settings.clone(), - ) - } -} - -impl CommandContext { - fn base_with_settings( - printer: Printer, - cli_layer: &CliLayer, - process_local_json: bool, - machine_settings: SettingsLayer, - user_settings: UserSettings, - ) -> Result { let cwd = std::env::current_dir().context("Failed to get current directory")?; let base_config_path = user_config::active_settings_path(None); diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 9072860b4..d2aad2df0 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -32,8 +32,8 @@ use fabro_install::{ use fabro_model::Provider; use fabro_server::serve; use fabro_store::ArtifactStore; -use fabro_types::settings::server::ServerAuthMethod; use fabro_types::settings::SettingsLayer; +use fabro_types::settings::server::ServerAuthMethod; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_util::version::FABRO_VERSION; diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 4ebec92ec..c05961151 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -35,7 +35,7 @@ use fabro_util::{browser, exit}; use rustls::crypto::ring::default_provider; use tracing::debug; -use crate::command_context::ResolvedBaseContext; +use crate::command_context::CommandContext; #[derive(Parser)] #[command(name = "fabro", version, long_version = LONG_VERSION)] @@ -164,14 +164,14 @@ async fn main_inner() -> (String, Result<()>) { Err(err) => return (command_name, Err(err)), }; - let resolved_base = match ResolvedBaseContext::from_disk(&cli_layer, process_local_json) { - Ok(resolved) => resolved, + let base_ctx = match CommandContext::from_disk(&cli_layer, process_local_json) { + Ok(ctx) => ctx, Err(err) => return (command_name, Err(err)), }; - let printer = resolved_base.printer(); + let printer = base_ctx.printer(); let config_log_level = match &pre_tracing_bootstrap.sink { - logging::InternalLogSink::Cli => resolved_base.user_settings().cli.logging.level.clone(), + logging::InternalLogSink::Cli => base_ctx.user_settings().cli.logging.level.clone(), logging::InternalLogSink::Server { .. } => pre_tracing_bootstrap.config_log_level.clone(), }; if let Err(err) = logging::init_tracing( @@ -195,57 +195,43 @@ async fn main_inner() -> (String, Result<()>) { | Commands::Repo(_) | Commands::Install { .. } ) { - commands::upgrade::spawn_upgrade_check( - resolved_base.user_settings().cli.updates.check, - printer, - ) + commands::upgrade::spawn_upgrade_check(base_ctx.user_settings().cli.updates.check, printer) } else { None }; let result = Box::pin(async move { - let build_base_ctx = || resolved_base.to_context(); - match *command { Commands::Exec(args) => { - let base_ctx = build_base_ctx()?; commands::exec::execute(args, &base_ctx).await?; } Commands::RunCmd(cmd) => { - let base_ctx = build_base_ctx()?; Box::pin(commands::run::dispatch(cmd, &base_ctx)).await?; } Commands::Preflight(args) => { - let base_ctx = build_base_ctx()?; commands::preflight::execute(args, &base_ctx).await?; } Commands::Validate(args) => { let styles = Styles::detect_stderr(); - let base_ctx = build_base_ctx()?; commands::validate::run(&args, &styles, &base_ctx).await?; } Commands::Graph(args) => { let styles = Styles::detect_stderr(); - let base_ctx = build_base_ctx()?; commands::graph::run(&args, &styles, &base_ctx).await?; } Commands::Parse(args) => { commands::parse::run(&args)?; } Commands::Artifact(ns) => { - let base_ctx = build_base_ctx()?; commands::artifact::dispatch(ns, &base_ctx).await?; } Commands::Store(ns) => { - let base_ctx = build_base_ctx()?; commands::store::dispatch(ns, &base_ctx).await?; } Commands::RunsCmd(cmd) => { - let base_ctx = build_base_ctx()?; commands::runs::dispatch(cmd, &base_ctx).await?; } Commands::Model { command } => { - let base_ctx = build_base_ctx()?; commands::model::execute(command, &base_ctx).await?; } Commands::Server(ns) => { @@ -258,12 +244,10 @@ async fn main_inner() -> (String, Result<()>) { .await?; } Commands::Doctor(args) => { - let base_ctx = build_base_ctx()?; let exit_code = Box::pin(commands::doctor::run_doctor(&args, &base_ctx)).await?; std::process::exit(exit_code); } Commands::Version(args) => { - let base_ctx = build_base_ctx()?; commands::version::version_command(&args, &base_ctx).await?; } Commands::Discord => { @@ -285,51 +269,39 @@ async fn main_inner() -> (String, Result<()>) { } } Commands::Repo(ns) => { - let base_ctx = build_base_ctx()?; commands::repo::dispatch(ns, &base_ctx).await?; } Commands::Install { args, command } => { - let base_ctx = build_base_ctx()?; Box::pin(commands::install::execute(&args, command, &base_ctx)).await?; } Commands::Uninstall(args) => { - let base_ctx = build_base_ctx()?; commands::uninstall::run_uninstall(&args, &base_ctx).await?; } Commands::Auth(ns) => { - let base_ctx = build_base_ctx()?; commands::auth::dispatch(ns, &base_ctx).await?; } Commands::Pr(ns) => { - let base_ctx = build_base_ctx()?; Box::pin(commands::pr::dispatch(ns, &base_ctx)).await?; } Commands::Secret(ns) => { - let base_ctx = build_base_ctx()?; commands::secret::dispatch(ns, &base_ctx).await?; } Commands::Settings(args) => { - let base_ctx = build_base_ctx()?; Box::pin(commands::config::execute(&args, &base_ctx)).await?; } Commands::Workflow(ns) => { - let base_ctx = build_base_ctx()?; commands::workflow::dispatch(ns, &base_ctx)?; } Commands::Upgrade(args) => { - let base_ctx = build_base_ctx()?; commands::upgrade::run_upgrade(args, &base_ctx).await?; } Commands::Provider(ns) => { - let base_ctx = build_base_ctx()?; commands::provider::dispatch(ns, &base_ctx).await?; } Commands::Sandbox { command } => { - let base_ctx = build_base_ctx()?; commands::sandbox::dispatch(command, &base_ctx).await?; } Commands::System(ns) => { - let base_ctx = build_base_ctx()?; commands::system::dispatch(ns, &base_ctx).await?; } Commands::Completion(args) => { From 4318e07ad7be9ba5df23b832694e67e695eaa621 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 08:21:48 -0400 Subject: [PATCH 17/19] simplify: drop with_server_mode_from_loaded_settings test seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cfg(test) helper only re-implemented the production `with_server_mode` struct literal so tests could inject settings without disk I/O. Three tests used it, but each one was self-referential — asserting what the helper itself does rather than exercising production code. Remove the helper and those three tests. `synthetic_context_with_settings` is no longer needed either (its flexibility only mattered for the removed tests); collapse it into `synthetic_context`. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/command_context.rs | 146 ++------------------ 1 file changed, 8 insertions(+), 138 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index bca31e481..eef6594a8 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -149,26 +149,6 @@ impl CommandContext { server: OnceCell::new(), }) } - - #[cfg(test)] - fn with_server_mode_from_loaded_settings( - &self, - server_mode: ServerMode, - machine_settings: SettingsLayer, - user_settings: UserSettings, - ) -> Self { - Self { - printer: self.printer, - process_local_json: self.process_local_json, - cwd: self.cwd.clone(), - base_config_path: self.base_config_path.clone(), - cli_layer: self.cli_layer.clone(), - machine_settings, - user_settings, - server_mode, - server: OnceCell::new(), - } - } } fn load_merged_settings( @@ -202,9 +182,9 @@ mod tests { use std::path::PathBuf; use fabro_config::user::apply_storage_dir_override; - use fabro_config::{UserSettings, parse_settings_layer}; + use fabro_config::parse_settings_layer; use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity}; - use fabro_types::settings::{InterpString, SettingsLayer}; + use fabro_types::settings::InterpString; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -220,14 +200,11 @@ mod tests { } } - fn synthetic_context_with_settings( - process_local_json: bool, - printer: Printer, - cli_layer: CliLayer, - machine_settings: SettingsLayer, - user_settings: UserSettings, - server_mode: ServerMode, - ) -> CommandContext { + fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext { + let cli_layer = cli_layer_with_json_and_verbose(); + let (machine_settings, user_settings) = + merge_settings_layer(parse_settings_layer("_version = 1\n").unwrap(), &cli_layer) + .expect("settings should merge"); CommandContext { printer, process_local_json, @@ -236,26 +213,11 @@ mod tests { cli_layer, machine_settings, user_settings, - server_mode, + server_mode: ServerMode::None, server: OnceCell::new(), } } - fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext { - let cli_layer = cli_layer_with_json_and_verbose(); - let (machine_settings, user_settings) = - merge_settings_layer(parse_settings_layer("_version = 1\n").unwrap(), &cli_layer) - .expect("settings should merge"); - synthetic_context_with_settings( - process_local_json, - printer, - cli_layer, - machine_settings, - user_settings, - ServerMode::None, - ) - } - #[test] fn context_exposes_resolved_output_and_explicit_json_state() { let ctx = synthetic_context(true, Printer::Default); @@ -269,29 +231,6 @@ mod tests { assert_eq!(ctx.printer(), Printer::Default); } - #[test] - fn deriving_target_context_preserves_invocation_state() { - let base = synthetic_context(true, Printer::Verbose); - let derived = base.with_server_mode_from_loaded_settings( - ServerMode::ByTarget { - target_override: Some("https://fabro.example.com".to_string()), - }, - base.machine_settings().clone(), - base.user_settings().clone(), - ); - - assert_eq!(derived.printer(), Printer::Verbose); - assert!(derived.explicit_json_requested()); - assert_eq!(derived.user_settings(), base.user_settings()); - assert_eq!(derived.machine_settings(), base.machine_settings()); - assert_eq!(derived.cwd(), base.cwd()); - assert!(matches!( - derived.server_mode, - ServerMode::ByTarget { target_override: Some(ref target) } - if target == "https://fabro.example.com" - )); - } - #[test] fn storage_dir_override_only_changes_storage_root_in_merged_settings() { let cli_layer = cli_layer_with_json_and_verbose(); @@ -338,61 +277,6 @@ root = "/srv/fabro/default" ); } - #[test] - fn deriving_target_from_connection_context_discards_storage_override() { - let cli_layer = cli_layer_with_json_and_verbose(); - let base_disk_settings = parse_settings_layer( - r#" -_version = 1 - -[server.storage] -root = "/srv/fabro/default" -"#, - ) - .expect("settings fixture should parse"); - let override_disk_settings = apply_storage_dir_override( - base_disk_settings.clone(), - Some(std::path::Path::new("/srv/fabro/override")), - ); - - let (base_settings, base_user_settings) = - merge_settings_layer(base_disk_settings, &cli_layer) - .expect("base settings should merge"); - let (connection_settings, connection_user_settings) = - merge_settings_layer(override_disk_settings, &cli_layer) - .expect("connection settings should merge"); - let connection_ctx = synthetic_context_with_settings( - false, - Printer::Default, - cli_layer, - connection_settings, - connection_user_settings, - ServerMode::ByStorageDir { - target_override: None, - storage_dir_override: Some(PathBuf::from("/srv/fabro/override")), - }, - ); - - let derived = connection_ctx.with_server_mode_from_loaded_settings( - ServerMode::ByTarget { - target_override: Some("https://fabro.example.com".to_string()), - }, - base_settings, - base_user_settings, - ); - - assert_eq!( - derived - .machine_settings() - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.as_ref()) - .map(InterpString::as_source), - Some("/srv/fabro/default".to_string()) - ); - } - #[test] fn explicit_json_guard_uses_invocation_flag_not_resolved_output_format() { let json_ctx = synthetic_context(true, Printer::Default); @@ -401,18 +285,4 @@ root = "/srv/fabro/default" assert!(json_ctx.require_no_json_override().is_err()); assert!(text_ctx.require_no_json_override().is_ok()); } - - #[test] - fn target_resolution_errors_remain_deferred() { - let base = synthetic_context(false, Printer::Default); - let ctx = base.with_server_mode_from_loaded_settings( - ServerMode::ByTarget { - target_override: Some("not-a-valid-target".to_string()), - }, - base.machine_settings().clone(), - base.user_settings().clone(), - ); - - assert!(matches!(ctx.server_mode, ServerMode::ByTarget { .. })); - } } From 829a02f1f8390b6809448213a2e51f9f4e2a58e4 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 08:23:39 -0400 Subject: [PATCH 18/19] fix: reorder test imports to satisfy rustfmt Auto-applied by `cargo +nightly-2026-04-14 fmt --all`. `cargo fix` earlier inserted the import in a position that violated the grouped ordering. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/command_context.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index eef6594a8..14503abd8 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -181,10 +181,10 @@ fn merge_settings_layer( mod tests { use std::path::PathBuf; - use fabro_config::user::apply_storage_dir_override; use fabro_config::parse_settings_layer; - use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity}; + use fabro_config::user::apply_storage_dir_override; use fabro_types::settings::InterpString; + use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; From 3bfd93aa602a17e06a35bc84338c1fe4786bf76a Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 08:36:52 -0400 Subject: [PATCH 19/19] simplify: add CommandContext::verbose() accessor The check `ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose` repeated 6 times across doctor, preflight, run command/resume/mod. Add a `verbose()` method and replace every call site. `cargo fix` handles the now-unused `OutputVerbosity` imports. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/command_context.rs | 6 +++++- lib/crates/fabro-cli/src/commands/doctor.rs | 4 +--- lib/crates/fabro-cli/src/commands/preflight.rs | 4 +--- lib/crates/fabro-cli/src/commands/run/command.rs | 6 ++---- lib/crates/fabro-cli/src/commands/run/mod.rs | 3 +-- lib/crates/fabro-cli/src/commands/run/resume.rs | 3 +-- 6 files changed, 11 insertions(+), 15 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 14503abd8..29e260063 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -5,7 +5,7 @@ use anyhow::{Context as _, Result, bail}; use fabro_config::UserSettings; use fabro_config::merge::combine_files; use fabro_types::settings::SettingsLayer; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -100,6 +100,10 @@ impl CommandContext { self.user_settings.cli.output.format == OutputFormat::Json } + pub(crate) fn verbose(&self) -> bool { + self.user_settings.cli.output.verbosity == OutputVerbosity::Verbose + } + pub(crate) async fn server(&self) -> Result> { let server_mode = self.server_mode.clone(); let base_config_path = self.base_config_path.clone(); diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index 052ed191c..2ba2b813d 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -3,7 +3,6 @@ use std::path::PathBuf; use anyhow::Result; use fabro_api::types as api_types; use fabro_config::user::active_settings_path; -use fabro_types::settings::cli::OutputVerbosity; pub(crate) use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, }; @@ -143,8 +142,7 @@ pub(crate) async fn run_doctor( args: &DoctorArgs, base_ctx: &CommandContext, ) -> Result { - let verbose = - args.verbose || base_ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose; + let verbose = args.verbose || base_ctx.verbose(); let printer = base_ctx.printer(); let styles = Styles::detect_stdout(); let json = base_ctx.json_output(); diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 46144d8da..5f98e0179 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,7 +1,6 @@ use anyhow::bail; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::cli::OutputVerbosity; use fabro_util::terminal::Styles; use crate::args::PreflightArgs; @@ -20,8 +19,7 @@ pub(crate) async fn execute( let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let printer = base_ctx.printer(); let ctx = base_ctx.with_target(&args.target)?; - args.verbose = - args.verbose || ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose; + args.verbose = args.verbose || ctx.verbose(); let manifest = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index 90386bc3b..f824e4408 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use fabro_types::settings::cli::OutputVerbosity; use fabro_util::terminal::Styles; use crate::args::RunArgs; @@ -10,8 +9,7 @@ pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Res let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let printer = base_ctx.printer(); let ctx = base_ctx.with_target(&args.target)?; - args.verbose = - args.verbose || ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose; + args.verbose = args.verbose || ctx.verbose(); let quiet = args.detach; let prevent_idle_sleep = ctx.user_settings().cli.exec.prevent_idle_sleep; @@ -49,7 +47,7 @@ pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Res true, styles, json, - ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose, + ctx.verbose(), printer, )) .await?; diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 979b95528..a8d44ed9f 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use fabro_types::settings::cli::OutputVerbosity; use fabro_util::terminal::Styles; use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs}; @@ -62,7 +61,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, base_ctx: &CommandContext) -> Res false, styles, json, - ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose, + ctx.verbose(), printer, )) .await?; diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index 18afc4e3f..22db1bc5d 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -1,4 +1,3 @@ -use fabro_types::settings::cli::OutputVerbosity; use fabro_util::terminal::Styles; use crate::args::ResumeArgs; @@ -36,7 +35,7 @@ pub(crate) async fn resume_command( true, styles, json, - ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose, + ctx.verbose(), printer, )) .await?;