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

#DeletionBookkeepingError

The archival succeeded; updating claudewheel's own stores did not.

The window this names is small and real: :meth:ProfileStore.delete archives the directory first and writes options.json and state.json afterwards, so a full disk, a read-only home or a vanished store between the two leaves a profile that is archived, removed, and still registered.

It exists so the handle survives that failure. A deletion is recoverable only for as long as somebody knows the uuid, and an OSError raised out of a store write carries none -- the profile would be gone with its restore handle never printed. Raising this instead keeps one invariant: a successful archival always tells the caller the uuid, whatever happens after it.

archive is that handle (None only when there was nothing to archive), and the message names it, the command that restores it, the write that failed, and the deletion to re-run to finish the de-registration. reason is the underlying failure on its own, for a caller that composes its own report around the handle rather than printing the message whole.

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

#DeletionResult

Success record from :meth:ProfileStore.delete (refusals raise instead).

Mirrors the success-path fields of profile_ops.DeleteResult: symlink and real-entry counts, taken by a read-only pass over the directory before it is removed, plus which stores were touched. The profile's claudewheel data (its token entry) needs no field of its own: it lives inside the profile directory, so removing that directory removes it and it is counted among removed_real.

archive is the handle the archiving tool handed back -- the one thing that turns this record into something reversible. It is carried out to the caller to be reported, never written anywhere: the archive is the authority on what was deleted and holds its own audit trail, so a launcher-side copy of the handle would be duplicate state with nobody owning its lifetime. It is None under a preview, where the archival was recorded rather than performed.

#DirSurvey

What a profile directory holds, read before anything is removed.

symlinks are the shared-store links (unlinked, never followed); real_children is everything else at the top level, files and directories alike. names is what was seen, so a caller can say which entries the counts came from.

#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 data comes from each profile's own :class:~claudewheel.profile_data.ProfileDataStore, reached through :meth:data_for.

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

#reserved_reason

python
def reserved_reason(self, name: str) -> str | None

Why name cannot be destroyed here, or None when it can.

The query every deletion path asks BEFORE it renders anything. It exists because the answer has to arrive earlier than the confirmation: the vanilla default profile used to reach a data-destruction page advertising a command the store would then refuse, which is a destructive-looking dialogue about an operation that could never happen. The message deliberately names no command -- there is no invocation, forced or otherwise, that deletes ~/.claude.

#data_for

python
def data_for(self, name: str) -> ProfileDataStore

The claudewheel data store inside name's profile directory.

The single door to a profile's token entry and plan-tier fields: one file per profile, inside the profile directory itself.

#enumerate

python
def enumerate(self) -> list[Profile]

Discover all profiles, encoding the historical discovery rules verbatim.

has_token is read from each profile's own data store, so a corrupt token file raises :class:TokenStoreError -- the hard-error contract. :meth:discover is the variant that takes an explicit policy for that.

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, settings.json, or claudewheel's own per-profile data directory (:data:PROFILE_DATA_DIRNAME); has_credentials tracks the .credentials.json presence.

  1. has_token is True when the profile's own data store holds a token.

Result is sorted by name.

#_records

python
def _records(self) -> list[tuple[str, Path, bool]]

Apply the discovery rules WITHOUT opening any token file.

Returns (name, path, has_credentials) for every profile the directory layout reveals -- rules 1 and 2 of :meth:enumerate, which need nothing but directory presence. Token reads (rule 3) happen in the callers, so a caller resolving ONE profile opens ONE profile's secret file and a corrupt file in an unrelated profile cannot decide its fate.

#_record_for

python
def _record_for(self, name: str) -> tuple[str, Path, bool] | None

The discovery record for name, or None when no profile answers to it.

Presence only -- no token file is opened, by this profile or any other.

#_enumerate

python
def _enumerate(self, *, on_corrupt_tokens: Literal['raise', 'swallow']) -> list[Profile]

Enumeration proper; the per-profile corrupt-token policy is applied here.

#discover

python
def discover(self, *, on_corrupt_tokens: Literal['raise', 'swallow']) -> list[Profile]

Enumerate profiles with an EXPLICIT corrupt-token policy.

The single shared home of the "enumerate profiles, deciding what to do about a corrupt token file" 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 token file raises :class:TokenStoreError

(the hard-error contract; health reports the failing profile).

  • "swallow": a corrupt token file leaves that profile's

has_token False (additive maintenance that touches permissions/hooks, not tokens).

The policy is applied per profile now that each profile carries its own token file: one unreadable file no longer decides the whole run.

#get

python
def get(self, name: str) -> Profile | None

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

Single-profile resolution: the name is answered from directory presence and only name's own token file is read, so a corrupt token file in an unrelated profile cannot break this lookup. A corrupt file in name itself still raises :class:TokenStoreError, naming that file.

#env

python
def env(self, name: str) -> dict[str, str]

Resolve a profile name to launch env vars. Read-only, no terminal I/O.

