cc-swap 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +30 -0
- package/README.md +416 -0
- package/dist/cli.js +275 -0
- package/docs/superpowers/plans/2026-03-22-cc-switch.md +55 -0
- package/docs/superpowers/specs/2026-03-22-cc-switch-design.md +117 -0
- package/package.json +31 -0
- package/src/accounts.ts +105 -0
- package/src/cli.ts +158 -0
- package/src/index.ts +4 -0
- package/src/items.ts +57 -0
- package/src/paths.ts +11 -0
- package/src/symlink.ts +34 -0
- package/src/validate.ts +10 -0
- package/tests/accounts.test.ts +186 -0
- package/tests/cli.test.ts +76 -0
- package/tests/symlink.test.ts +127 -0
- package/tests/validate.test.ts +33 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +9 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import path3 from "path";
|
|
7
|
+
import { createInterface } from "readline/promises";
|
|
8
|
+
import { stdin as input, stdout as output } from "process";
|
|
9
|
+
|
|
10
|
+
// src/paths.ts
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
var HOME = process.env.CC_SWAP_HOME ?? homedir();
|
|
14
|
+
var CLAUDE_DIR = process.env.CC_SWAP_CLAUDE_DIR ?? join(HOME, ".claude");
|
|
15
|
+
var CC_SWAP_DIR = join(HOME, ".cc-swap");
|
|
16
|
+
var ACCOUNTS_DIR = join(CC_SWAP_DIR, "claude-accounts");
|
|
17
|
+
var CURRENT_FILE = join(CC_SWAP_DIR, ".current");
|
|
18
|
+
var CONFIG_FILE = join(CC_SWAP_DIR, "config.json");
|
|
19
|
+
|
|
20
|
+
// src/accounts.ts
|
|
21
|
+
import fsPromises2 from "fs/promises";
|
|
22
|
+
import path2 from "path";
|
|
23
|
+
|
|
24
|
+
// src/validate.ts
|
|
25
|
+
function validateName(name) {
|
|
26
|
+
if (!name) return "Name cannot be empty";
|
|
27
|
+
if (name.length > 50) return "Name must be 50 characters or fewer";
|
|
28
|
+
if (name.startsWith(".") || name.startsWith("-"))
|
|
29
|
+
return "Name cannot start with '.' or '-'";
|
|
30
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name))
|
|
31
|
+
return "Name can only contain letters, numbers, hyphens, and underscores";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/symlink.ts
|
|
36
|
+
import fsPromises from "fs/promises";
|
|
37
|
+
import path from "path";
|
|
38
|
+
async function populateAccount(claudeDir, accountDir, syncItems) {
|
|
39
|
+
await fsPromises.mkdir(accountDir, { recursive: true, mode: 448 });
|
|
40
|
+
for (const item of syncItems) {
|
|
41
|
+
const source = path.join(claudeDir, item);
|
|
42
|
+
const link = path.join(accountDir, item);
|
|
43
|
+
const sourceExists = await fsPromises.access(source).then(() => true).catch(() => false);
|
|
44
|
+
if (!sourceExists) continue;
|
|
45
|
+
const linkExists = await fsPromises.access(link).then(() => true).catch(() => false);
|
|
46
|
+
if (linkExists) continue;
|
|
47
|
+
await fsPromises.symlink(source, link);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function readCurrent(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
const content = (await fsPromises.readFile(filePath, "utf-8")).trim();
|
|
53
|
+
return content || null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function writeCurrent(filePath, name) {
|
|
59
|
+
await fsPromises.writeFile(filePath, name + "\n", { mode: 384 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/accounts.ts
|
|
63
|
+
async function listAccounts(accountsDir) {
|
|
64
|
+
try {
|
|
65
|
+
const entries = await fsPromises2.readdir(accountsDir, { withFileTypes: true });
|
|
66
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function nextAccount(current, accounts) {
|
|
72
|
+
if (accounts.length <= 1) return null;
|
|
73
|
+
const idx = accounts.indexOf(current);
|
|
74
|
+
return accounts[(idx + 1) % accounts.length];
|
|
75
|
+
}
|
|
76
|
+
async function addAccount(name, paths) {
|
|
77
|
+
const { claudeDir, ccSwitchDir, accountsDir, currentFile } = paths;
|
|
78
|
+
const nameErr = validateName(name);
|
|
79
|
+
if (nameErr) throw new Error(nameErr);
|
|
80
|
+
const accountsExist = await fsPromises2.access(accountsDir).then(() => true).catch(() => false);
|
|
81
|
+
if (!accountsExist) {
|
|
82
|
+
await fsPromises2.mkdir(accountsDir, { recursive: true, mode: 448 });
|
|
83
|
+
if (ccSwitchDir) {
|
|
84
|
+
await fsPromises2.chmod(ccSwitchDir, 448);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const target = path2.join(accountsDir, name);
|
|
88
|
+
const exists = await fsPromises2.access(target).then(() => true).catch(() => false);
|
|
89
|
+
if (exists) throw new Error(`Account '${name}' already exists`);
|
|
90
|
+
await populateAccount(claudeDir, target, paths.syncItems ?? []);
|
|
91
|
+
const current = await readCurrent(currentFile);
|
|
92
|
+
if (!current) {
|
|
93
|
+
await writeCurrent(currentFile, name);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function switchAccount(name, paths) {
|
|
97
|
+
const nameErr = validateName(name);
|
|
98
|
+
if (nameErr) throw new Error(nameErr);
|
|
99
|
+
const { accountsDir, currentFile } = paths;
|
|
100
|
+
const target = path2.join(accountsDir, name);
|
|
101
|
+
const exists = await fsPromises2.access(target).then(() => true).catch(() => false);
|
|
102
|
+
if (!exists) throw new Error(`Account '${name}' does not exist`);
|
|
103
|
+
const current = await readCurrent(currentFile);
|
|
104
|
+
if (current === name) return `'${name}' is already active`;
|
|
105
|
+
await writeCurrent(currentFile, name);
|
|
106
|
+
return `Switched to: ${name}`;
|
|
107
|
+
}
|
|
108
|
+
async function removeAccount(name, paths) {
|
|
109
|
+
const nameErr = validateName(name);
|
|
110
|
+
if (nameErr) throw new Error(nameErr);
|
|
111
|
+
const { accountsDir, currentFile } = paths;
|
|
112
|
+
const target = path2.join(accountsDir, name);
|
|
113
|
+
const exists = await fsPromises2.access(target).then(() => true).catch(() => false);
|
|
114
|
+
if (!exists) throw new Error(`Account '${name}' does not exist`);
|
|
115
|
+
const current = await readCurrent(currentFile);
|
|
116
|
+
if (current === name) {
|
|
117
|
+
throw new Error(`Cannot remove '${name}' \u2014 it is currently active. Switch to another account first.`);
|
|
118
|
+
}
|
|
119
|
+
const all = await listAccounts(accountsDir);
|
|
120
|
+
if (all.length <= 1) {
|
|
121
|
+
throw new Error(`Cannot remove '${name}' \u2014 it is the last remaining account.`);
|
|
122
|
+
}
|
|
123
|
+
await fsPromises2.rm(target, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/items.ts
|
|
127
|
+
import fsPromises3 from "fs/promises";
|
|
128
|
+
var DEFAULT_ITEMS = [
|
|
129
|
+
"agents",
|
|
130
|
+
"commands",
|
|
131
|
+
"file-history",
|
|
132
|
+
"hooks",
|
|
133
|
+
"plugins",
|
|
134
|
+
"projects",
|
|
135
|
+
"rules",
|
|
136
|
+
"session-env",
|
|
137
|
+
"sessions",
|
|
138
|
+
"settings.json",
|
|
139
|
+
"skills",
|
|
140
|
+
"tasks"
|
|
141
|
+
];
|
|
142
|
+
var DEFAULT_CONFIG = {
|
|
143
|
+
syncItems: [...DEFAULT_ITEMS],
|
|
144
|
+
autoContinue: true
|
|
145
|
+
};
|
|
146
|
+
async function loadConfig(configFile) {
|
|
147
|
+
try {
|
|
148
|
+
const raw = await fsPromises3.readFile(configFile, "utf-8");
|
|
149
|
+
const parsed = JSON.parse(raw);
|
|
150
|
+
return {
|
|
151
|
+
syncItems: Array.isArray(parsed.syncItems) ? parsed.syncItems : [...DEFAULT_ITEMS],
|
|
152
|
+
autoContinue: parsed.autoContinue !== false
|
|
153
|
+
};
|
|
154
|
+
} catch {
|
|
155
|
+
const config = { ...DEFAULT_CONFIG, syncItems: [...DEFAULT_ITEMS] };
|
|
156
|
+
const dir = configFile.substring(0, configFile.lastIndexOf("/"));
|
|
157
|
+
await fsPromises3.mkdir(dir, { recursive: true, mode: 448 }).catch(() => void 0);
|
|
158
|
+
await saveConfig(configFile, config);
|
|
159
|
+
return config;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function saveConfig(configFile, config) {
|
|
163
|
+
await fsPromises3.writeFile(configFile, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/cli.ts
|
|
167
|
+
function launchClaude(accountDir, config) {
|
|
168
|
+
if (process.env.CC_SWAP_NO_LAUNCH) return;
|
|
169
|
+
const args = config.autoContinue ? ["--continue"] : [];
|
|
170
|
+
console.log(`Launching Claude Code (config: ${accountDir})...
|
|
171
|
+
`);
|
|
172
|
+
const result = spawnSync("claude", args, {
|
|
173
|
+
stdio: "inherit",
|
|
174
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: accountDir }
|
|
175
|
+
});
|
|
176
|
+
process.exit(result.status ?? 0);
|
|
177
|
+
}
|
|
178
|
+
var program = new Command();
|
|
179
|
+
program.name("cc-swap").description("Switch between multiple Claude Code accounts").version("0.1.0");
|
|
180
|
+
program.action(async () => {
|
|
181
|
+
const config = await loadConfig(CONFIG_FILE);
|
|
182
|
+
const accounts = await listAccounts(ACCOUNTS_DIR);
|
|
183
|
+
if (accounts.length === 0) {
|
|
184
|
+
console.log("No accounts found. Run 'cc-swap add <name>' to get started.");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const current = await readCurrent(CURRENT_FILE);
|
|
188
|
+
if (!current) {
|
|
189
|
+
console.log("No active account. Run 'cc-swap switch <name>' to activate one.");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const next = nextAccount(current, accounts);
|
|
193
|
+
if (!next) {
|
|
194
|
+
console.log(`Using account: ${current}`);
|
|
195
|
+
launchClaude(path3.join(ACCOUNTS_DIR, current), config);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
await switchAccount(next, {
|
|
199
|
+
claudeDir: CLAUDE_DIR,
|
|
200
|
+
accountsDir: ACCOUNTS_DIR,
|
|
201
|
+
currentFile: CURRENT_FILE
|
|
202
|
+
});
|
|
203
|
+
console.log(`Switched: ${current} \u2192 ${next}`);
|
|
204
|
+
launchClaude(path3.join(ACCOUNTS_DIR, next), config);
|
|
205
|
+
});
|
|
206
|
+
program.command("add <name>").description("Add a new account (populated with symlinks to ~/.claude)").action(async (name) => {
|
|
207
|
+
try {
|
|
208
|
+
const config = await loadConfig(CONFIG_FILE);
|
|
209
|
+
await addAccount(name, {
|
|
210
|
+
claudeDir: CLAUDE_DIR,
|
|
211
|
+
ccSwitchDir: CC_SWAP_DIR,
|
|
212
|
+
accountsDir: ACCOUNTS_DIR,
|
|
213
|
+
currentFile: CURRENT_FILE,
|
|
214
|
+
syncItems: config.syncItems
|
|
215
|
+
});
|
|
216
|
+
console.log(`Added account '${name}'.`);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error(`Error: ${err.message}`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
program.command("switch <name>").description("Switch to a specific account and launch Claude Code").action(async (name) => {
|
|
223
|
+
try {
|
|
224
|
+
const config = await loadConfig(CONFIG_FILE);
|
|
225
|
+
const msg = await switchAccount(name, {
|
|
226
|
+
claudeDir: CLAUDE_DIR,
|
|
227
|
+
accountsDir: ACCOUNTS_DIR,
|
|
228
|
+
currentFile: CURRENT_FILE
|
|
229
|
+
});
|
|
230
|
+
console.log(msg);
|
|
231
|
+
if (!msg.includes("already active")) {
|
|
232
|
+
launchClaude(path3.join(ACCOUNTS_DIR, name), config);
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
console.error(`Error: ${err.message}`);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
program.command("list").description("List all accounts").action(async () => {
|
|
240
|
+
const accounts = await listAccounts(ACCOUNTS_DIR);
|
|
241
|
+
if (accounts.length === 0) {
|
|
242
|
+
console.log("No accounts found. Run 'cc-swap add <name>' to get started.");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const current = await readCurrent(CURRENT_FILE);
|
|
246
|
+
for (const name of accounts) {
|
|
247
|
+
const marker = name === current ? "* " : " ";
|
|
248
|
+
const label = name === current ? " (active)" : "";
|
|
249
|
+
console.log(`${marker}${name}${label}`);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
program.command("remove <name>").description("Remove an account").option("-f, --force", "Skip confirmation prompt").action(async (name, opts) => {
|
|
253
|
+
try {
|
|
254
|
+
if (!opts.force) {
|
|
255
|
+
const rl = createInterface({ input, output });
|
|
256
|
+
const answer = await rl.question(
|
|
257
|
+
`Remove account '${name}'? This will delete all its data. (y/N) `
|
|
258
|
+
);
|
|
259
|
+
rl.close();
|
|
260
|
+
if (answer.toLowerCase() !== "y") {
|
|
261
|
+
console.log("Cancelled.");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
await removeAccount(name, {
|
|
266
|
+
accountsDir: ACCOUNTS_DIR,
|
|
267
|
+
currentFile: CURRENT_FILE
|
|
268
|
+
});
|
|
269
|
+
console.log(`Removed account '${name}'.`);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error(`Error: ${err.message}`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
program.parseAsync();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# cc-switch Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **Status: IMPLEMENTED** — All tasks completed.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a Node.js CLI tool that manages Claude Code accounts. Each account is a directory with symlinks back to `~/.claude` (source of truth). Switching sets `CLAUDE_CONFIG_DIR` and launches `claude --continue`.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Single-binary CLI with Commander.js. Core logic: path constants, name validation, account population (symlinks back to `~/.claude`), account CRUD, and CLI entry point. All async with `fs/promises`. TDD with vitest.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js 18+, TypeScript, Commander.js, vitest, tsup (bundler)
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-03-22-cc-switch-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Structure
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/
|
|
19
|
+
├── cli.ts # Commander.js setup, command definitions, launches claude --continue
|
|
20
|
+
├── paths.ts # Path constants with env var overrides for testing
|
|
21
|
+
├── items.ts # SWITCH_ITEMS list (12 items that get symlinked per-account)
|
|
22
|
+
├── validate.ts # Account name validation (sync - pure string logic)
|
|
23
|
+
├── symlink.ts # populateAccount (symlinks back to ~/.claude), readCurrent, writeCurrent
|
|
24
|
+
├── accounts.ts # listAccounts, addAccount, switchAccount, removeAccount, nextAccount
|
|
25
|
+
└── index.ts # Re-exports
|
|
26
|
+
|
|
27
|
+
tests/
|
|
28
|
+
├── validate.test.ts # 6 tests
|
|
29
|
+
├── symlink.test.ts # 10 tests (populateAccount, readCurrent, writeCurrent)
|
|
30
|
+
├── accounts.test.ts # 15 tests (add, switch, remove, list, nextAccount)
|
|
31
|
+
└── cli.test.ts # 6 integration tests
|
|
32
|
+
|
|
33
|
+
package.json
|
|
34
|
+
tsconfig.json
|
|
35
|
+
tsup.config.ts
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Key Design Decisions
|
|
39
|
+
|
|
40
|
+
1. **`~/.claude` is NEVER modified** — it is the source of truth
|
|
41
|
+
2. **Account dirs contain symlinks BACK to `~/.claude`** — not the other way around
|
|
42
|
+
3. **Custom overrides** — user replaces a symlink in account dir with real file/dir; `populateAccount` skips existing items
|
|
43
|
+
4. **`CLAUDE_CONFIG_DIR` env var** — used to tell Claude Code to read config from account dir
|
|
44
|
+
5. **`claude --continue`** — always resumes previous session when launching
|
|
45
|
+
6. **Single account = launch directly** — `cc-switch` with 1 account just launches it, no error
|
|
46
|
+
|
|
47
|
+
## Tasks (all completed)
|
|
48
|
+
|
|
49
|
+
- [x] Task 1: Project scaffolding (package.json, tsconfig, tsup, .gitignore)
|
|
50
|
+
- [x] Task 2: Path constants with env var overrides
|
|
51
|
+
- [x] Task 3: Account name validation + tests
|
|
52
|
+
- [x] Task 4: Symlink operations (populateAccount, readCurrent, writeCurrent) + tests
|
|
53
|
+
- [x] Task 5: Account CRUD operations + tests
|
|
54
|
+
- [x] Task 6: CLI entry point with all commands + integration tests
|
|
55
|
+
- [x] Task 7: Build, exports, link
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# cc-switch - Claude Code Account Switcher
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
CLI tool (Node.js/TypeScript) to switch between multiple Claude Code accounts. Each account is a directory containing symlinks back to `~/.claude` (shared config) or custom override files. `~/.claude` is the source of truth and is **never modified** by cc-switch.
|
|
6
|
+
|
|
7
|
+
When launching Claude Code, the tool sets `CLAUDE_CONFIG_DIR` to the account directory, so Claude reads config from there.
|
|
8
|
+
|
|
9
|
+
## Core Principle
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
~/.claude/ # SOURCE OF TRUTH — never modified
|
|
13
|
+
├── settings.json # real file
|
|
14
|
+
├── hooks/ # real dir
|
|
15
|
+
├── plugins/ # real dir
|
|
16
|
+
└── ...
|
|
17
|
+
|
|
18
|
+
~/.cc-switch/
|
|
19
|
+
├── .current # active account name
|
|
20
|
+
└── claude-accounts/
|
|
21
|
+
├── work/
|
|
22
|
+
│ ├── settings.json → ~/.claude/settings.json # symlink (shared)
|
|
23
|
+
│ ├── hooks → ~/.claude/hooks # symlink (shared)
|
|
24
|
+
│ └── plugins/ # REAL dir (custom override)
|
|
25
|
+
└── personal/
|
|
26
|
+
├── settings.json # REAL file (custom override)
|
|
27
|
+
├── hooks → ~/.claude/hooks # symlink (shared)
|
|
28
|
+
└── ...
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Shared items** = symlinks back to `~/.claude` (default when adding account)
|
|
32
|
+
**Custom overrides** = replace the symlink with a real file/dir in the account folder
|
|
33
|
+
|
|
34
|
+
## Switchable Items
|
|
35
|
+
|
|
36
|
+
The following items inside `~/.claude` are symlinked per-account:
|
|
37
|
+
|
|
38
|
+
- `agents/`, `commands/`, `file-history/`, `hooks/`, `plugins/`
|
|
39
|
+
- `projects/`, `rules/`, `session-env/`, `sessions/`
|
|
40
|
+
- `settings.json`, `skills/`, `tasks/`
|
|
41
|
+
|
|
42
|
+
## CLI Commands
|
|
43
|
+
|
|
44
|
+
### `cc-switch` (no arguments)
|
|
45
|
+
|
|
46
|
+
Auto switch to the next account (round-robin) and launch Claude Code.
|
|
47
|
+
|
|
48
|
+
1. Read `.current` to get active account name
|
|
49
|
+
2. List folders in `claude-accounts/`, sorted alphabetically (case-insensitive)
|
|
50
|
+
3. If only 1 account → use that account directly
|
|
51
|
+
4. If 2+ accounts → switch to the next one (wrap to first at end), update `.current`
|
|
52
|
+
5. Launch `claude --continue` with `CLAUDE_CONFIG_DIR` set to the account directory
|
|
53
|
+
|
|
54
|
+
### `cc-switch add <name>`
|
|
55
|
+
|
|
56
|
+
Create a new account populated with symlinks back to `~/.claude`.
|
|
57
|
+
|
|
58
|
+
1. Validate name (alphanumeric, hyphens, underscores; max 50 chars; no `.` or `-` prefix)
|
|
59
|
+
2. Create `~/.cc-switch/claude-accounts/<name>/`
|
|
60
|
+
3. For each switchable item that exists in `~/.claude`: create symlink `<name>/<item>` → `~/.claude/<item>`
|
|
61
|
+
4. If this is the first account, set it as `.current`
|
|
62
|
+
5. `~/.claude` is NOT modified
|
|
63
|
+
|
|
64
|
+
### `cc-switch switch <name>`
|
|
65
|
+
|
|
66
|
+
Switch to a specific account and launch Claude Code.
|
|
67
|
+
|
|
68
|
+
1. Validate `<name>` exists in `claude-accounts/`
|
|
69
|
+
2. If already active → print message and exit
|
|
70
|
+
3. Update `.current`
|
|
71
|
+
4. Launch `claude --continue` with `CLAUDE_CONFIG_DIR=<account-dir>`
|
|
72
|
+
|
|
73
|
+
### `cc-switch list`
|
|
74
|
+
|
|
75
|
+
List all accounts, marking the active one.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
work
|
|
79
|
+
* personal (active)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `cc-switch remove <name>`
|
|
83
|
+
|
|
84
|
+
Remove an account.
|
|
85
|
+
|
|
86
|
+
1. Prevent removing the currently active account
|
|
87
|
+
2. Prevent removing the last remaining account
|
|
88
|
+
3. Confirm with user (prompt y/n), support `--force` flag to skip
|
|
89
|
+
4. Remove `~/.cc-switch/claude-accounts/<name>/`
|
|
90
|
+
|
|
91
|
+
## How It Works
|
|
92
|
+
|
|
93
|
+
- **Adding**: `cc-switch add work` creates `~/.cc-switch/claude-accounts/work/` with symlinks pointing back to `~/.claude/` for each switchable item
|
|
94
|
+
- **Switching**: `cc-switch switch work` updates `.current` and runs `claude --continue` with `CLAUDE_CONFIG_DIR=~/.cc-switch/claude-accounts/work`
|
|
95
|
+
- **Custom overrides**: User can replace any symlink in the account folder with a real file/dir. `cc-switch` will not overwrite existing items when populating.
|
|
96
|
+
- **`~/.claude` is sacred**: cc-switch never reads from, writes to, or modifies `~/.claude` contents. It only reads to create initial symlinks during `add`.
|
|
97
|
+
|
|
98
|
+
## Safety Checks
|
|
99
|
+
|
|
100
|
+
- **`~/.claude` is never modified** — only used as symlink target
|
|
101
|
+
- **Validate account names**: alphanumeric, hyphens, underscores only. Max 50 chars. Cannot start with `.` or `-`
|
|
102
|
+
- **Preserve custom overrides**: `populateAccount` skips items that already exist in account dir
|
|
103
|
+
- **Permissions**: create `~/.cc-switch/` with mode `700`
|
|
104
|
+
|
|
105
|
+
## Tech Stack
|
|
106
|
+
|
|
107
|
+
- Node.js + TypeScript
|
|
108
|
+
- Commander.js for CLI parsing
|
|
109
|
+
- Native `fs/promises` for async filesystem operations
|
|
110
|
+
- `CLAUDE_CONFIG_DIR` env var to direct Claude Code to account folder
|
|
111
|
+
- No heavy dependencies
|
|
112
|
+
|
|
113
|
+
## Package
|
|
114
|
+
|
|
115
|
+
- Name: `cc-switch`
|
|
116
|
+
- Binary: `cc-switch`
|
|
117
|
+
- Install: `npm install -g cc-switch`
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cc-swap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Switch between multiple Claude Code accounts using symlinks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cc-swap": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsup",
|
|
11
|
+
"dev": "tsup --watch",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"test:watch": "vitest"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"account-switcher"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^25.5.0",
|
|
23
|
+
"tsup": "^8.5.1",
|
|
24
|
+
"tsx": "^4.21.0",
|
|
25
|
+
"typescript": "^5.9.3",
|
|
26
|
+
"vitest": "^4.1.0"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"commander": "^14.0.3"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fsPromises from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { validateName } from "./validate.js";
|
|
4
|
+
import { populateAccount, readCurrent, writeCurrent } from "./symlink.js";
|
|
5
|
+
|
|
6
|
+
interface AccountPaths {
|
|
7
|
+
claudeDir: string;
|
|
8
|
+
ccSwitchDir?: string;
|
|
9
|
+
accountsDir: string;
|
|
10
|
+
currentFile: string;
|
|
11
|
+
syncItems?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function listAccounts(accountsDir: string): Promise<string[]> {
|
|
15
|
+
try {
|
|
16
|
+
const entries = await fsPromises.readdir(accountsDir, { withFileTypes: true });
|
|
17
|
+
return entries
|
|
18
|
+
.filter((e) => e.isDirectory())
|
|
19
|
+
.map((e) => e.name)
|
|
20
|
+
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function nextAccount(current: string, accounts: string[]): string | null {
|
|
27
|
+
if (accounts.length <= 1) return null;
|
|
28
|
+
const idx = accounts.indexOf(current);
|
|
29
|
+
return accounts[(idx + 1) % accounts.length];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function addAccount(name: string, paths: AccountPaths): Promise<void> {
|
|
33
|
+
const { claudeDir, ccSwitchDir, accountsDir, currentFile } = paths;
|
|
34
|
+
|
|
35
|
+
const nameErr = validateName(name);
|
|
36
|
+
if (nameErr) throw new Error(nameErr);
|
|
37
|
+
|
|
38
|
+
const accountsExist = await fsPromises.access(accountsDir).then(() => true).catch(() => false);
|
|
39
|
+
|
|
40
|
+
if (!accountsExist) {
|
|
41
|
+
await fsPromises.mkdir(accountsDir, { recursive: true, mode: 0o700 });
|
|
42
|
+
if (ccSwitchDir) {
|
|
43
|
+
await fsPromises.chmod(ccSwitchDir, 0o700);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const target = path.join(accountsDir, name);
|
|
48
|
+
const exists = await fsPromises.access(target).then(() => true).catch(() => false);
|
|
49
|
+
if (exists) throw new Error(`Account '${name}' already exists`);
|
|
50
|
+
|
|
51
|
+
// Create account dir with symlinks pointing back to ~/.claude
|
|
52
|
+
await populateAccount(claudeDir, target, paths.syncItems ?? []);
|
|
53
|
+
|
|
54
|
+
// Set as current if this is the first account
|
|
55
|
+
const current = await readCurrent(currentFile);
|
|
56
|
+
if (!current) {
|
|
57
|
+
await writeCurrent(currentFile, name);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function switchAccount(
|
|
62
|
+
name: string,
|
|
63
|
+
paths: Pick<AccountPaths, "claudeDir" | "accountsDir" | "currentFile">
|
|
64
|
+
): Promise<string> {
|
|
65
|
+
const nameErr = validateName(name);
|
|
66
|
+
if (nameErr) throw new Error(nameErr);
|
|
67
|
+
|
|
68
|
+
const { accountsDir, currentFile } = paths;
|
|
69
|
+
const target = path.join(accountsDir, name);
|
|
70
|
+
|
|
71
|
+
const exists = await fsPromises.access(target).then(() => true).catch(() => false);
|
|
72
|
+
if (!exists) throw new Error(`Account '${name}' does not exist`);
|
|
73
|
+
|
|
74
|
+
const current = await readCurrent(currentFile);
|
|
75
|
+
if (current === name) return `'${name}' is already active`;
|
|
76
|
+
|
|
77
|
+
await writeCurrent(currentFile, name);
|
|
78
|
+
return `Switched to: ${name}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function removeAccount(
|
|
82
|
+
name: string,
|
|
83
|
+
paths: Pick<AccountPaths, "accountsDir" | "currentFile">
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
const nameErr = validateName(name);
|
|
86
|
+
if (nameErr) throw new Error(nameErr);
|
|
87
|
+
|
|
88
|
+
const { accountsDir, currentFile } = paths;
|
|
89
|
+
const target = path.join(accountsDir, name);
|
|
90
|
+
|
|
91
|
+
const exists = await fsPromises.access(target).then(() => true).catch(() => false);
|
|
92
|
+
if (!exists) throw new Error(`Account '${name}' does not exist`);
|
|
93
|
+
|
|
94
|
+
const current = await readCurrent(currentFile);
|
|
95
|
+
if (current === name) {
|
|
96
|
+
throw new Error(`Cannot remove '${name}' — it is currently active. Switch to another account first.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const all = await listAccounts(accountsDir);
|
|
100
|
+
if (all.length <= 1) {
|
|
101
|
+
throw new Error(`Cannot remove '${name}' — it is the last remaining account.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await fsPromises.rm(target, { recursive: true, force: true });
|
|
105
|
+
}
|