On this page
ConfigManager class for claudewheel.
#claudewheel.config
#claudewheel.config
The app-config store: the TUI's config/segments/options/state hub.
This module owns :class:AppConfigStore, the workspace-backed store that loads and migrates the four JSON config files (config, segments, options, state) plus the theme files. Construction is eager (it ensures directories, runs schema migrations, recovers interrupted renames, and materializes shared-settings.json) but performs zero terminal I/O -- theme "auto" resolution lives in the module-level :func:resolve_theme_name, called at the UI boundaries, never during construction.
#_migration_1_github_optional
def _migration_1_github_optional(config: dict[str, Any], segments_def: list[dict[str, Any]], theme: dict[str, Any], options_def: dict[str, Any]) -> NoneMake github segment optional (was incorrectly required).
#_migration_2_profile_paths
def _migration_2_profile_paths(config: dict[str, Any], segments_def: list[dict[str, Any]], theme: dict[str, Any], options_def: dict[str, Any]) -> NoneRewrite profile metadata config_dir from ~/.claude-
Knowingly vestigial post-strip: profile locations are no longer persisted (derived from the profile directory instead), and migration 4 deletes the entire profile metadata block this migration rewrites. It is kept solely so the versioned-migration replay order stays stable for configs that migrate forward from an old _schema_version -- migration 2 still runs, then migration 4 removes its output in the same forward pass.
#_migration_3_classify_pinned
def _migration_3_classify_pinned(config: dict[str, Any], segments_def: list[dict[str, Any]], theme: dict[str, Any], options_def: dict[str, Any]) -> NoneClassify existing 'values' into 'pinned' vs discard.
Discovery-backed segments: values with metadata -> pinned (wizard-created), values without metadata -> discard (from discovery, will be re-discovered).
Static segments (no discovery): values in HISTORICAL_DEFAULTS that are still in DEFAULT_OPTIONS -> discard (they come from defaults now). Values in HISTORICAL_DEFAULTS but NOT in current defaults -> pinned (conservative). Values not in any defaults -> pinned (user-added).
#_migration_4_drop_profile_metadata
def _migration_4_drop_profile_metadata(config: dict[str, Any], segments_def: list[dict[str, Any]], theme: dict[str, Any], options_def: dict[str, Any]) -> NoneRemove the legacy metadata block from the profile segment only.
Profile locations are no longer stored -- they are always derived from the profile directory via ProfileStore.path_for. This deletes only the profile segment's metadata dict; every other segment's metadata (e.g. the model segment's model_id entries) and every segment's values/pinned lists are left untouched.
#_migration_5_drop_fable_1m
def _migration_5_drop_fable_1m(config: dict[str, Any], segments_def: list[dict[str, Any]], theme: dict[str, Any], options_def: dict[str, Any]) -> NoneDrop the claude-fable-5[1m] model option, which never meant anything.
Fable 5 runs at 1M context unconditionally: the client's model registry marks it native_1m and, unlike Opus, withholds the supports_1m_suffix flag, so a [1m] suffix on it is discarded before the request is sent. Selecting it produced a session identical to plain claude-fable-5 in every respect -- same model id on the wire, same 1M window -- while implying a choice existed.
Removing it from the defaults does not reach an options.json that already lists it, and migration 3 would classify it as pinned (conservative, correct in general -- but there is no user intent to preserve in a value that cannot alter a launch). Both values and pinned are cleaned so the option cannot survive in either place. Selections referring to it are left alone: the id still resolves, and the launch it produces is the one the user wanted.
#resolve_theme_name
def resolve_theme_name(theme_name: str) -> strResolve 'auto' theme to 'light' or 'dark' via terminal detection.
Explicit 'light' or 'dark' (or any other custom name) are returned as-is. 'auto' queries the terminal background color; detection failure falls back to 'dark'. This performs terminal I/O and therefore lives OUTSIDE store construction -- callers invoke it at the UI boundary, never during :class:AppConfigStore init.
#AppConfigStore
Workspace-backed store for the four JSON config files plus themes.
Construct it via :meth:claudewheel.workspace.Workspace.appconfig; all paths are derived from the workspace. Construction is eager (ensure dirs, load, migrate, recover renames, materialize shared-settings) but does ZERO terminal I/O -- theme "auto" resolution is deferred to :func:resolve_theme_name at the UI boundary.
#load_theme
def load_theme(self, name: str) -> dict[str, Any]Read themes/<name>.json and return a complete theme dict.
Uses the same default-fallback + deep-merge-missing semantics the theme files get during migration, so a partial or missing file still yields a fully populated theme. Pure read -- performs no writes and no terminal I/O. Callers resolve name via :func:resolve_theme_name first.
#_theme_specs
def _theme_specs(self) -> list[tuple[Path, dict[str, Any]]]The (path, default) pairs for the built-in theme files.
Migrations run against BOTH files uniformly (not just a terminal-resolved one), so schema fixes are deterministic and mount-agnostic regardless of which theme the user ends up rendering.
#_recover_incomplete_renames
def _recover_incomplete_renames(self) -> NoneFinish any interrupted profile renames (crash recovery).
#_ensure_shared_settings
def _ensure_shared_settings(self) -> NoneCreate shared-settings.json from canonical values if it doesn't exist.
#_ensure_dir
def _ensure_dir(self) -> NoneCreate config directories and write default files on first run.
#_migrate
def _migrate(self) -> NoneAdd missing default keys to existing config files on startup.
Only adds keys that are absent — never overwrites existing user values. Saves each file only when something actually changed, so running twice is a no-op (idempotent).
#_run_versioned_migrations
def _run_versioned_migrations(self) -> NoneRun schema-versioned migrations that change existing values.
Complements _migrate() which only adds missing keys. Versioned migrations can mutate values and run exactly once per version bump. Theme migrations run against BOTH theme files uniformly: the primary pass mutates config/segments/options plus the first theme file, and secondary passes apply only theme changes to the remaining files (using throwaway copies of config/segments/options so they are not mutated twice).
#_deep_merge_missing
def _deep_merge_missing(target: dict[str, Any], defaults: dict[str, Any]) -> boolRecursively add keys from defaults that are absent in target.
Returns True if any key was added (i.e. the target was mutated).
#add_option
def add_option(self, segment_key: str, value: str) -> NoneAdd a new option value to the pinned list in options.json for the given segment.
#save_state
def save_state(self) -> NoneSave in-memory state to disk.
Merges the authoritative out-of-band keys (see state.OUT_OF_BAND_STATE_KEYS: auth_browser plus the per-project hook-approval and vanilla-guardrails-opt-in maps) from disk before writing, to prevent clobber by stale in-memory state. Out-of-band writers (the auth wizard, preflight steps) write these keys straight to disk via StateFile.set_value while the TUI holds its own in-memory state loaded at startup; this merge ensures those values survive.