@remeic/ccm 0.4.0 → 0.5.1

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 +68 -23
  2. package/dist/index.js +355 -124
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -13,33 +13,37 @@
13
13
  </table>
14
14
  </div>
15
15
 
16
- ## IMPORTANT: Claude Code ToS (Multi-Terminal Use)
17
-
18
- > **Mandatory compliance rule for this project**
19
- > Do **not** use the **same Claude account** from multiple terminals at the same time.
20
- > We enforce: **1 account = 1 person = 1 active terminal session**.
21
-
22
- - `ccm` isolates profiles. It does **not** grant any extra rights under Anthropic terms.
23
- - For parallel work, use separate licensed accounts/seats (or API keys under Commercial Terms).
24
- - No credential sharing, no shared account handoff, no "one account used by multiple people".
25
- - This is an intentionally strict project rule to remove ambiguity and reduce compliance risk.
26
- - The CLI also surfaces this via `ccm compliance` (alias: `ccm tos`) and after every successful `ccm create`.
27
-
28
- Official basis (as of April 8, 2026):
29
- - [Claude Code Legal & Compliance](https://code.claude.com/docs/en/legal-and-compliance): usage limits for Pro/Max assume ordinary, individual use; Anthropic may enforce auth restrictions without notice.
30
- - [Anthropic Consumer Terms](https://www.anthropic.com/legal/consumer-terms): account credentials/API keys must not be shared or made available to others; Anthropic may suspend/terminate for material breach.
31
- - [Anthropic Commercial Terms](https://www.anthropic.com/legal/commercial-terms): customer is responsible for all activity under its account.
32
16
 
33
17
  [![CI](https://github.com/remeic/ccm/actions/workflows/ci.yml/badge.svg)](https://github.com/remeic/ccm/actions/workflows/ci.yml)
34
18
  [![npm version](https://img.shields.io/npm/v/%40remeic%2Fccm.svg)](https://www.npmjs.com/package/@remeic/ccm)
35
19
  [![Homebrew tap](https://img.shields.io/badge/Homebrew-tap-FBB040?logo=homebrew&logoColor=white)](https://github.com/Remeic/homebrew-tap)
36
20
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
37
- [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
21
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D24-brightgreen)](https://nodejs.org)
38
22
  [![codecov](https://codecov.io/github/Remeic/ccm/graph/badge.svg?token=E16LCLDHYV)](https://codecov.io/github/Remeic/ccm)
39
23
  [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2FRemeic%2Fccm%2Fmain)](https://dashboard.stryker-mutator.io/reports/github.com/Remeic/ccm/main)
40
24
 
41
25
  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.
42
26
 
27
+ ### IMPORTANT: Claude Code ToS (Multi-Terminal Use)
28
+
29
+ > [!IMPORTANT]
30
+ > **Mandatory compliance rule for this project**
31
+ > Do **not** use the **same Claude account** from multiple terminals at the same time.
32
+ > We enforce: **1 account = 1 person = 1 active terminal session**.
33
+ >
34
+ > - `ccm` isolates profiles. It does **not** grant any extra rights under Anthropic terms.
35
+ > - For parallel work, use separate licensed accounts/seats (or API keys under Commercial Terms).
36
+ > - No credential sharing, no shared account handoff, no "one account used by multiple people".
37
+ > - This is an intentionally strict project rule to remove ambiguity and reduce compliance risk.
38
+ > - The CLI also surfaces this via `ccm compliance` (alias: `ccm tos`) and after every successful `ccm create`.
39
+ >
40
+ > Official basis (as of April 8, 2026):
41
+ > - [Claude Code Legal & Compliance](https://code.claude.com/docs/en/legal-and-compliance): usage limits for Pro/Max assume ordinary, individual use; Anthropic may enforce auth restrictions without notice.
42
+ > - [Anthropic Consumer Terms](https://www.anthropic.com/legal/consumer-terms): account credentials/API keys must not be shared or made available to others; Anthropic may suspend/terminate for material breach.
43
+ > - [Anthropic Commercial Terms](https://www.anthropic.com/legal/commercial-terms): customer is responsible for all activity under its account.
44
+
45
+
46
+
43
47
  <p align="center">
44
48
  <img src="./docs/assets/intro.gif" alt="ccm in action in a terminal view" width="100%" />
45
49
  </p>
@@ -47,7 +51,6 @@ Manage separate Claude Code profiles for personal, work, and client accounts wit
47
51
  ## Table of Contents
48
52
 
49
53
  - [Table of Contents](#table-of-contents)
50
- - [IMPORTANT: Claude Code ToS (Multi-Terminal Use)](#important-claude-code-tos-multi-terminal-use)
51
54
  - [Why](#why)
52
55
  - [Prerequisites](#prerequisites)
53
56
  - [Install](#install)
@@ -59,6 +62,7 @@ Manage separate Claude Code profiles for personal, work, and client accounts wit
59
62
  - [Different Browser per Profile](#different-browser-per-profile)
60
63
  - [URL-Only Mode](#url-only-mode)
61
64
  - [API Key Auth](#api-key-auth)
65
+ - [Copying Config Between Profiles](#copying-config-between-profiles)
62
66
  - [How It Works](#how-it-works)
63
67
  - [Architecture Overview](#architecture-overview)
64
68
  - [Profile Isolation](#profile-isolation)
@@ -73,6 +77,7 @@ Manage separate Claude Code profiles for personal, work, and client accounts wit
73
77
  - [Comparison](#comparison)
74
78
  - [FAQ](#faq)
75
79
  - [Contributing](#contributing)
80
+ - [Homebrew Releases](#homebrew-releases)
76
81
  - [License](#license)
77
82
 
78
83
  ## Why
@@ -81,7 +86,7 @@ Claude Code stores authentication in a single config directory. If you use multi
81
86
 
82
87
  ## Prerequisites
83
88
 
84
- - [Node.js](https://nodejs.org) >= 18
89
+ - [Node.js](https://nodejs.org) >= 24
85
90
  - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and available on your `PATH`
86
91
 
87
92
  ## Install
@@ -128,14 +133,15 @@ $ ccm use work
128
133
 
129
134
  | Command | Description |
130
135
  | -------------------------------------------------------- | ------------------------------------------------------------- |
131
- | `ccm create <name> [-l label] [-b browser]` | Create a profile. `-b` sets the browser for OAuth |
136
+ | `ccm create <name> [-l label] [-b browser] [--from p]` | Create a profile. `--from` seeds non-auth config |
132
137
  | `ccm compliance` / `ccm tos` | Show the compliance notice, disclaimer, and official sources |
133
138
  | `ccm list` | List all profiles with auth status, including drifted entries |
134
- | `ccm use <name> [-- args]` | Launch Claude Code. Args after `--` are passed to Claude |
139
+ | `ccm use <name> [args...]` | Launch Claude Code. Extra args are passed to Claude |
135
140
  | `ccm login <name> [--console] [-b browser] [--url-only]` | Authenticate a profile |
136
141
  | `ccm status [name]` | Show auth status and storage state for one or all profiles |
137
- | `ccm rename <old-name> <new-name>` | Rename a profile (config, directory, and browser wrapper) |
142
+ | `ccm rename <old-name> <new-name>` | Rename a profile (config, directory, and browser wrapper) |
138
143
  | `ccm remove <name> [-f]` | Remove a profile. `-f` skips confirmation |
144
+ | `ccm copy-config <source> <target> [--only x] [--dry-run] [-f]` | Copy non-auth config (settings/hooks/skills plugins) |
139
145
  | `ccm run <name> -p <prompt>` | Run a prompt non-interactively |
140
146
 
141
147
  ## Compliance Notice
@@ -157,10 +163,13 @@ The notice is intentionally conservative:
157
163
 
158
164
  ## Passing Flags and Environment Variables
159
165
 
160
- Everything after `--` in `ccm use` is forwarded to `claude`. Env vars from your shell are inherited.
166
+ Extra args in `ccm use` are forwarded to `claude` (with or without the `--` separator). Env vars from your shell are inherited.
161
167
 
162
168
  ```bash
163
169
  # Pass flags
170
+ ccm use work --resume e56f8bd7-a2bb-4c0e-9aa0-3eb7f717bf6a
171
+
172
+ # Pass flags (explicit separator)
164
173
  ccm use work -- --dangerously-skip-permissions
165
174
 
166
175
  # Env vars + flags
@@ -212,6 +221,40 @@ Suppresses auto-opening a browser. Claude prints the auth URL — copy it, open
212
221
  ccm login work --console
213
222
  ```
214
223
 
224
+ ### Copying Config Between Profiles
225
+
226
+ ```bash
227
+ # Preview before applying
228
+ ccm copy-config work personal --dry-run
229
+
230
+ # Copy with interactive overwrite confirmation
231
+ ccm copy-config work personal
232
+
233
+ # Force overwrite without prompt
234
+ ccm copy-config work personal --force
235
+
236
+ # Copy only settings (or only plugins)
237
+ ccm copy-config work personal --only settings
238
+ ccm copy-config work personal --only plugins
239
+ ```
240
+
241
+ `copy-config` is intentionally conservative. It copies only non-auth profile config paths:
242
+
243
+ - `settings.json`
244
+ - `plugins/`
245
+
246
+ It does **not** copy runtime/auth state such as `.claude.json`, `sessions/`, `history.jsonl`,
247
+ `projects/`, `session-env/`, `telemetry/`, or top-level profile cache directories.
248
+
249
+ You can narrow the copy scope with `--only settings` or `--only plugins`. The option is repeatable and also supports comma-separated values (e.g. `--only settings,plugins`).
250
+
251
+ You can also bootstrap at creation time:
252
+
253
+ ```bash
254
+ # Create profile and seed non-auth config from an existing profile
255
+ ccm create personal --from work
256
+ ```
257
+
215
258
  ## How It Works
216
259
 
217
260
  ### Architecture Overview
@@ -240,10 +283,12 @@ src/
240
283
  │ ├── config.ts # Config file I/O with atomic writes
241
284
  │ ├── profiles.ts # Profile directory management and validation
242
285
  │ ├── profile-store.ts # Reconciled config/filesystem profile view
286
+ │ ├── profile-config-copy.ts # Config copy planner + transactional apply/rollback
243
287
  │ ├── claude.ts # Claude binary discovery, spawning, auth status
244
288
  │ ├── compliance.ts # Centralized compliance notice text and official sources
245
289
  │ └── browsers.ts # Browser wrapper generation and validation
246
290
  └── commands/
291
+ ├── copy-config.ts # Copy non-auth config between profiles (dry-run + rollback)
247
292
  ├── compliance.ts # Dedicated compliance/TOS command
248
293
  ├── create.ts # Create profile (with rollback on failure)
249
294
  ├── list.ts # List profiles with auth status and drift state
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { existsSync, mkdirSync, writeFileSync, chmodSync, renameSync, rmSync, unlinkSync, readFileSync, readdirSync } from 'fs';
3
+ import { createInterface } from 'readline';
4
+ import { existsSync, mkdirSync, writeFileSync, chmodSync, renameSync, statSync, rmSync, unlinkSync, cpSync, readFileSync, readdirSync } from 'fs';
4
5
  import { join, dirname, basename } from 'path';
5
6
  import { homedir } from 'os';
6
7
  import { z } from 'zod';
7
8
  import { spawn, execFileSync } from 'child_process';
8
- import { createInterface } from 'readline';
9
9
 
10
10
  // src/lib/compliance.ts
11
11
  var COMPLIANCE_CHECKED_AT = "2026-04-08";
@@ -73,8 +73,332 @@ var CCM_HOME = join(homedir(), ".ccm");
73
73
  var PROFILES_DIR = join(CCM_HOME, "profiles");
74
74
  var CONFIG_FILE = join(CCM_HOME, "config.json");
75
75
  var BROWSERS_DIR = join(CCM_HOME, "browsers");
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();
76
96
 
77
- // src/lib/browsers.ts
97
+ // src/lib/profiles.ts
98
+ function validateProfileName(name) {
99
+ const result = ProfileNameSchema.safeParse(name);
100
+ if (result.success) {
101
+ return;
102
+ }
103
+ if (name.length > MAX_PROFILE_NAME_LENGTH) {
104
+ throw new Error(`Profile name too long (max ${MAX_PROFILE_NAME_LENGTH} chars).`);
105
+ }
106
+ throw new Error(
107
+ `Invalid profile name "${name}". Use only letters, numbers, hyphens, underscores.`
108
+ );
109
+ }
110
+ function createProfileDir(name, profilesDir = PROFILES_DIR) {
111
+ validateProfileName(name);
112
+ const dir = join(profilesDir, name);
113
+ if (existsSync(dir)) {
114
+ throw new Error(`Profile directory "${name}" already exists`);
115
+ }
116
+ mkdirSync(dir, { recursive: true });
117
+ return dir;
118
+ }
119
+ function renameProfileDir(oldName, newName, profilesDir = PROFILES_DIR) {
120
+ const oldDir = join(profilesDir, oldName);
121
+ const newDir = join(profilesDir, newName);
122
+ if (!existsSync(oldDir)) {
123
+ throw new Error(`Profile directory "${oldName}" does not exist`);
124
+ }
125
+ if (existsSync(newDir)) {
126
+ throw new Error(`Profile directory "${newName}" already exists`);
127
+ }
128
+ renameSync(oldDir, newDir);
129
+ }
130
+ function removeProfileDir(name, profilesDir = PROFILES_DIR) {
131
+ const dir = join(profilesDir, name);
132
+ if (!existsSync(dir)) {
133
+ throw new Error(`Profile directory "${name}" does not exist`);
134
+ }
135
+ rmSync(dir, { recursive: true });
136
+ }
137
+ function getStagedPath(path) {
138
+ const parentDir = dirname(path);
139
+ const baseName = basename(path);
140
+ for (let attempt = 0; attempt < 100; attempt++) {
141
+ const suffix = attempt === 0 ? `${process.pid}-${Date.now()}` : `${process.pid}-${Date.now()}-${attempt.toString()}`;
142
+ const candidate = join(parentDir, `.${baseName}.staged-${suffix}`);
143
+ if (!existsSync(candidate)) {
144
+ return candidate;
145
+ }
146
+ }
147
+ throw new Error(`Could not allocate a temporary path for "${baseName}"`);
148
+ }
149
+ function stageProfileDirRemoval(name, profilesDir = PROFILES_DIR) {
150
+ const dir = join(profilesDir, name);
151
+ if (!existsSync(dir)) {
152
+ throw new Error(`Profile directory "${name}" does not exist`);
153
+ }
154
+ const stagedDir = getStagedPath(dir);
155
+ renameSync(dir, stagedDir);
156
+ return stagedDir;
157
+ }
158
+ function restoreStagedProfileDir(stagedDir, name, profilesDir = PROFILES_DIR) {
159
+ renameSync(stagedDir, join(profilesDir, name));
160
+ }
161
+ function finalizeStagedProfileDirRemoval(stagedDir) {
162
+ rmSync(stagedDir, { recursive: true });
163
+ }
164
+ function getProfileDir(name, profilesDir = PROFILES_DIR) {
165
+ return join(profilesDir, name);
166
+ }
167
+ function profileExists(name, profilesDir = PROFILES_DIR) {
168
+ return existsSync(join(profilesDir, name));
169
+ }
170
+ function listProfileDirs(profilesDir = PROFILES_DIR) {
171
+ if (!existsSync(profilesDir)) return [];
172
+ return readdirSync(profilesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => d.name);
173
+ }
174
+
175
+ // src/lib/profile-config-copy.ts
176
+ var SUPPORTED_CONFIG_PATHS = ["settings.json", "plugins"];
177
+ function planProfileConfigCopy(sourceName, targetName, profilesDir = PROFILES_DIR, options = {}) {
178
+ validateProfileName(sourceName);
179
+ validateProfileName(targetName);
180
+ if (sourceName === targetName) {
181
+ throw new Error("Source and target profiles must be different");
182
+ }
183
+ if (!profileExists(sourceName, profilesDir)) {
184
+ throw new Error(`Profile "${sourceName}" does not exist`);
185
+ }
186
+ if (!profileExists(targetName, profilesDir)) {
187
+ throw new Error(`Profile "${targetName}" does not exist`);
188
+ }
189
+ const sourceDir = getProfileDir(sourceName, profilesDir);
190
+ const targetDir = getProfileDir(targetName, profilesDir);
191
+ const selectedPaths = normalizeSelectedPaths(options.only);
192
+ const operations = [];
193
+ for (const relativePath of selectedPaths) {
194
+ const sourcePath = join(sourceDir, relativePath);
195
+ if (!existsSync(sourcePath)) {
196
+ continue;
197
+ }
198
+ const kind = statSync(sourcePath).isDirectory() ? "directory" : "file";
199
+ const targetPath = join(targetDir, relativePath);
200
+ operations.push({
201
+ sourcePath,
202
+ targetPath,
203
+ relativePath,
204
+ kind,
205
+ overwrite: existsSync(targetPath)
206
+ });
207
+ }
208
+ if (operations.length === 0) {
209
+ throw new Error(
210
+ `No supported configuration found in profile "${sourceName}". Supported paths: ${SUPPORTED_CONFIG_PATHS.join(", ")}`
211
+ );
212
+ }
213
+ const overwriteCount = operations.filter((operation) => operation.overwrite).length;
214
+ return {
215
+ sourceName,
216
+ targetName,
217
+ operations,
218
+ overwriteCount,
219
+ createCount: operations.length - overwriteCount
220
+ };
221
+ }
222
+ function normalizeSelectedPaths(only) {
223
+ if (!only || only.length === 0) {
224
+ return [...SUPPORTED_CONFIG_PATHS];
225
+ }
226
+ const selected = /* @__PURE__ */ new Set();
227
+ for (const entry of only) {
228
+ if (entry === "settings" || entry === "settings.json") {
229
+ selected.add("settings.json");
230
+ continue;
231
+ }
232
+ if (entry === "plugins") {
233
+ selected.add("plugins");
234
+ continue;
235
+ }
236
+ throw new Error(`Unsupported --only target "${entry}". Allowed values: settings, plugins.`);
237
+ }
238
+ return SUPPORTED_CONFIG_PATHS.filter((path) => selected.has(path));
239
+ }
240
+ function applyProfileConfigCopy(plan) {
241
+ const stagedTargets = [];
242
+ const copiedTargets = [];
243
+ try {
244
+ for (const operation of plan.operations) {
245
+ if (operation.overwrite) {
246
+ const stagedPath = getStagedPath2(operation.targetPath);
247
+ renameSync(operation.targetPath, stagedPath);
248
+ stagedTargets.push({ targetPath: operation.targetPath, stagedPath });
249
+ }
250
+ mkdirSync(dirname(operation.targetPath), { recursive: true });
251
+ copyOperation(operation);
252
+ copiedTargets.push(operation.targetPath);
253
+ }
254
+ } catch (error) {
255
+ const rollbackErrors = rollbackCopy(copiedTargets, stagedTargets);
256
+ if (rollbackErrors.length > 0) {
257
+ throw new Error(
258
+ `Failed to copy profile configuration: ${formatError(error)}. Rollback failed: ${rollbackErrors.join("; ")}`
259
+ );
260
+ }
261
+ throw new Error(`Failed to copy profile configuration: ${formatError(error)}`);
262
+ }
263
+ try {
264
+ for (const { stagedPath } of stagedTargets) {
265
+ rmSync(stagedPath, { recursive: true, force: true });
266
+ }
267
+ } catch (error) {
268
+ const rollbackErrors = rollbackCopy(copiedTargets, stagedTargets);
269
+ if (rollbackErrors.length > 0) {
270
+ throw new Error(
271
+ `Failed to finalize profile configuration copy: ${formatError(error)}. Rollback failed: ${rollbackErrors.join("; ")}`
272
+ );
273
+ }
274
+ throw new Error(`Failed to finalize profile configuration copy: ${formatError(error)}`);
275
+ }
276
+ return {
277
+ copiedCount: plan.operations.length,
278
+ overwrittenCount: plan.overwriteCount,
279
+ createdCount: plan.createCount
280
+ };
281
+ }
282
+ function copyOperation(operation) {
283
+ const baseOptions = { errorOnExist: true, force: false };
284
+ if (operation.kind === "directory") {
285
+ cpSync(operation.sourcePath, operation.targetPath, { ...baseOptions, recursive: true });
286
+ return;
287
+ }
288
+ cpSync(operation.sourcePath, operation.targetPath, baseOptions);
289
+ }
290
+ function rollbackCopy(copiedTargets, stagedTargets) {
291
+ const rollbackErrors = [];
292
+ for (const copiedPath of [...copiedTargets].reverse()) {
293
+ try {
294
+ rmSync(copiedPath, { recursive: true, force: true });
295
+ } catch (error) {
296
+ rollbackErrors.push(`remove ${copiedPath}: ${formatError(error)}`);
297
+ }
298
+ }
299
+ for (const { targetPath, stagedPath } of [...stagedTargets].reverse()) {
300
+ try {
301
+ if (existsSync(stagedPath)) {
302
+ renameSync(stagedPath, targetPath);
303
+ }
304
+ } catch (error) {
305
+ rollbackErrors.push(`restore ${targetPath}: ${formatError(error)}`);
306
+ }
307
+ }
308
+ return rollbackErrors;
309
+ }
310
+ function getStagedPath2(path) {
311
+ const parent = dirname(path);
312
+ const base = basename(path);
313
+ for (const attempt of Array.from({ length: 100 }, (_, index) => index)) {
314
+ const suffix = attempt === 0 ? `${process.pid}-${Date.now()}` : `${process.pid}-${Date.now()}-${attempt.toString()}`;
315
+ const candidate = join(parent, `.${base}.staged-${suffix}`);
316
+ if (!existsSync(candidate)) {
317
+ return candidate;
318
+ }
319
+ }
320
+ throw new Error(`Could not allocate a temporary path for "${base}"`);
321
+ }
322
+ function formatError(error) {
323
+ return error instanceof Error ? error.message : String(error);
324
+ }
325
+
326
+ // src/commands/copy-config.ts
327
+ function confirm(question) {
328
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
329
+ return new Promise((resolve) => {
330
+ rl.question(question, (answer) => {
331
+ rl.close();
332
+ resolve(answer.toLowerCase() === "y");
333
+ });
334
+ });
335
+ }
336
+ function printPlan(plan) {
337
+ for (const operation of plan.operations) {
338
+ const mode = operation.overwrite ? "overwrite" : "create";
339
+ console.log(`- ${operation.relativePath} (${operation.kind}, ${mode})`);
340
+ }
341
+ }
342
+ function collectOnlySelector(value, previous) {
343
+ const tokens = value.split(",").map((token) => token.trim());
344
+ if (tokens.some((token) => token.length === 0)) {
345
+ throw new Error("Invalid --only value. Empty entries are not allowed.");
346
+ }
347
+ return [...previous, ...tokens];
348
+ }
349
+ function resolvePlanOptions(only) {
350
+ if (!only || only.length === 0) {
351
+ return void 0;
352
+ }
353
+ return { only };
354
+ }
355
+ function registerCopyConfig(program2) {
356
+ program2.command("copy-config <source> <target>").description(
357
+ "Copy non-auth configuration (hooks, skills, settings) from one profile to another"
358
+ ).option(
359
+ "--only <path>",
360
+ "Copy only selected path(s): settings, plugins (repeatable or comma-separated)",
361
+ collectOnlySelector,
362
+ []
363
+ ).option("--dry-run", "Preview what would be copied without writing files").option("-f, --force", "Skip overwrite confirmation and apply immediately").action(
364
+ async (source, target, opts) => {
365
+ try {
366
+ const plan = planProfileConfigCopy(
367
+ source,
368
+ target,
369
+ void 0,
370
+ resolvePlanOptions(opts.only)
371
+ );
372
+ if (opts.dryRun) {
373
+ console.log(
374
+ `Dry run: ${plan.operations.length} item(s) would be copied from "${source}" to "${target}"`
375
+ );
376
+ printPlan(plan);
377
+ return;
378
+ }
379
+ if (plan.overwriteCount > 0) {
380
+ console.log(
381
+ `! ${plan.overwriteCount} existing path(s) in "${target}" will be overwritten.`
382
+ );
383
+ if (!opts.force) {
384
+ const ok = await confirm("Continue? (y/N) ");
385
+ if (!ok) {
386
+ console.log("Cancelled");
387
+ return;
388
+ }
389
+ }
390
+ }
391
+ const result = applyProfileConfigCopy(plan);
392
+ console.log(
393
+ `\x1B[32m\u2713\x1B[0m Copied ${result.copiedCount} item(s) from "${source}" to "${target}" (${result.createdCount} created, ${result.overwrittenCount} overwritten)`
394
+ );
395
+ } catch (e) {
396
+ console.error(`\x1B[31m\u2717\x1B[0m ${e instanceof Error ? e.message : String(e)}`);
397
+ process.exit(1);
398
+ }
399
+ }
400
+ );
401
+ }
78
402
  var SHELL_METACHAR = /[;|&`$(){}<>\\!\n\r]/;
79
403
  function validateBrowserCommand(cmd) {
80
404
  if (SHELL_METACHAR.test(cmd)) {
@@ -97,7 +421,7 @@ exec ${browserCommand} "$@"
97
421
  renameSync(tmp, scriptPath);
98
422
  return scriptPath;
99
423
  }
100
- function getStagedPath(path) {
424
+ function getStagedPath3(path) {
101
425
  for (let attempt = 0; attempt < 100; attempt++) {
102
426
  const suffix = attempt === 0 ? `${process.pid}-${Date.now()}` : `${process.pid}-${Date.now()}-${attempt.toString()}`;
103
427
  const candidate = `${path}.staged-${suffix}`;
@@ -130,7 +454,7 @@ function stageBrowserWrapperRemoval(profileName, browsersDir = BROWSERS_DIR) {
130
454
  if (!existsSync(wrapperPath)) {
131
455
  return void 0;
132
456
  }
133
- const stagedPath = getStagedPath(wrapperPath);
457
+ const stagedPath = getStagedPath3(wrapperPath);
134
458
  renameSync(wrapperPath, stagedPath);
135
459
  return stagedPath;
136
460
  }
@@ -143,28 +467,6 @@ function finalizeStagedBrowserWrapperRemoval(stagedPath) {
143
467
  function resolveBrowser(cliOverride, meta) {
144
468
  return cliOverride ?? meta?.browser;
145
469
  }
146
- var MAX_PROFILE_NAME_LENGTH = 64;
147
- var PROFILE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
148
- var ProfileNameSchema = z.string().min(1).max(MAX_PROFILE_NAME_LENGTH).regex(PROFILE_NAME_PATTERN);
149
- var ProfileMetaSchema = z.object({
150
- name: ProfileNameSchema,
151
- label: z.string().optional(),
152
- browser: z.string().optional(),
153
- createdAt: z.string()
154
- }).passthrough();
155
- var CcmConfigSchema = z.object({
156
- profiles: z.record(z.string(), ProfileMetaSchema)
157
- }).passthrough();
158
- var ClaudeAuthStatusSchema = z.object({
159
- loggedIn: z.boolean(),
160
- authMethod: z.string(),
161
- email: z.string().optional(),
162
- orgName: z.string().optional(),
163
- subscriptionType: z.string().optional(),
164
- apiKeySource: z.string().optional()
165
- }).passthrough();
166
-
167
- // src/lib/config.ts
168
470
  var DEFAULT_CONFIG = { profiles: {} };
169
471
  function loadConfig(configFile = CONFIG_FILE) {
170
472
  try {
@@ -216,97 +518,26 @@ function renameProfile(oldName, newName, configFile = CONFIG_FILE) {
216
518
  function getProfile(name, configFile = CONFIG_FILE) {
217
519
  return loadConfig(configFile).profiles[name];
218
520
  }
219
- function validateProfileName(name) {
220
- const result = ProfileNameSchema.safeParse(name);
221
- if (result.success) {
222
- return;
223
- }
224
- if (name.length > MAX_PROFILE_NAME_LENGTH) {
225
- throw new Error(`Profile name too long (max ${MAX_PROFILE_NAME_LENGTH} chars).`);
226
- }
227
- throw new Error(
228
- `Invalid profile name "${name}". Use only letters, numbers, hyphens, underscores.`
229
- );
230
- }
231
- function createProfileDir(name, profilesDir = PROFILES_DIR) {
232
- validateProfileName(name);
233
- const dir = join(profilesDir, name);
234
- if (existsSync(dir)) {
235
- throw new Error(`Profile directory "${name}" already exists`);
236
- }
237
- mkdirSync(dir, { recursive: true });
238
- return dir;
239
- }
240
- function renameProfileDir(oldName, newName, profilesDir = PROFILES_DIR) {
241
- const oldDir = join(profilesDir, oldName);
242
- const newDir = join(profilesDir, newName);
243
- if (!existsSync(oldDir)) {
244
- throw new Error(`Profile directory "${oldName}" does not exist`);
245
- }
246
- if (existsSync(newDir)) {
247
- throw new Error(`Profile directory "${newName}" already exists`);
248
- }
249
- renameSync(oldDir, newDir);
250
- }
251
- function removeProfileDir(name, profilesDir = PROFILES_DIR) {
252
- const dir = join(profilesDir, name);
253
- if (!existsSync(dir)) {
254
- throw new Error(`Profile directory "${name}" does not exist`);
255
- }
256
- rmSync(dir, { recursive: true });
257
- }
258
- function getStagedPath2(path) {
259
- const parentDir = dirname(path);
260
- const baseName = basename(path);
261
- for (let attempt = 0; attempt < 100; attempt++) {
262
- const suffix = attempt === 0 ? `${process.pid}-${Date.now()}` : `${process.pid}-${Date.now()}-${attempt.toString()}`;
263
- const candidate = join(parentDir, `.${baseName}.staged-${suffix}`);
264
- if (!existsSync(candidate)) {
265
- return candidate;
266
- }
267
- }
268
- throw new Error(`Could not allocate a temporary path for "${baseName}"`);
269
- }
270
- function stageProfileDirRemoval(name, profilesDir = PROFILES_DIR) {
271
- const dir = join(profilesDir, name);
272
- if (!existsSync(dir)) {
273
- throw new Error(`Profile directory "${name}" does not exist`);
274
- }
275
- const stagedDir = getStagedPath2(dir);
276
- renameSync(dir, stagedDir);
277
- return stagedDir;
278
- }
279
- function restoreStagedProfileDir(stagedDir, name, profilesDir = PROFILES_DIR) {
280
- renameSync(stagedDir, join(profilesDir, name));
281
- }
282
- function finalizeStagedProfileDirRemoval(stagedDir) {
283
- rmSync(stagedDir, { recursive: true });
284
- }
285
- function getProfileDir(name, profilesDir = PROFILES_DIR) {
286
- return join(profilesDir, name);
287
- }
288
- function profileExists(name, profilesDir = PROFILES_DIR) {
289
- return existsSync(join(profilesDir, name));
290
- }
291
- function listProfileDirs(profilesDir = PROFILES_DIR) {
292
- if (!existsSync(profilesDir)) return [];
293
- return readdirSync(profilesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => d.name);
294
- }
295
521
 
296
522
  // src/commands/create.ts
297
523
  function registerCreate(program2) {
298
- 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) => {
524
+ program2.command("create <name>").description("Create a new profile").option("-l, --label <label>", "Profile label").option("-b, --browser <path>", "Browser for OAuth login").option("--from <profile>", "Initialize non-auth config from an existing profile").action((name, opts) => {
299
525
  try {
300
526
  createProfileDir(name);
301
- const browser = opts.browser ? ensureBrowserWrapper(name, opts.browser) : void 0;
527
+ let browser;
302
528
  try {
529
+ browser = opts.browser ? ensureBrowserWrapper(name, opts.browser) : void 0;
530
+ if (opts.from) {
531
+ const plan = planProfileConfigCopy(opts.from, name);
532
+ applyProfileConfigCopy(plan);
533
+ }
303
534
  addProfile({
304
535
  name,
305
536
  label: opts.label,
306
537
  browser,
307
538
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
308
539
  });
309
- } catch (configErr) {
540
+ } catch (setupErr) {
310
541
  try {
311
542
  removeProfileDir(name);
312
543
  } catch {
@@ -315,7 +546,7 @@ function registerCreate(program2) {
315
546
  removeBrowserWrapper(name);
316
547
  } catch {
317
548
  }
318
- throw configErr;
549
+ throw setupErr;
319
550
  }
320
551
  console.log(`\x1B[32m\u2713\x1B[0m Profile "${name}" created`);
321
552
  for (const line of getCompactComplianceNoticeLines()) {
@@ -439,7 +670,7 @@ function removeStoredProfile(name, configFile = CONFIG_FILE, profilesDir = PROFI
439
670
  rollbackStorage(name, stagedDir, stagedWrapper, profilesDir, browsersDir);
440
671
  } catch (rollbackError) {
441
672
  throw new Error(
442
- `Failed to remove profile "${name}": ${formatError(error)}. Rollback failed: ${formatError(
673
+ `Failed to remove profile "${name}": ${formatError2(error)}. Rollback failed: ${formatError2(
443
674
  rollbackError
444
675
  )}`
445
676
  );
@@ -458,18 +689,18 @@ function removeStoredProfile(name, configFile = CONFIG_FILE, profilesDir = PROFI
458
689
  try {
459
690
  rollbackStorage(name, stagedDir, stagedWrapper, profilesDir, browsersDir);
460
691
  } catch (rollbackError) {
461
- recoveryErrors.push(formatError(rollbackError));
692
+ recoveryErrors.push(formatError2(rollbackError));
462
693
  }
463
694
  if (profile.meta) {
464
695
  try {
465
696
  restoreConfigEntry(profile.meta, configFile);
466
697
  } catch (configRestoreError) {
467
- recoveryErrors.push(`config: ${formatError(configRestoreError)}`);
698
+ recoveryErrors.push(`config: ${formatError2(configRestoreError)}`);
468
699
  }
469
700
  }
470
701
  if (recoveryErrors.length > 0) {
471
702
  throw new Error(
472
- `Failed to remove profile "${name}": ${formatError(error)}. Recovery failed: ${recoveryErrors.join(
703
+ `Failed to remove profile "${name}": ${formatError2(error)}. Recovery failed: ${recoveryErrors.join(
473
704
  "; "
474
705
  )}`
475
706
  );
@@ -504,7 +735,7 @@ function renameStoredProfile(oldName, newName, configFile = CONFIG_FILE, profile
504
735
  rollbackRename(oldName, newName, profile.hasDirectory, false, profilesDir, browsersDir);
505
736
  } catch (rollbackError) {
506
737
  throw new Error(
507
- `Failed to rename profile "${oldName}" to "${newName}": ${formatError(error)}. Rollback failed: ${formatError(rollbackError)}`
738
+ `Failed to rename profile "${oldName}" to "${newName}": ${formatError2(error)}. Rollback failed: ${formatError2(rollbackError)}`
508
739
  );
509
740
  }
510
741
  throw error;
@@ -517,7 +748,7 @@ function renameStoredProfile(oldName, newName, configFile = CONFIG_FILE, profile
517
748
  rollbackRename(oldName, newName, profile.hasDirectory, hadWrapper, profilesDir, browsersDir);
518
749
  } catch (rollbackError) {
519
750
  throw new Error(
520
- `Failed to rename profile "${oldName}" to "${newName}": ${formatError(error)}. Rollback failed: ${formatError(rollbackError)}`
751
+ `Failed to rename profile "${oldName}" to "${newName}": ${formatError2(error)}. Rollback failed: ${formatError2(rollbackError)}`
521
752
  );
522
753
  }
523
754
  throw error;
@@ -529,14 +760,14 @@ function rollbackRename(oldName, newName, hadDirectory, hadWrapper, profilesDir,
529
760
  try {
530
761
  renameBrowserWrapper(newName, oldName, browsersDir);
531
762
  } catch (error) {
532
- rollbackErrors.push(`browser wrapper: ${formatError(error)}`);
763
+ rollbackErrors.push(`browser wrapper: ${formatError2(error)}`);
533
764
  }
534
765
  }
535
766
  if (hadDirectory) {
536
767
  try {
537
768
  renameProfileDir(newName, oldName, profilesDir);
538
769
  } catch (error) {
539
- rollbackErrors.push(`profile dir: ${formatError(error)}`);
770
+ rollbackErrors.push(`profile dir: ${formatError2(error)}`);
540
771
  }
541
772
  }
542
773
  if (rollbackErrors.length > 0) {
@@ -551,14 +782,14 @@ function rollbackStorage(name, stagedDir, stagedWrapper, profilesDir, browsersDi
551
782
  try {
552
783
  restoreStagedProfileDir(stagedDir, name, profilesDir);
553
784
  } catch (error) {
554
- rollbackErrors.push(`profile dir: ${formatError(error)}`);
785
+ rollbackErrors.push(`profile dir: ${formatError2(error)}`);
555
786
  }
556
787
  }
557
788
  if (stagedWrapper) {
558
789
  try {
559
790
  restoreStagedBrowserWrapper(stagedWrapper, name, browsersDir);
560
791
  } catch (error) {
561
- rollbackErrors.push(`browser wrapper: ${formatError(error)}`);
792
+ rollbackErrors.push(`browser wrapper: ${formatError2(error)}`);
562
793
  }
563
794
  }
564
795
  if (rollbackErrors.length > 0) {
@@ -571,7 +802,7 @@ function restoreConfigEntry(meta, configFile) {
571
802
  }
572
803
  addProfile(meta, configFile);
573
804
  }
574
- function formatError(error) {
805
+ function formatError2(error) {
575
806
  return error instanceof Error ? error.message : String(error);
576
807
  }
577
808
 
@@ -636,7 +867,7 @@ function registerLogin(program2) {
636
867
  }
637
868
  });
638
869
  }
639
- function confirm(question) {
870
+ function confirm2(question) {
640
871
  const rl = createInterface({ input: process.stdin, output: process.stdout });
641
872
  return new Promise((resolve) => {
642
873
  rl.question(question, (answer) => {
@@ -653,7 +884,7 @@ function registerRemove(program2) {
653
884
  throw new Error(`Profile "${name}" does not exist`);
654
885
  }
655
886
  if (!opts.force) {
656
- const ok = await confirm(`Remove profile "${name}"? (y/N) `);
887
+ const ok = await confirm2(`Remove profile "${name}"? (y/N) `);
657
888
  if (!ok) {
658
889
  console.log("Cancelled");
659
890
  return;
@@ -749,14 +980,13 @@ function registerStatus(program2) {
749
980
 
750
981
  // src/commands/use.ts
751
982
  function registerUse(program2) {
752
- program2.command("use <name>").description("Launch Claude Code with a profile").allowUnknownOption().helpOption(false).action((name, _opts, cmd) => {
983
+ program2.command("use <name> [args...]").description("Launch Claude Code with a profile").allowUnknownOption().helpOption(false).action((name, args) => {
753
984
  try {
754
985
  if (!profileExists(name)) {
755
986
  throw new Error(`Profile "${name}" does not exist. Create it first: ccm create ${name}`);
756
987
  }
757
988
  const dir = getProfileDir(name);
758
- const passthroughArgs = cmd.args.slice(1);
759
- const child = spawnClaude(dir, passthroughArgs);
989
+ const child = spawnClaude(dir, args);
760
990
  child.on("close", (code) => process.exit(code ?? 0));
761
991
  } catch (e) {
762
992
  console.error(`\x1B[31m\u2717\x1B[0m ${e instanceof Error ? e.message : String(e)}`);
@@ -766,8 +996,9 @@ function registerUse(program2) {
766
996
  }
767
997
 
768
998
  // src/index.ts
769
- var program = new Command().name("ccm").version("0.4.0").description("Manage multiple Claude Code profiles");
999
+ var program = new Command().name("ccm").version("0.5.1").description("Manage multiple Claude Code profiles");
770
1000
  registerCreate(program);
1001
+ registerCopyConfig(program);
771
1002
  registerCompliance(program);
772
1003
  registerList(program);
773
1004
  registerRemove(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remeic/ccm",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "nvm-like manager for Claude Code profiles",
5
5
  "license": "MIT",
6
6
  "author": "Giulio Fagioli",
@@ -42,7 +42,7 @@
42
42
  "prepare": "husky"
43
43
  },
44
44
  "engines": {
45
- "node": ">=18"
45
+ "node": ">=24"
46
46
  },
47
47
  "dependencies": {
48
48
  "commander": "^13.1.0",
@@ -55,11 +55,11 @@
55
55
  "@stryker-mutator/core": "^9.6.0",
56
56
  "@stryker-mutator/typescript-checker": "^9.6.0",
57
57
  "@stryker-mutator/vitest-runner": "^9.6.0",
58
- "@vitest/coverage-v8": "^3.1.1",
58
+ "@vitest/coverage-v8": "^4.1.4",
59
59
  "husky": "^9.1.7",
60
60
  "lint-staged": "^16.4.0",
61
61
  "tsup": "^8.4.0",
62
62
  "typescript": "^5.8.3",
63
- "vitest": "^3.1.1"
63
+ "vitest": "^4.1.4"
64
64
  }
65
65
  }