feat(dev): generate options reference

Add a settings reference generator backed by OptionsMetadata on the sparse config layer structs. The generated user-configuration page is fenced and checked in CI alongside the CLI reference.
This commit is contained in:
Bryan Helmkamp 2026-04-24 16:49:16 -04:00
parent 0f3c28db87
commit 12ea5341bb
No known key found for this signature in database
14 changed files with 796 additions and 211 deletions

View file

@ -12,6 +12,7 @@ on:
- ".config/**"
- "bin/dev/**"
- "docs/reference/cli.mdx"
- "docs/reference/user-configuration.mdx"
- "openapi/**"
- ".github/workflows/rust.yml"
pull_request:
@ -25,6 +26,7 @@ on:
- ".config/**"
- "bin/dev/**"
- "docs/reference/cli.mdx"
- "docs/reference/user-configuration.mdx"
- "openapi/**"
- ".github/workflows/rust.yml"
workflow_dispatch:
@ -86,6 +88,7 @@ jobs:
with:
cache-on-failure: true
- run: cargo dev generate-cli-reference --check
- run: cargo dev generate-options-reference --check
test:
name: Test (Linux)

3
Cargo.lock generated
View file

@ -1741,6 +1741,7 @@ dependencies = [
"clap",
"dirs",
"fabro-macros",
"fabro-options-metadata",
"fabro-proc",
"fabro-static",
"fabro-types",
@ -1781,6 +1782,8 @@ dependencies = [
"chrono",
"clap",
"fabro-cli",
"fabro-config",
"fabro-options-metadata",
"regex",
"tempfile",
"tracing-subscriber",

View file

@ -858,7 +858,7 @@ impl fabro_options_metadata::OptionsMetadata for RunArgs {
---
- [ ] **Unit 6.3: Add `cargo dev generate-options-reference` for `user-configuration.mdx`**
- [x] **Unit 6.3: Add `cargo dev generate-options-reference` for `user-configuration.mdx`**
**Goal:** Close the settings-schema drift gap in `docs/reference/user-configuration.mdx` with a generator driven by `OptionsMetadata` on settings structs.
@ -880,6 +880,8 @@ impl fabro_options_metadata::OptionsMetadata for RunArgs {
- Use the same fenced-region approach as Unit 6.2: restructure `user-configuration.mdx` once to introduce fences, commit, then regenerate.
- Same determinism requirements: sorted order, LF-only, trimmed whitespace.
**Implementation note:** Landed against `fabro-config`'s sparse layer structs rather than `fabro-types`' resolved runtime structs. The sparse layer structs are the TOML input schema and preserve names like `prevent_idle_sleep`, `auto_merge`, and transport-specific MCP entries, so they are the correct metadata source for `user-configuration.mdx`. The resolved `fabro-types` structs remain the runtime view after defaults and validation.
**Patterns to follow:**
- Unit 6.2 (same generator shape, different source structs and different target file).
- uv: `uv-dev/src/generate_options_reference.rs`.

View file

@ -112,128 +112,8 @@ Authorization = "Bearer sk-xxx"
All fields are optional. Include only the sections and keys you want to override. A single file can still include both CLI and server sections when you run both processes on one machine, but explicit remote targets do not read remote server state from the local machine.
## `[cli.updates]`
Controls whether Fabro runs a daily background check for new releases. The check runs during `run`, `exec`, `init`, and `install` commands and prints a notice to stderr when a newer version is available.
```toml title="settings.toml"
[cli.updates]
check = true
```
| Key | Value | Description |
|---|---|---|
| `check` | `true` | Check for new releases (default) |
| `check` | `false` | Disable automatic upgrade checks |
The `--no-upgrade-check` CLI flag overrides this for a single invocation. See [`fabro upgrade`](/reference/cli#fabro-upgrade) for manual upgrades.
## `[cli.output]`
Generic CLI output defaults.
```toml title="settings.toml"
[cli.output]
format = "text"
verbosity = "verbose"
```
| Key | Values | Default |
|---|---|---|
| `format` | `"text"`, `"json"` | `"text"` |
| `verbosity` | `"quiet"`, `"normal"`, `"verbose"` | `"normal"` |
The `-v` / `--verbose` CLI flag always takes effect regardless of this setting.
## `[cli.exec]` section
Defaults for `fabro exec` sessions.
```toml title="settings.toml"
[cli.exec]
prevent_idle_sleep = true
[cli.exec.model]
provider = "anthropic"
name = "claude-opus-4-6"
[cli.exec.agent]
permissions = "read-write"
```
`[cli.exec.model]` selects the default LLM for exec:
| Key | Description | Values |
|---|---|---|
| `provider` | LLM provider | `"anthropic"`, `"openai"`, `"gemini"`, etc. |
| `name` | Model name | Any model ID from `fabro model list` |
`[cli.exec.agent]` controls agent behavior during exec:
| Key | Description | Values | Default |
|---|---|---|---|
| `permissions` | Tool permission level | `"read-only"`, `"read-write"`, `"full"` | `"read-write"` |
### Permission levels
- **`read-only`** — auto-approves read tools (`read_file`, `grep`, `glob`, `list_dir`) and subagent tools
- **`read-write`** — adds write tools (`write_file`, `edit_file`, `apply_patch`)
- **`full`** — allows all tools including shell commands
Tools outside the permission level are interactively prompted (if a TTY is present) or denied (with `--auto-approve`).
## `[run.model]` section
Defaults for workflow model selection in commands like `fabro run` and `fabro preflight`.
```toml title="settings.toml"
[run.model]
provider = "anthropic"
name = "claude-sonnet-4-5"
fallbacks = ["openai", "gpt-5.4", "gemini/gemini-flash"]
```
| Key | Description | Values | Default |
|---|---|---|---|
| `name` | Model name | Any model ID from `fabro model list` | Per provider |
| `provider` | Provider name | `"anthropic"`, `"openai"`, `"gemini"`, etc. | Auto-inferred from model/catalog |
| `fallbacks` | Ordered list of fallback model references | bare provider, bare alias, or `provider/model` | `[]` |
<Note>
Use `[cli.exec.model]` to configure provider and model for `fabro exec`. Use `[run.model]` for workflow-oriented defaults.
</Note>
## `[cli.logging]` section
Configure the default CLI log level. Precedence: `FABRO_LOG` env var > `--debug` flag > `[cli.logging].level` > `"info"`.
```toml title="settings.toml"
[cli.logging]
level = "info"
```
| Key | Values | Default |
|---|---|---|
| `level` | `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"` | `"info"` |
Server-side logging is a separate namespace at `[server.logging]`.
## `[run.git.author]`
Customize the git author identity used for checkpoint commits.
```toml title="settings.toml"
[run.git.author]
name = "fabro-bot"
email = "fabro-bot@company.com"
```
| Key | Description | Default |
|---|---|---|
| `name` | Git author name | `"fabro"` |
| `email` | Git author email | `"fabro@local"` |
## `[cli.target]` section
<!-- generated:options -->
## `[cli.target]`
Connection info for commands that target a remote Fabro server.
@ -243,49 +123,154 @@ type = "http"
url = "https://fabro.example.com/api/v1"
```
| Key | Description |
|---|---|
| `type` | `"http"` or `"unix"` — explicit transport selection |
| `url` | Required for `type = "http"` — the API base URL |
| `path` | Required for `type = "unix"` — the absolute Unix socket path |
| Key | Type / values | Default | Description |
|---|---|---|---|
| `type` | `"http"` \| `"unix"` | None | Explicit transport selection. |
| `url` | string | None | Required for `type = "http"`; the API base URL. |
| `path` | string | None | Required for `type = "unix"`; the absolute Unix socket path. |
`fabro model` uses `[cli.target]` by default when no explicit `--storage-dir` is passed. An explicit `--server` flag overrides the configured target:
## `[cli.updates]`
```bash
fabro model list --server https://fabro.example.com/api/v1
`[cli.updates]` — upgrade check toggle
```toml title="settings.toml"
[cli.updates]
check = true
```
An explicit `http(s)://...` target is always remote-by-contract. Fabro does not derive auth for that target from a local storage dir, an active local daemon record, or `~/.fabro/dev-token`. Use CLI OAuth (`fabro auth login --server ...`) or an explicit `FABRO_DEV_TOKEN` when you need remote auth.
| Key | Type / values | Default | Description |
|---|---|---|---|
| `check` | boolean | true | Check for new Fabro releases during supported CLI commands. |
`fabro auth login` only works with `type = "http"` targets. Unix-socket targets use the local dev-token flow instead of browser OAuth. Plain `http://...` targets are supported for local or trusted deployments; operators remain responsible for providing HTTPS anywhere real credentials cross an untrusted network.
## `[cli.output]`
`fabro exec` does not automatically use `[cli.target]`. It only routes model traffic through a Fabro server when you pass `--server` for that invocation.
`[cli.output]` — generic CLI output defaults
```toml title="settings.toml"
[cli.output]
format = "text"
verbosity = "verbose"
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `format` | "text" \| "json" | "text" | Output format for commands that support machine-readable output. |
| `verbosity` | "quiet" \| "normal" \| "verbose" | "normal" | Default output verbosity. |
## `[cli.exec]`
`[cli.exec]` — `fabro exec` defaults
```toml title="settings.toml"
[cli.exec]
prevent_idle_sleep = true
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `prevent_idle_sleep` | boolean | false | Prevent idle sleep on macOS while an exec run is in flight. |
## `[cli.exec.model]`
```toml title="settings.toml"
[cli.exec.model]
provider = "anthropic"
name = "claude-opus-4-6"
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `name` | string | None | Model name for `fabro exec`. |
| `provider` | string | None | LLM provider for `fabro exec`. |
## `[cli.exec.agent]`
```toml title="settings.toml"
[cli.exec.agent]
permissions = "read-write"
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `mcps` | table | None | Agent-scoped MCP entries for `fabro exec`. |
| `permissions` | "read-only" \| "read-write" \| "full" | "read-write" | Tool permission level for `fabro exec`. |
## `[run.model]`
`[run.model]` — provider-neutral default model selection
```toml title="settings.toml"
[run.model]
provider = "anthropic"
name = "claude-sonnet-4-5"
fallbacks = ["openai", "gpt-5.4"]
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `fallbacks` | array<string> | [] | Ordered list of fallback model references. Supports `...` splice marker<br />at layering time — see [`super::splice_array`]. |
| `name` | string | None | Model name for workflow runs. |
| `provider` | string | None | Provider name for workflow model selection. |
## `[cli.logging]`
`[cli.logging]` — process-owned logging configuration for the CLI
```toml title="settings.toml"
[cli.logging]
level = "info"
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `level` | "error" \| "warn" \| "info" \| "debug" \| "trace" | "info" | Default CLI log level. |
## `[run.git.author]`
```toml title="settings.toml"
[run.git.author]
name = "fabro-bot"
email = "fabro-bot@company.com"
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `email` | string | "fabro@local" | Git author email for checkpoint commits. |
| `name` | string | "fabro" | Git author name for checkpoint commits. |
## `[run.pull_request]`
Enable auto-PR globally so workflows open a GitHub pull request on successful completion.
`[run.pull_request]` — provider-neutral PR behavior
```toml title="settings.toml"
[run.pull_request]
enabled = true
```
| Key | Description | Default |
|---|---|---|
| `enabled` | Automatically create a PR after successful runs | `false` |
| `draft` | Open the PR as a draft | `true` |
| `auto_merge` | Enable GitHub auto-merge on the created PR (implies `draft = false`) | `false` |
| `merge_strategy` | One of `"squash"`, `"merge"`, `"rebase"` | `"squash"` |
| Key | Type / values | Default | Description |
|---|---|---|---|
| `auto_merge` | boolean | false | Enable GitHub auto-merge for created pull requests. Implies `draft =<br />false`. |
| `draft` | boolean | true | Open created pull requests as drafts. |
| `enabled` | boolean | false | Automatically create a PR after successful runs. |
| `merge_strategy` | "merge" \| "squash" \| "rebase" | "squash" | Merge method to configure for the pull request. |
Precedence: `workflow.toml` > `.fabro/project.toml` > `~/.fabro/settings.toml` > built-in default (`false`).
## `[run.agent]`
## `[run.agent.mcps]` section
`[run.agent]` — agent knobs only (permissions, MCPs)
Configure [MCP servers](/agents/mcp) to connect to during agent-driven runs. Each server is a named TOML table under `[run.agent.mcps]`. For `fabro exec`-only MCPs, use `[cli.exec.agent.mcps.*]` with the same shape.
```toml title="settings.toml"
[run.agent]
permissions = "read-write"
```
### Stdio transport
| Key | Type / values | Default | Description |
|---|---|---|---|
| `mcps` | table | None | Agent-scoped MCP server entries, keyed by name. |
| `permissions` | "read-only" \| "read-write" \| "full" | "read-write" | Default tool permission level for workflow agents. |
Spawn a local process and communicate over stdin/stdout:
## `[run.agent.mcps.<name>]`
Configure MCP servers for workflow agents. For `fabro exec`-only MCPs, use `[cli.exec.agent.mcps.<name>]` with the same shape.
```toml title="settings.toml"
[run.agent.mcps.filesystem]
@ -293,60 +278,19 @@ type = "stdio"
command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
startup_timeout = "15s"
tool_timeout = "90s"
[run.agent.mcps.filesystem.env]
NODE_ENV = "production"
```
| Key | Description | Default |
|---|---|---|
| `type` | Must be `"stdio"` | — |
| `command` | Array: executable + arguments | — |
| `env` | Additional environment variables for the child process | `{}` |
| `startup_timeout` | Max duration for the MCP handshake (e.g. `"10s"`, `"30s"`) | `"10s"` |
| `tool_timeout` | Max duration for a single tool call (e.g. `"60s"`, `"2m"`) | `"60s"` |
| Key | Type / values | Default | Description |
|---|---|---|---|
| `type` | `"stdio"` \| `"http"` \| `"sandbox"` | None | MCP transport type. |
| `command` | array<string> | None | Command and arguments for `stdio` or `sandbox` transports. |
| `script` | string | None | Shell script alternative to `command` for process-launching transports. |
| `url` | string | None | Remote MCP URL for `http` transport. |
| `port` | integer | None | Sandbox port for `sandbox` transport. |
| `env` | table | `{}` | Additional environment variables for process-launching transports. |
| `headers` | table | `{}` | HTTP headers for `http` transport. |
| `startup_timeout` | duration | `"10s"` | Max duration for startup and MCP handshake. |
| `tool_timeout` | duration | `"60s"` | Max duration for a single MCP tool call. |
### HTTP transport
Connect to a remote MCP server over Streamable HTTP:
```toml title="settings.toml"
[run.agent.mcps.sentry]
type = "http"
url = "https://mcp.sentry.dev/mcp"
[run.agent.mcps.sentry.headers]
Authorization = "Bearer sk-xxx"
```
| Key | Description | Default |
|---|---|---|
| `type` | Must be `"http"` | — |
| `url` | The MCP server endpoint URL | — |
| `headers` | Optional HTTP headers (for example, for authentication) | `{}` |
| `startup_timeout` | Max duration for the MCP handshake | `"10s"` |
| `tool_timeout` | Max duration for a single tool call | `"60s"` |
### Sandbox transport
Run an MCP server inside the workflow's sandbox and connect via preview URL. Only available with remote sandbox providers ([Daytona](/integrations/daytona)) that support port previews. Typically configured in `workflow.toml` rather than `settings.toml`:
```toml title="workflow.toml"
[run.agent.mcps.playwright]
type = "sandbox"
command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"]
port = 3100
startup_timeout = "60s"
tool_timeout = "2m"
```
| Key | Description | Default |
|---|---|---|
| `type` | Must be `"sandbox"` | — |
| `command` | Array: the command to run inside the sandbox | — |
| `port` | Port the server listens on inside the sandbox | — |
| `env` | Additional environment variables for the server process | `{}` |
| `startup_timeout` | Max duration for startup + MCP handshake | `"10s"` |
| `tool_timeout` | Max duration for a single tool call | `"60s"` |
See [MCP — Sandbox transport](/agents/mcp#sandbox) for how Fabro launches and connects to sandbox MCP servers.
See [MCP](/agents/mcp) for transport-specific examples.
<!-- /generated:options -->

View file

@ -21,6 +21,7 @@ anyhow.workspace = true
clap = { workspace = true, optional = true }
chrono.workspace = true
fabro-macros = { path = "../fabro-macros" }
fabro-options-metadata.workspace = true
fabro-proc = { path = "../fabro-proc" }
fabro-static.workspace = true
fabro-types = { path = "../fabro-types" }

View file

@ -50,11 +50,21 @@ pub struct CliAuthLayer {
}
/// `[cli.exec]` — `fabro exec` defaults.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[derive(
Debug,
Clone,
Default,
PartialEq,
Serialize,
Deserialize,
fabro_macros::Combine,
fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct CliExecLayer {
/// Prevent idle sleep on macOS while an exec run is in flight.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(name = "prevent_idle_sleep", default = "false", value_type = "boolean")]
pub prevent_idle_sleep: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<CliExecModelLayer>,
@ -62,47 +72,109 @@ pub struct CliExecLayer {
pub agent: Option<CliExecAgentLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[derive(
Debug,
Clone,
Default,
PartialEq,
Serialize,
Deserialize,
fabro_macros::Combine,
fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct CliExecModelLayer {
/// LLM provider for `fabro exec`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(value_type = "string")]
pub provider: Option<InterpString>,
/// Model name for `fabro exec`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(value_type = "string")]
pub name: Option<InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[derive(
Debug,
Clone,
Default,
PartialEq,
Serialize,
Deserialize,
fabro_macros::Combine,
fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct CliExecAgentLayer {
/// Tool permission level for `fabro exec`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(
default = "\"read-write\"",
value_type = "\"read-only\" | \"read-write\" | \"full\""
)]
pub permissions: Option<AgentPermissions>,
/// Agent-scoped MCP entries for `fabro exec`.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
#[option(value_type = "table")]
pub mcps: StickyMap<McpEntryLayer>,
}
/// `[cli.output]` — generic CLI output defaults.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[derive(
Debug,
Clone,
Default,
PartialEq,
Serialize,
Deserialize,
fabro_macros::Combine,
fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct CliOutputLayer {
/// Output format for commands that support machine-readable output.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(default = "\"text\"", value_type = "\"text\" | \"json\"")]
pub format: Option<OutputFormat>,
/// Default output verbosity.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(
default = "\"normal\"",
value_type = "\"quiet\" | \"normal\" | \"verbose\""
)]
pub verbosity: Option<OutputVerbosity>,
}
/// `[cli.updates]` — upgrade check toggle.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[derive(
Debug,
Clone,
Default,
PartialEq,
Serialize,
Deserialize,
fabro_macros::Combine,
fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct CliUpdatesLayer {
/// Check for new Fabro releases during supported CLI commands.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(default = "true", value_type = "boolean")]
pub check: Option<bool>,
}
/// `[cli.logging]` — process-owned logging configuration for the CLI.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[derive(
Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct CliLoggingLayer {
/// Default CLI log level.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(
default = "\"info\"",
value_type = "\"error\" | \"warn\" | \"info\" | \"debug\" | \"trace\""
)]
pub level: Option<String>,
}

View file

@ -82,16 +82,30 @@ pub enum RunGoalLayer {
}
/// `[run.model]` — provider-neutral default model selection.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[derive(
Debug,
Clone,
Default,
PartialEq,
Serialize,
Deserialize,
fabro_macros::Combine,
fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct RunModelLayer {
/// Provider name for workflow model selection.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(value_type = "string")]
pub provider: Option<InterpString>,
/// Model name for workflow runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(value_type = "string")]
pub name: Option<InterpString>,
/// Ordered list of fallback model references. Supports `...` splice marker
/// at layering time — see [`super::splice_array`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[option(default = "[]", value_type = "array<string>")]
pub fallbacks: Vec<ModelRefOrSplice>,
}
@ -131,12 +145,25 @@ pub struct RunGitLayer {
pub author: Option<GitAuthorLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[derive(
Debug,
Clone,
Default,
PartialEq,
Serialize,
Deserialize,
fabro_macros::Combine,
fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct GitAuthorLayer {
/// Git author name for checkpoint commits.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(default = "\"fabro\"", value_type = "string")]
pub name: Option<InterpString>,
/// Git author email for checkpoint commits.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(default = "\"fabro@local\"", value_type = "string")]
pub email: Option<InterpString>,
}
@ -326,13 +353,28 @@ pub struct InterviewProviderLayer {
}
/// `[run.agent]` — agent knobs only (permissions, MCPs).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[derive(
Debug,
Clone,
Default,
PartialEq,
Serialize,
Deserialize,
fabro_macros::Combine,
fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct RunAgentLayer {
/// Default tool permission level for workflow agents.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(
default = "\"read-write\"",
value_type = "\"read-only\" | \"read-write\" | \"full\""
)]
pub permissions: Option<AgentPermissions>,
/// Agent-scoped MCP server entries, keyed by name.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
#[option(value_type = "table")]
pub mcps: StickyMap<McpEntryLayer>,
}
@ -470,16 +512,38 @@ pub struct RunScmLayer {
pub struct ScmGitHubLayer;
/// `[run.pull_request]` — provider-neutral PR behavior.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[derive(
Debug,
Clone,
Default,
PartialEq,
Serialize,
Deserialize,
fabro_macros::Combine,
fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct RunPullRequestLayer {
/// Automatically create a PR after successful runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(default = "false", value_type = "boolean")]
pub enabled: Option<bool>,
/// Open created pull requests as drafts.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(default = "true", value_type = "boolean")]
pub draft: Option<bool>,
/// Enable GitHub auto-merge for created pull requests. Implies `draft =
/// false`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(name = "auto_merge", default = "false", value_type = "boolean")]
pub auto_merge: Option<bool>,
/// Merge method to configure for the pull request.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(
name = "merge_strategy",
default = "\"squash\"",
value_type = "\"merge\" | \"squash\" | \"rebase\""
)]
pub merge_strategy: Option<MergeStrategy>,
}

View file

@ -18,6 +18,8 @@ anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
fabro-cli = { path = "../fabro-cli" }
fabro-config = { path = "../fabro-config" }
fabro-options-metadata.workspace = true
regex.workspace = true
tracing-subscriber.workspace = true
walkdir.workspace = true

View file

@ -0,0 +1,328 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use fabro_options_metadata::{OptionField, OptionSet, Visit};
const OPTIONS_REFERENCE_PATH: &str = "docs/reference/user-configuration.mdx";
const FENCE_START: &str = "<!-- generated:options -->";
const FENCE_END: &str = "<!-- /generated:options -->";
#[derive(Debug, clap::Args)]
pub(crate) struct GenerateOptionsReferenceArgs {
/// Verify docs/reference/user-configuration.mdx is up to date without
/// rewriting it.
#[arg(long)]
check: bool,
/// Workspace root containing docs/reference/user-configuration.mdx.
#[arg(long, hide = true)]
root: Option<PathBuf>,
}
#[expect(
clippy::print_stdout,
clippy::disallowed_methods,
reason = "dev generator reports the generated docs path directly and intentionally uses sync filesystem I/O"
)]
pub(crate) fn generate_options_reference(args: GenerateOptionsReferenceArgs) -> Result<()> {
let root = args.root.unwrap_or_else(workspace_root);
let path = root.join(OPTIONS_REFERENCE_PATH);
let current =
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let generated = render_options_reference();
let updated = replace_generated_region(&current, &generated)?;
if args.check {
if current != updated {
bail!("{OPTIONS_REFERENCE_PATH} is stale; run `cargo dev generate-options-reference`");
}
println!("{OPTIONS_REFERENCE_PATH} is up to date.");
return Ok(());
}
if current != updated {
std::fs::write(&path, updated).with_context(|| format!("writing {}", path.display()))?;
}
println!("Generated {OPTIONS_REFERENCE_PATH}.");
Ok(())
}
struct Section {
path: &'static str,
set: OptionSet,
example: &'static str,
}
impl Section {
fn of<T>(path: &'static str, example: &'static str) -> Self
where
T: fabro_options_metadata::OptionsMetadata + 'static,
{
Self {
path,
set: OptionSet::of::<T>(),
example,
}
}
}
fn render_options_reference() -> String {
let mut output = String::new();
render_manual_cli_target(&mut output);
for section in metadata_sections() {
render_section(&mut output, &section);
}
render_manual_mcp(&mut output);
output.trim_end().to_string()
}
fn metadata_sections() -> Vec<Section> {
vec![
Section::of::<fabro_config::CliUpdatesLayer>(
"[cli.updates]",
r"[cli.updates]
check = true",
),
Section::of::<fabro_config::CliOutputLayer>(
"[cli.output]",
r#"[cli.output]
format = "text"
verbosity = "verbose""#,
),
Section::of::<fabro_config::CliExecLayer>(
"[cli.exec]",
r"[cli.exec]
prevent_idle_sleep = true",
),
Section::of::<fabro_config::CliExecModelLayer>(
"[cli.exec.model]",
r#"[cli.exec.model]
provider = "anthropic"
name = "claude-opus-4-6""#,
),
Section::of::<fabro_config::CliExecAgentLayer>(
"[cli.exec.agent]",
r#"[cli.exec.agent]
permissions = "read-write""#,
),
Section::of::<fabro_config::RunModelLayer>(
"[run.model]",
r#"[run.model]
provider = "anthropic"
name = "claude-sonnet-4-5"
fallbacks = ["openai", "gpt-5.4"]"#,
),
Section::of::<fabro_config::CliLoggingLayer>(
"[cli.logging]",
r#"[cli.logging]
level = "info""#,
),
Section::of::<fabro_config::GitAuthorLayer>(
"[run.git.author]",
r#"[run.git.author]
name = "fabro-bot"
email = "fabro-bot@company.com""#,
),
Section::of::<fabro_config::RunPullRequestLayer>(
"[run.pull_request]",
r"[run.pull_request]
enabled = true",
),
Section::of::<fabro_config::RunAgentLayer>(
"[run.agent]",
r#"[run.agent]
permissions = "read-write""#,
),
]
}
fn render_section(output: &mut String, section: &Section) {
output.push_str("## `");
output.push_str(section.path);
output.push_str("`\n\n");
if let Some(doc) = section.set.documentation() {
output.push_str(&normalize_doc(doc));
output.push_str("\n\n");
}
output.push_str("```toml title=\"settings.toml\"\n");
output.push_str(section.example);
output.push_str("\n```\n\n");
render_field_table(output, collect_fields(section.set));
}
fn render_field_table(output: &mut String, fields: BTreeMap<String, OptionField>) {
output.push_str("| Key | Type / values | Default | Description |\n");
output.push_str("|---|---|---|---|\n");
for (name, field) in fields {
output.push_str("| `");
output.push_str(&name);
output.push_str("` | ");
output.push_str(&field_type(&field));
output.push_str(" | ");
output.push_str(field.default.unwrap_or("None"));
output.push_str(" | ");
output.push_str(&markdown_cell(
field.doc.unwrap_or("TODO: add settings help text."),
));
output.push_str(" |\n");
}
output.push('\n');
}
fn collect_fields(set: OptionSet) -> BTreeMap<String, OptionField> {
struct CollectVisitor<'a> {
prefix: String,
entries: &'a mut BTreeMap<String, OptionField>,
}
impl Visit for CollectVisitor<'_> {
fn record_field(&mut self, name: &str, field: OptionField) {
self.entries
.insert(format!("{}{}", self.prefix, name), field);
}
fn record_set(&mut self, name: &str, set: OptionSet) {
let previous = self.prefix.clone();
self.prefix.push_str(name);
self.prefix.push('.');
set.record(self);
self.prefix = previous;
}
}
let mut entries = BTreeMap::new();
set.record(&mut CollectVisitor {
prefix: String::new(),
entries: &mut entries,
});
entries
}
fn field_type(field: &OptionField) -> String {
if let Some(possible_values) = field
.possible_values
.as_ref()
.filter(|values| !values.is_empty())
{
possible_values
.iter()
.map(|value| format!("`{}`", value.name))
.collect::<Vec<_>>()
.join(", ")
} else {
field
.value_type
.map_or_else(|| "inferred".to_string(), markdown_cell)
}
}
fn render_manual_cli_target(output: &mut String) {
output.push_str(
r#"## `[cli.target]`
Connection info for commands that target a remote Fabro server.
```toml title="settings.toml"
[cli.target]
type = "http"
url = "https://fabro.example.com/api/v1"
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `type` | `"http"` \| `"unix"` | None | Explicit transport selection. |
| `url` | string | None | Required for `type = "http"`; the API base URL. |
| `path` | string | None | Required for `type = "unix"`; the absolute Unix socket path. |
"#,
);
}
fn render_manual_mcp(output: &mut String) {
output.push_str(
r#"## `[run.agent.mcps.<name>]`
Configure MCP servers for workflow agents. For `fabro exec`-only MCPs, use `[cli.exec.agent.mcps.<name>]` with the same shape.
```toml title="settings.toml"
[run.agent.mcps.filesystem]
type = "stdio"
command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
startup_timeout = "15s"
tool_timeout = "90s"
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `type` | `"stdio"` \| `"http"` \| `"sandbox"` | None | MCP transport type. |
| `command` | array<string> | None | Command and arguments for `stdio` or `sandbox` transports. |
| `script` | string | None | Shell script alternative to `command` for process-launching transports. |
| `url` | string | None | Remote MCP URL for `http` transport. |
| `port` | integer | None | Sandbox port for `sandbox` transport. |
| `env` | table | `{}` | Additional environment variables for process-launching transports. |
| `headers` | table | `{}` | HTTP headers for `http` transport. |
| `startup_timeout` | duration | `"10s"` | Max duration for startup and MCP handshake. |
| `tool_timeout` | duration | `"60s"` | Max duration for a single MCP tool call. |
See [MCP](/agents/mcp) for transport-specific examples.
"#,
);
}
fn normalize_doc(doc: &str) -> String {
doc.trim().trim_end_matches('.').to_string()
}
fn markdown_cell(value: &str) -> String {
value
.replace('|', "\\|")
.replace('\n', "<br />")
.trim()
.to_string()
}
fn replace_generated_region(current: &str, generated: &str) -> Result<String> {
let start = current
.find(FENCE_START)
.with_context(|| format!("{OPTIONS_REFERENCE_PATH} is missing {FENCE_START}"))?;
let content_start = start + FENCE_START.len();
let relative_end = current[content_start..]
.find(FENCE_END)
.with_context(|| format!("{OPTIONS_REFERENCE_PATH} is missing {FENCE_END}"))?;
let end = content_start + relative_end;
let before = &current[..content_start];
let after = &current[end..];
Ok(format!("{before}\n{generated}\n{after}"))
}
fn workspace_root() -> PathBuf {
let mut root = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf();
root.pop();
root.pop();
root.pop();
root
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replace_generated_region_preserves_manual_content() {
let updated = replace_generated_region(
"before\n<!-- generated:options -->\nstale\n<!-- /generated:options -->\nafter\n",
"fresh",
)
.expect("generated region should be replaced");
assert_eq!(
updated,
"before\n<!-- generated:options -->\nfresh\n<!-- /generated:options -->\nafter\n"
);
}
}

