@synth1s/cloak 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 synth1s
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # @synth1s/cloak
2
+
3
+ > Cloak your Claude. Switch identities in seconds.
4
+
5
+ Every developer wears a different cloak. One for work, one for personal projects, one for that freelance gig. **Cloak** lets you dress your Claude Code in the right identity — and switch between them without breaking a sweat.
6
+
7
+ ## The problem
8
+
9
+ Claude Code stores your session in `~/.claude.json`. There's no built-in support for multiple accounts, so switching between personal and work means running `/logout` and `/login` every time — losing your session state in the process.
10
+
11
+ ## The solution
12
+
13
+ Cloak gives each account its own isolated directory using Claude Code's official [`CLAUDE_CONFIG_DIR`](https://code.claude.com/docs/en/env-vars) environment variable. Each identity stays separate. No file conflicts. No data loss. Full support for concurrent sessions.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install -g @synth1s/cloak
19
+ ```
20
+
21
+ Add to your `.bashrc` or `.zshrc`:
22
+
23
+ ```bash
24
+ eval "$(cloak init)"
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```bash
30
+ # Cloak your Claude with your work identity
31
+ claude account create work
32
+
33
+ # Log out, log in with another account, then:
34
+ claude account create home
35
+
36
+ # Throw on a cloak and go
37
+ claude -a work
38
+ claude -a home
39
+ ```
40
+
41
+ ## Commands
42
+
43
+ ### Wardrobe management
44
+
45
+ | Command | Alias | Description |
46
+ |---------|-------|-------------|
47
+ | `claude account create [name]` | | Save current session as a new cloak |
48
+ | `claude account switch <name>` | `use` | Wear a different cloak |
49
+ | `claude account list` | `ls` | See all cloaks in your wardrobe |
50
+ | `claude account delete <name>` | `rm` | Discard a cloak |
51
+ | `claude account whoami` | | Which cloak are you wearing? |
52
+ | `claude account rename <old> <new>` | | Rename a cloak |
53
+
54
+ ### Shortcut
55
+
56
+ | Command | Description |
57
+ |---------|-------------|
58
+ | `claude -a <name>` | Throw on a cloak and launch Claude |
59
+ | `claude -a <name> [args...]` | Throw on a cloak and launch with arguments |
60
+
61
+ ## Concurrent sessions
62
+
63
+ Different terminal, different cloak. No conflicts.
64
+
65
+ ```bash
66
+ # Terminal A — wearing the work cloak:
67
+ claude -a work
68
+
69
+ # Terminal B — wearing the home cloak:
70
+ claude -a home
71
+ ```
72
+
73
+ ## How it works
74
+
75
+ Each cloak is an isolated directory that acts as a [`CLAUDE_CONFIG_DIR`](https://code.claude.com/docs/en/env-vars):
76
+
77
+ ```
78
+ ~/.cloak/
79
+ └── profiles/
80
+ ├── work/ # Work identity
81
+ │ ├── .claude.json
82
+ │ ├── settings.json
83
+ │ └── ...
84
+ └── home/ # Personal identity
85
+ ├── .claude.json
86
+ └── ...
87
+ ```
88
+
89
+ When you run `claude -a work`, Cloak sets `CLAUDE_CONFIG_DIR=~/.cloak/profiles/work` and launches Claude Code. That's it. Each terminal gets its own environment, so you can wear different cloaks simultaneously.
90
+
91
+ ## Requirements
92
+
93
+ - Node.js >= 18
94
+ - bash or zsh (for shell integration)
95
+
96
+ ## Documentation
97
+
98
+ - [Requirements & use cases](docs/requirements.md)
99
+ - [Technical specification](docs/technical-spec.md)
100
+
101
+ ## License
102
+
103
+ MIT
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@synth1s/cloak",
3
+ "version": "1.0.0",
4
+ "description": "Cloak your Claude. Switch identities in seconds.",
5
+ "type": "module",
6
+ "bin": {
7
+ "cloak": "./src/cli.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test tests/"
11
+ },
12
+ "keywords": [
13
+ "claude",
14
+ "claude-code",
15
+ "anthropic",
16
+ "account",
17
+ "profile",
18
+ "cli",
19
+ "switch",
20
+ "multi-account",
21
+ "cloak"
22
+ ],
23
+ "author": "synth1s",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "commander": "^12.1.0",
27
+ "chalk": "^5.3.0",
28
+ "inquirer": "^10.1.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/synth1s/cloak.git"
36
+ },
37
+ "homepage": "https://github.com/synth1s/cloak#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/synth1s/cloak/issues"
40
+ }
41
+ }
package/src/cli.js ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander'
4
+ import { readFileSync } from 'fs'
5
+ import { fileURLToPath } from 'url'
6
+ import { dirname, join } from 'path'
7
+
8
+ import { createAccount } from './commands/create.js'
9
+ import { switchAccount } from './commands/switch.js'
10
+ import { listAccounts } from './commands/list.js'
11
+ import { deleteAccount } from './commands/delete.js'
12
+ import { whoami } from './commands/whoami.js'
13
+ import { renameAccount } from './commands/rename.js'
14
+ import { launchAccount } from './commands/launch.js'
15
+ import { initShell } from './commands/init.js'
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url))
18
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
19
+
20
+ program
21
+ .name('cloak')
22
+ .description('Cloak your Claude. Switch identities in seconds.')
23
+ .version(pkg.version)
24
+
25
+ program
26
+ .command('create [name]')
27
+ .description('Save current session as a new cloak')
28
+ .action(createAccount)
29
+
30
+ program
31
+ .command('switch <name>')
32
+ .alias('use')
33
+ .description('Wear a different cloak')
34
+ .option('--print-env', 'Output export command for eval (used by shell integration)')
35
+ .action((name, opts) => switchAccount(name, { printEnv: opts.printEnv }))
36
+
37
+ program
38
+ .command('list')
39
+ .alias('ls')
40
+ .description('See all cloaks in your wardrobe')
41
+ .action(listAccounts)
42
+
43
+ program
44
+ .command('delete <name>')
45
+ .alias('rm')
46
+ .description('Discard a cloak')
47
+ .action((name) => deleteAccount(name))
48
+
49
+ program
50
+ .command('whoami')
51
+ .description('Which cloak are you wearing?')
52
+ .action(whoami)
53
+
54
+ program
55
+ .command('rename <old> <new>')
56
+ .description('Rename a cloak')
57
+ .action(renameAccount)
58
+
59
+ program
60
+ .command('launch <name>')
61
+ .description('Wear a cloak and launch Claude')
62
+ .argument('[args...]', 'Arguments to pass to claude')
63
+ .action((name, args) => launchAccount(name, args))
64
+
65
+ program
66
+ .command('init')
67
+ .description('Output shell integration code')
68
+ .action(initShell)
69
+
70
+ program.parse()
@@ -0,0 +1,77 @@
1
+ import { existsSync, copyFileSync, mkdirSync } from 'fs'
2
+ import chalk from 'chalk'
3
+ import inquirer from 'inquirer'
4
+ import {
5
+ claudeAuthPath,
6
+ claudeSettingsPath,
7
+ profileDir,
8
+ profileAuthPath,
9
+ profileSettingsPath,
10
+ profileExists,
11
+ ensureProfilesDir,
12
+ } from '../lib/paths.js'
13
+ import { validateAccountName } from '../lib/validate.js'
14
+
15
+ export async function createAccount(name, options = {}) {
16
+ // Interactive prompt if no name given
17
+ if (!name) {
18
+ const answer = await inquirer.prompt([{
19
+ type: 'input',
20
+ name: 'accountName',
21
+ message: 'Account name:',
22
+ validate: (v) => {
23
+ const result = validateAccountName(v.trim())
24
+ return result.valid || result.error
25
+ },
26
+ }])
27
+ name = answer.accountName.trim()
28
+ }
29
+
30
+ const validation = validateAccountName(name)
31
+ if (!validation.valid) {
32
+ console.error(chalk.red(`✖ ${validation.error}`))
33
+ process.exit(1)
34
+ return
35
+ }
36
+
37
+ const authSource = claudeAuthPath()
38
+ if (!existsSync(authSource)) {
39
+ console.error(chalk.red('✖ No active Claude Code session found.'))
40
+ console.log(chalk.dim(' Open Claude Code and log in first.'))
41
+ process.exit(1)
42
+ return
43
+ }
44
+
45
+ if (profileExists(name)) {
46
+ if (options.confirm === false) {
47
+ console.log(chalk.dim('Cancelled.'))
48
+ return
49
+ }
50
+ if (options.confirm === undefined) {
51
+ const { overwrite } = await inquirer.prompt([{
52
+ type: 'confirm',
53
+ name: 'overwrite',
54
+ message: `Cloak "${name}" already exists. Overwrite?`,
55
+ default: false,
56
+ }])
57
+ if (!overwrite) {
58
+ console.log(chalk.dim('Cancelled.'))
59
+ return
60
+ }
61
+ }
62
+ // options.confirm === true → proceed
63
+ }
64
+
65
+ ensureProfilesDir()
66
+ const dir = profileDir(name)
67
+ mkdirSync(dir, { recursive: true })
68
+
69
+ copyFileSync(authSource, profileAuthPath(name))
70
+
71
+ const settingsSource = claudeSettingsPath()
72
+ if (existsSync(settingsSource)) {
73
+ copyFileSync(settingsSource, profileSettingsPath(name))
74
+ }
75
+
76
+ console.log(chalk.green(`✔ Cloak "${name}" created.`))
77
+ }
@@ -0,0 +1,48 @@
1
+ import { rmSync } from 'fs'
2
+ import chalk from 'chalk'
3
+ import inquirer from 'inquirer'
4
+ import { profileDir, profileExists, getActiveProfile } from '../lib/paths.js'
5
+ import { validateAccountName } from '../lib/validate.js'
6
+
7
+ export async function deleteAccount(name, options = {}) {
8
+ const validation = validateAccountName(name)
9
+ if (!validation.valid) {
10
+ console.error(chalk.red(`✖ ${validation.error}`))
11
+ process.exit(1)
12
+ return
13
+ }
14
+
15
+ if (!profileExists(name)) {
16
+ console.error(chalk.red(`✖ Account "${name}" not found.`))
17
+ process.exit(1)
18
+ return
19
+ }
20
+
21
+ if (getActiveProfile() === name) {
22
+ console.error(chalk.red(`✖ Can't discard a cloak you're wearing.`))
23
+ console.log(chalk.dim(' Switch to another account first.'))
24
+ process.exit(1)
25
+ return
26
+ }
27
+
28
+ if (options.confirm === false) {
29
+ console.log(chalk.dim('Cancelled.'))
30
+ return
31
+ }
32
+
33
+ if (options.confirm === undefined) {
34
+ const { confirm } = await inquirer.prompt([{
35
+ type: 'confirm',
36
+ name: 'confirm',
37
+ message: `Delete cloak "${name}"?`,
38
+ default: false,
39
+ }])
40
+ if (!confirm) {
41
+ console.log(chalk.dim('Cancelled.'))
42
+ return
43
+ }
44
+ }
45
+
46
+ rmSync(profileDir(name), { recursive: true, force: true })
47
+ console.log(chalk.green(`✔ Cloak "${name}" discarded.`))
48
+ }
@@ -0,0 +1,30 @@
1
+ export function getInitScript() {
2
+ /* eslint-disable no-template-curly-in-string */
3
+ const lines = [
4
+ 'claude() {',
5
+ ' if [ "$1" = "account" ]; then',
6
+ ' local subcmd="$2"',
7
+ ' shift 2',
8
+ ' if [ "$subcmd" = "switch" ] || [ "$subcmd" = "use" ]; then',
9
+ ' local output',
10
+ ' output=$(command cloak switch --print-env "$@")',
11
+ ' local exit_code=$?',
12
+ ' if [ $exit_code -eq 0 ]; then',
13
+ ' eval "$output"',
14
+ ' fi',
15
+ ' else',
16
+ ' command cloak "$subcmd" "$@"',
17
+ ' fi',
18
+ ' elif [ "$1" = "-a" ] && [ -n "$2" ]; then',
19
+ ' command cloak launch "${@:2}"',
20
+ ' else',
21
+ ' command claude "$@"',
22
+ ' fi',
23
+ '}',
24
+ ]
25
+ return lines.join('\n') + '\n'
26
+ }
27
+
28
+ export async function initShell() {
29
+ process.stdout.write(getInitScript())
30
+ }
@@ -0,0 +1,29 @@
1
+ import { spawn as defaultSpawn } from 'child_process'
2
+ import chalk from 'chalk'
3
+ import { profileDir, profileExists } from '../lib/paths.js'
4
+ import { validateAccountName } from '../lib/validate.js'
5
+
6
+ export function launchAccount(name, extraArgs = [], spawner = defaultSpawn) {
7
+ const validation = validateAccountName(name)
8
+ if (!validation.valid) {
9
+ return Promise.reject(new Error(validation.error))
10
+ }
11
+
12
+ if (!profileExists(name)) {
13
+ return Promise.reject(new Error(`Account "${name}" not found. Run: claude account create ${name}`))
14
+ }
15
+
16
+ process.env.CLAUDE_CONFIG_DIR = profileDir(name)
17
+ console.log(chalk.green(`✔ Now wearing cloak "${name}".`))
18
+
19
+ return new Promise((resolve, reject) => {
20
+ const child = spawner('claude', extraArgs, {
21
+ stdio: 'inherit',
22
+ env: process.env,
23
+ })
24
+
25
+ child.on('close', (code) => {
26
+ resolve(code)
27
+ })
28
+ })
29
+ }
@@ -0,0 +1,29 @@
1
+ import chalk from 'chalk'
2
+ import { listProfileNames, getActiveProfile } from '../lib/paths.js'
3
+
4
+ export function listAccounts() {
5
+ const names = listProfileNames().sort()
6
+ const active = getActiveProfile()
7
+
8
+ const accounts = names.map(name => ({
9
+ name,
10
+ active: name === active,
11
+ }))
12
+
13
+ if (accounts.length === 0) {
14
+ console.log(chalk.dim('No cloaks in your wardrobe yet.'))
15
+ console.log(chalk.dim('Run: claude account create <name>'))
16
+ return accounts
17
+ }
18
+
19
+ console.log(chalk.bold('\nClaude Code Accounts\n'))
20
+ accounts.forEach(({ name, active: isActive }) => {
21
+ const marker = isActive ? chalk.green('● ') : chalk.dim('○ ')
22
+ const label = isActive ? chalk.green.bold(name) : chalk.white(name)
23
+ const tag = isActive ? chalk.green(' (active)') : ''
24
+ console.log(` ${marker}${label}${tag}`)
25
+ })
26
+ console.log()
27
+
28
+ return accounts
29
+ }
@@ -0,0 +1,40 @@
1
+ import { renameSync } from 'fs'
2
+ import chalk from 'chalk'
3
+ import { profileDir, profileExists, getActiveProfile } from '../lib/paths.js'
4
+ import { validateAccountName } from '../lib/validate.js'
5
+
6
+ export async function renameAccount(oldName, newName) {
7
+ const oldValidation = validateAccountName(oldName)
8
+ if (!oldValidation.valid) {
9
+ console.error(chalk.red(`✖ ${oldValidation.error}`))
10
+ process.exit(1)
11
+ return
12
+ }
13
+
14
+ const newValidation = validateAccountName(newName)
15
+ if (!newValidation.valid) {
16
+ console.error(chalk.red(`✖ ${newValidation.error}`))
17
+ process.exit(1)
18
+ return
19
+ }
20
+
21
+ if (!profileExists(oldName)) {
22
+ console.error(chalk.red(`✖ Account "${oldName}" not found.`))
23
+ process.exit(1)
24
+ return
25
+ }
26
+
27
+ if (profileExists(newName)) {
28
+ console.error(chalk.red(`✖ Account "${newName}" is already in use.`))
29
+ process.exit(1)
30
+ return
31
+ }
32
+
33
+ renameSync(profileDir(oldName), profileDir(newName))
34
+
35
+ if (getActiveProfile() === oldName) {
36
+ console.log(chalk.yellow(`⚠ Run \`claude account switch ${newName}\` to update your session.`))
37
+ }
38
+
39
+ console.log(chalk.green(`✔ Cloak "${oldName}" renamed to "${newName}".`))
40
+ }
@@ -0,0 +1,37 @@
1
+ import chalk from 'chalk'
2
+ import { profileDir, profileExists, getActiveProfile } from '../lib/paths.js'
3
+ import { validateAccountName } from '../lib/validate.js'
4
+
5
+ export async function switchAccount(name, options = {}) {
6
+ const validation = validateAccountName(name)
7
+ if (!validation.valid) {
8
+ console.error(chalk.red(`✖ ${validation.error}`))
9
+ process.exit(1)
10
+ return
11
+ }
12
+
13
+ if (!profileExists(name)) {
14
+ console.error(chalk.red(`✖ Account "${name}" not found.`))
15
+ console.log(chalk.dim(` Run: claude account create ${name}`))
16
+ process.exit(1)
17
+ return
18
+ }
19
+
20
+ const active = getActiveProfile()
21
+ if (active === name) {
22
+ console.log(chalk.yellow(`⚡ Already wearing cloak "${name}".`))
23
+ return
24
+ }
25
+
26
+ const dir = profileDir(name)
27
+
28
+ if (options.printEnv) {
29
+ process.stdout.write(`export CLAUDE_CONFIG_DIR=${dir}\n`)
30
+ process.stdout.write(`echo "${chalk.green(`✔ Now wearing cloak "${name}".`)}"\n`)
31
+ return
32
+ }
33
+
34
+ // Manual instructions (no shell integration)
35
+ console.log(chalk.dim('Run this command to switch:'))
36
+ console.log(`\n export CLAUDE_CONFIG_DIR=${dir}\n`)
37
+ }
@@ -0,0 +1,12 @@
1
+ import chalk from 'chalk'
2
+ import { getActiveProfile } from '../lib/paths.js'
3
+
4
+ export function whoami() {
5
+ const active = getActiveProfile()
6
+ if (!active) {
7
+ console.log(chalk.dim('No cloak. Using default Claude Code config.'))
8
+ return null
9
+ }
10
+ console.log(active)
11
+ return active
12
+ }
@@ -0,0 +1,69 @@
1
+ import { homedir } from 'os'
2
+ import { join, resolve, sep } from 'path'
3
+ import { existsSync, mkdirSync, readdirSync } from 'fs'
4
+
5
+ function getHome() {
6
+ return process.env.HOME || homedir()
7
+ }
8
+
9
+ export const HOME = getHome()
10
+ export const CLOAK_DIR = join(HOME, '.cloak')
11
+ export const PROFILES_DIR = join(CLOAK_DIR, 'profiles')
12
+
13
+ export function claudeAuthPath() {
14
+ const configDir = process.env.CLAUDE_CONFIG_DIR
15
+ if (configDir) {
16
+ return join(configDir, '.claude.json')
17
+ }
18
+ return join(HOME, '.claude.json')
19
+ }
20
+
21
+ export function claudeSettingsPath() {
22
+ const configDir = process.env.CLAUDE_CONFIG_DIR
23
+ if (configDir) {
24
+ return join(configDir, 'settings.json')
25
+ }
26
+ return join(HOME, '.claude', 'settings.json')
27
+ }
28
+
29
+ export function profileDir(name) {
30
+ return join(PROFILES_DIR, name)
31
+ }
32
+
33
+ export function profileAuthPath(name) {
34
+ return join(PROFILES_DIR, name, '.claude.json')
35
+ }
36
+
37
+ export function profileSettingsPath(name) {
38
+ return join(PROFILES_DIR, name, 'settings.json')
39
+ }
40
+
41
+ export function ensureProfilesDir() {
42
+ if (!existsSync(PROFILES_DIR)) {
43
+ mkdirSync(PROFILES_DIR, { recursive: true })
44
+ }
45
+ }
46
+
47
+ export function profileExists(name) {
48
+ return existsSync(profileDir(name))
49
+ }
50
+
51
+ export function listProfileNames() {
52
+ ensureProfilesDir()
53
+ return readdirSync(PROFILES_DIR, { withFileTypes: true })
54
+ .filter(d => d.isDirectory())
55
+ .map(d => d.name)
56
+ }
57
+
58
+ export function getActiveProfile() {
59
+ const configDir = process.env.CLAUDE_CONFIG_DIR
60
+ if (!configDir) return null
61
+
62
+ const resolved = resolve(configDir)
63
+ const profilesResolved = resolve(PROFILES_DIR)
64
+
65
+ if (!resolved.startsWith(profilesResolved + sep)) return null
66
+
67
+ const name = resolved.slice(profilesResolved.length + 1).split(sep)[0]
68
+ return name || null
69
+ }
@@ -0,0 +1,21 @@
1
+ const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/
2
+
3
+ export function validateAccountName(name) {
4
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
5
+ return { valid: false, error: 'Account name is required.' }
6
+ }
7
+
8
+ if (name.length > 64) {
9
+ return { valid: false, error: 'Account name must be at most 64 characters.' }
10
+ }
11
+
12
+ if (!/^[a-zA-Z0-9]/.test(name)) {
13
+ return { valid: false, error: 'Account name must start with a letter or number.' }
14
+ }
15
+
16
+ if (!NAME_PATTERN.test(name)) {
17
+ return { valid: false, error: 'Account name can only contain letters, numbers, hyphens and underscores.' }
18
+ }
19
+
20
+ return { valid: true }
21
+ }