Resolving name reads name's data and nothing else: the name is answered from directory presence (the discovery rules), then that one profile's token file is opened. A corrupt token file in some unrelated profile therefore cannot break this launch, while a corrupt file in name itself raises :class:TokenStoreError naming that file. An unknown name raises :class:ValueError listing the available profile names -- itself derived from directory presence, so producing the list opens no token file either.

For every named profile the result carries CLAUDE_CONFIG_DIR and adds CLAUDE_CODE_OAUTH_TOKEN when the profile's own data store yields a truthy token. 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 token 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.

Every named profile also carries CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL, which stops Claude Code cloning the official plugin marketplace into the profile on first launch. Two things to know about it. There is no settings key for the same effect -- the marketplace settings keys are managed-policy-only -- so the environment is the only lever, and it is undocumented client surface. And the suppression is effectively ONE-WAY per profile: once the client has recorded the install as policy_blocked it treats that as final, so removing the variable later does not make it try again. Un-suppressing a profile means installing the marketplace yourself.

Every named profile also carries DISABLE_AUTOUPDATER and DISABLE_GROWTHBOOK. The first stops the client updating itself over the versions directory claudewheel owns -- the settings route (autoUpdates: false) is overridden on a native install, so the environment is the only lever. The second turns off feature-flag evaluation, which is the only way to stop the server-delivered model-upsell tip at startup; as a side effect the client also disables Remote Control, which is wanted here since the startup auto-connect is one of the things these launches are quieting. Both are undocumented client surface.

Every named profile also carries CLAUDE_CODE_DISABLE_TERMINAL_TITLE, which stops the client generating a title of the session -- a Haiku-generated string the client then promotes into the session name shown on the prompt bar, with no display switch of its own -- and stops it writing the terminal title. A name set with --name or /rename is unaffected. Undocumented client surface as well.

#_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.

#survey_profile_dir

python
def survey_profile_dir(self, name: str) -> DirSurvey

Count what name's directory holds, WITHOUT touching any of it.

A pure read, taken before anything is removed. The counts used to fall out of the removal loop, which tied two things together that are not the same thing: what the directory contained, and how it was emptied. Only the second is going to change (the removal is later delegated to an archiving tool), and the first must survive that intact.

Symlinks are counted as symlinks and never followed -- the shared-store links point at data that outlives the profile.

#_discard_partial_profile_dir

python
def _discard_partial_profile_dir(self, name: str) -> None

Remove the debris of a FAILED :meth:create, symlink-safe.

Only :meth:create's rollback calls this, and only over a directory this same call made moments ago. It is not a deletion: nothing in it is the user's data, so there is nothing to archive and no handle anyone would ever restore. Deleting a real profile goes through :meth:_archive_profile_dir.

Symlinks are unlinked without being followed, exactly as before, so a shared-store link created a moment ago cannot take the store with it.

#_archive_profile_dir

python
def _archive_profile_dir(self, name: str, archiver: ProfileArchiver) -> ArchiveHandle | None

Hand name's directory to the archiving tool, which then removes it.

Removal only: what was there is :meth:survey_profile_dir's answer, taken before this runs.

This used to be a per-child removal loop ending in rmdir, whose must-be-empty failure was the safety property: a child left behind was a hard error rather than something quietly taken with the tree. That property belongs to the deletion, not to the loop that implemented it, so it survives the delegation in the shape the delegation can express: the directory itself must be gone afterwards, and a directory still standing after a reportedly successful archival raises instead of being removed some other way.

The whole directory is handed over in one piece, which is safe for the shared store precisely because the walk does not follow symlinks: the shared-store links are recorded as links and recreated on restore, so the store behind them is never read, never copied and never touched.

Under a preview the invocation is recorded and nothing ran, so there is no handle and no directory to check.

#_bookkeeping_failure

python
def _bookkeeping_failure(name: str, handle: 'ArchiveHandle | None', error: OSError) -> str

What to say when the archival worked and the store writes did not.

Four things, in the order they are useful: what really happened, the handle (first, and spelled out, because it is the only thing that undoes the deletion), the write that failed, and how to finish the cleanup -- re-running the same deletion, which now finds no directory to archive and only updates the stores.

#_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, *, archiver: ProfileArchiver, 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")

The reserved-name refusal is checked first, ahead of the write-store requirement: callers consult :meth:reserved_reason before they render anything, and this backstop must give the same answer whatever else is or is not wired up.

archiver is required rather than defaulted, and the store neither finds one nor decides what to do without one: whether the archiving tool is present, whether it ships what the delegation uses, and whether to offer to install it are decisions with a user to ask, so they belong to the handler. A store that silently removed the directory when no archiver was handed in would be exactly the kind of quiet degradation this delegation exists to remove.

Order is the contract's, and the refusals come first: the archival is the first destructive step, so an ArchiveError out of it leaves the profile on disk AND leaves every store still naming it -- options.json and state.json are touched only after the directory is really gone.

#_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, options values+pinned swap (plus a verbatim metadata-key move -- NO config_dir rewrite), state swap, breadcrumb removal. Refuses "default" in either position.

The profile's token entry needs no step of its own: it lives inside the directory being renamed, so it travels with it.

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

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