Design description. Stage 0 (macOS only) tested masking via --settings, --setting-sources, --strict-mcp-config, auth under those flags, and parallel runs. Everything else here is design intent or inference from the docs, marked as such. ccshelf is the chosen command name.
1. Invocation flow: ccshelf run sre -- --resume#
The launcher is a compiler plus a process starter. It is never in the data path: it doesn't proxy or intercept the conversation.
- Resolve the profile (pure file work):
- Read config and the source list.
- Find
sre. - Follow
extends. - Produce one resolved profile (plugins, skill overrides, MCP servers, session defaults).
- Look at the machine (read-only):
claude --versionfor feature gating.claude plugin list --jsonfor installed plugin ids, install paths and scopes.- Managed-settings files and other policy signals.
- Report profile plugins that aren't installed, with the install command. Never install silently. With
run --install-missing[=id,id](D-65), print the install commands, ask (or take--yes), then runclaude plugin install --scope user <id>for each plugin whose marketplace is already added.
- Compile into generated files:
- A settings file. Its
enabledPluginssetsfalsefor every installed plugin not in the profile (default-deny, regenerated each run so new installs don't leak). It setstruefor the profile's plugins (whethertrueis needed is untested). The file also hasskillOverridesfor standalone skills, andenv/model/outputStyle/effortdefaults. - An MCP config file when the profile uses
strict(its servers, or an empty list). - The launcher names files by a hash of their content and writes them atomically into its cache dir, so concurrent runs share or never collide.
{ "enabledPlugins": { "design-kit@acme": true, "sre-kit@acme": true, "seo-tools@acme": false, "frontend-design@claude-plugins-official": false }, "skillOverrides": { "legacy-helper": "off" }, "env": { "CCSHELF_PROFILE": "sre" } } - A settings file. Its
- Build the command line:
claude --settings <cache>/settings-<hash>.json --mcp-config <cache>/mcp-<hash>.json # only if the profile adds MCP servers --append-system-prompt-file <path> # if the profile sets one --add-dir <cache>/instructions-<hash> # if the profile has instructions --model opus --effort high # if the profile sets them --resume # passthrough argsWith instructions, the launcher also sets
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1in the environment ofclaudeand in theenvof the generated settings file. The settings entry is needed because a settingsenvvalue beats the process environment, and the--settingsfile beats project and user settings verified (stage 0 addendum). Managed settings beat--settings. If managed settings turn the variable off, the instructions do not load. The launcher cannot change that (R3). It reads the managedenventry of this one variable and prints a warning at launch. Other managedenventries are not read. The detection is best effort. It does not see server-managed settings. UndermanagedSourcesBehavior: "merge", the reader of ccshelf lets theenvmap of a higher tier replace the wholeenvmap of a lower tier. How Claude Code mergesenvacross tiers is unverified. A profile cannot set the variable itself, because the profile env allowlist refusesCLAUDE_CODE_names. A profile without instructions gets no new key. The directory<cache>/instructions-<hash>holds one file,CLAUDE.md, with the joined instructions of the profile (mode 0500 for the directory and 0400 for the file). The name carries the SHA-256 of the content. Without the variable,--add-dirdoes not load CLAUDE.md verified (stage 0 note). Claude Code can edit an added directory verified. The launcher builds the directory read-only (directory mode 0500, file mode 0400; on Windows the file gets the read-only attribute and the directory stays writable unverified). This stops an accidental write only. The same user can change the modes. Before every launch the launcher therefore checks that the directory is a real directory (no link or junction) that the user owns, with mode 0500 on Unix, and that it holds exactly one regular file namedCLAUDE.md(no link, mode 0400 on Unix) with the expected bytes. If the check fails, the launcher rebuilds the directory in a temporary directory and renames it into place. If the content is right and the modes are wrong, the launcher probes the cache file system with a temporary file. If the file system ignores file modes (some network and FAT file systems), the launcher accepts the directory on the content check alone, does not rebuild it (a rebuild would replace the directory under running sessions) and prints one warning that the directory is not read-only. A directory with wrong content is rebuilt on every file system. A--add-dirin the passthrough arguments gets a warning, because the variable also loads CLAUDE.md files from the directories that the user adds.The generated settings file hides plugins, skills, connectors and other MCP servers (
enabledPlugins,skillOverrides,disableClaudeAiConnectors,deniedMcpServerswith full server names). So the core path uses no sideload flags. The launcher validates the generated JSON before every launch, because Claude Code ignores an invalid settings file silently (exit 0, no message). Withinherit_user_settings = falseit adds--setting-sources project,local(this drops the user layer, and the launcher re-adds only what the profile sets). If policy blocks a flag or key, behavior follows[policy] on_blocked(warndrops that part,failrefuses). - Start
claudeand step aside:execon Unix, spawn and wait on Windows (see platform.md, "Startingclaude"). Passthrough works for non-interactive use:ccshelf run sre -- -p "summarize this repo". - Write nothing shared: it never touches
~/.claude/settings.json,~/.claude.jsonor the plugin cache (Claude Code rewrites its own state as usual).
Concurrency: two terminals produce two command lines pointing at two immutable files, so neither reads anything the other wrote (tested on macOS with three parallel sessions).
What breaks if Claude Code changes the instructions route: the route depends on --add-dir, on the variable CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD and on the rule that an added directory loads CLAUDE.md. If Claude Code removes or renames the variable, the instructions are silently absent from the session. The stage 0 runs used -p mode only verified. For interactive mode the docs say the same CLAUDE.md hierarchy loads, but no interactive run was made unverified. The built-in Explore and Plan subagents skip CLAUDE.md, so they do not see the instructions verified. In -p mode without compaction, Claude Code did not read the added CLAUDE.md again during the session, not even for a subagent that started later verified. After compaction, the docs say that Claude Code re-injects the project-root CLAUDE.md from disk verified, so an added-directory CLAUDE.md may be read from disk again, and an edit during a session could then reach that session unverified. The read-only directory and the check before launch repair the file for the next launch. They do not protect the running session. Open points, all unverified: whether Claude Code reloads skills or rules from the added directory during a session; whether a user settings.json env value of 0 turns the loading off (inferred from the precedence, not run); and whether the CLAUDE.md files of project permissions.additionalDirectories load when the variable is set. A real-claude check of the init event cannot show the instructions, so the opt-in integration test must ask the model for a marker phrase.
What breaks if Claude Code changes: the design depends on --settings honoring enabledPlugins, skillOverrides, disableClaudeAiConnectors and deniedMcpServers, and on claude plugin list --json. Gate on claude --version, and run the real-claude init-event test in CI against new releases.
2. What is shared between profiles#
Without an account (section 3), the launcher never sets CLAUDE_CONFIG_DIR, so all profiles use the same ~/.claude and ~/.claude.json. Profiles in one account share the rows below. Auth and parallel runs were tested. The rest is inferred from how CLAUDE_CONFIG_DIR is documented.
| Thing | Shared? | Notes |
|---|---|---|
| Auth (login) | Yes | Tested: OAuth works with the flags used. Log in once. |
| Projects and folder trust | Yes | Common per-project state in ~/.claude.json. |
| Session history | Yes, mixed | Transcripts are per project, not tagged by profile. /resume lists sessions from all profiles. Resuming under a different profile loads that session with different plugins (untested). |
| Auto memory and CLAUDE.md | Yes | Per project, not per profile. Per-profile memory is not possible without a separate config dir (#91770 asks for it natively). Decision: shared is fine. A profile can add its own CLAUDE.md-style text with [instructions]. That text is generated per launch and does not change your files. |
| User settings | Yes by default | Permissions, hooks, model, statusline stay in effect because masking merges per key. With inherit_user_settings = false they are dropped. Auth still works (tested). |
| Plugin install cache | Yes | One copy of each plugin. Profiles only change what's enabled. Avoids the duplication and "corrupted" warnings of config-dir-per-profile. |
| claude.ai connector auth | Yes | Connected once. strict hides connectors for a session without affecting their auth. |
Not hidden by a profile: a repo's own .claude/skills/, .claude/agents/ and .claude/settings.json still apply. Its .mcp.json also applies, unless the profile uses strict, which drops project .mcp.json servers too. strict degrades to a deniedMcpServers list in two cases:
- Managed policy blocks
--strict-mcp-configand the profile hason_blocked = "warn". Withon_blocked = "fail", the launcher exits. - The org protects an MCP server the profile does not provide.
The list holds the installed plugins' servers (never the profile's own or protected ones). The launcher then warns that servers from the user's own settings or project files stay available. Plugin masking outranks project settings (--settings has higher precedence).
A profile is a loading filter, not a security or identity boundary: profiles share credentials, history and memory.
3. Combining profiles with accounts (decided: document it)#
Two independent axes:
- Profile = which plugins/skills/MCP are active (task-based). Launcher feature, shared state.
- Account = which Claude Code identity and data directory is used (work vs personal, different login). Done with a separate
CLAUDE_CONFIG_DIR.
An account switch isolates credentials, user settings, installed plugins and marketplaces, history, memory and plugin cache. A profile then filters within that account.
Three ways to combine, from least to most built-in#
- Honor the environment (works from day one). The launcher inherits the environment, and its own
claude plugin list --jsoncall runs under the same environment. SoCLAUDE_CONFIG_DIR=~/.claude-work ccshelf run srejust works, and so does any existing account switcher (e.g. quinnjr/claude-code-profiles, shell aliases) that sets that variable first. Requirement: the launcher never unsets an existingCLAUDE_CONFIG_DIR, and only an explicit--accountflag replaces it (D-24). - Built-in accounts (nicer UX). Config names accounts and the launcher sets the variable for the child:
# ~/.config/ccshelf/config.toml default_account = "work" # top-level key; omit to use Claude Code's default dir [accounts.work] config_dir = "~/.claude-work" # sets CLAUDE_CONFIG_DIR for the child [accounts.personal] config_dir = "~/.claude-personal"Usage:
ccshelf run --account personal sre. ccshelf flags go before the profile name. Everything after it is passed toclaude, and a known ccshelf flag there is rejected. A profile may pin a default with the top-levelaccount = "work"field. - Delegate to an existing tool. Keep using a config-dir switcher and run
ccshelfinside it (way 1).
Which account a command uses#
config.ResolveAccount picks the account in this order (D-24):
- The
--accountflag. It wins over an existingCLAUDE_CONFIG_DIR, because the flag is the stated intent for this run. ccshelf prints a warning that says so. - An existing
CLAUDE_CONFIG_DIRin the environment. ccshelf passes it through unchanged. When a profileaccountordefault_accountwould apply, ccshelf ignores it and prints a warning that names it. - The
accountfield of the profile. default_accountinconfig.toml.- None: Claude Code's default directory. ccshelf does not set the variable.
--account is a global flag, so every command accepts it. The launcher commands that open the profile sources (ls, show, diff, edit, new, trust, shell-init, dry-run and run) resolve the account in this order. Their claude plugin list calls run under that account. doctor --installed also resolves the account in this order, without step 3, because no profile applies. It prints the same warnings and runs claude plugin list under that account. Before it reads the profile, run uses the flag, the environment or default_account. Then it applies the profile's account field. A plugin profile source is read before that step. If the profile then selects another account, run refuses with exit 2 and tells you to pass --account explicitly. An unknown account name is a usage error (exit 2) that lists the known names. dry-run puts the variable in the command that it prints. The equivalent command that ccshelf prints after a prompt keeps --account.
Managing accounts#
ccshelf account add <name>adds an account. The steps:- It checks the name: lower-case letters, digits and hyphens, at most 32. An account is a name, never a path.
- It checks the directory (
--dir, default~/.claude-<name>). The directory must be absolute after~and$VARexpansion. It must not be~/.claude, inside it or above it. It must not go through a symlink or belong to another account. If it exists, it must be empty or already this account's directory. - It creates the directory with mode 0700 and saves the account in
config.toml.--defaultalso setsdefault_account. - It prints the one-time steps that you run yourself: start
claudewith the variable set,/login, then the marketplace adds (--marketplace, repeatable) and plugin installs (--plugin name@marketplace, repeatable) inside the account.
ccshelf account lslists the accounts and marks the default.ccshelf account rm <name>removes the account fromconfig.toml. When it was the default,default_accountbecomes unset. The directory, with its login, plugins and history, is never deleted.ccshelf init --account-name <name>(with--account-dir) adds an account during setup. Theinitwizard also offers one.ccshelf config set default_account <name>changes the default. You can change the accounts themselves only withaccount add,account rmorconfig edit(see cli.md).
The launcher never reads, copies or moves credentials, never touches the macOS Keychain and never deletes an account directory.
What changes per account#
- Installed plugins and marketplaces are per config dir, so each account must install its plugins (
/plugin marketplace add ...and installs) separately. The launcher's installed-plugin list is also per account. A profile can list plugins that exist in one account but not another.runthen warns "plugin <id> is not installed and was not written", anddoctor --installedreports DOC008. - Plugin cache is duplicated per account. Do not symlink
plugins/between accounts: that triggers "corrupted" warnings (#82272, #85325). Accept the duplication, or evaluateCLAUDE_CODE_PLUGIN_SEED_DIR(a read-only pre-populated plugin dir for containers, where plugins still must be enabled) as a way to dedupe. Untested. - Org profiles are shared across accounts, because they come from the profile sources, not from the config dir. The
pluginsource type needs the data-only plugin installed in each account that uses it.gitanddirsources work in any account. - Auth storage differs by OS (see platform.md, "Facts from the docs"). Either way each account has its own login.
- Managed settings still apply to every account (Claude Code reads them from system paths).
- Base preferences diverge: the user layer (
settings.json) is per account, while profile settings arrive through--settingsand are common to all accounts.
Unverified, to test (O-12)#
The automated tests use a fake claude. They check the account choice and the environment of the child. These Claude Code behaviors still need a real second config dir:
claude plugin list --jsonunder a non-defaultCLAUDE_CONFIG_DIRreturns that dir's plugins (expected).- Masking,
--strict-mcp-configand--setting-sourcesbehave the same under a second config dir. - Whether a second login is needed to run the test: a human has to do the interactive
/loginin the second dir (e.g. in the prompt:! CLAUDE_CONFIG_DIR=~/.claude-test claude). Then Stage 0 tests can be repeated against it. - Seed-dir dedupe behavior.
Hazards a profile does not remove (from the review round; reported, not all tested)#
- A profile session can change global state.
/pluginenable and disable, andclaude plugin enable(user scope by default), write the user'senabledPlugins. Enabling a masked plugin inside a profile session does nothing there (the command-line settings win) but enables it everywhere else./skillswrites.claude/settings.local.json. - Default-deny is not airtight mid-session. Plugins installed or synced after launch load on
/reload-pluginsand are not in the mask. Closing the/pluginpanel runs/reload-plugins. Synced plugins also sync in the background after start. --resumeand--continueacross profiles. The recorded system prompt, including an appended prompt, is reused on resume until compaction. The old transcript lists the other profile's skills and agents. The launcher should warn when the profile differs from the one that started the session.- Project plugins. Default-deny also masks plugins a repo enables in its own
.claude/settings.jsonand plugins under.claude/skills/(@skills-dir). The installed list must be built in the session's working directory, and aproject_plugins = keep|maskoption is needed. - Sessions that don't use the launcher (IDE extensions, the desktop app, and probably agent-team teammates) see everything. A VS Code setting,
claudeCode.claudeProcessWrapper, could run the launcher as a wrapper (unverified).