cpm 0.3.0: a profile is now an environment, not just an account

cpm 0.3.0: a profile is now an environment, not just an account

A new version of Claude Profile Manager: per-profile MCP servers and settings, separate session history, profile management from the command line, cpm exec for scripts and CI, and an API key check before cloud sync.

Jakub Kontra
Jakub Kontra
Developer

As of today, the work profile has its own Jira MCP server that the personal profile never sees. Three lines in config.toml are all it takes. In April I wrote about cpm as a tool for running multiple Claude Code accounts on one machine. Since then, version 0.2.0 added cloud sync for settings (settings.json, CLAUDE.md, commands/…) through a private git repo. Version 0.3.0 expands what switches along with the account. I'm only picking the things that change how you use it.

A profile is now an environment, not just a token

Each profile can have its own MCP servers. mcp_exclude hides unwanted global servers, and [profiles.work.mcp_servers.jira] adds a server just for this profile, with the same keys as in ~/.claude.json. When names collide, the profile server wins over the global one.

[profiles.work.settings] works the same way: values get merged into settings.json with a deep merge. Maps are combined; arrays and scalar values are replaced by the profile. Attribution, until now the only per-profile setting in settings.json, is now just one of the keys.

Shared directories are symlinks to ~/.claude/<dir> inside the profile. isolate = ["projects"] replaces the symlink with an empty real directory, so the profile gets its own session history – Claude Code keeps it in projects/. cpm never deletes a real directory with data.

A new profile gets only selected keys from ~/.claude.json: onboarding flags, theme and install method, never oauthAccount or projects. cpm then immediately writes the MCP servers into the profile as well: the global ones minus mcp_exclude, plus the profile ones. So the first claude-work skips onboarding and sees the servers right away.

[profiles.work]
description = "Company account"
isolate = ["projects"]
mcp_exclude = ["personal-notes"]

[profiles.work.mcp_servers.jira]
type = "http"
url = "https://jira.example.com/mcp"

[profiles.work.settings]
model = "sonnet"

Profiles without editing the config by hand

cpm add work -d "Company account" -m sonnet -e JIRA_TOKEN=… writes the profile to the config and installs it right away. cpm remove work --purge removes the wrapper, the directory, the credentials and the Keychain entry; --yes skips the confirmation. cpm clone writes the new profile on its own.

The claude-work wrapper can only run claude. cpm exec <profile> -- <cmd> runs anything else with the profile's environment: a script that calls claude -p, a git hook, a CI step, or a tool that needs, say, the Vertex variables from [profiles.vertex.env].

cpm exec work -- ./scripts/review.sh
cpm exec work -- .git/hooks/prepare-commit-msg

What cpm watches for you

Before a push to the cloud, cpm goes through the env block in settings*.json and stops the push if it finds an API key – once a key is in git history, you can't take it back. You override this with --allow-secrets.

The shell hook (eval "$(cpm hook)") clears only the variables it set itself (CPM_MANAGED_VARS) when you leave a project with .claude-profile, so your own ANTHROPIC_* variables survive a directory change. cpm hook --default personal switches to a default profile outside such projects.

On macOS, list, credentials and a regular doctor only check that the Keychain entry exists and don't read the token, so macOS doesn't pop up asking for access. That's why cpm list shows "authenticated", but you only see expiration and subscription type with doctor --verify, which reads the token. On Linux the token lives in .credentials.json, and both cpm credentials and doctor show expiration. doctor returns exit 1 when any check fails, so it fits into CI.

The baseline in <profile>/.cpm/baseline/ remembers the state after a sync. Thanks to it, install --sync tells a local edit apart from an upstream change and won't overwrite your work in progress.

And more: --json on read commands, fish, cpm completion, cloud diff, push --dry-run, auto_push and auto_pull_on_install.

What got fixed

  • cpm cloud pull writes profiles from the cloud into config.toml.
  • cpm cloud remote saves the URL even next to the commented-out # [cloud] from the example config.
  • cpm list on macOS shows "authenticated" (see above).
  • The expiration parser reads claudeAiOauth.expiresAt correctly in milliseconds.
  • auto_push works.
  • cpm upgrade verifies the SHA-256 checksum and replaces the running binary.
  • Env variable values in wrappers are escaped, and HTTP calls have a 10 s timeout.
  • The rest is under "Fixed" in the CHANGELOG.

Backward compatibility

The new fields are optional; an old config keeps working. There are three intentional changes. Profile names may only contain [A-Za-z0-9_-]. Cloud sync skips settings.local.json because it belongs to a specific machine; you bring it back with include = ["settings.local.json"]. And the first cpm install after the upgrade regenerates the wrappers – if you haven't edited them by hand, there's nothing to do.

In April cpm switched accounts; now it switches the whole environment. brew upgrade cpm, and you'll find the rest in the repo.