claudewheel v0.24.2 /claudewheel.profile_store
Edit
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

python
def config_dir(self) -> Path

Alias 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

python
def path_for(self, name: str) -> Path

Map 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

python
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:

  1. claude_dir qualifies 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.

  1. Each subdir of profiles_dir qualifies when it holds

.credentials.json OR settings.json; has_credentials tracks the .credentials.json presence.

  1. Token-only: each tokens key not already found whose path_for() dir

exists qualifies with has_credentials=False.

  1. has_token is True for any profile whose name is a tokens key.

Result is sorted by name.

#discover

python
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

python
def get(self, name: str, tokens: dict[str, Any] | None=None) -> Profile | None

Return the enumerated :class:Profile for name, or None if absent.

#audit

python
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 whose path_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

python
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

python
def _require_write_stores(self) -> None

Guard: every write op needs shared/options/state wired.

#_require_shared

python
def _require_shared(self) -> None

Guard for shared-store-only helpers (classify_shared_dirs).

#_set_onboarding_flag

python
def _set_onboarding_flag(self, config_dir: Path) -> None

Merge 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

python
def create(self, name: str, settings: dict[str, Any], *, set_onboarding: bool=True, symlink_shared: bool=True) -> Profile

Create 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

python
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

python
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

python
def _purge_last_config(self, name: str) -> bool

Drop last_config['profile'] from state.json when it names name.

Replicates profile_ops._purge_last_config_profile.

#delete

python
def delete(self, name: str, *, allow_data_destruction: bool=False) -> DeletionResult

Delete 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

python
def _update_state_rename(self, old: str, new: str) -> None

Swap last_config['profile'] old->new. Replicates _update_state_rename.

#rename

python
def rename(self, old: str, new: str) -> None

Rename 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

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

Search