On this page
Path-injected profile enumeration and env resolution beside discovery.
#claudewheel.profile_store
#claudewheel.profile_store
The profile store: enumerate, resolve, create, delete, and rename profiles.
#Profile
A single discovered profile: name, on-disk path, and credential/token presence.
#config_dir
def config_dir(self) -> PathAlias for :attr:path -- the CLAUDE_CONFIG_DIR of this profile.
#AuditFinding
One structured integrity finding from :meth:ProfileStore.audit.
kind: a stable finding category. Currently the only kind is
"orphan-token-entry" -- a tokens.json key with no directory on disk.
name: the profile name the finding is about.detail: a human-readable explanation.
#DeletionResult
Success record from :meth:ProfileStore.delete (refusals raise instead).
Mirrors the success-path fields of profile_ops.DeleteResult: symlink and real-entry removal counts plus which stores were touched.
#ProfileStore
Path-injected facade that enumerates profiles and resolves launch env.
All paths are explicit -- the store never reads module path constants and never calls Path.home(). profiles_dir is the claudewheel profiles directory; claude_dir is Claude Code's built-in ~/.claude (the "default" profile); token_store supplies token data. Every method is read-only: zero filesystem writes, zero terminal I/O.
#path_for
def path_for(self, name: str) -> PathMap a profile name to its config dir. The single home of this convention.
"default" maps to :attr:claude_dir; every other name maps to profiles_dir / name.
#enumerate
def enumerate(self, tokens: dict[str, Any] | None=None) -> list[Profile]Discover all profiles, encoding the historical discovery rules verbatim.
tokens None loads token data via token_store.load() (a corrupt tokens.json raises :class:TokenStoreError -- the hard-error contract). An explicit dict (e.g. {}) is the explicit token view for callers that must proceed without token data.
Rules encoding the profile-discovery behavior:
claude_dirqualifies as "default" whenever it IS A DIRECTORY.
~/.claude is Claude Code's own config dir -- managed by Claude Code, not cw -- so cw cannot verify its auth (.credentials.json may live elsewhere, e.g. macOS Keychain). has_credentials tracks the .credentials.json presence but is NOT required for discovery.
- Each subdir of
profiles_dirqualifies when it holds
.credentials.json OR settings.json; has_credentials tracks the .credentials.json presence.
- Token-only: each tokens key not already found whose path_for() dir
exists qualifies with has_credentials=False.
- has_token is True for any profile whose name is a tokens key.
Result is sorted by name.
#discover
def discover(self, *, on_corrupt_tokens: Literal['raise', 'swallow'], tokens: dict[str, Any] | None=None) -> list[Profile]Enumerate profiles with an EXPLICIT corrupt-tokens policy.
The single shared home of the "enumerate profiles, deciding what to do about a corrupt tokens.json" convention. Every consumer (health, reconcile, patch-profiles) routes through here so the swallow try/except lives in exactly one place.
on_corrupt_tokens is mandatory and has no default -- the caller must choose:
"raise": a corrupt tokens.json raises :class:TokenStoreError
(the hard-error contract; health records the error once elsewhere).
"swallow": a corrupt tokens.json is swallowed to{}(additive
maintenance that touches permissions/hooks, not tokens).
tokens is an explicit preloaded token view. When provided it is used verbatim and never re-loaded, so on_corrupt_tokens is moot -- this is how health passes the single view it loaded once. When None, this loads via token_store.load() and applies on_corrupt_tokens.
#get
def get(self, name: str, tokens: dict[str, Any] | None=None) -> Profile | NoneReturn the enumerated :class:Profile for name, or None if absent.
#audit
def audit(self, tokens: dict[str, Any] | None=None) -> list[AuditFinding]Return structured integrity findings about the profile store.
Read-only: zero filesystem writes. tokens None loads token data via token_store.load() (a corrupt tokens.json raises :class:TokenStoreError); an explicit dict is used verbatim.
Currently one finding kind:
"orphan-token-entry": a tokens.json key whosepath_for()dir
does not exist on disk (a token entry with no profile behind it).
Findings are returned in sorted-name order for deterministic output.
#env
def env(self, name: str) -> dict[str, str]Resolve a profile name to launch env vars. Read-only, no terminal I/O.
Enumerates via the TokenStore (a corrupt tokens.json raises :class:TokenStoreError). An unknown name raises :class:ValueError listing the available profile names.
For every named profile the result carries CLAUDE_CONFIG_DIR and adds CLAUDE_CODE_OAUTH_TOKEN when the token_store yields a truthy token for name. The "default" profile is the EXCEPTION: it is Claude Code's own ~/.claude, managed by Claude Code and strictly read-only to cw, so it resolves to an EMPTY env -- no CLAUDE_CONFIG_DIR and no token injection (the vanilla launch path).
A profile whose tokens entry declares plan-tier fields additionally carries CLAUDE_CODE_SUBSCRIPTION_TYPE and/or CLAUDE_CODE_RATE_LIMIT_TIER. Claude Code reads a subscription tier from those variables and ONLY from them when auth arrives as a setup token (CLAUDE_CODE_OAUTH_TOKEN); its own fallback -- fetching the OAuth profile -- is unavailable because setup tokens lack the user:profile scope. Without them the tier resolves to null and tier-dependent checks fail closed. Declared values are validated here: an unrecognized one is a hard error, never a silently ignored field.
#_require_write_stores
def _require_write_stores(self) -> NoneGuard: every write op needs shared/options/state wired.
#_require_shared
def _require_shared(self) -> NoneGuard for shared-store-only helpers (classify_shared_dirs).
#_set_onboarding_flag
def _set_onboarding_flag(self, config_dir: Path) -> NoneMerge hasCompletedOnboarding: true into <config_dir>/.claude.json.
Replicates wizard._set_onboarding_flag exactly: no-op if the dir is absent, read-merge-write preserving other keys, tolerating a corrupt or missing file, atomic write.
#create
def create(self, name: str, settings: dict[str, Any], *, set_onboarding: bool=True, symlink_shared: bool=True) -> ProfileCreate a profile from FINAL settings content. Returns the Profile.
Settings assembly (clone/defaults/checkbox overrides/hook merging) stays in the wizard -- the store takes the finished dict and lands it durably: atomic settings.json write, onboarding flag, all six shared-store symlinks plus skills, and options.json registration. No metadata is written (config_dir is never persisted -- a deliberate core decision).
symlink_shared mirrors the wizard's "Symlink to shared store" checkbox: when False, neither the six shared-store subdir links nor the skills link are created and the profile gets a plain dir (settings + registration still land). When True (default), all seven links are created.
#classify_shared_dirs
def classify_shared_dirs(self, name: str) -> dict[str, str]Classify each shared-store entry in name's dir into one of four states.
Four states (intact, wrong-target, real-dir, missing) over SHARED_SUBDIRS + skills, resolved against this store's shared paths rather than module constants.
#_remove_profile_dir
def _remove_profile_dir(self, name: str) -> tuple[int, int]Remove name's dir, unlinking symlinks WITHOUT following real data.
Replicates profile_ops._remove_profile_dir. Returns (removed_symlinks, removed_real).
#_purge_last_config
def _purge_last_config(self, name: str) -> boolDrop last_config['profile'] from state.json when it names name.
Replicates profile_ops._purge_last_config_profile.
#delete
def delete(self, name: str, *, allow_data_destruction: bool=False) -> DeletionResultDelete a profile and clean up its stores. Refusals raise; success returns.
Mirrors profile_ops.delete_profile_core's decision flow MINUS the running check (that is CLI policy, applied by callers at cutover). Refusal mapping (exceptions instead of a DeleteResult.refusal_reason):
- reserved "default" ->
ValueError - neither registered nor present on disk ->
ValueError(known
profiles listed), mirroring the old "not-found" refusal
- real data at a shared-dir name without allow_data_destruction ->
ValueError naming the offending entries (old "data-destruction")
#_update_state_rename
def _update_state_rename(self, old: str, new: str) -> NoneSwap last_config['profile'] old->new. Replicates _update_state_rename.
#rename
def rename(self, old: str, new: str) -> NoneRename a profile dir and swap all stores, crash-safe via a breadcrumb.
Redesigned transaction: atomic breadcrumb write into the old dir, os.rename of the dir, token key move, options values+pinned swap (plus a verbatim metadata-key move -- NO config_dir rewrite), state swap, breadcrumb removal. Refuses "default" in either position.
#recover_incomplete_renames
def recover_incomplete_renames(self) -> list[dict[str, Any]]Finish or unwind interrupted renames from breadcrumbs. Returns a summary.
Scans profiles_dir/*/.rename_pending. Two crash windows:
- dir already at
to-> POST-rename crash: re-run the three idempotent
store updates and drop the breadcrumb (the old code's behavior).
- dir still at
from-> PRE-rename crash: remove the stale breadcrumb.
This fixes today's leak, where a pre-rename crash left the crumb forever (the old recovery only handled the post-rename window).
Malformed breadcrumbs (unparseable or missing from/to) are reported and skipped, mirroring the old code's tolerant except behavior. Returns a list of {"action", ...} dicts for callers to log.