@remeic/ccm 0.3.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 +89 -3
  2. package/dist/index.js +419 -121
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -13,16 +13,37 @@
13
13
  </table>
14
14
  </div>
15
15
 
16
+
16
17
  [![CI](https://github.com/remeic/ccm/actions/workflows/ci.yml/badge.svg)](https://github.com/remeic/ccm/actions/workflows/ci.yml)
17
18
  [![npm version](https://img.shields.io/npm/v/%40remeic%2Fccm.svg)](https://www.npmjs.com/package/@remeic/ccm)
18
19
  [![Homebrew tap](https://img.shields.io/badge/Homebrew-tap-FBB040?logo=homebrew&logoColor=white)](https://github.com/Remeic/homebrew-tap)
19
20
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
20
21
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
21
22
  [![codecov](https://codecov.io/github/Remeic/ccm/graph/badge.svg?token=E16LCLDHYV)](https://codecov.io/github/Remeic/ccm)
22
- [![mutation testing](https://img.shields.io/badge/mutation%20testing-100%25-brightgreen)](https://stryker-mutator.io/)
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)
23
24
 
24
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.
25
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
+
26
47
  <p align="center">
27
48
  <img src="./docs/assets/intro.gif" alt="ccm in action in a terminal view" width="100%" />
28
49
  </p>
@@ -35,11 +56,13 @@ Manage separate Claude Code profiles for personal, work, and client accounts wit
35
56
  - [Install](#install)
36
57
  - [Quick Start](#quick-start)
37
58
  - [Commands](#commands)
59
+ - [Compliance Notice](#compliance-notice)
38
60
  - [Passing Flags and Environment Variables](#passing-flags-and-environment-variables)
39
61
  - [Multi-Account Login](#multi-account-login)
40
62
  - [Different Browser per Profile](#different-browser-per-profile)
41
63
  - [URL-Only Mode](#url-only-mode)
42
64
  - [API Key Auth](#api-key-auth)
65
+ - [Copying Config Between Profiles](#copying-config-between-profiles)
43
66
  - [How It Works](#how-it-works)
44
67
  - [Architecture Overview](#architecture-overview)
45
68
  - [Profile Isolation](#profile-isolation)
@@ -54,6 +77,7 @@ Manage separate Claude Code profiles for personal, work, and client accounts wit
54
77
  - [Comparison](#comparison)
55
78
  - [FAQ](#faq)
56
79
  - [Contributing](#contributing)
80
+ - [Homebrew Releases](#homebrew-releases)
57
81
  - [License](#license)
58
82
 
59
83
  ## Why
@@ -91,6 +115,11 @@ The installed command remains `ccm`.
91
115
  ```
92
116
  $ ccm create work
93
117
  ✓ Profile "work" created
118
+ ! Compliance notice
119
+ ccm isolates profiles but does not expand Anthropic usage rights.
120
+ Use each Claude account with one person and one active terminal session.
121
+ Do not share credentials or API keys across users or parallel operators.
122
+ Details: ccm compliance
94
123
  Next: ccm login work
95
124
 
96
125
  $ ccm login work
@@ -104,15 +133,34 @@ $ ccm use work
104
133
 
105
134
  | Command | Description |
106
135
  | -------------------------------------------------------- | ------------------------------------------------------------- |
107
- | `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 |
137
+ | `ccm compliance` / `ccm tos` | Show the compliance notice, disclaimer, and official sources |
108
138
  | `ccm list` | List all profiles with auth status, including drifted entries |
109
139
  | `ccm use <name> [-- args]` | Launch Claude Code. Args after `--` are passed to Claude |
110
140
  | `ccm login <name> [--console] [-b browser] [--url-only]` | Authenticate a profile |
111
141
  | `ccm status [name]` | Show auth status and storage state for one or all profiles |
112
- | `ccm rename <old-name> <new-name>` | Rename a profile (config, directory, and browser wrapper) |
142
+ | `ccm rename <old-name> <new-name>` | Rename a profile (config, directory, and browser wrapper) |
113
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) |
114
145
  | `ccm run <name> -p <prompt>` | Run a prompt non-interactively |
115
146
 
147
+ ## Compliance Notice
148
+
149
+ Every successful `ccm create` prints a short compliance warning. Users can re-open the full text at any time with:
150
+
151
+ ```bash
152
+ ccm compliance
153
+ ccm tos
154
+ ```
155
+
156
+ The notice is intentionally conservative:
157
+
158
+ - `ccm` is a profile-isolation tool, not a license-expansion tool.
159
+ - This project treats each Claude account as single-user and single-session.
160
+ - Shared credentials, shared operators, and parallel use of the same account are out of scope.
161
+ - If a team needs parallel access, they should use separate seats/accounts or API-key-based access under applicable Anthropic commercial terms.
162
+ - The notice is compliance guidance, not legal advice. Users remain responsible for reviewing Anthropic terms for their exact workflow.
163
+
116
164
  ## Passing Flags and Environment Variables
117
165
 
118
166
  Everything after `--` in `ccm use` is forwarded to `claude`. Env vars from your shell are inherited.
@@ -170,6 +218,40 @@ Suppresses auto-opening a browser. Claude prints the auth URL — copy it, open
170
218
  ccm login work --console
171
219
  ```
172
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
+
173
255
  ## How It Works
174
256
 
175
257
  ### Architecture Overview
@@ -198,9 +280,13 @@ src/
198
280
  │ ├── config.ts # Config file I/O with atomic writes
199
281
  │ ├── profiles.ts # Profile directory management and validation
200
282
  │ ├── profile-store.ts # Reconciled config/filesystem profile view
283
+ │ ├── profile-config-copy.ts # Config copy planner + transactional apply/rollback
201
284
  │ ├── claude.ts # Claude binary discovery, spawning, auth status
285
+ │ ├── compliance.ts # Centralized compliance notice text and official sources
202
286
  │ └── browsers.ts # Browser wrapper generation and validation
203
287
  └── commands/
288
+ ├── copy-config.ts # Copy non-auth config between profiles (dry-run + rollback)
289
+ ├── compliance.ts # Dedicated compliance/TOS command
204
290
  ├── create.ts # Create profile (with rollback on failure)
205
291
  ├── list.ts # List profiles with auth status and drift state
206
292
  ├── login.ts # Authenticate via Claude TUI or --console
package/dist/index.js CHANGED
@@ -1,18 +1,404 @@
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
+ // src/lib/compliance.ts
11
+ var COMPLIANCE_CHECKED_AT = "2026-04-08";
12
+ var COMPLIANCE_SOURCES = [
13
+ {
14
+ label: "Claude Code Legal & Compliance",
15
+ url: "https://code.claude.com/docs/en/legal-and-compliance",
16
+ summary: "Pro and Max usage limits assume ordinary, individual use. Anthropic may enforce authentication restrictions without notice."
17
+ },
18
+ {
19
+ label: "Anthropic Consumer Terms",
20
+ url: "https://www.anthropic.com/legal/consumer-terms",
21
+ summary: "Account login information, API keys, and account credentials must not be shared or made available to anyone else."
22
+ },
23
+ {
24
+ label: "Anthropic Commercial Terms",
25
+ url: "https://www.anthropic.com/legal/commercial-terms",
26
+ summary: "Commercial customers are responsible for all activity and fees incurred under their account."
27
+ }
28
+ ];
29
+ function buildRuleLines() {
30
+ return [
31
+ "Project compliance boundary:",
32
+ "- ccm isolates profiles, but it does not grant any extra rights under Anthropic terms.",
33
+ "- Treat each Claude account as single-user and single-session: 1 account = 1 person = 1 active terminal session.",
34
+ "- Do not share account credentials or API keys, and do not hand off the same account between multiple operators.",
35
+ "- For parallel operators, separate seats/accounts or API-key-based access under Commercial Terms are required."
36
+ ];
37
+ }
38
+ function getCompactComplianceNoticeLines() {
39
+ return [
40
+ "\x1B[33m!\x1B[0m Compliance notice",
41
+ " ccm isolates profiles but does not expand Anthropic usage rights.",
42
+ " Use each Claude account with one person and one active terminal session.",
43
+ " Do not share credentials or API keys across users or parallel operators.",
44
+ " Details: ccm compliance"
45
+ ];
46
+ }
47
+ function getFullComplianceNoticeLines() {
48
+ const sourceLines = COMPLIANCE_SOURCES.flatMap((source) => [
49
+ `- ${source.label}: ${source.url}`,
50
+ ` ${source.summary}`
51
+ ]);
52
+ return [
53
+ "Claude Code compliance notice",
54
+ "This project publishes an intentionally conservative operational rule to reduce misuse and ambiguity.",
55
+ ...buildRuleLines(),
56
+ "Important:",
57
+ "- This notice is compliance guidance for ccm users. It is not legal advice and it does not replace review of Anthropic terms for your specific use case.",
58
+ "- If you are building a product, automation, or shared workflow around Claude access, verify your model with Anthropic before deployment.",
59
+ `Official sources reviewed on ${COMPLIANCE_CHECKED_AT}:`,
60
+ ...sourceLines
61
+ ];
62
+ }
63
+
64
+ // src/commands/compliance.ts
65
+ function registerCompliance(program2) {
66
+ program2.command("compliance").alias("tos").description("Show the Claude Code compliance notice and official source links").action(() => {
67
+ for (const line of getFullComplianceNoticeLines()) {
68
+ console.log(line);
69
+ }
70
+ });
71
+ }
10
72
  var CCM_HOME = join(homedir(), ".ccm");
11
73
  var PROFILES_DIR = join(CCM_HOME, "profiles");
12
74
  var CONFIG_FILE = join(CCM_HOME, "config.json");
13
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();
96
+
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
+ }
14
174
 
15
- // src/lib/browsers.ts
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
+ }
16
402
  var SHELL_METACHAR = /[;|&`$(){}<>\\!\n\r]/;
17
403
  function validateBrowserCommand(cmd) {
18
404
  if (SHELL_METACHAR.test(cmd)) {
@@ -35,7 +421,7 @@ exec ${browserCommand} "$@"
35
421
  renameSync(tmp, scriptPath);
36
422
  return scriptPath;
37
423
  }
38
- function getStagedPath(path) {
424
+ function getStagedPath3(path) {
39
425
  for (let attempt = 0; attempt < 100; attempt++) {
40
426
  const suffix = attempt === 0 ? `${process.pid}-${Date.now()}` : `${process.pid}-${Date.now()}-${attempt.toString()}`;
41
427
  const candidate = `${path}.staged-${suffix}`;
@@ -68,7 +454,7 @@ function stageBrowserWrapperRemoval(profileName, browsersDir = BROWSERS_DIR) {
68
454
  if (!existsSync(wrapperPath)) {
69
455
  return void 0;
70
456
  }
71
- const stagedPath = getStagedPath(wrapperPath);
457
+ const stagedPath = getStagedPath3(wrapperPath);
72
458
  renameSync(wrapperPath, stagedPath);
73
459
  return stagedPath;
74
460
  }
@@ -81,28 +467,6 @@ function finalizeStagedBrowserWrapperRemoval(stagedPath) {
81
467
  function resolveBrowser(cliOverride, meta) {
82
468
  return cliOverride ?? meta?.browser;
83
469
  }
84
- var MAX_PROFILE_NAME_LENGTH = 64;
85
- var PROFILE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
86
- var ProfileNameSchema = z.string().min(1).max(MAX_PROFILE_NAME_LENGTH).regex(PROFILE_NAME_PATTERN);
87
- var ProfileMetaSchema = z.object({
88
- name: ProfileNameSchema,
89
- label: z.string().optional(),
90
- browser: z.string().optional(),
91
- createdAt: z.string()
92
- }).passthrough();
93
- var CcmConfigSchema = z.object({
94
- profiles: z.record(z.string(), ProfileMetaSchema)
95
- }).passthrough();
96
- var ClaudeAuthStatusSchema = z.object({
97
- loggedIn: z.boolean(),
98
- authMethod: z.string(),
99
- email: z.string().optional(),
100
- orgName: z.string().optional(),
101
- subscriptionType: z.string().optional(),
102
- apiKeySource: z.string().optional()
103
- }).passthrough();
104
-
105
- // src/lib/config.ts
106
470
  var DEFAULT_CONFIG = { profiles: {} };
107
471
  function loadConfig(configFile = CONFIG_FILE) {
108
472
  try {
@@ -154,97 +518,26 @@ function renameProfile(oldName, newName, configFile = CONFIG_FILE) {
154
518
  function getProfile(name, configFile = CONFIG_FILE) {
155
519
  return loadConfig(configFile).profiles[name];
156
520
  }
157
- function validateProfileName(name) {
158
- const result = ProfileNameSchema.safeParse(name);
159
- if (result.success) {
160
- return;
161
- }
162
- if (name.length > MAX_PROFILE_NAME_LENGTH) {
163
- throw new Error(`Profile name too long (max ${MAX_PROFILE_NAME_LENGTH} chars).`);
164
- }
165
- throw new Error(
166
- `Invalid profile name "${name}". Use only letters, numbers, hyphens, underscores.`
167
- );
168
- }
169
- function createProfileDir(name, profilesDir = PROFILES_DIR) {
170
- validateProfileName(name);
171
- const dir = join(profilesDir, name);
172
- if (existsSync(dir)) {
173
- throw new Error(`Profile directory "${name}" already exists`);
174
- }
175
- mkdirSync(dir, { recursive: true });
176
- return dir;
177
- }
178
- function renameProfileDir(oldName, newName, profilesDir = PROFILES_DIR) {
179
- const oldDir = join(profilesDir, oldName);
180
- const newDir = join(profilesDir, newName);
181
- if (!existsSync(oldDir)) {
182
- throw new Error(`Profile directory "${oldName}" does not exist`);
183
- }
184
- if (existsSync(newDir)) {
185
- throw new Error(`Profile directory "${newName}" already exists`);
186
- }
187
- renameSync(oldDir, newDir);
188
- }
189
- function removeProfileDir(name, profilesDir = PROFILES_DIR) {
190
- const dir = join(profilesDir, name);
191
- if (!existsSync(dir)) {
192
- throw new Error(`Profile directory "${name}" does not exist`);
193
- }
194
- rmSync(dir, { recursive: true });
195
- }
196
- function getStagedPath2(path) {
197
- const parentDir = dirname(path);
198
- const baseName = basename(path);
199
- for (let attempt = 0; attempt < 100; attempt++) {
200
- const suffix = attempt === 0 ? `${process.pid}-${Date.now()}` : `${process.pid}-${Date.now()}-${attempt.toString()}`;
201
- const candidate = join(parentDir, `.${baseName}.staged-${suffix}`);
202
- if (!existsSync(candidate)) {
203
- return candidate;
204
- }
205
- }
206
- throw new Error(`Could not allocate a temporary path for "${baseName}"`);
207
- }
208
- function stageProfileDirRemoval(name, profilesDir = PROFILES_DIR) {
209
- const dir = join(profilesDir, name);
210
- if (!existsSync(dir)) {
211
- throw new Error(`Profile directory "${name}" does not exist`);
212
- }
213
- const stagedDir = getStagedPath2(dir);
214
- renameSync(dir, stagedDir);
215
- return stagedDir;
216
- }
217
- function restoreStagedProfileDir(stagedDir, name, profilesDir = PROFILES_DIR) {
218
- renameSync(stagedDir, join(profilesDir, name));
219
- }
220
- function finalizeStagedProfileDirRemoval(stagedDir) {
221
- rmSync(stagedDir, { recursive: true });
222
- }
223
- function getProfileDir(name, profilesDir = PROFILES_DIR) {
224
- return join(profilesDir, name);
225
- }
226
- function profileExists(name, profilesDir = PROFILES_DIR) {
227
- return existsSync(join(profilesDir, name));
228
- }
229
- function listProfileDirs(profilesDir = PROFILES_DIR) {
230
- if (!existsSync(profilesDir)) return [];
231
- return readdirSync(profilesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => d.name);
232
- }
233
521
 
234
522
  // src/commands/create.ts
235
523
  function registerCreate(program2) {
236
- 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) => {
237
525
  try {
238
526
  createProfileDir(name);
239
- const browser = opts.browser ? ensureBrowserWrapper(name, opts.browser) : void 0;
527
+ let browser;
240
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
+ }
241
534
  addProfile({
242
535
  name,
243
536
  label: opts.label,
244
537
  browser,
245
538
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
246
539
  });
247
- } catch (configErr) {
540
+ } catch (setupErr) {
248
541
  try {
249
542
  removeProfileDir(name);
250
543
  } catch {
@@ -253,9 +546,12 @@ function registerCreate(program2) {
253
546
  removeBrowserWrapper(name);
254
547
  } catch {
255
548
  }
256
- throw configErr;
549
+ throw setupErr;
257
550
  }
258
551
  console.log(`\x1B[32m\u2713\x1B[0m Profile "${name}" created`);
552
+ for (const line of getCompactComplianceNoticeLines()) {
553
+ console.log(line);
554
+ }
259
555
  console.log(` Next: ccm login ${name}`);
260
556
  } catch (e) {
261
557
  console.error(`\x1B[31m\u2717\x1B[0m ${e instanceof Error ? e.message : String(e)}`);
@@ -374,7 +670,7 @@ function removeStoredProfile(name, configFile = CONFIG_FILE, profilesDir = PROFI
374
670
  rollbackStorage(name, stagedDir, stagedWrapper, profilesDir, browsersDir);
375
671
  } catch (rollbackError) {
376
672
  throw new Error(
377
- `Failed to remove profile "${name}": ${formatError(error)}. Rollback failed: ${formatError(
673
+ `Failed to remove profile "${name}": ${formatError2(error)}. Rollback failed: ${formatError2(
378
674
  rollbackError
379
675
  )}`
380
676
  );
@@ -393,18 +689,18 @@ function removeStoredProfile(name, configFile = CONFIG_FILE, profilesDir = PROFI
393
689
  try {
394
690
  rollbackStorage(name, stagedDir, stagedWrapper, profilesDir, browsersDir);
395
691
  } catch (rollbackError) {
396
- recoveryErrors.push(formatError(rollbackError));
692
+ recoveryErrors.push(formatError2(rollbackError));
397
693
  }
398
694
  if (profile.meta) {
399
695
  try {
400
696
  restoreConfigEntry(profile.meta, configFile);
401
697
  } catch (configRestoreError) {
402
- recoveryErrors.push(`config: ${formatError(configRestoreError)}`);
698
+ recoveryErrors.push(`config: ${formatError2(configRestoreError)}`);
403
699
  }
404
700
  }
405
701
  if (recoveryErrors.length > 0) {
406
702
  throw new Error(
407
- `Failed to remove profile "${name}": ${formatError(error)}. Recovery failed: ${recoveryErrors.join(
703
+ `Failed to remove profile "${name}": ${formatError2(error)}. Recovery failed: ${recoveryErrors.join(
408
704
  "; "
409
705
  )}`
410
706
  );
@@ -439,7 +735,7 @@ function renameStoredProfile(oldName, newName, configFile = CONFIG_FILE, profile
439
735
  rollbackRename(oldName, newName, profile.hasDirectory, false, profilesDir, browsersDir);
440
736
  } catch (rollbackError) {
441
737
  throw new Error(
442
- `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)}`
443
739
  );
444
740
  }
445
741
  throw error;
@@ -452,7 +748,7 @@ function renameStoredProfile(oldName, newName, configFile = CONFIG_FILE, profile
452
748
  rollbackRename(oldName, newName, profile.hasDirectory, hadWrapper, profilesDir, browsersDir);
453
749
  } catch (rollbackError) {
454
750
  throw new Error(
455
- `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)}`
456
752
  );
457
753
  }
458
754
  throw error;
@@ -464,14 +760,14 @@ function rollbackRename(oldName, newName, hadDirectory, hadWrapper, profilesDir,
464
760
  try {
465
761
  renameBrowserWrapper(newName, oldName, browsersDir);
466
762
  } catch (error) {
467
- rollbackErrors.push(`browser wrapper: ${formatError(error)}`);
763
+ rollbackErrors.push(`browser wrapper: ${formatError2(error)}`);
468
764
  }
469
765
  }
470
766
  if (hadDirectory) {
471
767
  try {
472
768
  renameProfileDir(newName, oldName, profilesDir);
473
769
  } catch (error) {
474
- rollbackErrors.push(`profile dir: ${formatError(error)}`);
770
+ rollbackErrors.push(`profile dir: ${formatError2(error)}`);
475
771
  }
476
772
  }
477
773
  if (rollbackErrors.length > 0) {
@@ -486,14 +782,14 @@ function rollbackStorage(name, stagedDir, stagedWrapper, profilesDir, browsersDi
486
782
  try {
487
783
  restoreStagedProfileDir(stagedDir, name, profilesDir);
488
784
  } catch (error) {
489
- rollbackErrors.push(`profile dir: ${formatError(error)}`);
785
+ rollbackErrors.push(`profile dir: ${formatError2(error)}`);
490
786
  }
491
787
  }
492
788
  if (stagedWrapper) {
493
789
  try {
494
790
  restoreStagedBrowserWrapper(stagedWrapper, name, browsersDir);
495
791
  } catch (error) {
496
- rollbackErrors.push(`browser wrapper: ${formatError(error)}`);
792
+ rollbackErrors.push(`browser wrapper: ${formatError2(error)}`);
497
793
  }
498
794
  }
499
795
  if (rollbackErrors.length > 0) {
@@ -506,7 +802,7 @@ function restoreConfigEntry(meta, configFile) {
506
802
  }
507
803
  addProfile(meta, configFile);
508
804
  }
509
- function formatError(error) {
805
+ function formatError2(error) {
510
806
  return error instanceof Error ? error.message : String(error);
511
807
  }
512
808
 
@@ -571,7 +867,7 @@ function registerLogin(program2) {
571
867
  }
572
868
  });
573
869
  }
574
- function confirm(question) {
870
+ function confirm2(question) {
575
871
  const rl = createInterface({ input: process.stdin, output: process.stdout });
576
872
  return new Promise((resolve) => {
577
873
  rl.question(question, (answer) => {
@@ -588,7 +884,7 @@ function registerRemove(program2) {
588
884
  throw new Error(`Profile "${name}" does not exist`);
589
885
  }
590
886
  if (!opts.force) {
591
- const ok = await confirm(`Remove profile "${name}"? (y/N) `);
887
+ const ok = await confirm2(`Remove profile "${name}"? (y/N) `);
592
888
  if (!ok) {
593
889
  console.log("Cancelled");
594
890
  return;
@@ -701,8 +997,10 @@ function registerUse(program2) {
701
997
  }
702
998
 
703
999
  // src/index.ts
704
- var program = new Command().name("ccm").version("0.3.0").description("Manage multiple Claude Code profiles");
1000
+ var program = new Command().name("ccm").version("0.5.0").description("Manage multiple Claude Code profiles");
705
1001
  registerCreate(program);
1002
+ registerCopyConfig(program);
1003
+ registerCompliance(program);
706
1004
  registerList(program);
707
1005
  registerRemove(program);
708
1006
  registerRename(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remeic/ccm",
3
- "version": "0.3.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",