@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.
- package/LICENSE +21 -0
- package/README.md +385 -0
- package/dist/index.js +602 -0
- 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
|
+
[](https://github.com/remeic/ccm-cli/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/@remeic/ccm)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](https://codecov.io/github/Remeic/ccm-cli)
|
|
10
|
+
[](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 <name> [-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/<name>/"]
|
|
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 <name>"]
|
|
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 <name> [-- args]
|
|
232
|
+
ccm run <name> -p <prompt>"] --> B{"Profile
|
|
233
|
+
exists?"}
|
|
234
|
+
B -->|No| ERR["Exit with error"]
|
|
235
|
+
B -->|Yes| C["Resolve profile directory
|
|
236
|
+
~/.ccm/profiles/<name>/"]
|
|
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 <name> [-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
|
+
}
|