@remeic/ccm 0.1.0 → 0.3.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 (3) hide show
  1. package/README.md +74 -26
  2. package/dist/index.js +114 -3
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -1,23 +1,45 @@
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)
1
+ <div id="toc">
2
+ <table>
3
+ <tr>
4
+ <td width="170" valign="top">
5
+ <img src="./docs/assets/ccm-logo.png" alt="CCM Logo" width="150" height="150" />
6
+ </td>
7
+ <td valign="middle">
8
+ <h1 align="left">ccm</h1>
9
+ <p><strong>Multi-profile manager for Claude Code</strong></p>
10
+ <p>Switch between Claude Code accounts instantly. Like <code>nvm</code> for Claude Code profiles.</p>
11
+ </td>
12
+ </tr>
13
+ </table>
14
+ </div>
15
+
16
+ [![CI](https://github.com/remeic/ccm/actions/workflows/ci.yml/badge.svg)](https://github.com/remeic/ccm/actions/workflows/ci.yml)
6
17
  [![npm version](https://img.shields.io/npm/v/%40remeic%2Fccm.svg)](https://www.npmjs.com/package/@remeic/ccm)
18
+ [![Homebrew tap](https://img.shields.io/badge/Homebrew-tap-FBB040?logo=homebrew&logoColor=white)](https://github.com/Remeic/homebrew-tap)
7
19
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
20
  [![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)
21
+ [![codecov](https://codecov.io/github/Remeic/ccm/graph/badge.svg?token=E16LCLDHYV)](https://codecov.io/github/Remeic/ccm)
10
22
  [![mutation testing](https://img.shields.io/badge/mutation%20testing-100%25-brightgreen)](https://stryker-mutator.io/)
11
23
 
12
- Switch between Claude Code accounts instantly. Like `nvm` for Claude Code profiles.
24
+ Manage separate Claude Code profiles for personal, work, and client accounts without repeated logout/login cycles. `ccm` keeps each profile isolated so switching is immediate and parallel sessions stay clean.
25
+
26
+ <p align="center">
27
+ <img src="./docs/assets/intro.gif" alt="ccm in action in a terminal view" width="100%" />
28
+ </p>
13
29
 
14
30
  ## Table of Contents
15
31
 
32
+ - [Table of Contents](#table-of-contents)
16
33
  - [Why](#why)
17
34
  - [Prerequisites](#prerequisites)
18
35
  - [Install](#install)
19
36
  - [Quick Start](#quick-start)
20
37
  - [Commands](#commands)
38
+ - [Passing Flags and Environment Variables](#passing-flags-and-environment-variables)
39
+ - [Multi-Account Login](#multi-account-login)
40
+ - [Different Browser per Profile](#different-browser-per-profile)
41
+ - [URL-Only Mode](#url-only-mode)
42
+ - [API Key Auth](#api-key-auth)
21
43
  - [How It Works](#how-it-works)
22
44
  - [Architecture Overview](#architecture-overview)
23
45
  - [Profile Isolation](#profile-isolation)
@@ -46,13 +68,20 @@ Claude Code stores authentication in a single config directory. If you use multi
46
68
  ## Install
47
69
 
48
70
  ```sh
49
- npm i -g @remeic/ccm
71
+ npm install -g @remeic/ccm
72
+ pnpm add -g @remeic/ccm
73
+ yarn global add @remeic/ccm
74
+ bun add -g @remeic/ccm
75
+ brew install remeic/tap/ccm
50
76
  ```
51
77
 
52
- Or run without installing:
78
+ Homebrew core already ships an unrelated `ccm` formula, so install this one with the fully qualified tap name.
53
79
 
54
80
  ```sh
55
81
  npx @remeic/ccm <command>
82
+ pnpm dlx @remeic/ccm <command>
83
+ yarn dlx @remeic/ccm <command>
84
+ bunx @remeic/ccm <command>
56
85
  ```
57
86
 
58
87
  The installed command remains `ccm`.
@@ -73,15 +102,16 @@ $ ccm use work
73
102
 
74
103
  ## Commands
75
104
 
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 |
105
+ | Command | Description |
106
+ | -------------------------------------------------------- | ------------------------------------------------------------- |
107
+ | `ccm create <name> [-l label] [-b browser]` | Create a profile. `-b` sets the browser for OAuth |
108
+ | `ccm list` | List all profiles with auth status, including drifted entries |
109
+ | `ccm use <name> [-- args]` | Launch Claude Code. Args after `--` are passed to Claude |
110
+ | `ccm login <name> [--console] [-b browser] [--url-only]` | Authenticate a profile |
111
+ | `ccm status [name]` | Show auth status and storage state for one or all profiles |
112
+ | `ccm rename <old-name> <new-name>` | Rename a profile (config, directory, and browser wrapper) |
113
+ | `ccm remove <name> [-f]` | Remove a profile. `-f` skips confirmation |
114
+ | `ccm run <name> -p <prompt>` | Run a prompt non-interactively |
85
115
 
86
116
  ## Passing Flags and Environment Variables
87
117
 
@@ -176,6 +206,7 @@ src/
176
206
  ├── login.ts # Authenticate via Claude TUI or --console
177
207
  ├── use.ts # Launch Claude with profile config dir
178
208
  ├── status.ts # Show auth status and drift state
209
+ ├── rename.ts # Rename profile with atomic rollback
179
210
  ├── remove.ts # Remove profile with staged rollback
180
211
  └── run.ts # Run prompt with specific profile
181
212
  ```
@@ -331,8 +362,8 @@ ccm locates the Claude binary using the following strategy:
331
362
 
332
363
  ## Configuration
333
364
 
334
- | Variable | Description |
335
- |---|---|
365
+ | Variable | Description |
366
+ | ------------ | ------------------------------------------------------------------------------------------------ |
336
367
  | `CLAUDE_BIN` | Override the path to the Claude binary. Useful if Claude is installed in a non-standard location |
337
368
 
338
369
  All ccm data is stored in `~/.ccm/`. This includes the config file and all profile directories.
@@ -351,12 +382,12 @@ You can verify this yourself: the runtime dependencies are [Commander.js](https:
351
382
 
352
383
  ## Comparison
353
384
 
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` |
385
+ | | Without ccm | With ccm |
386
+ | ------------------ | --------------------------------------------- | ------------------------------- |
387
+ | Switch accounts | `claude auth logout` then `claude auth login` | `ccm use work` |
388
+ | Multiple sessions | Not possible simultaneously | Each profile runs independently |
389
+ | Config mixing risk | High — single config directory | None — full isolation |
390
+ | Setup per account | Manual every time | One-time `create` + `login` |
360
391
 
361
392
  ## FAQ
362
393
 
@@ -378,6 +409,23 @@ ccm uses cross-platform binary discovery (`which`/`where`) and standard Node.js
378
409
 
379
410
  ## Contributing
380
411
 
412
+ ### Homebrew Releases
413
+
414
+ Homebrew publication is handled through a dedicated tap, not `homebrew/core`. After `npm publish`, the release workflow updates `Formula/ccm.rb` in the tap repository.
415
+
416
+ To keep the release PR and changelog accurate, prefer **Squash and merge** with a Conventional Commit PR title like `feat: add profile import command`. `release-please` uses the merged commit on `main`, so `docs:` and `refactor:` changes are typically omitted from Node release notes while `feat:` and `fix:` become releasable entries.
417
+
418
+ Required repository configuration:
419
+
420
+ - `HOMEBREW_TAP_GITHUB_TOKEN`: GitHub token with write access to the tap repo
421
+ - `HOMEBREW_TAP_REPOSITORY`: optional repository override, defaults to `remeic/homebrew-tap`
422
+
423
+ Generate the formula locally:
424
+
425
+ ```sh
426
+ bun run homebrew:formula -- --sha256 <npm-tarball-sha256> --output /tmp/ccm.rb
427
+ ```
428
+
381
429
  See [CONTRIBUTING.md](CONTRIBUTING.md).
382
430
 
383
431
  ## License
package/dist/index.js CHANGED
@@ -48,6 +48,14 @@ function getStagedPath(path) {
48
48
  function getBrowserWrapperPath(profileName, browsersDir = BROWSERS_DIR) {
49
49
  return join(browsersDir, `${profileName}.sh`);
50
50
  }
51
+ function renameBrowserWrapper(oldName, newName, browsersDir = BROWSERS_DIR) {
52
+ const oldPath = getBrowserWrapperPath(oldName, browsersDir);
53
+ if (!existsSync(oldPath)) {
54
+ return;
55
+ }
56
+ const newPath = getBrowserWrapperPath(newName, browsersDir);
57
+ renameSync(oldPath, newPath);
58
+ }
51
59
  function removeBrowserWrapper(profileName, browsersDir = BROWSERS_DIR) {
52
60
  const wrapperPath = getBrowserWrapperPath(profileName, browsersDir);
53
61
  if (!existsSync(wrapperPath)) {
@@ -131,6 +139,18 @@ function removeProfile(name, configFile = CONFIG_FILE) {
131
139
  delete config.profiles[name];
132
140
  saveConfig(config, configFile);
133
141
  }
142
+ function renameProfile(oldName, newName, configFile = CONFIG_FILE) {
143
+ const config = loadConfig(configFile);
144
+ if (!config.profiles[oldName]) {
145
+ throw new Error(`Profile "${oldName}" not found`);
146
+ }
147
+ if (config.profiles[newName]) {
148
+ throw new Error(`Profile "${newName}" already exists`);
149
+ }
150
+ config.profiles[newName] = { ...config.profiles[oldName], name: newName };
151
+ delete config.profiles[oldName];
152
+ saveConfig(config, configFile);
153
+ }
134
154
  function getProfile(name, configFile = CONFIG_FILE) {
135
155
  return loadConfig(configFile).profiles[name];
136
156
  }
@@ -155,6 +175,17 @@ function createProfileDir(name, profilesDir = PROFILES_DIR) {
155
175
  mkdirSync(dir, { recursive: true });
156
176
  return dir;
157
177
  }
178
+ function renameProfileDir(oldName, newName, profilesDir = PROFILES_DIR) {
179
+ const oldDir = join(profilesDir, oldName);
180
+ const newDir = join(profilesDir, newName);
181
+ if (!existsSync(oldDir)) {
182
+ throw new Error(`Profile directory "${oldName}" does not exist`);
183
+ }
184
+ if (existsSync(newDir)) {
185
+ throw new Error(`Profile directory "${newName}" already exists`);
186
+ }
187
+ renameSync(oldDir, newDir);
188
+ }
158
189
  function removeProfileDir(name, profilesDir = PROFILES_DIR) {
159
190
  const dir = join(profilesDir, name);
160
191
  if (!existsSync(dir)) {
@@ -288,8 +319,6 @@ function getAuthStatus(profileDir) {
288
319
  });
289
320
  });
290
321
  }
291
-
292
- // src/lib/profile-store.ts
293
322
  function getProfileState(hasConfig, hasDirectory) {
294
323
  if (hasConfig && hasDirectory) return "ready";
295
324
  if (hasDirectory) return "orphaned";
@@ -383,6 +412,74 @@ function removeStoredProfile(name, configFile = CONFIG_FILE, profilesDir = PROFI
383
412
  throw error;
384
413
  }
385
414
  }
415
+ function renameStoredProfile(oldName, newName, configFile = CONFIG_FILE, profilesDir = PROFILES_DIR, browsersDir = BROWSERS_DIR) {
416
+ validateProfileName(newName);
417
+ if (oldName === newName) {
418
+ throw new Error("Old and new profile names are the same");
419
+ }
420
+ const profile = getStoredProfile(oldName, configFile, profilesDir);
421
+ if (!profile) {
422
+ throw new Error(`Profile "${oldName}" does not exist`);
423
+ }
424
+ if (getProfile(newName, configFile)) {
425
+ throw new Error(`Profile "${newName}" already exists`);
426
+ }
427
+ if (profileExists(newName, profilesDir)) {
428
+ throw new Error(`Profile directory "${newName}" already exists`);
429
+ }
430
+ if (profile.hasDirectory) {
431
+ renameProfileDir(oldName, newName, profilesDir);
432
+ }
433
+ const hadWrapper = existsSync(getBrowserWrapperPath(oldName, browsersDir));
434
+ if (hadWrapper) {
435
+ try {
436
+ renameBrowserWrapper(oldName, newName, browsersDir);
437
+ } catch (error) {
438
+ try {
439
+ rollbackRename(oldName, newName, profile.hasDirectory, false, profilesDir, browsersDir);
440
+ } catch (rollbackError) {
441
+ throw new Error(
442
+ `Failed to rename profile "${oldName}" to "${newName}": ${formatError(error)}. Rollback failed: ${formatError(rollbackError)}`
443
+ );
444
+ }
445
+ throw error;
446
+ }
447
+ }
448
+ try {
449
+ renameProfile(oldName, newName, configFile);
450
+ } catch (error) {
451
+ try {
452
+ rollbackRename(oldName, newName, profile.hasDirectory, hadWrapper, profilesDir, browsersDir);
453
+ } catch (rollbackError) {
454
+ throw new Error(
455
+ `Failed to rename profile "${oldName}" to "${newName}": ${formatError(error)}. Rollback failed: ${formatError(rollbackError)}`
456
+ );
457
+ }
458
+ throw error;
459
+ }
460
+ }
461
+ function rollbackRename(oldName, newName, hadDirectory, hadWrapper, profilesDir, browsersDir) {
462
+ const rollbackErrors = [];
463
+ if (hadWrapper) {
464
+ try {
465
+ renameBrowserWrapper(newName, oldName, browsersDir);
466
+ } catch (error) {
467
+ rollbackErrors.push(`browser wrapper: ${formatError(error)}`);
468
+ }
469
+ }
470
+ if (hadDirectory) {
471
+ try {
472
+ renameProfileDir(newName, oldName, profilesDir);
473
+ } catch (error) {
474
+ rollbackErrors.push(`profile dir: ${formatError(error)}`);
475
+ }
476
+ }
477
+ if (rollbackErrors.length > 0) {
478
+ throw new Error(
479
+ `Rollback failed for rename "${oldName}" to "${newName}": ${rollbackErrors.join("; ")}`
480
+ );
481
+ }
482
+ }
386
483
  function rollbackStorage(name, stagedDir, stagedWrapper, profilesDir, browsersDir) {
387
484
  const rollbackErrors = [];
388
485
  if (stagedDir) {
@@ -507,6 +604,19 @@ function registerRemove(program2) {
507
604
  });
508
605
  }
509
606
 
607
+ // src/commands/rename.ts
608
+ function registerRename(program2) {
609
+ program2.command("rename <old-name> <new-name>").description("Rename a profile").action((oldName, newName) => {
610
+ try {
611
+ renameStoredProfile(oldName, newName);
612
+ console.log(`\x1B[32m\u2713\x1B[0m Profile "${oldName}" renamed to "${newName}"`);
613
+ } catch (e) {
614
+ console.error(`\x1B[31m\u2717\x1B[0m ${e instanceof Error ? e.message : String(e)}`);
615
+ process.exit(1);
616
+ }
617
+ });
618
+ }
619
+
510
620
  // src/commands/run.ts
511
621
  function registerRun(program2) {
512
622
  program2.command("run <name>").description("Run Claude Code with a prompt using a profile").requiredOption("-p, --prompt <prompt>", "Prompt to send").action((name, opts) => {
@@ -591,10 +701,11 @@ function registerUse(program2) {
591
701
  }
592
702
 
593
703
  // src/index.ts
594
- var program = new Command().name("ccm").version("0.1.0").description("Manage multiple Claude Code profiles");
704
+ var program = new Command().name("ccm").version("0.3.0").description("Manage multiple Claude Code profiles");
595
705
  registerCreate(program);
596
706
  registerList(program);
597
707
  registerRemove(program);
708
+ registerRename(program);
598
709
  registerLogin(program);
599
710
  registerStatus(program);
600
711
  registerUse(program);
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@remeic/ccm",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "nvm-like manager for Claude Code profiles",
5
5
  "license": "MIT",
6
6
  "author": "Giulio Fagioli",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/remeic/ccm-cli.git"
9
+ "url": "git+https://github.com/Remeic/ccm.git"
10
10
  },
11
11
  "bugs": {
12
- "url": "https://github.com/remeic/ccm-cli/issues"
12
+ "url": "https://github.com/Remeic/ccm/issues"
13
13
  },
14
- "homepage": "https://github.com/remeic/ccm-cli#readme",
14
+ "homepage": "https://github.com/Remeic/ccm#readme",
15
15
  "keywords": [
16
16
  "claude",
17
17
  "claude-code",
@@ -30,6 +30,7 @@
30
30
  "scripts": {
31
31
  "build": "tsup",
32
32
  "dev": "tsup --watch",
33
+ "homebrew:formula": "node scripts/homebrew/generate-formula.mjs",
33
34
  "test": "vitest run",
34
35
  "test:watch": "vitest",
35
36
  "test:coverage": "vitest run --coverage",