@remeic/ccm 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +61 -19
  2. package/dist/index.js +353 -121
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -13,22 +13,6 @@
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)
@@ -40,6 +24,26 @@ Official basis (as of April 8, 2026):
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
@@ -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
139
  | `ccm use <name> [-- args]` | Launch Claude Code. Args after `--` 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
@@ -212,6 +218,40 @@ Suppresses auto-opening a browser. Claude prints the auth URL — copy it, open
212
218
  ccm login work --console
213
219
  ```
214
220
 
221
+ ### Copying Config Between Profiles
222
+
223
+ ```bash
224
+ # Preview before applying
225
+ ccm copy-config work personal --dry-run
226
+
227
+ # Copy with interactive overwrite confirmation
228
+ ccm copy-config work personal
229
+
230
+ # Force overwrite without prompt
231
+ ccm copy-config work personal --force
232
+
233
+ # Copy only settings (or only plugins)
234
+ ccm copy-config work personal --only settings
235
+ ccm copy-config work personal --only plugins
236
+ ```
237
+
238
+ `copy-config` is intentionally conservative. It copies only non-auth profile config paths:
239
+
240
+ - `settings.json`
241
+ - `plugins/`
242
+
243
+ It does **not** copy runtime/auth state such as `.claude.json`, `sessions/`, `history.jsonl`,
244
+ `projects/`, `session-env/`, `telemetry/`, or top-level profile cache directories.
245
+
246
+ 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`).
247
+
248
+ You can also bootstrap at creation time:
249
+
250
+ ```bash
251
+ # Create profile and seed non-auth config from an existing profile
252
+ ccm create personal --from work
253
+ ```
254
+
215
255
  ## How It Works
216
256
 
217
257
  ### Architecture Overview
@@ -240,10 +280,12 @@ src/
240
280
  │ ├── config.ts # Config file I/O with atomic writes
241
281
  │ ├── profiles.ts # Profile directory management and validation
242
282
  │ ├── profile-store.ts # Reconciled config/filesystem profile view
283
+ │ ├── profile-config-copy.ts # Config copy planner + transactional apply/rollback
243
284
  │ ├── claude.ts # Claude binary discovery, spawning, auth status
244
285
  │ ├── compliance.ts # Centralized compliance notice text and official sources
245
286
  │ └── browsers.ts # Browser wrapper generation and validation
246
287
  └── commands/
288
+ ├── copy-config.ts # Copy non-auth config between profiles (dry-run + rollback)
247
289
  ├── compliance.ts # Dedicated compliance/TOS command
248
290
  ├── create.ts # Create profile (with rollback on failure)
249
291
  ├── 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;
@@ -766,8 +997,9 @@ function registerUse(program2) {
766
997
  }
767
998
 
768
999
  // src/index.ts
769
- var program = new Command().name("ccm").version("0.4.0").description("Manage multiple Claude Code profiles");
1000
+ var program = new Command().name("ccm").version("0.5.0").description("Manage multiple Claude Code profiles");
770
1001
  registerCreate(program);
1002
+ registerCopyConfig(program);
771
1003
  registerCompliance(program);
772
1004
  registerList(program);
773
1005
  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.0",
4
4
  "description": "nvm-like manager for Claude Code profiles",
5
5
  "license": "MIT",
6
6
  "author": "Giulio Fagioli",