@remeic/ccm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +385 -0
  3. package/dist/index.js +602 -0
  4. package/package.json +64 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Giulio Fagioli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,385 @@
1
+ # ccm
2
+
3
+ > Multi-profile manager for Claude Code
4
+
5
+ [![CI](https://github.com/remeic/ccm-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/remeic/ccm-cli/actions/workflows/ci.yml)
6
+ [![npm version](https://img.shields.io/npm/v/%40remeic%2Fccm.svg)](https://www.npmjs.com/package/@remeic/ccm)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
9
+ [![codecov](https://codecov.io/github/Remeic/ccm-cli/graph/badge.svg?token=E16LCLDHYV)](https://codecov.io/github/Remeic/ccm-cli)
10
+ [![mutation testing](https://img.shields.io/badge/mutation%20testing-100%25-brightgreen)](https://stryker-mutator.io/)
11
+
12
+ Switch between Claude Code accounts instantly. Like `nvm` for Claude Code profiles.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Why](#why)
17
+ - [Prerequisites](#prerequisites)
18
+ - [Install](#install)
19
+ - [Quick Start](#quick-start)
20
+ - [Commands](#commands)
21
+ - [How It Works](#how-it-works)
22
+ - [Architecture Overview](#architecture-overview)
23
+ - [Profile Isolation](#profile-isolation)
24
+ - [Profile Lifecycle](#profile-lifecycle)
25
+ - [Launching Claude](#launching-claude)
26
+ - [Authentication](#authentication)
27
+ - [Removing Profiles](#removing-profiles)
28
+ - [Config Persistence](#config-persistence)
29
+ - [Claude Binary Discovery](#claude-binary-discovery)
30
+ - [Configuration](#configuration)
31
+ - [Privacy](#privacy)
32
+ - [Comparison](#comparison)
33
+ - [FAQ](#faq)
34
+ - [Contributing](#contributing)
35
+ - [License](#license)
36
+
37
+ ## Why
38
+
39
+ Claude Code stores authentication in a single config directory. If you use multiple Anthropic accounts (personal, work, client projects), you need to log out and back in every time you switch. **ccm** manages isolated profile directories so you can switch instantly — or run multiple accounts simultaneously.
40
+
41
+ ## Prerequisites
42
+
43
+ - [Node.js](https://nodejs.org) >= 18
44
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and available on your `PATH`
45
+
46
+ ## Install
47
+
48
+ ```sh
49
+ npm i -g @remeic/ccm
50
+ ```
51
+
52
+ Or run without installing:
53
+
54
+ ```sh
55
+ npx @remeic/ccm <command>
56
+ ```
57
+
58
+ The installed command remains `ccm`.
59
+
60
+ ## Quick Start
61
+
62
+ ```
63
+ $ ccm create work
64
+ ✓ Profile "work" created
65
+ Next: ccm login work
66
+
67
+ $ ccm login work
68
+ # Opens Claude auth flow in browser...
69
+
70
+ $ ccm use work
71
+ # Launches Claude Code with the "work" profile
72
+ ```
73
+
74
+ ## Commands
75
+
76
+ | Command | Description |
77
+ |---|---|
78
+ | `ccm create <name> [-l label] [-b browser]` | Create a profile. `-b` sets the browser for OAuth |
79
+ | `ccm list` | List all profiles with auth status, including drifted entries |
80
+ | `ccm use <name> [-- args]` | Launch Claude Code. Args after `--` are passed to Claude |
81
+ | `ccm login <name> [--console] [-b browser] [--url-only]` | Authenticate a profile |
82
+ | `ccm status [name]` | Show auth status and storage state for one or all profiles |
83
+ | `ccm remove <name> [-f]` | Remove a profile. `-f` skips confirmation |
84
+ | `ccm run <name> -p <prompt>` | Run a prompt non-interactively |
85
+
86
+ ## Passing Flags and Environment Variables
87
+
88
+ Everything after `--` in `ccm use` is forwarded to `claude`. Env vars from your shell are inherited.
89
+
90
+ ```bash
91
+ # Pass flags
92
+ ccm use work -- --dangerously-skip-permissions
93
+
94
+ # Env vars + flags
95
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 ccm use work -- --dangerously-skip-permissions
96
+
97
+ # Non-interactive with env vars
98
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 ccm run work -p "explain this codebase"
99
+ ```
100
+
101
+ For combos you use often, set up shell aliases:
102
+
103
+ ```bash
104
+ # ~/.zshrc or ~/.bashrc
105
+ alias cwork='CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 ccm use work -- --dangerously-skip-permissions'
106
+ alias cpersonal='ccm use personal -- --dangerously-skip-permissions'
107
+ ```
108
+
109
+ ## Multi-Account Login
110
+
111
+ Each profile has isolated auth. Claude Code manages credentials in macOS Keychain; ccm stores nothing.
112
+
113
+ `ccm login` launches the interactive Claude TUI which supports both auto-redirect (localhost callback) and manual code paste.
114
+
115
+ ### Different Browser per Profile
116
+
117
+ ```bash
118
+ # Specify browser for this login
119
+ ccm login work --browser firefox
120
+
121
+ # Persist browser in the profile
122
+ ccm create work --browser "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
123
+ ccm login work # always opens Chrome
124
+
125
+ # Chrome with a specific profile directory
126
+ ccm create client --browser "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --profile-directory=Profile\ 2"
127
+ ```
128
+
129
+ ### URL-Only Mode
130
+
131
+ ```bash
132
+ ccm login work --url-only
133
+ ```
134
+
135
+ Suppresses auto-opening a browser. Claude prints the auth URL — copy it, open in whichever browser you want. After ~3 seconds the TUI shows "Paste code here if prompted >" where you paste the code from the browser.
136
+
137
+ ### API Key Auth
138
+
139
+ ```bash
140
+ ccm login work --console
141
+ ```
142
+
143
+ ## How It Works
144
+
145
+ ### Architecture Overview
146
+
147
+ ccm stores all data under `~/.ccm/`:
148
+
149
+ ```
150
+ ~/.ccm/
151
+ ├── config.json # Profile metadata (name, label, createdAt)
152
+ ├── browsers/ # Browser wrapper scripts (when browser has args)
153
+ └── profiles/
154
+ ├── work/ # Isolated Claude config directory
155
+ └── personal/ # Isolated Claude config directory
156
+ ```
157
+
158
+ Each profile directory acts as a standalone Claude Code config directory. A central `config.json` tracks metadata. Runtime dependencies are [Commander.js](https://github.com/tj/commander.js) for CLI parsing and [Zod](https://zod.dev) for schema validation and type inference; everything else uses Node.js built-ins.
159
+
160
+ Source code follows a clean separation between library logic and CLI wiring:
161
+
162
+ ```
163
+ src/
164
+ ├── index.ts # Entry point — registers all commands
165
+ ├── types.ts # Zod schemas and inferred TypeScript types
166
+ ├── lib/
167
+ │ ├── constants.ts # Path constants (~/.ccm, profiles dir, config file)
168
+ │ ├── config.ts # Config file I/O with atomic writes
169
+ │ ├── profiles.ts # Profile directory management and validation
170
+ │ ├── profile-store.ts # Reconciled config/filesystem profile view
171
+ │ ├── claude.ts # Claude binary discovery, spawning, auth status
172
+ │ └── browsers.ts # Browser wrapper generation and validation
173
+ └── commands/
174
+ ├── create.ts # Create profile (with rollback on failure)
175
+ ├── list.ts # List profiles with auth status and drift state
176
+ ├── login.ts # Authenticate via Claude TUI or --console
177
+ ├── use.ts # Launch Claude with profile config dir
178
+ ├── status.ts # Show auth status and drift state
179
+ ├── remove.ts # Remove profile with staged rollback
180
+ └── run.ts # Run prompt with specific profile
181
+ ```
182
+
183
+ ### Profile Isolation
184
+
185
+ Claude Code reads auth tokens, settings, and project data from whatever directory `CLAUDE_CONFIG_DIR` points to. ccm exploits this by creating a separate directory per profile and setting this environment variable when spawning Claude:
186
+
187
+ ```ts
188
+ spawn(claudeBinary, args, {
189
+ env: { ...process.env, CLAUDE_CONFIG_DIR: profileDir },
190
+ stdio: 'inherit',
191
+ })
192
+ ```
193
+
194
+ No symlinks, no file copying, no modification of Claude's own config directory. Profiles are fully independent — logging in with one profile does not affect another. You can run multiple profiles simultaneously in different terminals.
195
+
196
+ ### Profile Lifecycle
197
+
198
+ When you create a profile, ccm validates the name, creates the directory, creates a browser wrapper if needed, and writes metadata to `config.json`. If the config write fails, any partially created on-disk state is rolled back.
199
+
200
+ ```mermaid
201
+ flowchart TD
202
+ A["ccm create &lt;name&gt; [-l label]"] --> B{"Validate name
203
+ (a-z, 0-9, -, _; max 64 chars)"}
204
+ B -->|Invalid| ERR1["Exit with error"]
205
+ B -->|Valid| C["Create directory
206
+ ~/.ccm/profiles/&lt;name&gt;/"]
207
+ C -->|Already exists| ERR2["Exit with error"]
208
+ C -->|Created| D{"Write metadata
209
+ to config.json"}
210
+ D -->|Success| E["Profile ready
211
+ ✓ Next: ccm login &lt;name&gt;"]
212
+ D -->|Failure| F["Rollback: remove
213
+ directory and wrapper"]
214
+ F --> ERR3["Exit with error"]
215
+ ```
216
+
217
+ Profile names must match `[a-zA-Z0-9_-]+` and cannot exceed 64 characters.
218
+
219
+ `ccm list` and `ccm status` reconcile `config.json` with `~/.ccm/profiles/` instead of trusting just one source. Each profile is surfaced with one of these states:
220
+
221
+ - `ready`: config entry and profile directory both exist
222
+ - `orphaned`: directory exists but metadata is missing from `config.json`
223
+ - `config-only`: metadata exists but the profile directory is missing
224
+
225
+ ### Launching Claude
226
+
227
+ Both `ccm use` and `ccm run` resolve the profile directory, locate the Claude binary, and spawn it with the isolated config.
228
+
229
+ ```mermaid
230
+ flowchart TD
231
+ A["ccm use &lt;name&gt; [-- args]
232
+ ccm run &lt;name&gt; -p &lt;prompt&gt;"] --> B{"Profile
233
+ exists?"}
234
+ B -->|No| ERR["Exit with error"]
235
+ B -->|Yes| C["Resolve profile directory
236
+ ~/.ccm/profiles/&lt;name&gt;/"]
237
+ C --> D["Find Claude binary
238
+ (CLAUDE_BIN or PATH)"]
239
+ D --> E["Spawn claude with
240
+ CLAUDE_CONFIG_DIR=profile_dir"]
241
+ E --> F["Inherit stdio
242
+ Forward exit code"]
243
+ ```
244
+
245
+ `use` launches an interactive Claude session. Any args after `--` are passed through directly. `run` sends a prompt non-interactively via `-p`.
246
+
247
+ ### Authentication
248
+
249
+ Login delegates entirely to Claude's own auth flow. OAuth launches the interactive Claude TUI directly; `--console` uses `claude auth login --console`. Status checks use `claude auth status --json` with a 10-second timeout.
250
+
251
+ ```mermaid
252
+ sequenceDiagram
253
+ participant User
254
+ participant ccm
255
+ participant Claude
256
+
257
+ User->>ccm: ccm login work
258
+ ccm->>ccm: Verify profile exists
259
+ ccm->>Claude: spawn "claude"<br/>CLAUDE_CONFIG_DIR=~/.ccm/profiles/work/
260
+ Claude-->>User: Interactive TUI for OAuth (or --console for API key)
261
+ Note over Claude: Auth tokens stored<br/>in profile directory
262
+
263
+ User->>ccm: ccm status work
264
+ ccm->>Claude: spawn "claude auth status --json"<br/>(10s timeout)
265
+ Claude-->>ccm: JSON response
266
+ ccm-->>User: Display: logged in, method, email, org
267
+ Note over ccm: On timeout or error:<br/>reports "unknown" gracefully
268
+ ```
269
+
270
+ The `--console` flag on `login` enables API key authentication instead of the browser flow.
271
+
272
+ ### Removing Profiles
273
+
274
+ Removal deletes the config entry, the profile directory, and any browser wrapper script. By default, it asks for confirmation.
275
+
276
+ To avoid leaving config and filesystem out of sync, removal is staged: ccm temporarily renames on-disk assets, updates `config.json`, and only then finalizes deletion. If the config update fails, the staged assets are restored.
277
+
278
+ ```mermaid
279
+ flowchart TD
280
+ A["ccm remove &lt;name&gt; [-f]"] --> B{"Profile
281
+ exists in config or filesystem?"}
282
+ B -->|No| ERR["Exit with error"]
283
+ B -->|Yes| C{"--force
284
+ flag?"}
285
+ C -->|Yes| E["Stage profile directory
286
+ and browser wrapper"]
287
+ C -->|No| D["Prompt: Remove profile? (y/N)"]
288
+ D -->|N| CANCEL["Cancelled"]
289
+ D -->|y| E
290
+ E --> F["Remove config entry
291
+ if present"]
292
+ F -->|Success| G["Finalize deletion"]
293
+ F -->|Failure| H["Restore staged assets"]
294
+ G --> I["✓ Profile removed"]
295
+ ```
296
+
297
+ ### Config Persistence
298
+
299
+ ccm uses an atomic write pattern to prevent config corruption. Data is written to a temporary file, then atomically renamed:
300
+
301
+ ```ts
302
+ const tmp = `${configFile}.tmp`
303
+ writeFileSync(tmp, JSON.stringify(config, null, 2))
304
+ renameSync(tmp, configFile) // Atomic on POSIX filesystems
305
+ ```
306
+
307
+ If the process crashes mid-write, the original config file remains intact. The config schema:
308
+
309
+ ```json
310
+ {
311
+ "profiles": {
312
+ "work": {
313
+ "name": "work",
314
+ "label": "Work account",
315
+ "browser": "/path/to/browser",
316
+ "createdAt": "2025-03-15T10:30:00.000Z"
317
+ }
318
+ }
319
+ }
320
+ ```
321
+
322
+ On read errors (missing file, malformed JSON, invalid structure), ccm gracefully falls back to a default empty config rather than crashing.
323
+
324
+ ### Claude Binary Discovery
325
+
326
+ ccm locates the Claude binary using the following strategy:
327
+
328
+ 1. Check the `CLAUDE_BIN` environment variable (explicit override)
329
+ 2. Use `which claude` (Unix/macOS) or `where claude` (Windows) to search `PATH`
330
+ 3. If neither succeeds, exit with a clear installation message
331
+
332
+ ## Configuration
333
+
334
+ | Variable | Description |
335
+ |---|---|
336
+ | `CLAUDE_BIN` | Override the path to the Claude binary. Useful if Claude is installed in a non-standard location |
337
+
338
+ All ccm data is stored in `~/.ccm/`. This includes the config file and all profile directories.
339
+
340
+ ## Privacy
341
+
342
+ **ccm does not collect, store, or transmit any user data.** There is no telemetry, no analytics, no network calls of any kind.
343
+
344
+ Everything stays on your machine:
345
+
346
+ - **Profile metadata** (name, label, creation date) is stored locally in `~/.ccm/config.json`
347
+ - **Auth tokens** are managed entirely by Claude Code inside each profile directory — ccm never reads or touches them
348
+ - **No outbound connections** — ccm only spawns the local Claude binary, it never contacts any remote server
349
+
350
+ You can verify this yourself: the runtime dependencies are [Commander.js](https://github.com/tj/commander.js) for CLI parsing and [Zod](https://zod.dev) for schema validation and type inference, and the CLI still makes zero HTTP requests.
351
+
352
+ ## Comparison
353
+
354
+ | | Without ccm | With ccm |
355
+ |---|---|---|
356
+ | Switch accounts | `claude auth logout` then `claude auth login` | `ccm use work` |
357
+ | Multiple sessions | Not possible simultaneously | Each profile runs independently |
358
+ | Config mixing risk | High — single config directory | None — full isolation |
359
+ | Setup per account | Manual every time | One-time `create` + `login` |
360
+
361
+ ## FAQ
362
+
363
+ **Can I use two profiles at the same time?**
364
+
365
+ Yes. Each profile has its own config directory. Run `ccm use work` in one terminal and `ccm use personal` in another — they are fully independent.
366
+
367
+ **Does ccm modify Claude Code itself?**
368
+
369
+ No. ccm only sets the `CLAUDE_CONFIG_DIR` environment variable when spawning Claude. It never modifies Claude's files or installation.
370
+
371
+ **What happens if I delete `~/.ccm/`?**
372
+
373
+ All profiles and their auth tokens are lost. Claude Code itself is unaffected.
374
+
375
+ **Is Windows supported?**
376
+
377
+ ccm uses cross-platform binary discovery (`which`/`where`) and standard Node.js filesystem APIs. It works on macOS, Linux, and Windows.
378
+
379
+ ## Contributing
380
+
381
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
382
+
383
+ ## License
384
+
385
+ [MIT](LICENSE)
package/dist/index.js ADDED
@@ -0,0 +1,602 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { existsSync, mkdirSync, writeFileSync, chmodSync, renameSync, rmSync, unlinkSync, readFileSync, readdirSync } from 'fs';
4
+ import { join, dirname, basename } from 'path';
5
+ import { homedir } from 'os';
6
+ import { z } from 'zod';
7
+ import { spawn, execFileSync } from 'child_process';
8
+ import { createInterface } from 'readline';
9
+
10
+ var CCM_HOME = join(homedir(), ".ccm");
11
+ var PROFILES_DIR = join(CCM_HOME, "profiles");
12
+ var CONFIG_FILE = join(CCM_HOME, "config.json");
13
+ var BROWSERS_DIR = join(CCM_HOME, "browsers");
14
+
15
+ // src/lib/browsers.ts
16
+ var SHELL_METACHAR = /[;|&`$(){}<>\\!\n\r]/;
17
+ function validateBrowserCommand(cmd) {
18
+ if (SHELL_METACHAR.test(cmd)) {
19
+ throw new Error(
20
+ `Unsafe browser command "${cmd}". Shell metacharacters (;|&\`$(){}<>\\!) are not allowed.`
21
+ );
22
+ }
23
+ }
24
+ function ensureBrowserWrapper(profileName, browserCommand, browsersDir = BROWSERS_DIR) {
25
+ validateBrowserCommand(browserCommand);
26
+ if (!browserCommand.includes(" ")) return browserCommand;
27
+ if (!existsSync(browsersDir)) mkdirSync(browsersDir, { recursive: true });
28
+ const scriptPath = join(browsersDir, `${profileName}.sh`);
29
+ const content = `#!/bin/sh
30
+ exec ${browserCommand} "$@"
31
+ `;
32
+ const tmp = `${scriptPath}.tmp`;
33
+ writeFileSync(tmp, content);
34
+ chmodSync(tmp, 493);
35
+ renameSync(tmp, scriptPath);
36
+ return scriptPath;
37
+ }
38
+ function getStagedPath(path) {
39
+ for (let attempt = 0; attempt < 100; attempt++) {
40
+ const suffix = attempt === 0 ? `${process.pid}-${Date.now()}` : `${process.pid}-${Date.now()}-${attempt.toString()}`;
41
+ const candidate = `${path}.staged-${suffix}`;
42
+ if (!existsSync(candidate)) {
43
+ return candidate;
44
+ }
45
+ }
46
+ throw new Error(`Could not allocate a temporary path for "${path}"`);
47
+ }
48
+ function getBrowserWrapperPath(profileName, browsersDir = BROWSERS_DIR) {
49
+ return join(browsersDir, `${profileName}.sh`);
50
+ }
51
+ function removeBrowserWrapper(profileName, browsersDir = BROWSERS_DIR) {
52
+ const wrapperPath = getBrowserWrapperPath(profileName, browsersDir);
53
+ if (!existsSync(wrapperPath)) {
54
+ return;
55
+ }
56
+ unlinkSync(wrapperPath);
57
+ }
58
+ function stageBrowserWrapperRemoval(profileName, browsersDir = BROWSERS_DIR) {
59
+ const wrapperPath = getBrowserWrapperPath(profileName, browsersDir);
60
+ if (!existsSync(wrapperPath)) {
61
+ return void 0;
62
+ }
63
+ const stagedPath = getStagedPath(wrapperPath);
64
+ renameSync(wrapperPath, stagedPath);
65
+ return stagedPath;
66
+ }
67
+ function restoreStagedBrowserWrapper(stagedPath, profileName, browsersDir = BROWSERS_DIR) {
68
+ renameSync(stagedPath, getBrowserWrapperPath(profileName, browsersDir));
69
+ }
70
+ function finalizeStagedBrowserWrapperRemoval(stagedPath) {
71
+ rmSync(stagedPath, { force: true });
72
+ }
73
+ function resolveBrowser(cliOverride, meta) {
74
+ return cliOverride ?? meta?.browser;
75
+ }
76
+ var MAX_PROFILE_NAME_LENGTH = 64;
77
+ var PROFILE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
78
+ var ProfileNameSchema = z.string().min(1).max(MAX_PROFILE_NAME_LENGTH).regex(PROFILE_NAME_PATTERN);
79
+ var ProfileMetaSchema = z.object({
80
+ name: ProfileNameSchema,
81
+ label: z.string().optional(),
82
+ browser: z.string().optional(),
83
+ createdAt: z.string()
84
+ }).passthrough();
85
+ var CcmConfigSchema = z.object({
86
+ profiles: z.record(z.string(), ProfileMetaSchema)
87
+ }).passthrough();
88
+ var ClaudeAuthStatusSchema = z.object({
89
+ loggedIn: z.boolean(),
90
+ authMethod: z.string(),
91
+ email: z.string().optional(),
92
+ orgName: z.string().optional(),
93
+ subscriptionType: z.string().optional(),
94
+ apiKeySource: z.string().optional()
95
+ }).passthrough();
96
+
97
+ // src/lib/config.ts
98
+ var DEFAULT_CONFIG = { profiles: {} };
99
+ function loadConfig(configFile = CONFIG_FILE) {
100
+ try {
101
+ const raw = readFileSync(configFile, "utf-8");
102
+ const parsed = JSON.parse(raw);
103
+ const result = CcmConfigSchema.safeParse(parsed);
104
+ return result.success ? result.data : { ...DEFAULT_CONFIG, profiles: {} };
105
+ } catch {
106
+ return { ...DEFAULT_CONFIG, profiles: {} };
107
+ }
108
+ }
109
+ function saveConfig(config, configFile = CONFIG_FILE) {
110
+ const validatedConfig = CcmConfigSchema.parse(config);
111
+ const dir = dirname(configFile);
112
+ mkdirSync(dir, { recursive: true });
113
+ const tmp = `${configFile}.tmp`;
114
+ writeFileSync(tmp, JSON.stringify(validatedConfig, null, 2), { mode: 384 });
115
+ renameSync(tmp, configFile);
116
+ }
117
+ function addProfile(meta, configFile = CONFIG_FILE) {
118
+ const config = loadConfig(configFile);
119
+ const validatedMeta = ProfileMetaSchema.parse(meta);
120
+ if (config.profiles[validatedMeta.name]) {
121
+ throw new Error(`Profile "${validatedMeta.name}" already exists`);
122
+ }
123
+ config.profiles[validatedMeta.name] = validatedMeta;
124
+ saveConfig(config, configFile);
125
+ }
126
+ function removeProfile(name, configFile = CONFIG_FILE) {
127
+ const config = loadConfig(configFile);
128
+ if (!config.profiles[name]) {
129
+ throw new Error(`Profile "${name}" not found`);
130
+ }
131
+ delete config.profiles[name];
132
+ saveConfig(config, configFile);
133
+ }
134
+ function getProfile(name, configFile = CONFIG_FILE) {
135
+ return loadConfig(configFile).profiles[name];
136
+ }
137
+ function validateProfileName(name) {
138
+ const result = ProfileNameSchema.safeParse(name);
139
+ if (result.success) {
140
+ return;
141
+ }
142
+ if (name.length > MAX_PROFILE_NAME_LENGTH) {
143
+ throw new Error(`Profile name too long (max ${MAX_PROFILE_NAME_LENGTH} chars).`);
144
+ }
145
+ throw new Error(
146
+ `Invalid profile name "${name}". Use only letters, numbers, hyphens, underscores.`
147
+ );
148
+ }
149
+ function createProfileDir(name, profilesDir = PROFILES_DIR) {
150
+ validateProfileName(name);
151
+ const dir = join(profilesDir, name);
152
+ if (existsSync(dir)) {
153
+ throw new Error(`Profile directory "${name}" already exists`);
154
+ }
155
+ mkdirSync(dir, { recursive: true });
156
+ return dir;
157
+ }
158
+ function removeProfileDir(name, profilesDir = PROFILES_DIR) {
159
+ const dir = join(profilesDir, name);
160
+ if (!existsSync(dir)) {
161
+ throw new Error(`Profile directory "${name}" does not exist`);
162
+ }
163
+ rmSync(dir, { recursive: true });
164
+ }
165
+ function getStagedPath2(path) {
166
+ const parentDir = dirname(path);
167
+ const baseName = basename(path);
168
+ for (let attempt = 0; attempt < 100; attempt++) {
169
+ const suffix = attempt === 0 ? `${process.pid}-${Date.now()}` : `${process.pid}-${Date.now()}-${attempt.toString()}`;
170
+ const candidate = join(parentDir, `.${baseName}.staged-${suffix}`);
171
+ if (!existsSync(candidate)) {
172
+ return candidate;
173
+ }
174
+ }
175
+ throw new Error(`Could not allocate a temporary path for "${baseName}"`);
176
+ }
177
+ function stageProfileDirRemoval(name, profilesDir = PROFILES_DIR) {
178
+ const dir = join(profilesDir, name);
179
+ if (!existsSync(dir)) {
180
+ throw new Error(`Profile directory "${name}" does not exist`);
181
+ }
182
+ const stagedDir = getStagedPath2(dir);
183
+ renameSync(dir, stagedDir);
184
+ return stagedDir;
185
+ }
186
+ function restoreStagedProfileDir(stagedDir, name, profilesDir = PROFILES_DIR) {
187
+ renameSync(stagedDir, join(profilesDir, name));
188
+ }
189
+ function finalizeStagedProfileDirRemoval(stagedDir) {
190
+ rmSync(stagedDir, { recursive: true });
191
+ }
192
+ function getProfileDir(name, profilesDir = PROFILES_DIR) {
193
+ return join(profilesDir, name);
194
+ }
195
+ function profileExists(name, profilesDir = PROFILES_DIR) {
196
+ return existsSync(join(profilesDir, name));
197
+ }
198
+ function listProfileDirs(profilesDir = PROFILES_DIR) {
199
+ if (!existsSync(profilesDir)) return [];
200
+ return readdirSync(profilesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => d.name);
201
+ }
202
+
203
+ // src/commands/create.ts
204
+ function registerCreate(program2) {
205
+ program2.command("create <name>").description("Create a new profile").option("-l, --label <label>", "Profile label").option("-b, --browser <path>", "Browser for OAuth login").action((name, opts) => {
206
+ try {
207
+ createProfileDir(name);
208
+ const browser = opts.browser ? ensureBrowserWrapper(name, opts.browser) : void 0;
209
+ try {
210
+ addProfile({
211
+ name,
212
+ label: opts.label,
213
+ browser,
214
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
215
+ });
216
+ } catch (configErr) {
217
+ try {
218
+ removeProfileDir(name);
219
+ } catch {
220
+ }
221
+ try {
222
+ removeBrowserWrapper(name);
223
+ } catch {
224
+ }
225
+ throw configErr;
226
+ }
227
+ console.log(`\x1B[32m\u2713\x1B[0m Profile "${name}" created`);
228
+ console.log(` Next: ccm login ${name}`);
229
+ } catch (e) {
230
+ console.error(`\x1B[31m\u2717\x1B[0m ${e instanceof Error ? e.message : String(e)}`);
231
+ process.exit(1);
232
+ }
233
+ });
234
+ }
235
+ var UNKNOWN_AUTH_STATUS = { loggedIn: false, authMethod: "unknown" };
236
+ function findClaudeBinary() {
237
+ if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN;
238
+ const cmd = process.platform === "win32" ? "where" : "which";
239
+ try {
240
+ const result = execFileSync(cmd, ["claude"], { encoding: "utf-8" }).trim().split("\n")[0];
241
+ if (!result) throw new Error("not found");
242
+ return result;
243
+ } catch {
244
+ throw new Error("Claude Code not found. Install: npm i -g @anthropic-ai/claude-code");
245
+ }
246
+ }
247
+ function spawnClaude(profileDir, args, opts) {
248
+ const bin = findClaudeBinary();
249
+ const env = {
250
+ ...process.env,
251
+ CLAUDE_CONFIG_DIR: profileDir
252
+ };
253
+ if (opts?.browser) env.BROWSER = opts.browser;
254
+ return spawn(bin, args, { env, stdio: "inherit" });
255
+ }
256
+ function getAuthStatus(profileDir) {
257
+ const bin = findClaudeBinary();
258
+ return new Promise((resolve) => {
259
+ const child = spawn(bin, ["auth", "status", "--json"], {
260
+ env: { ...process.env, CLAUDE_CONFIG_DIR: profileDir },
261
+ stdio: ["ignore", "pipe", "ignore"]
262
+ });
263
+ let stdout = "";
264
+ if (!child.stdout) {
265
+ resolve(UNKNOWN_AUTH_STATUS);
266
+ return;
267
+ }
268
+ child.stdout.on("data", (chunk) => {
269
+ stdout += chunk.toString();
270
+ });
271
+ const timeout = setTimeout(() => {
272
+ child.kill();
273
+ resolve(UNKNOWN_AUTH_STATUS);
274
+ }, 1e4);
275
+ child.on("close", () => {
276
+ clearTimeout(timeout);
277
+ try {
278
+ const parsed = JSON.parse(stdout);
279
+ const result = ClaudeAuthStatusSchema.safeParse(parsed);
280
+ resolve(result.success ? result.data : UNKNOWN_AUTH_STATUS);
281
+ } catch {
282
+ resolve(UNKNOWN_AUTH_STATUS);
283
+ }
284
+ });
285
+ child.on("error", () => {
286
+ clearTimeout(timeout);
287
+ resolve(UNKNOWN_AUTH_STATUS);
288
+ });
289
+ });
290
+ }
291
+
292
+ // src/lib/profile-store.ts
293
+ function getProfileState(hasConfig, hasDirectory) {
294
+ if (hasConfig && hasDirectory) return "ready";
295
+ if (hasDirectory) return "orphaned";
296
+ return "config-only";
297
+ }
298
+ function listStoredProfiles(configFile = CONFIG_FILE, profilesDir = PROFILES_DIR) {
299
+ const config = loadConfig(configFile);
300
+ const dirNames = new Set(listProfileDirs(profilesDir));
301
+ const names = /* @__PURE__ */ new Set([...Object.keys(config.profiles), ...dirNames]);
302
+ return [...names].sort((left, right) => left.localeCompare(right)).map((name) => {
303
+ const meta = config.profiles[name];
304
+ const hasConfig = meta !== void 0;
305
+ const hasDirectory = dirNames.has(name);
306
+ return {
307
+ name,
308
+ dir: getProfileDir(name, profilesDir),
309
+ meta,
310
+ state: getProfileState(hasConfig, hasDirectory),
311
+ hasConfig,
312
+ hasDirectory
313
+ };
314
+ });
315
+ }
316
+ function getStoredProfile(name, configFile = CONFIG_FILE, profilesDir = PROFILES_DIR) {
317
+ const meta = getProfile(name, configFile);
318
+ const hasConfig = meta !== void 0;
319
+ const hasDirectory = profileExists(name, profilesDir);
320
+ if (!hasConfig && !hasDirectory) {
321
+ return void 0;
322
+ }
323
+ return {
324
+ name,
325
+ dir: getProfileDir(name, profilesDir),
326
+ meta,
327
+ state: getProfileState(hasConfig, hasDirectory),
328
+ hasConfig,
329
+ hasDirectory
330
+ };
331
+ }
332
+ function removeStoredProfile(name, configFile = CONFIG_FILE, profilesDir = PROFILES_DIR, browsersDir = BROWSERS_DIR) {
333
+ const profile = getStoredProfile(name, configFile, profilesDir);
334
+ if (!profile) {
335
+ throw new Error(`Profile "${name}" does not exist`);
336
+ }
337
+ const stagedDir = profile.hasDirectory ? stageProfileDirRemoval(name, profilesDir) : void 0;
338
+ const stagedWrapper = stageBrowserWrapperRemoval(name, browsersDir);
339
+ try {
340
+ if (profile.hasConfig) {
341
+ removeProfile(name, configFile);
342
+ }
343
+ } catch (error) {
344
+ try {
345
+ rollbackStorage(name, stagedDir, stagedWrapper, profilesDir, browsersDir);
346
+ } catch (rollbackError) {
347
+ throw new Error(
348
+ `Failed to remove profile "${name}": ${formatError(error)}. Rollback failed: ${formatError(
349
+ rollbackError
350
+ )}`
351
+ );
352
+ }
353
+ throw error;
354
+ }
355
+ try {
356
+ if (stagedDir) {
357
+ finalizeStagedProfileDirRemoval(stagedDir);
358
+ }
359
+ if (stagedWrapper) {
360
+ finalizeStagedBrowserWrapperRemoval(stagedWrapper);
361
+ }
362
+ } catch (error) {
363
+ const recoveryErrors = [];
364
+ try {
365
+ rollbackStorage(name, stagedDir, stagedWrapper, profilesDir, browsersDir);
366
+ } catch (rollbackError) {
367
+ recoveryErrors.push(formatError(rollbackError));
368
+ }
369
+ if (profile.meta) {
370
+ try {
371
+ restoreConfigEntry(profile.meta, configFile);
372
+ } catch (configRestoreError) {
373
+ recoveryErrors.push(`config: ${formatError(configRestoreError)}`);
374
+ }
375
+ }
376
+ if (recoveryErrors.length > 0) {
377
+ throw new Error(
378
+ `Failed to remove profile "${name}": ${formatError(error)}. Recovery failed: ${recoveryErrors.join(
379
+ "; "
380
+ )}`
381
+ );
382
+ }
383
+ throw error;
384
+ }
385
+ }
386
+ function rollbackStorage(name, stagedDir, stagedWrapper, profilesDir, browsersDir) {
387
+ const rollbackErrors = [];
388
+ if (stagedDir) {
389
+ try {
390
+ restoreStagedProfileDir(stagedDir, name, profilesDir);
391
+ } catch (error) {
392
+ rollbackErrors.push(`profile dir: ${formatError(error)}`);
393
+ }
394
+ }
395
+ if (stagedWrapper) {
396
+ try {
397
+ restoreStagedBrowserWrapper(stagedWrapper, name, browsersDir);
398
+ } catch (error) {
399
+ rollbackErrors.push(`browser wrapper: ${formatError(error)}`);
400
+ }
401
+ }
402
+ if (rollbackErrors.length > 0) {
403
+ throw new Error(`Rollback failed for profile "${name}": ${rollbackErrors.join("; ")}`);
404
+ }
405
+ }
406
+ function restoreConfigEntry(meta, configFile) {
407
+ if (getProfile(meta.name, configFile)) {
408
+ return;
409
+ }
410
+ addProfile(meta, configFile);
411
+ }
412
+ function formatError(error) {
413
+ return error instanceof Error ? error.message : String(error);
414
+ }
415
+
416
+ // src/commands/list.ts
417
+ function registerList(program2) {
418
+ program2.command("list").description("List all profiles, including drifted config/filesystem entries").action(async () => {
419
+ const profiles = listStoredProfiles();
420
+ if (profiles.length === 0) {
421
+ console.log("No profiles. Create one: ccm create <name>");
422
+ return;
423
+ }
424
+ const statuses = await Promise.all(
425
+ profiles.map(async (profile) => {
426
+ const status = profile.hasDirectory ? await getAuthStatus(profile.dir) : void 0;
427
+ return { profile, status };
428
+ })
429
+ );
430
+ console.log(
431
+ `${"NAME".padEnd(20)}${"STATE".padEnd(14)}${"AUTH".padEnd(15)}${"ACCOUNT".padEnd(
432
+ 35
433
+ )}CREATED`
434
+ );
435
+ console.log("\u2500".repeat(99));
436
+ for (const { profile, status } of statuses) {
437
+ const account = status?.email ?? status?.apiKeySource ?? "\u2014";
438
+ const authMethod = status?.authMethod ?? "unavailable";
439
+ const created = profile.meta?.createdAt.slice(0, 10) ?? "\u2014";
440
+ console.log(
441
+ `${profile.name.padEnd(20)}${profile.state.padEnd(14)}${authMethod.padEnd(
442
+ 15
443
+ )}${account.padEnd(35)}${created}`
444
+ );
445
+ }
446
+ });
447
+ }
448
+
449
+ // src/commands/login.ts
450
+ function registerLogin(program2) {
451
+ program2.command("login <name>").description("Login to Claude Code with a profile").option("--console", "Use Anthropic Console (API key) auth").option("-b, --browser <path>", "Browser to use for OAuth").option("--url-only", "Print login URL without opening browser (supports code paste)").action((name, opts) => {
452
+ try {
453
+ if (!profileExists(name)) {
454
+ throw new Error(`Profile "${name}" does not exist. Create it first: ccm create ${name}`);
455
+ }
456
+ const dir = getProfileDir(name);
457
+ const meta = getProfile(name);
458
+ let browser;
459
+ if (opts.urlOnly) {
460
+ browser = "true";
461
+ } else {
462
+ browser = resolveBrowser(opts.browser, meta);
463
+ }
464
+ if (opts.console) {
465
+ const child = spawnClaude(dir, ["auth", "login", "--console"], { browser });
466
+ child.on("close", (code) => process.exit(code ?? 0));
467
+ } else {
468
+ const child = spawnClaude(dir, [], { browser });
469
+ child.on("close", (code) => process.exit(code ?? 0));
470
+ }
471
+ } catch (e) {
472
+ console.error(`\x1B[31m\u2717\x1B[0m ${e instanceof Error ? e.message : String(e)}`);
473
+ process.exit(1);
474
+ }
475
+ });
476
+ }
477
+ function confirm(question) {
478
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
479
+ return new Promise((resolve) => {
480
+ rl.question(question, (answer) => {
481
+ rl.close();
482
+ resolve(answer.toLowerCase() === "y");
483
+ });
484
+ });
485
+ }
486
+ function registerRemove(program2) {
487
+ program2.command("remove <name>").description("Remove a profile").option("-f, --force", "Skip confirmation").action(async (name, opts) => {
488
+ try {
489
+ const profile = getStoredProfile(name);
490
+ if (!profile) {
491
+ throw new Error(`Profile "${name}" does not exist`);
492
+ }
493
+ if (!opts.force) {
494
+ const ok = await confirm(`Remove profile "${name}"? (y/N) `);
495
+ if (!ok) {
496
+ console.log("Cancelled");
497
+ return;
498
+ }
499
+ }
500
+ removeStoredProfile(name);
501
+ const stateSuffix = profile.state === "ready" ? "" : ` (${profile.state})`;
502
+ console.log(`\x1B[32m\u2713\x1B[0m Profile "${name}" removed${stateSuffix}`);
503
+ } catch (e) {
504
+ console.error(`\x1B[31m\u2717\x1B[0m ${e instanceof Error ? e.message : String(e)}`);
505
+ process.exit(1);
506
+ }
507
+ });
508
+ }
509
+
510
+ // src/commands/run.ts
511
+ function registerRun(program2) {
512
+ program2.command("run <name>").description("Run Claude Code with a prompt using a profile").requiredOption("-p, --prompt <prompt>", "Prompt to send").action((name, opts) => {
513
+ try {
514
+ if (!profileExists(name)) {
515
+ throw new Error(`Profile "${name}" does not exist`);
516
+ }
517
+ const dir = getProfileDir(name);
518
+ const child = spawnClaude(dir, ["-p", opts.prompt]);
519
+ child.on("close", (code) => process.exit(code ?? 0));
520
+ } catch (e) {
521
+ console.error(`\x1B[31m\u2717\x1B[0m ${e instanceof Error ? e.message : String(e)}`);
522
+ process.exit(1);
523
+ }
524
+ });
525
+ }
526
+
527
+ // src/commands/status.ts
528
+ function registerStatus(program2) {
529
+ program2.command("status [name]").description("Show auth status for a profile (or all profiles)").action(async (name) => {
530
+ try {
531
+ if (name) {
532
+ const profile = getStoredProfile(name);
533
+ if (!profile) {
534
+ throw new Error(`Profile "${name}" does not exist`);
535
+ }
536
+ const status = profile.hasDirectory ? await getAuthStatus(profile.dir) : void 0;
537
+ console.log(`Profile: ${name}`);
538
+ console.log(`State: ${profile.state}`);
539
+ console.log(`Logged in: ${status?.loggedIn ?? "unknown"}`);
540
+ console.log(`Auth method: ${status?.authMethod ?? "unavailable"}`);
541
+ if (profile.meta?.createdAt) console.log(`Created: ${profile.meta.createdAt}`);
542
+ if (!profile.hasDirectory) console.log("Directory: missing");
543
+ if (status?.email) console.log(`Email: ${status.email}`);
544
+ if (status?.orgName) console.log(`Org: ${status.orgName}`);
545
+ if (status?.subscriptionType) console.log(`Subscription: ${status.subscriptionType}`);
546
+ } else {
547
+ const profiles = listStoredProfiles();
548
+ if (profiles.length === 0) {
549
+ console.log("No profiles.");
550
+ return;
551
+ }
552
+ const statuses = await Promise.all(
553
+ profiles.map(async (profile) => ({
554
+ profile,
555
+ status: profile.hasDirectory ? await getAuthStatus(profile.dir) : void 0
556
+ }))
557
+ );
558
+ for (const { profile, status } of statuses) {
559
+ const icon = status?.loggedIn === true ? "\x1B[32m\u25CF\x1B[0m" : "\x1B[31m\u25CF\x1B[0m";
560
+ const account = status?.email ?? status?.apiKeySource ?? "\u2014";
561
+ const authMethod = status?.authMethod ?? "unavailable";
562
+ const stateSuffix = profile.state === "ready" ? "" : ` [${profile.state}]`;
563
+ console.log(
564
+ `${icon} ${profile.name.padEnd(20)} ${authMethod.padEnd(15)} ${account}${stateSuffix}`
565
+ );
566
+ }
567
+ }
568
+ } catch (e) {
569
+ console.error(`\x1B[31m\u2717\x1B[0m ${e instanceof Error ? e.message : String(e)}`);
570
+ process.exit(1);
571
+ }
572
+ });
573
+ }
574
+
575
+ // src/commands/use.ts
576
+ function registerUse(program2) {
577
+ program2.command("use <name>").description("Launch Claude Code with a profile").allowUnknownOption().helpOption(false).action((name, _opts, cmd) => {
578
+ try {
579
+ if (!profileExists(name)) {
580
+ throw new Error(`Profile "${name}" does not exist. Create it first: ccm create ${name}`);
581
+ }
582
+ const dir = getProfileDir(name);
583
+ const passthroughArgs = cmd.args.slice(1);
584
+ const child = spawnClaude(dir, passthroughArgs);
585
+ child.on("close", (code) => process.exit(code ?? 0));
586
+ } catch (e) {
587
+ console.error(`\x1B[31m\u2717\x1B[0m ${e instanceof Error ? e.message : String(e)}`);
588
+ process.exit(1);
589
+ }
590
+ });
591
+ }
592
+
593
+ // src/index.ts
594
+ var program = new Command().name("ccm").version("0.1.0").description("Manage multiple Claude Code profiles");
595
+ registerCreate(program);
596
+ registerList(program);
597
+ registerRemove(program);
598
+ registerLogin(program);
599
+ registerStatus(program);
600
+ registerUse(program);
601
+ registerRun(program);
602
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@remeic/ccm",
3
+ "version": "0.1.0",
4
+ "description": "nvm-like manager for Claude Code profiles",
5
+ "license": "MIT",
6
+ "author": "Giulio Fagioli",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/remeic/ccm-cli.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/remeic/ccm-cli/issues"
13
+ },
14
+ "homepage": "https://github.com/remeic/ccm-cli#readme",
15
+ "keywords": [
16
+ "claude",
17
+ "claude-code",
18
+ "profile-manager",
19
+ "cli",
20
+ "multi-account",
21
+ "anthropic"
22
+ ],
23
+ "type": "module",
24
+ "bin": {
25
+ "ccm": "dist/index.js"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsup",
32
+ "dev": "tsup --watch",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "test:coverage": "vitest run --coverage",
36
+ "test:mutation": "stryker run",
37
+ "lint": "biome check src tests",
38
+ "format": "biome format --write src tests",
39
+ "lint:fix": "biome check --write src tests",
40
+ "prepublishOnly": "bun run build",
41
+ "prepare": "husky"
42
+ },
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
46
+ "dependencies": {
47
+ "commander": "^13.1.0",
48
+ "zod": "^4.3.6"
49
+ },
50
+ "devDependencies": {
51
+ "@biomejs/biome": "^2.4.10",
52
+ "@commitlint/cli": "^20.5.0",
53
+ "@commitlint/config-conventional": "^20.5.0",
54
+ "@stryker-mutator/core": "^9.6.0",
55
+ "@stryker-mutator/typescript-checker": "^9.6.0",
56
+ "@stryker-mutator/vitest-runner": "^9.6.0",
57
+ "@vitest/coverage-v8": "^3.1.1",
58
+ "husky": "^9.1.7",
59
+ "lint-staged": "^16.4.0",
60
+ "tsup": "^8.4.0",
61
+ "typescript": "^5.8.3",
62
+ "vitest": "^3.1.1"
63
+ }
64
+ }