@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.
- package/README.md +74 -26
- package/dist/index.js +114 -3
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,23 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
>
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
[](https://github.com/remeic/ccm/actions/workflows/ci.yml)
|
|
6
17
|
[](https://www.npmjs.com/package/@remeic/ccm)
|
|
18
|
+
[](https://github.com/Remeic/homebrew-tap)
|
|
7
19
|
[](LICENSE)
|
|
8
20
|
[](https://nodejs.org)
|
|
9
|
-
[](https://codecov.io/github/Remeic/ccm)
|
|
10
22
|
[](https://stryker-mutator.io/)
|
|
11
23
|
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
| `ccm create <name> [-l label] [-b browser]`
|
|
79
|
-
| `ccm list`
|
|
80
|
-
| `ccm use <name> [-- args]`
|
|
81
|
-
| `ccm login <name> [--console] [-b browser] [--url-only]` | Authenticate a profile
|
|
82
|
-
| `ccm status [name]`
|
|
83
|
-
| `ccm
|
|
84
|
-
| `ccm
|
|
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
|
|
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
|
-
|
|
|
355
|
-
|
|
356
|
-
| Switch accounts
|
|
357
|
-
| Multiple sessions
|
|
358
|
-
| Config mixing risk | High — single config directory
|
|
359
|
-
| Setup per account
|
|
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.
|
|
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.
|
|
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/
|
|
9
|
+
"url": "git+https://github.com/Remeic/ccm.git"
|
|
10
10
|
},
|
|
11
11
|
"bugs": {
|
|
12
|
-
"url": "https://github.com/
|
|
12
|
+
"url": "https://github.com/Remeic/ccm/issues"
|
|
13
13
|
},
|
|
14
|
-
"homepage": "https://github.com/
|
|
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",
|