View file

@ -2,6 +2,7 @@ mod check_boundary;
mod check_spa_budgets;
mod docker_build;
mod generate_cli_reference;
mod generate_options_reference;
mod refresh_spa;
mod release;
@ -9,5 +10,8 @@ pub(crate) use check_boundary::{CheckBoundaryArgs, check_boundary};
pub(crate) use check_spa_budgets::{CheckSpaBudgetsArgs, check_spa_budgets};
pub(crate) use docker_build::{DockerBuildArgs, docker_build};
pub(crate) use generate_cli_reference::{GenerateCliReferenceArgs, generate_cli_reference};
pub(crate) use generate_options_reference::{
GenerateOptionsReferenceArgs, generate_options_reference,
};
pub(crate) use refresh_spa::{RefreshSpaArgs, refresh_spa};
pub(crate) use release::{ReleaseArgs, release};

View file

@ -24,6 +24,8 @@ enum Command {
DockerBuild(commands::DockerBuildArgs),
/// Generate docs/reference/cli.mdx from the Fabro clap command tree.
GenerateCliReference(commands::GenerateCliReferenceArgs),
/// Generate docs/reference/user-configuration.mdx from settings metadata.
GenerateOptionsReference(commands::GenerateOptionsReferenceArgs),
/// Run Fabro release automation.
Release(commands::ReleaseArgs),
/// Refresh the embedded Fabro web SPA bundle.
@ -38,6 +40,7 @@ impl Command {
Self::CheckBoundary(args) => commands::check_boundary(args),
Self::DockerBuild(args) => commands::docker_build(args),
Self::GenerateCliReference(args) => commands::generate_cli_reference(args),
Self::GenerateOptionsReference(args) => commands::generate_options_reference(args),
Self::Release(args) => commands::release(args),
Self::RefreshSpa(args) => commands::refresh_spa(args),
Self::CheckSpaBudgets(args) => commands::check_spa_budgets(args),

View file

@ -0,0 +1,154 @@
use std::fs;
use std::path::Path;
fn fabro_dev() -> assert_cmd::Command {
assert_cmd::cargo::cargo_bin_cmd!("fabro-dev")
}
fn output_text(bytes: &[u8]) -> String {
String::from_utf8(bytes.to_vec()).expect("command output should be valid utf-8")
}
#[expect(
clippy::disallowed_methods,
reason = "integration tests stage temporary options reference fixtures with sync std::fs::write"
)]
fn write_file(root: &Path, path: &str, contents: &str) {
let path = root.join(path);
fs::create_dir_all(path.parent().expect("fixture path should have parent"))
.expect("creating fixture parent directory");
fs::write(path, contents).expect("writing fixture file");
}
#[expect(
clippy::disallowed_methods,
reason = "integration tests inspect generated options reference fixtures with sync std::fs::read_to_string"
)]
fn read_file(root: &Path, path: &str) -> String {
fs::read_to_string(root.join(path)).expect("reading fixture file")
}
fn options_reference(root: &Path) -> assert_cmd::Command {
let mut cmd = fabro_dev();
cmd.args(["generate-options-reference", "--root"]).arg(root);
cmd
}
#[test]
fn write_updates_only_generated_region() {
let fixture = tempfile::tempdir().expect("creating fixture");
write_file(
fixture.path(),
"docs/reference/user-configuration.mdx",
r"---
title: Settings
---
Intro copy.
<!-- generated:options -->
stale
<!-- /generated:options -->
Tail copy.
",
);
options_reference(fixture.path()).assert().success();
let contents = read_file(fixture.path(), "docs/reference/user-configuration.mdx");
assert!(
contents.contains("Intro copy."),
"manual intro should be preserved:\n{contents}"
);
assert!(
contents.contains("Tail copy."),
"manual tail should be preserved:\n{contents}"
);
assert!(
contents.contains("## `[cli.output]`"),
"generated output should include cli output settings:\n{contents}"
);
assert!(
contents.contains("| `format` |"),
"generated output should include option fields:\n{contents}"
);
assert!(
contents.contains("## `[run.model]`"),
"generated output should include run model settings:\n{contents}"
);
assert!(
!contents.contains("stale"),
"stale generated content should be replaced:\n{contents}"
);
}
#[test]
fn check_passes_after_write() {
let fixture = tempfile::tempdir().expect("creating fixture");
write_file(
fixture.path(),
"docs/reference/user-configuration.mdx",
r"<!-- generated:options -->
stale
<!-- /generated:options -->
",
);
options_reference(fixture.path()).assert().success();
options_reference(fixture.path())
.arg("--check")
.assert()
.success();
}
#[test]
fn check_fails_when_generated_region_is_stale() {
let fixture = tempfile::tempdir().expect("creating fixture");
write_file(
fixture.path(),
"docs/reference/user-configuration.mdx",
r"<!-- generated:options -->
stale
<!-- /generated:options -->
",
);
let output = options_reference(fixture.path())
.arg("--check")
.assert()
.failure()
.code(1)
.get_output()
.clone();
let stderr = output_text(&output.stderr);
assert!(
stderr.contains(
"docs/reference/user-configuration.mdx is stale; run `cargo dev generate-options-reference`"
),
"check failure should explain how to regenerate:\n{stderr}"
);
}
#[test]
fn generated_reference_is_deterministic() {
let fixture = tempfile::tempdir().expect("creating fixture");
write_file(
fixture.path(),
"docs/reference/user-configuration.mdx",
r"<!-- generated:options -->
stale
<!-- /generated:options -->
",
);
options_reference(fixture.path()).assert().success();
let first = read_file(fixture.path(), "docs/reference/user-configuration.mdx");
options_reference(fixture.path()).assert().success();
let second = read_file(fixture.path(), "docs/reference/user-configuration.mdx");
assert_eq!(first, second);
}

View file

@ -4,6 +4,7 @@ use std::process::{Command, Output};
mod check_boundary;
mod docker_build;
mod generate_cli_reference;
mod generate_options_reference;
mod release;
mod spa;
@ -50,6 +51,7 @@ fn help_lists_scaffolded_commands() {
"check-boundary",
"docker-build",
"generate-cli-reference",
"generate-options-reference",
"release",
"refresh-spa",
"check-spa-budgets",

View file

@ -4,7 +4,7 @@ use syn::meta::ParseNestedMeta;
use syn::spanned::Spanned;
use syn::{
Attribute, Data, DataStruct, DeriveInput, ExprLit, Field, Fields, GenericArgument, Lit, Meta,
PathArguments, Type,
PathArguments, Token, Type,
};
pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream> {
@ -304,6 +304,9 @@ fn has_serde_flatten(field: &Field) -> syn::Result<bool> {
if meta.path.is_ident("flatten") {
flatten = true;
}
if meta.input.peek(Token![=]) {
let _ = meta.value()?.parse::<syn::Expr>()?;
}
Ok(())
})?;
}