feat(install): make LLM setup optional in web installer and CLI (#265)
Some checks failed
Rust / Clippy (push) Waiting to run
Rust / Format (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled

## Summary

Makes LLM setup explicitly skippable in both the web installer and
`fabro install`, without making omission accidental. A skipped LLM step
lets install complete with zero LLM credentials; later LLM-dependent
workflows keep using the existing provider-not-configured behavior.
`fabro doctor` is intentionally unchanged.

Plan: `docs/superpowers/plans/2026-05-14-optional-llm-install.md`

## Key changes

**Server + API**
- `PUT /install/llm` now accepts `{"providers":[]}` as "LLM step
completed, skipped" — the empty-list rejection is removed; per-provider
validation for non-empty lists is retained.
- OpenAPI: dropped `minItems: 1` from
`InstallLlmProvidersInput.providers`, updated schema descriptions so
empty = skipped and `llm: null` = incomplete. TypeScript client
regenerated.
- `/install/finish` still requires the LLM step to be present, but
tolerates zero credentials — it writes settings, runtime auth secrets,
and GitHub secrets normally and writes no LLM vault entries.

**Web installer**
- New "Skip LLM setup" secondary action on the LLM step (via a
`secondaryAction` prop on `StepPanel`) that records an empty provider
list and advances to GitHub.
- Review screen shows `LLM providers: Skipped` (step completed, empty)
vs `Not configured` (step never completed), via a new
`describeLlmSummary` helper.
- Continue with no API keys still shows the existing validation error —
skipping is only reachable through the explicit skip action.

**CLI**
- Interactive `fabro install` asks "Configure LLM providers now?"
(default yes) before provider selection; declining returns an empty
selection and continues to GitHub.
- Hidden non-interactive `--skip-llm` flag, mutually exclusive with
`--llm-provider` / `--llm-api-key-stdin` / `--llm-api-key-env` via clap
`conflicts_with_all`. Missing LLM flags are still validation errors
unless `--skip-llm` is present. Non-interactive usage text updated with
a skip example.

## Code review

Ran a 12-reviewer `ce:review` pass (correctness, testing,
maintainability, project-standards, agent-native, learnings, security,
api-contract, reliability, adversarial, cli-readiness,
kieran-typescript). No P0/P1 findings; agent-native parity PASS. Applied
fixes in `40a29c591`:
- Re-entrancy guard on `runStepSubmit` so a fast double-click on "Skip
LLM setup" can't fire two requests.
- `validate()` only suggests `--skip-llm` in the missing-provider error
when no credential flag is set (it conflicts with those flags).
- Added tests: all three `--skip-llm` conflict arms, the review screen's
"Not configured" branch, and the skip-button failure path.

One advisory finding left as report-only: an empty `PUT /install/llm`
overwrites previously-saved credentials if a user navigates Back and
clicks Skip — judged acceptable since the button is explicitly labeled
and clicking it is deliberate.

## Testing

- `cargo nextest run -p fabro-server -p fabro-cli -p fabro-install` —
1521 passed
- `cargo build -p fabro-api`, `cargo fmt --check`, `cargo clippy`
(changed crates) — clean
- `bun test` (install-app) — 14 passed; `bun run typecheck` — clean
- New coverage: server accepts empty providers + session shows `llm`
complete with `providers:[]`; finish with skipped LLM persists no LLM
vault credentials but keeps GitHub secrets; web skip button PUTs
`providers:[]` and navigates to GitHub; review renders Skipped / Not
configured; CLI `--skip-llm` requires `--non-interactive`, conflicts
with all credential flags, `validate()` succeeds with `--skip-llm`,
usage text documents `--skip-llm`.

Not added (out of plan scope): an automated test for the interactive
`InstallInputSource` skip branch — `InteractiveInstallInputSource` is
TTY-coupled and has no existing tests; the non-interactive `--skip-llm`
path is fully covered.

## Post-Deploy Monitoring & Validation

This change is install-time only; there is no continuous runtime impact.
Validate during the next install/release smoke:

- **Web installer:** run a fresh browser install, click "Skip LLM setup"
on the LLM step, confirm it advances to GitHub and the review screen
reads `LLM providers: Skipped`. Finish the install and confirm the
server restarts into normal mode with no LLM credentials in the vault
(`secrets.json` has no credential entries) and
GitHub/server/object-store/sandbox settings written normally.
- **CLI:** run `fabro install --non-interactive --skip-llm
--github-strategy token --github-username <user>` and confirm it
completes; run interactive `fabro install` and confirm declining
"Configure LLM providers now?" continues to GitHub.
- **Healthy signals:** install completes (web `/install/finish` → 202;
CLI exits 0), server boots in normal mode, `fabro doctor` runs and
reports no LLM providers configured (expected, unchanged behavior).
- **Failure signals / rollback trigger:** install fails to finish,
server fails to boot after a skipped install, or `/install/finish`
rejects a completed-but-empty LLM step. Rollback = revert this PR;
install behavior returns to requiring at least one LLM provider.
- **Validation window/owner:** next install smoke / release
verification, owned by whoever runs the release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-14 19:34:39 -07:00 committed by GitHub
parent 869b94c0c4
commit 32f100cbe7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 821 additions and 44 deletions

View file

@ -904,4 +904,382 @@ describe("InstallApp", () => {
console.error = originalConsoleError;
}
});
test("skips LLM setup with an empty providers list and advances to GitHub", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const fetchMock = mock((input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
if (String(input) === "/install/session" && fetchCalls.length === 1) {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "sandbox"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: { provider: "docker" },
github: null,
prefill: INSTALL_PREFILL,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
}
if (String(input) === "/install/llm") {
return Promise.resolve(new Response(null, { status: 204 }));
}
if (String(input) === "/install/session") {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "sandbox", "llm"],
llm: { providers: [] },
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: { provider: "docker" },
github: null,
prefill: INSTALL_PREFILL,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/llm");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/llm"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
});
const skipButton = renderer!.root.findAll(
(node) => node.type === "button" && node.children.includes("Skip LLM setup"),
)[0];
expect(skipButton).toBeDefined();
await act(async () => {
skipButton!.props.onClick();
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Connect GitHub");
});
const calls = fetchCalls.map((call) => String(call.input));
const putIdx = calls.indexOf("/install/llm");
expect(putIdx).toBeGreaterThanOrEqual(0);
expect(fetchCalls[putIdx]?.init?.body).toBe(JSON.stringify({ providers: [] }));
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("blocks Continue on the LLM step when no API keys are entered", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchMock = mock((input: RequestInfo | URL) => {
if (String(input) === "/install/session") {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "sandbox"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: { provider: "docker" },
github: null,
prefill: INSTALL_PREFILL,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/llm");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/llm"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
});
const form = renderer!.root.findByType("form");
await act(async () => {
form.props.onSubmit({ preventDefault() {} });
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain(
"Add at least one provider API key before continuing.",
);
});
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("shows a skipped LLM step as Skipped on the review step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchMock = mock((input: RequestInfo | URL) => {
expect(String(input)).toBe("/install/session");
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "sandbox", "llm", "github"],
llm: { providers: [] },
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: { provider: "docker" },
github: { strategy: "token", username: "octocat" },
prefill: INSTALL_PREFILL,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
});
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/review");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/review"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
const text = renderTreeText(renderer!.toJSON());
expect(text).toContain("LLM providers");
expect(text).toContain("Skipped");
});
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("shows an incomplete LLM step as Not configured on the review step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchMock = mock((input: RequestInfo | URL) => {
expect(String(input)).toBe("/install/session");
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "sandbox", "github"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: { provider: "docker" },
github: { strategy: "token", username: "octocat" },
prefill: INSTALL_PREFILL,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
});
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/review");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/review"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
const text = renderTreeText(renderer!.toJSON());
expect(text).toContain("LLM providers");
expect(text).toContain("Not configured");
});
expect(renderTreeText(renderer!.toJSON())).not.toContain("Skipped");
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("keeps the user on the LLM step when skipping fails", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const fetchMock = mock((input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
if (String(input) === "/install/session") {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "sandbox"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: { provider: "docker" },
github: null,
prefill: INSTALL_PREFILL,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
}
if (String(input) === "/install/llm") {
return Promise.resolve(new Response(null, { status: 500 }));
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
useInstallFetchMock(fetchMock as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/llm");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/llm"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
});
const skipButton = renderer!.root.findAll(
(node) => node.type === "button" && node.children.includes("Skip LLM setup"),
)[0];
await act(async () => {
skipButton!.props.onClick();
});
// The failed PUT must not advance to GitHub or refresh the session.
await waitFor(() => {
expect(fetchCalls.map((call) => String(call.input))).toContain("/install/llm");
});
const text = renderTreeText(renderer!.toJSON());
expect(text).toContain("Add your LLM credentials");
expect(text).not.toContain("Connect GitHub");
expect(
fetchCalls.filter((call) => String(call.input) === "/install/session"),
).toHaveLength(1);
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
});

View file

@ -304,6 +304,11 @@ export default function InstallApp() {
fallback: string;
next?: string;
}) => {
// Re-entrancy guard: the StepPanel form guards its own onSubmit, but the
// LLM step's "Skip LLM setup" button calls this directly, so a fast
// double-click could otherwise fire two requests before `submitting`
// re-renders the disabled state.
if (submitting) return;
setSubmitting(true);
setSaveError(null);
try {
@ -359,10 +364,26 @@ export default function InstallApp() {
) : location.pathname === "/install/llm" ? (
<StepPanel
title="Add your LLM credentials"
description="Each key you enter is validated before it's saved. Skip a provider by leaving it blank."
description="Each key you enter is validated before it's saved. Skip a provider by leaving it blank, or skip LLM setup entirely and configure providers later."
error={saveError}
submitting={submitting}
backHref="/install/sandbox"
secondaryAction={
<button
type="button"
disabled={submitting}
className={SECONDARY_BUTTON_CLASS}
onClick={() => {
void runStepSubmit({
action: () => putInstallLlm(installToken, []),
fallback: "Failed to skip LLM setup.",
next: "/install/github",
});
}}
>
Skip LLM setup
</button>
}
onSubmit={async () => {
const providers = INSTALL_PROVIDERS.map(({ id }) => {
const current = llmSelection[id] ?? { apiKey: "" };
@ -1164,6 +1185,7 @@ function StepPanel({
submitLabel = "Continue",
submittingLabel = "Saving...",
backHref,
secondaryAction,
onSubmit,
}: {
title: string;
@ -1174,6 +1196,7 @@ function StepPanel({
submitLabel?: string;
submittingLabel?: string;
backHref?: string;
secondaryAction?: ReactNode;
onSubmit: () => Promise<void>;
}) {
return (
@ -1204,19 +1227,22 @@ function StepPanel({
) : (
<span />
)}
<button type="submit" disabled={submitting} className={PRIMARY_BUTTON_CLASS}>
{submitting ? (
<>
<Spinner />
{submittingLabel}
</>
) : (
<>
{submitLabel}
<ArrowRightIcon className="size-4 shrink-0" />
</>
)}
</button>
<div className="flex items-center gap-3">
{secondaryAction}
<button type="submit" disabled={submitting} className={PRIMARY_BUTTON_CLASS}>
{submitting ? (
<>
<Spinner />
{submittingLabel}
</>
) : (
<>
{submitLabel}
<ArrowRightIcon className="size-4 shrink-0" />
</>
)}
</button>
</div>
</div>
</form>
);
@ -1233,9 +1259,7 @@ function ReviewScreen({
submitting: boolean;
onInstall: () => Promise<void>;
}) {
const providers = (session?.llm?.providers ?? [])
.map((provider) => describeProvider(provider.provider))
.join(", ");
const llmSummary = describeLlmSummary(session?.llm);
const serverUrl =
session?.server?.canonical_url || session?.prefill.canonical_url || "Unknown";
return (
@ -1265,7 +1289,7 @@ function ReviewScreen({
/>
{renderObjectStoreSummaryRows(session?.object_store)}
{renderSandboxSummaryRows(session?.sandbox)}
<SummaryRow label="LLM providers" value={providers || "Not configured"} />
<SummaryRow label="LLM providers" value={llmSummary} />
{renderGithubSummaryRows(session?.github, serverUrl)}
</dl>
{error ? <ErrorMessage message={error} /> : null}
@ -1872,6 +1896,18 @@ function describeProvider(id: string): string {
return match?.label ?? id;
}
function describeLlmSummary(llm: InstallSessionResponse["llm"]): string {
// `null` means the LLM step has not been completed. A present summary with
// an empty providers list is an explicit skip.
if (!llm) {
return "Not configured";
}
const providers = (llm.providers ?? []).map((provider) =>
describeProvider(provider.provider),
);
return providers.length > 0 ? providers.join(", ") : "Skipped";
}
function renderGithubSummaryRows(
github: InstallSessionResponse["github"],
serverUrl: string,

View file

@ -143,7 +143,10 @@ paths:
operationId: putInstallLlm
tags: [Install]
summary: Save install LLM settings
description: Records the LLM providers and API keys chosen during the browser install. Requires the one-time install token.
description: >-
Records the LLM providers and API keys chosen during the browser
install. An empty `providers` list marks the LLM step as completed
and explicitly skipped. Requires the one-time install token.
security: []
requestBody:
required: true
@ -3978,14 +3981,18 @@ components:
type: string
InstallLlmProvidersInput:
description: LLM providers selected during browser install.
description: >-
LLM providers selected during browser install. An empty `providers`
list explicitly marks the LLM step as completed and skipped.
type: object
required:
- providers
properties:
providers:
type: array
minItems: 1
description: >-
LLM providers to persist. An empty list records an explicit skip:
the LLM step is marked complete with zero credentials.
items:
$ref: "#/components/schemas/InstallLlmProviderInput"
@ -4003,7 +4010,10 @@ components:
type: string
InstallLlmSummary:
description: Redacted summary of persisted LLM install choices.
description: >-
Redacted summary of persisted LLM install choices. Present with an
empty `providers` list when the LLM step was explicitly skipped;
`null` on the install session means the step is still incomplete.
type: object
properties:
providers:

View file

@ -0,0 +1,65 @@
# Optional LLM Setup in Installers Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make LLM setup explicitly skippable in both the web installer and `fabro install`, while preserving current behavior for users who do configure LLMs.
**Architecture:** Represent an explicit web-installer skip as a completed LLM step with an empty provider list. Represent an explicit CLI skip with an interactive confirmation path and a hidden non-interactive `--skip-llm` flag. Runtime model resolution and doctor behavior remain unchanged.
**Tech Stack:** Rust CLI/server (`fabro-cli`, `fabro-server`, `fabro-api`), OpenAPI-generated clients, React/TypeScript web installer, Bun tests, cargo-nextest.
---
## Summary
Make LLM setup optional without making omission accidental. Users can explicitly skip LLM setup and still complete installation. A skipped LLM step means install can complete with zero LLM credentials; later LLM-dependent workflows keep using existing provider-not-configured behavior. Per product decision, `fabro doctor` remains unchanged and may report no LLM providers configured.
## Key Changes
- **Web install API:** Treat `PUT /install/llm` with `{"providers":[]}` as "LLM step completed, skipped."
- Remove `minItems: 1` from `InstallLlmProvidersInput.providers` in `docs/public/api-reference/fabro-api.yaml`.
- Update schema descriptions so empty `providers` explicitly means skipped.
- Keep `/install/finish` requiring the LLM step to be completed, but allow the completed step to contain zero providers.
- **Web UI:** Add an explicit "Skip LLM setup" secondary action on the LLM step.
- It sends `putInstallLlm(token, [])`, refreshes the install session, and advances to GitHub.
- Review screen should show `LLM providers: Skipped`, not `Not configured`, when `session.llm` exists with an empty provider list.
- Keep blank/no-key "Continue" validation unchanged: users cannot accidentally continue with zero providers unless they press the skip action.
- **CLI installer:** Add explicit skip behavior.
- Interactive `fabro install`: before provider selection, ask whether to configure LLM providers now, defaulting to yes. Choosing no returns an empty LLM selection and continues to GitHub.
- Non-interactive `fabro install`: add hidden `--skip-llm`. It is mutually exclusive with `--llm-provider`, `--llm-api-key-stdin`, and `--llm-api-key-env`.
- Keep accidental missing LLM flags as validation errors unless `--skip-llm` is present.
- Update non-interactive usage text to include a skip example.
- **Generated clients/types:** After OpenAPI edit, regenerate/build the API surfaces used by Rust and web:
- `cargo build -p fabro-api`
- `cd lib/packages/fabro-api-client && bun run generate`
## Test Plan
- **Server API tests:** Add coverage that `PUT /install/llm` accepts an empty providers list, marks `llm` complete in `/install/session`, and returns `llm.providers: []`.
- **Finish persistence tests:** Add a browser-install finish test with skipped LLMs that asserts:
- `/install/finish` returns `202`
- settings and runtime auth secrets are written
- no LLM credential entries are written to the vault
- GitHub secrets still persist normally
- **Web tests:** Add/update tests for:
- clicking "Skip LLM setup" calls `PUT /install/llm` with `providers: []` and navigates to GitHub
- review screen renders skipped LLMs as `Skipped`
- pressing Continue with no API keys still shows the existing validation error
- **CLI tests:** Add/update unit and integration coverage for:
- `--skip-llm` requires `--non-interactive`
- `--skip-llm` conflicts with all LLM credential flags
- non-interactive validation succeeds with `--skip-llm` plus required GitHub/config flags
- hidden usage text includes `--skip-llm`
- existing no-input non-interactive install still fails
- **Verification commands:**
- `cargo nextest run -p fabro-server -p fabro-cli -p fabro-install`
- `cargo build -p fabro-api`
- `cd apps/fabro-web && bun test`
- `cd apps/fabro-web && bun run typecheck`
## Assumptions
- Empty `providers` is the install API representation of an explicit LLM skip; `llm: null` still means the step is incomplete.
- GitHub, server URL, object store, and sandbox setup remain required.
- `fabro doctor` behavior is intentionally unchanged after a skipped LLM install.
- No changes are made to workflow execution or model resolution beyond allowing install to finish without credentials.

View file

@ -1561,6 +1561,14 @@ pub(crate) struct InstallNonInteractiveArgs {
#[arg(long, hide = true)]
pub(crate) llm_api_key_env: Option<String>,
/// Skip LLM setup entirely; install completes with zero LLM credentials
#[arg(
long,
hide = true,
conflicts_with_all = ["llm_provider", "llm_api_key_stdin", "llm_api_key_env"]
)]
pub(crate) skip_llm: bool,
#[arg(long, hide = true)]
pub(crate) github_strategy: Option<InstallGitHubStrategyArg>,

View file

@ -181,6 +181,7 @@ impl InstallNonInteractiveArgs {
self.llm_provider.is_some()
|| self.llm_api_key_stdin
|| self.llm_api_key_env.is_some()
|| self.skip_llm
|| self.github_strategy.is_some()
|| self.github_owner.is_some()
|| self.github_username.is_some()
@ -196,6 +197,8 @@ impl InstallNonInteractiveArgs {
Some("--llm-api-key-stdin")
} else if self.llm_api_key_env.is_some() {
Some("--llm-api-key-env")
} else if self.skip_llm {
Some("--skip-llm")
} else if self.github_strategy.is_some() {
Some("--github-strategy")
} else if self.github_owner.is_some() {
@ -236,10 +239,16 @@ Non-interactive usage:
--github-strategy app \
--github-owner personal
fabro install --non-interactive \
--skip-llm \
--github-strategy token \
--github-username brynary
Hidden non-interactive flags:
--llm-provider <PROVIDER>
--llm-api-key-stdin
--llm-api-key-env <ENV_VAR>
--skip-llm
--github-strategy <token|app>
--github-owner <personal|org:SLUG>
--github-username <USERNAME>
@ -249,6 +258,8 @@ Hidden non-interactive flags:
Notes:
- Only one API-key-based LLM provider is supported in non-interactive mode.
- Pass --skip-llm to finish install without configuring any LLM provider;
it cannot be combined with the --llm-provider or --llm-api-key-* flags.
- GitHub App setup prints a local handoff URL and waits for the browser callback."#
}
@ -354,6 +365,19 @@ impl InstallInputSource for InteractiveInstallInputSource {
s: &Styles,
printer: Printer,
) -> Result<LlmInstallSelection> {
let configure_llm =
spawn_blocking(|| prompt_confirm("Configure LLM providers now?", true)).await??;
if !configure_llm {
fabro_util::printerr!(
printer,
" {} Skipping LLM setup — configure providers later with `fabro provider login`",
s.green.apply_to("")
);
return Ok(LlmInstallSelection {
credentials: Vec::new(),
});
}
let mut credentials = Vec::new();
let mut configured_providers: Vec<Provider> = Vec::new();
let mut openai_configured = false;
@ -514,10 +538,14 @@ impl NonInteractiveInstallInputSource {
bail!("{}", non_interactive_install_usage());
}
anyhow::ensure!(
args.scripted.llm_api_key_stdin ^ args.scripted.llm_api_key_env.is_some(),
"non-interactive install requires exactly one of --llm-api-key-stdin or --llm-api-key-env"
);
// `--skip-llm` opts out of LLM setup entirely, so the API-key flags are
// neither required nor allowed (clap enforces the conflict).
if !args.scripted.skip_llm {
anyhow::ensure!(
args.scripted.llm_api_key_stdin ^ args.scripted.llm_api_key_env.is_some(),
"non-interactive install requires exactly one of --llm-api-key-stdin or --llm-api-key-env"
);
}
anyhow::ensure!(
!(args.scripted.overwrite_settings && args.scripted.keep_existing_settings),
"--overwrite-settings and --keep-existing-settings cannot be used together"
@ -529,10 +557,17 @@ impl NonInteractiveInstallInputSource {
}
fn validate(&self, config_exists: bool) -> Result<()> {
anyhow::ensure!(
self.args.llm_provider.is_some(),
"non-interactive install requires --llm-provider"
);
if !self.args.skip_llm && self.args.llm_provider.is_none() {
// Only suggest --skip-llm when no LLM credential flag is present;
// it conflicts with the credential flags, so suggesting it
// alongside one would just send the caller into a conflict error.
let has_api_key_flag =
self.args.llm_api_key_stdin || self.args.llm_api_key_env.is_some();
if has_api_key_flag {
bail!("non-interactive install requires --llm-provider");
}
bail!("non-interactive install requires --llm-provider (or --skip-llm)");
}
match self.args.github_strategy {
Some(InstallGitHubStrategyArg::Token) => {
@ -599,6 +634,11 @@ impl InstallInputSource for NonInteractiveInstallInputSource {
s: &Styles,
printer: Printer,
) -> Result<LlmInstallSelection> {
if self.args.skip_llm {
return Ok(LlmInstallSelection {
credentials: Vec::new(),
});
}
let provider = self
.args
.llm_provider
@ -3044,6 +3084,66 @@ root = "{}"
);
}
#[test]
fn non_interactive_source_accepts_skip_llm_without_credential_flags() {
let args = install_args(true, InstallNonInteractiveArgs {
skip_llm: true,
github_strategy: Some(InstallGitHubStrategyArg::Token),
github_username: Some("brynary".to_string()),
..InstallNonInteractiveArgs::default()
});
// `--skip-llm` alone is enough scripted input; the API-key flags are
// neither required nor allowed when skipping LLM setup.
NonInteractiveInstallInputSource::new(&args)
.unwrap()
.expect("--skip-llm should be accepted as non-interactive input");
}
#[test]
fn non_interactive_source_validate_allows_skip_llm_without_provider() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
skip_llm: true,
github_strategy: Some(InstallGitHubStrategyArg::Token),
github_username: Some("brynary".to_string()),
..InstallNonInteractiveArgs::default()
},
};
source.validate(false).unwrap();
}
#[tokio::test]
async fn non_interactive_source_skip_llm_collects_no_credentials() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
skip_llm: true,
github_strategy: Some(InstallGitHubStrategyArg::Token),
github_username: Some("brynary".to_string()),
..InstallNonInteractiveArgs::default()
},
};
let facts = InstallFacts {
codex_detected: false,
};
let selection = source
.collect_llm_selection(&facts, &Styles::detect_stderr(), Printer::Silent)
.await
.unwrap();
assert!(
selection.credentials.is_empty(),
"--skip-llm should collect zero LLM credentials"
);
}
#[test]
fn non_interactive_install_usage_documents_skip_llm() {
let usage = non_interactive_install_usage();
assert!(usage.contains("--skip-llm"));
}
#[test]
fn non_interactive_source_rejects_missing_github_strategy() {
let source = NonInteractiveInstallInputSource {

View file

@ -180,6 +180,78 @@ fn hidden_non_interactive_args_require_non_interactive() {
assert!(stderr.contains("requires --non-interactive"));
}
#[test]
fn skip_llm_requires_non_interactive() {
let context = test_context!();
let output = context
.command()
.args(["install", "--skip-llm"])
.output()
.expect("command should run");
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("--skip-llm requires --non-interactive"));
}
#[test]
fn skip_llm_conflicts_with_llm_credential_flags() {
let context = test_context!();
let provider_conflict = context
.command()
.args([
"install",
"--non-interactive",
"--skip-llm",
"--llm-provider",
"anthropic",
])
.output()
.expect("command should run");
assert!(!provider_conflict.status.success());
let stderr = String::from_utf8(provider_conflict.stderr).unwrap();
assert!(
stderr.contains("--skip-llm") && stderr.contains("--llm-provider"),
"expected a conflict error between --skip-llm and --llm-provider: {stderr}"
);
let stdin_conflict = context
.command()
.args([
"install",
"--non-interactive",
"--skip-llm",
"--llm-api-key-stdin",
])
.output()
.expect("command should run");
assert!(!stdin_conflict.status.success());
let stderr = String::from_utf8(stdin_conflict.stderr).unwrap();
assert!(
stderr.contains("--skip-llm") && stderr.contains("--llm-api-key-stdin"),
"expected a conflict error between --skip-llm and --llm-api-key-stdin: {stderr}"
);
let env_conflict = context
.command()
.args([
"install",
"--non-interactive",
"--skip-llm",
"--llm-api-key-env",
"ANTHROPIC_API_KEY",
])
.output()
.expect("command should run");
assert!(!env_conflict.status.success());
let stderr = String::from_utf8(env_conflict.stderr).unwrap();
assert!(
stderr.contains("--skip-llm") && stderr.contains("--llm-api-key-env"),
"expected a conflict error between --skip-llm and --llm-api-key-env: {stderr}"
);
}
#[test]
fn github_requires_prior_install() {
let context = test_context!();

View file

@ -800,14 +800,9 @@ async fn put_install_llm(
}
observe_operator(&state, &headers);
if input.providers.is_empty() {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({ "error": "at least one LLM provider is required" })),
)
.into_response();
}
// An empty providers list is an explicit skip: the LLM step is recorded as
// completed with zero credentials. `/install/finish` still requires the
// step to be present, just not populated.
for provider in &input.providers {
if let Some(error) = unsupported_install_provider_error(provider.provider) {
return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, error);

View file

@ -162,6 +162,23 @@ async fn put_install_llm(app: &axum::Router, token: &str) {
response_status(response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
}
async fn put_install_llm_skipped(app: &axum::Router, token: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/llm")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"providers":[]}"#))
.expect("skipped LLM install request should build"),
)
.await
.unwrap();
response_status(response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
}
async fn put_install_github_token(app: &axum::Router, token: &str, username: &str) {
let response = app
.clone()
@ -884,6 +901,99 @@ async fn token_install_finish_persists_settings_env_and_vault() {
assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
}
#[tokio::test]
async fn install_llm_accepts_empty_providers_as_explicit_skip() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
put_install_llm_skipped(&app, "test-install-token").await;
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert!(
session_body["completed_steps"]
.as_array()
.unwrap()
.iter()
.any(|value| value == "llm"),
"skipped LLM step should still count as completed"
);
assert_eq!(
session_body["llm"]["providers"],
serde_json::json!([]),
"skipped LLM step should expose an empty providers list"
);
}
#[tokio::test]
async fn browser_install_finish_with_skipped_llm_persists_no_llm_credentials() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm_skipped(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
assert_eq!(finish_body["status"], "completing");
let settings = std::fs::read_to_string(&config_path).unwrap();
assert!(settings.contains("https://fabro.example.com"));
assert!(settings.contains("strategy = \"token\""));
let server_env = std::fs::read_to_string(
fabro_config::Storage::new(temp_dir.path())
.runtime_directory()
.env_path(),
)
.unwrap();
assert!(server_env.contains("SESSION_SECRET="));
assert!(server_env.contains("FABRO_DEV_TOKEN="));
let vault = Vault::load(fabro_config::Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert!(
vault.credential_entries().is_empty(),
"skipped LLM install should not write any credential vault entries"
);
assert_eq!(
vault.get("GITHUB_TOKEN"),
Some("ghp_test_token"),
"GitHub secrets still persist when the LLM step is skipped"
);
}
#[tokio::test]
async fn token_install_finish_invokes_finish_hook_before_response_returns() {
let temp_dir = tempfile::tempdir().unwrap();

View file

@ -233,7 +233,7 @@ export const InstallApiAxiosParamCreator = function (configuration?: Configurati
};
},
/**
* Records the LLM providers and API keys chosen during the browser install. Requires the one-time install token.
* Records the LLM providers and API keys chosen during the browser install. An empty `providers` list marks the LLM step as completed and explicitly skipped. Requires the one-time install token.
* @summary Save install LLM settings
* @param {InstallLlmProvidersInput} installLlmProvidersInput
* @param {*} [options] Override http request option.
@ -586,7 +586,7 @@ export const InstallApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Records the LLM providers and API keys chosen during the browser install. Requires the one-time install token.
* Records the LLM providers and API keys chosen during the browser install. An empty `providers` list marks the LLM step as completed and explicitly skipped. Requires the one-time install token.
* @summary Save install LLM settings
* @param {InstallLlmProvidersInput} installLlmProvidersInput
* @param {*} [options] Override http request option.
@ -748,7 +748,7 @@ export const InstallApiFactory = function (configuration?: Configuration, basePa
return localVarFp.putInstallGithubToken(installGithubTokenInput, options).then((request) => request(axios, basePath));
},
/**
* Records the LLM providers and API keys chosen during the browser install. Requires the one-time install token.
* Records the LLM providers and API keys chosen during the browser install. An empty `providers` list marks the LLM step as completed and explicitly skipped. Requires the one-time install token.
* @summary Save install LLM settings
* @param {InstallLlmProvidersInput} installLlmProvidersInput
* @param {*} [options] Override http request option.
@ -889,7 +889,7 @@ export class InstallApi extends BaseAPI {
}
/**
* Records the LLM providers and API keys chosen during the browser install. Requires the one-time install token.
* Records the LLM providers and API keys chosen during the browser install. An empty `providers` list marks the LLM step as completed and explicitly skipped. Requires the one-time install token.
* @summary Save install LLM settings
* @param {InstallLlmProvidersInput} installLlmProvidersInput
* @param {*} [options] Override http request option.

View file

@ -18,9 +18,12 @@
import type { InstallLlmProviderInput } from './install-llm-provider-input';
/**
* LLM providers selected during browser install.
* LLM providers selected during browser install. An empty `providers` list explicitly marks the LLM step as completed and skipped.
*/
export interface InstallLlmProvidersInput {
/**
* LLM providers to persist. An empty list records an explicit skip: the LLM step is marked complete with zero credentials.
*/
'providers': Array<InstallLlmProviderInput>;
}

View file

@ -18,7 +18,7 @@
import type { InstallLlmSummaryProvidersInner } from './install-llm-summary-providers-inner';
/**
* Redacted summary of persisted LLM install choices.
* Redacted summary of persisted LLM install choices. Present with an empty `providers` list when the LLM step was explicitly skipped; `null` on the install session means the step is still incomplete.
*/
export interface InstallLlmSummary {
'providers'?: Array<InstallLlmSummaryProvidersInner>;