Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
246cba2
feat: add Stage, FeatureFlag, FlagPolicy for feature-flagging
jpage-godaddy Jul 8, 2026
9ff8970
fix: address feature_flags code-quality review feedback
jpage-godaddy Jul 8, 2026
4d66ab1
feat: add feature_flag field to CommandSpec and GroupSpec
jpage-godaddy Jul 8, 2026
d35d139
docs: clarify feature_flag field is not yet resolved
jpage-godaddy Jul 8, 2026
97cd874
feat: add feature_flag field to Module
jpage-godaddy Jul 8, 2026
30a7cc6
feat: cascade feature flags and prune the command tree on mount
jpage-godaddy Jul 8, 2026
c25c546
feat: layer environment-level min_stage/feature_overrides into flag p…
jpage-godaddy Jul 8, 2026
2832f59
feat: add flags list/info introspection commands
jpage-godaddy Jul 8, 2026
1283056
docs: document feature flags and stages
jpage-godaddy Jul 8, 2026
79d66f5
docs: fix markdown line-wrapping in feature flags section
jpage-godaddy Jul 8, 2026
7c08e80
test: add feature-flag integration coverage
jpage-godaddy Jul 8, 2026
804d051
test: harden feature-flag env-name collision risk
jpage-godaddy Jul 8, 2026
5f22363
fix: address final review findings for feature flagging
jpage-godaddy Jul 8, 2026
cf4e3d6
docs: clarify feature-flag policy is fixed at startup, not --env-aware
jpage-godaddy Jul 8, 2026
0050ba9
docs: remove stale "a later addition" wording for flags introspection
jpage-godaddy Jul 8, 2026
cf6a688
docs: note that built-in flags group yields to a consumer-defined one
jpage-godaddy Jul 8, 2026
4a85d30
fix: rename environments.toml feature-overrides table from `features`…
jpage-godaddy Jul 8, 2026
7825a3f
fix: compute flags info decided_by per entry, not per key
jpage-godaddy Jul 8, 2026
63dd468
fix: preserve non-Unicode env var values in test EnvGuard
jpage-godaddy Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ Command checklist:
authenticate (this also suppresses default-env injection). Forgetting these annotations only
over-prompts; it never lets a gated command run unauthenticated.
- Use `.with_tier(...)` or `.mutates(true)` for mutating commands so `--dry-run` can short-circuit them.
- Commands, groups, and modules default to `Stage::Ga` (visible everywhere). Add `.with_feature_flag(key, Stage::Experimental)` (or `Stage::Beta`) only when a command needs extra scrutiny before it reaches a public/external consumer CLI; promoting to GA later is a one-line removal or bump, not a rewrite.
- Prefer returning structured JSON values from handlers; let cli-engine render JSON, human, and TOON formats.
- Prefer `CommandSpec::from_args::<T>()` + `RuntimeCommandSpec::new_typed` when the command has many flags, needs clap validation attributes, or when porting existing derive-based commands. Use the builder path for simple commands with one or two flags.

