Skip to content
claudewheel.config
Edit
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

python
def _migration_1_github_optional(config: dict[str, Any], segments_def: list[dict[str, Any]], theme: dict[str, Any], options_def: dict[str, Any], state: dict[str, Any]) -> None

Make github segment optional (was incorrectly required).

#_migration_2_profile_paths

python
def _migration_2_profile_paths(config: dict[str, Any], segments_def: list[dict[str, Any]], theme: dict[str, Any], options_def: dict[str, Any], state: dict[str, Any]) -> None

Rewrite profile metadata config_dir from ~/.claude- to ~/.claudewheel/profiles/.

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

python
def _migration_3_classify_pinned(config: dict[str, Any], segments_def: list[dict[str, Any]], theme: dict[str, Any], options_def: dict[str, Any], state: dict[str, Any]) -> None

Classify 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).

Non-string entries are skipped: a values list may still carry the retired {"value": ..., "requires": {...}} dict form, which is unhashable and would crash every membership test here. Migration 6 unwraps those, and it cannot run before this one -- so a file old enough to replay this migration has to survive them.

#_migration_4_drop_profile_metadata

python
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], state: dict[str, Any]) -> None

Remove 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

python
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], state: dict[str, Any]) -> None

Drop 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.

#_migration_6_unwrap_dict_option_values

python
def _migration_6_unwrap_dict_option_values(config: dict[str, Any], segments_def: list[dict[str, Any]], theme: dict[str, Any], options_def: dict[str, Any], state: dict[str, Any]) -> None

Replace dict entries in every segment's values list with their value.

A values list once accepted the dict form {"value": "x", "requires": {...}} to declare an option's cross-segment requirements. That parsing is gone -- the model segment's requirements are derived from MODEL_MIN_CLI_VERSION, and no other segment declares any -- so such an entry is now an unhashable dict reaching _deduplicate and crashing the segment build with a bare TypeError. Each dict is replaced by its "value" string; the rest of the dict, requirements included, is dropped as superseded. A dict carrying no usable "value" names no option and is removed.

#_migration_7_drop_plan_permission

python
def _migration_7_drop_plan_permission(config: dict[str, Any], segments_def: list[dict[str, Any]], theme: dict[str, Any], options_def: dict[str, Any], state: dict[str, Any]) -> None

Drop the plan option from the permissions segment.

Claude Code's own Shift+Tab cycle reaches plan mode from inside any session, whatever permission mode the launch passed, and nothing removes it from that cycle. So the launcher offering plan as a launch-time choice duplicates a control the client already has, for a mode these launches never start in -- they start in bypass or in manual (default) mode. The value stays ACCEPTED everywhere else: pin it in options.json or pass --set permissions=plan and the launch still honors it.

Removing it from the defaults does not reach an options.json that already lists it, and migration 3 would classify the removed default as pinned -- reading a user intent into a value that was only ever a shipped default. Both values and pinned are cleaned so the option cannot survive in either place.

A last_config selection naming plan is RESET to default (manual mode). Left alone it would restore as nothing at all: build_segment_bar restores a selection with Segment.select_value, which returns False and changes nothing when the value is not among the segment's options, and the permissions segment is not required -- so the bar would come up with the segment simply unselected. An unselected permissions segment passes no --permission-mode, and the launch then falls through to default_flags, which carry --dangerously-skip-permissions. A stale plan selection would silently become a bypass launch, which is the opposite of what it asked for; default is the manual mode nearest to it.

#resolve_theme_name

python
def resolve_theme_name(theme_name: str) -> str

Resolve '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

python
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

python
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

python
def _recover_incomplete_renames(self) -> None

Finish any interrupted profile renames (crash recovery).

#_ensure_shared_settings

python
def _ensure_shared_settings(self) -> None

Create shared-settings.json from canonical values if it doesn't exist.

#_ensure_dir

python
def _ensure_dir(self) -> None

Create config directories and write default files on first run.

#_load_json

python
def _load_json(self, path: Path, default: Any) -> Any

Read path, falling back to a DEEP COPY of default.

The callers pass the module-level DEFAULT_* dicts from defaults.py. Returning one of those by identity hands the store a live reference to the process-wide default, which the store and the app then mutate in place (schema versions, pinned values, launch counts, recent dirs) -- so a second store built later in the same process starts from the first one's leaked state and writes it to disk. The copy makes every fallback its own object.

#_migrate

python
def _migrate(self) -> None

Add 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).

#_sync_model_discovery

python
def _sync_model_discovery(self) -> None

Give the model segment its discovery config when options.json lacks it.

The registry dispatches on what options.json declares, so a user's file written before the model segment had a discovery type would never run the model discovery at all.

It runs after the versioned migrations, not inside _migrate: migration 3 branches on whether a segment declares a discovery config, and adding the key beforehand would change how it classifies an old file's model values.

#_run_versioned_migrations

python
def _run_versioned_migrations(self) -> None

Run 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/state plus the first theme file, and secondary passes apply only theme changes to the remaining files (using throwaway copies of config/segments/options/state so they are not mutated twice).

State is passed so a migration that retires an option can also repair a launch selection naming it: options.json and state.json describe the same choice from two sides, and cleaning only one of them leaves the other stale. Each of config/segments/options/state is written back only when a migration actually changed it.

#_deep_merge_missing

python
def _deep_merge_missing(target: dict[str, Any], defaults: dict[str, Any]) -> bool

Recursively add keys from defaults that are absent in target.

Returns True if any key was added (i.e. the target was mutated).

#record_discovered_models

python
def record_discovered_models(self, values: list[str], metadata: dict[str, dict[str, Any]]) -> None

Record discovered model ids and their release dates in options.json.

Append-only and idempotent: a model the API newly lists joins the end of the accumulated list once and stays there, offline runs included, while a model that stops being served is never removed. Release dates are merged for every discovered id -- including ids already in the list, which is how the shipped models acquire theirs.

Runs on the main thread only. Discovery itself happens on a background thread against a copy of the state, but options.json has one owner and the write belongs to it.

#add_option

python
def add_option(self, segment_key: str, value: str) -> None

Add a new option value to the pinned list in options.json for the given segment.

#save_state

python
def save_state(self) -> None

Save 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.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • rlsbl Release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, Go and more
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search