feat: injectable credential storage + per-CLI config file#21
Conversation
Decouple credential storage from PkceAuthProvider behind a `CredentialStorage`
trait with keyring/file/auto backends. The mode is selectable via a config-file
key, the `${PREFIX}_CREDENTIAL_STORE` env var, and a `--credential-store` global
flag (precedence: explicit builder > flag > env > config > default `keyring`),
giving WSL/headless users a way to disable the system keychain. Default behavior
is unchanged; `with_file_fallback` is deprecated in favor of
`with_credential_store`.
Add a general per-application TOML config file (`ConfigFile`) that the engine
shares with consumers: engine settings live in reserved top-level tables
(`[credentials]`), consumers own every other table and read them via
`CommandContext::config()` / `ModuleContext::config()`. An opt-in built-in
`config get/set/path/list` command group reads and writes it (format-preserving
via toml_edit; `config set` is dry-run aware and validates engine keys).
Factor shared filesystem/path primitives (`config_base_dir`,
`is_safe_path_component`, atomic `write_string_atomic`) into a new `fs` module
reused by both config and credential storage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a shared per-CLI config file and makes PKCE credential persistence pluggable, enabling keyring-less environments (e.g., headless/WSL) via file-based storage while keeping the default keyring-only behavior unchanged for pkce-auth.
Changes:
- Introduces
CredentialStorage+CredentialStoreselection (flag/env/config/default) and updatesPkceAuthProviderto use injectable storage backends. - Adds a per-application TOML config file (
<config-base>/<app_id>/config.toml) with typed consumer section reads and an opt-in built-inconfigcommand group. - Factors shared filesystem/path/atomic-write logic into a new
fsmodule and updates docs/tests accordingly.
Reviewed changes
Copilot reviewed 19 out of 20 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/foundation.rs | Updates expected global-flag defaults to include credential_store. |
| tests/exhaustive_public_api.rs | Adds parsing coverage for --credential-store file. |
| tests/credential_store_config.rs | New integration tests for credential-store selection precedence via config/env/flag. |
| tests/config_file.rs | New integration tests for config file read/write and built-in config commands. |
| src/module.rs | Exposes ModuleContext::config() for registration-time config access. |
| src/middleware.rs | Stores loaded ConfigFile in middleware for shared access during a run. |
| src/lib.rs | Publishes new config, config_commands, fs modules and related re-exports. |
| src/fs.rs | New shared filesystem utilities: base dir resolution, safe path component validation, atomic writes. |
| src/flags.rs | Adds --credential-store flag and factors app-id env prefix logic into app_id_env_prefix(). |
| src/config.rs | New config file implementation + credential-store resolution utilities and flag/env/config precedence. |
| src/config_commands.rs | New built-in config command group (path/get/set/list). |
| src/command.rs | Exposes CommandContext::config() for handler-time config access. |
| src/cli.rs | Loads config at startup, mounts optional config group, and publishes --credential-store selection per run. |
| src/auth/storage.rs | New injectable credential storage trait + file/keyring/auto backends and selectors. |
| src/auth/pkce.rs | Refactors PKCE provider to persist credentials via CredentialStorage and adds injection/selection APIs. |
| src/auth/mod.rs | Exposes new storage module + re-exports. |
| docs/concepts.md | Documents credential storage modes and the shared config file + built-in commands. |
| docs/auth.md | Updates PKCE docs to describe the storage abstraction and selection precedence. |
| Cargo.toml | Adds toml and toml_edit dependencies. |
| Cargo.lock | Locks new TOML dependencies and transitive updates. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- ConfigFile caches a parsed read-model at load (refreshed on set) instead of reparsing the whole document on every section/deserialize/engine call. - Restrict the process-global credential-store flag accessors (set_credential_store_flag / credential_store_flag) to pub(crate). - save() error message now mentions %APPDATA% alongside XDG_CONFIG_HOME/HOME. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ation gaps Blocking fixes: 1. Remove dual TOML dependency (toml 0.8 + toml_edit 0.22). The previous ConfigFile kept a `read_model: toml::Table` synced from the document via a string round-trip (toml_edit → string → toml::from_str). Two separate crates with the same purpose could version-drift and the round-trip silently yielded an empty config on failure. Replace with direct toml_edit::de::from_document and toml_edit::de::from_str; remove the read_model field and parse_read_model. Enable the "serde" feature on toml_edit = "0.22" in Cargo.toml. 2. Replace CREDENTIAL_STORE_FLAG AtomicU8 process-global with thread_local! Cell<u8>. The global AtomicU8 could leak the --credential-store flag across concurrent Cli::run calls on different OS threads; a thread_local scopes the flag per thread so concurrent runs on separate threads are safe. Also add clear_credential_store_flag() and call it from finish_run() so the flag does not persist across sequential runs on the same thread. Non-blocking fixes: 3. Document that default_storage reads thread-local state and is therefore non-deterministic when called outside a Cli::run execution context. 4. Clarify KeyringStorage::load docstring: both "no entry" and "keychain unavailable" collapse to None; callers cannot distinguish the two states. Point readers to read_three_state for the three-state variant. 5. Document infer_toml_value coercion rules in ConfigFile::set docstring and config set help text: bool → int → float (including 1e5/inf/nan) → string. 6. Expand engine-reserved key validation in ConfigFile::set from the single key "credentials.store" to the entire [credentials] table. Unknown keys in that table now return a clear error rather than silently persisting a no-op value. Add a test covering the rejection of unknown credentials.* keys. 7. Document snapshot semantics on CommandContext::config() and ModuleContext::config(): returns the startup snapshot, not updated by config set within the same process. Add a note to config set that the startup snapshot in context.config() is not updated by the write. 8. Document write_string_atomic and ConfigFile::load as performing blocking I/O, with a note to use spawn_blocking when called from async contexts.
Code Review Findings & FixesReviewed the implementation of injectable credential storage + per-CLI config file. I've pushed fixes for all findings to this branch as commit 🔴 Blocking — Fix 1: Dual TOML dependency with string round-tripFinding: Fix ( 🔴 Blocking — Fix 2: Process-global
|
The read_three_state method is pub(super) so rustdoc rejects the link with -D warnings when built with --features pkce-auth.
🤖 I have created a release *beep* *boop* --- ## [0.2.2](cli-engine-v0.2.1...cli-engine-v0.2.2) (2026-06-12) ### Features * injectable credential storage + per-CLI config file ([#21](#21)) ([3c20bf7](3c20bf7)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Summary
Two related capabilities, plus a small refactor:
1. Injectable credential storage (disable the keyring)
Credential persistence is decoupled from
PkceAuthProviderbehind aCredentialStoragetrait with three backends:KeyringStorage(default) — system keychain onlyFileStorage— unencrypted JSON under the config base dir (the WSL/headless escape hatch)AutoStorage— keychain with transparent file fallbackThe mode is selectable, precedence highest→lowest: explicit
.with_storage()/.with_credential_store()→--credential-storeflag →${PREFIX}_CREDENTIAL_STOREenv →[credentials].storein config → defaultkeyring. Default behavior is unchanged (keyring-only, hard error when unavailable).with_file_fallbackis deprecated in favor ofwith_credential_store.2. General per-CLI config file
A single per-application TOML file (
<config-base>/<app_id>/config.toml) the engine shares with consumers:[credentials]); consumers own every other table.ctx.config().section::<T>("deploy")?) and module registration (ModuleContext::config());tomlstays encapsulated (typed access only).configgroup (CliConfig::with_config_commands()):config get/set/path/list.config setis format-preserving (toml_edit, keeps comments), dry-run aware, and validates engine-reserved keys.3. Shared
fsmoduleFilesystem/path primitives (
config_base_dir,is_safe_path_component, atomicwrite_string_atomic) factored into a newfsmodule, reused by both config and credential storage instead of living inconfigand being reached into fromstorage.Testing
config,fs,auth::storage,auth::pkce(incl. aMemoryStoragedouble proving the provider delegates).Cli::run:tests/credential_store_config.rs(config/env/flag precedence) andtests/config_file.rs(consumer section read;config set→getround-trip preserving[credentials]+ comments; dry-run no-write;config path).pkce-auth:cargo fmt --check,clippy --all-targets -D warnings,RUSTDOCFLAGS='-D warnings' cargo doc, full tests + doc tests,missing-docs= 0.🤖 Generated with Claude Code