Expand Down
23 changes: 23 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ The framework registers built-in commands for common CLI behavior:
| `tree` | Always | Displays the full command hierarchy. |
| `auth login` / `auth status` / `auth logout` | Auth providers are registered or a default provider is configured | Manages credentials. |
| `guide [topic]` | Guides are registered | Lists and displays embedded guides. |
| `flags list` / `flags info <key>` | Always, unless a consumer module already registers a top-level `flags` group (the built-in group yields to it) | Inspects declared feature flags and the active policy; see [Feature Flags & Stages](#feature-flags--stages). |

`guide` accepts zero or one topic. Additional positional arguments are rejected before guide content
is rendered.
Expand Down Expand Up @@ -420,6 +421,28 @@ Risk tiers classify command impact:

`CommandSpec::mutates(true)` also marks a command as dry-run promptable.

## Feature Flags & Stages

Feature flags classify command readiness rather than risk. `Stage` orders `Experimental < Beta < Ga`; the default is `Ga`, so gating is opt-in — a command with no flag declaration is fully visible under every policy.

`.with_feature_flag(key, stage)` is available on `CommandSpec`, `GroupSpec`, and `Module`. A node's effective flag is its own declaration if set, else the nearest ancestor's, walking module → group → nested group → command; the nearest declaration wins. A node with no declaration anywhere in its chain resolves to `Stage::Ga` with no key, so existing commands are unaffected unless an author opts a node into a lower stage.

```rust
use cli_engine::{CommandSpec, Stage};

CommandSpec::new("preview", "Preview an upcoming feature")
.with_feature_flag("project-preview", Stage::Experimental);
```

`Cli::add_module`/`add_module_group` resolve the cascading flag for every node while building the command tree, record each flagged node into a `FlagRegistry`, and prune any node whose effective flag isn't visible under the active `FlagPolicy`. Pruning removes the node from the tree entirely — help, `--schema`, search, and dispatch — not just from a listing.

`FlagPolicy` has two fields: `min_stage` (the floor a node's stage must meet or exceed) and `overrides` (per-key stage substitutions, checked before `min_stage`). The policy is assembled from `CliConfig::with_min_stage`/`CliConfig::with_feature_override`, layered with the active environment's own `min_stage`/`feature_overrides` when `with_environments` is configured; see [Environments](environments.md) for the environment-layer precedence and TOML shape.

The built-in `flags` command group exposes:

- `flags list` — every declared flag node (path, key, stage, effective visibility).
- `flags info <key>` — the active policy for one key plus every node that resolved to it, with `decided_by: "override"` or `"min_stage"` indicating which policy layer decided visibility.

## Output

Handlers return JSON-serializable data and a system id. Middleware wraps the result in an envelope
Expand Down
49 changes: 49 additions & 0 deletions docs/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,16 @@ client_id = "ote-client-id"
auth_url = "https://api.ote.example.com/v2/oauth2/authorize"
token_url = "https://api.ote.example.com/v2/oauth2/token"
api_url = "https://api.ote.example.com"

[dev]
min_stage = "experimental"

[dev.feature_overrides]
"domain-bulk-transfer" = "beta"
```

The recognized OAuth keys — `client_id`, `auth_url`, `token_url`, and `scopes` (an array of strings) — are parsed into the typed `OAuthConfig` slice of the resolved `Environment`.
`min_stage` and the `[<env>.feature_overrides]` table are the feature-flag layer, described in [Feature-Flag Layering](#feature-flag-layering) below. `feature_overrides` (rather than a shorter name like `features`) is deliberately specific: `EnvironmentDef`'s unrecognized keys fall through to the free-form `extra` bag, so a generic name would collide with any existing app-specific `extra` key of the same name.
Every other key is captured as a free-form field in `Environment::extra`, which is a `BTreeMap<String, String>` — so these values **must be TOML strings** (for example `api_url` above).
A non-OAuth key whose value is a number, boolean, or array fails to parse; quote it as a string instead.
The `extra` bag is printed verbatim by `env info`, so it must not hold secrets.
Expand All @@ -62,6 +69,48 @@ Scopes are **not** env-var overridable; set them in the compiled-in layer or `en
Bag keys in `Environment::extra` are overridable via `<ENV>_<KEY>` only when the key is already present in the merged record after layers 1 and 2.
For example, `api_url` must exist in either the compiled defaults or the file before `PROD_API_URL` has any effect.

## Feature-Flag Layering

Feature-flag visibility is a fourth resolution axis, parallel to but independent from the OAuth/bag-key layers above. See [Feature Flags & Stages](concepts.md#feature-flags--stages) for what `Stage` and `FlagPolicy` mean; this section covers only the environment-specific plumbing.

`EnvironmentDef` carries `min_stage: Option<Stage>` and `feature_overrides: BTreeMap<String, Stage>`, set with `.with_min_stage(stage)` and `.with_feature_override(key, stage)`. The resolved `Environment` mirrors both fields. They merge through the same three layers as OAuth/bag fields — compiled defaults, then `environments.toml`, then environment variables — and are then layered onto the consumer's own `CliConfig`-level policy.

In `environments.toml`, `min_stage` is a plain key on the environment's table, and per-key overrides go in a nested `[<env>.feature_overrides]` table:

```toml
[dev]
min_stage = "experimental"

[staging.feature_overrides]
"domain-bulk-transfer" = "ga"
```

An override must itself meet the active `min_stage` floor to reveal a node — overriding a command's stage to `beta` while `min_stage` stays `ga` still hides it (visibility requires `effective_stage >= min_stage`, and `beta < ga`); override to `ga` to reveal it regardless of the node's own declared stage. That is why the `staging` example above forces `domain-bulk-transfer` to `ga`: `staging` sets no `min_stage`, so its floor is the `Ga` default, and only a `ga` override clears it — one surgical unlock of that command while every other still-gated node stays hidden.

Environment-variable overrides:

| Variable | Field overridden |
| --- | --- |
| `<ENV>_MIN_STAGE` | `min_stage` |
| `<ENV>_FEATURE_<KEY>` | `feature_overrides[<key>]` |

`<ENV>_FEATURE_<KEY>` follows the same restriction as bag keys: it only takes effect when `<key>` is already present in `feature_overrides` after the compiled+file merge (layers 1 and 2). `<KEY>` is the flag key uppercased with `-` replaced by `_` (`domain-bulk-transfer` → `PROD_FEATURE_DOMAIN_BULK_TRANSFER`).

The full precedence order, highest wins:

```text
env var for a specific key (<ENV>_FEATURE_<KEY>)
> env var min-stage (<ENV>_MIN_STAGE)
> environment file's feature_overrides for that key
> environment file's min_stage
> consumer .with_feature_override(...)
> consumer .with_min_stage(...) (default Stage::Ga)
```

The environment layer is applied only when environment resolution succeeds: if resolving the active environment errors — a malformed `<ENV>_MIN_STAGE` value, an unparsable `environments.toml`, or an active-environment name unknown to every layer — the engine silently falls back to the consumer-level `CliConfig` policy alone and drops the environment-layer contribution rather than failing the run. In the intended usage pattern (the consumer ships a `Ga` default and an environment *loosens* it for dev/experimental builds) this fails **closed**: a resolution error simply leaves the stricter compiled default in force. But the reverse pattern is unsafe: if a consumer ships a *permissive* compiled `min_stage` (or feature override) and relies on an environment to *tighten* it for a public/production build, a resolution error fails **open** — the gated nodes the environment would have re-hidden are mounted under the permissive compiled policy instead. A security model that depends on an environment tightening a permissive compiled default must therefore validate that environment resolution succeeds (for example via `env info`, or by not caching a build whose active environment cannot resolve) rather than assuming resolution errors cannot occur in practice; do not rely on this crate to fail closed for that direction.

Unlike the OAuth/bag-key layers, this resolved `FlagPolicy` is fixed for the life of the `Cli`: `Cli::new` computes it once from the environment seeded at startup (the sticky/persisted active environment, or the compiled default) and uses it immediately afterward to prune the command tree that `--help`, `--schema`, and dispatch all read from. Passing `--env <name>` on the command line (`apply_env_flag`) updates `middleware.env` for that invocation — which does change which environment other commands such as `env info` or an OAuth provider resolve against — but it does not recompute `flag_policy` or re-prune the already-built tree, so it has no effect on feature-flag visibility for that run, including what `flags list`/`flags info` report, since they read the same startup-fixed policy; the visible/hidden set is fixed to whichever environment was active when the process started, and `--env` cannot widen or narrow it.

## Active Environment

The active environment controls which environment is targeted when no `--env` flag is passed.
Expand Down
15 changes: 14 additions & 1 deletion examples/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::process::ExitCode;
use clap::Arg;
use cli_engine::{
BuildInfo, Cli, CliConfig, CommandResult, CommandSpec, GroupSpec, Module, RuntimeCommandSpec,
RuntimeGroupSpec,
RuntimeGroupSpec, Stage,
};
use serde_json::json;

Expand Down Expand Up @@ -37,14 +37,27 @@ async fn main() -> ExitCode {
},
);

// Gated behind Stage::Experimental, so it is pruned from help/schema/dispatch under the
// default policy (min_stage: Stage::Ga). To see it, uncomment the .with_min_stage(...) line
// below (or add .with_feature_override("project-preview", Stage::Ga) instead).
let preview = RuntimeCommandSpec::new(
CommandSpec::new("preview", "Preview an upcoming project feature")
.with_system("projects-api")
.with_feature_flag("project-preview", Stage::Experimental)
.no_auth(true),
async |_credential, _args| Ok(CommandResult::new(json!({ "status": "coming-soon" }))),
);

let project_module = Module::new("Platform Systems", move |_context| {
RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects"))
.with_command(list_projects.clone())
.with_command(preview.clone())
});

let cli = Cli::new(
CliConfig::new("example", "Example cli-engine application", "example")
.with_build(BuildInfo::new(env!("CARGO_PKG_VERSION")))
// .with_min_stage(Stage::Experimental)
.with_module(project_module),
);

Expand Down
Loading