claude-account-sync 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ccprofiles contributors
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,147 @@
1
+ # ccprofiles
2
+
3
+ **Profile manager for [Claude Code](https://claude.com/claude-code) multi-account setups.**
4
+
5
+ Run Claude Code with several accounts — personal subscription, work OAuth, API key, alternative providers — each in its own `CLAUDE_CONFIG_DIR`? Then you know the pain: MCP server lists drift apart, skills get shared via hand-made symlinks, API keys end up in plaintext in your `.zshrc`, and setting up a second machine means an afternoon of copy-paste.
6
+
7
+ The `ccprofiles` command (short alias `clp`) fixes that:
8
+
9
+ - 🔎 **Adopt** your existing `.claude*` directories into a declarative manifest — zero manual config
10
+ - 🧩 **Manage MCP servers** across profiles: drift matrix, add/remove everywhere at once, sync one profile's set to others
11
+ - 🔐 **Secrets out of your rc files** — macOS Keychain / libsecret / encrypted file, with `ccprofiles secrets migrate` to clean up existing plaintext keys
12
+ - 🖥️ **Replicate to another machine over LAN** — PIN pairing, end-to-end encrypted, no cloud, works macOS ↔ Windows ↔ Linux ↔ WSL
13
+ - 📦 **Offline bundles** for the no-network case (`ccprofiles export setup.ccb`)
14
+ - 🛟 **Safe by design**: surgical config edits only, automatic backups, `--dry-run` everywhere, never touches your sessions/history
15
+
16
+ ## Install
17
+
18
+ Requires **Node ≥ 20** (you have it — Claude Code needs it too).
19
+
20
+ ```bash
21
+ npm install -g claude-account-sync
22
+ ```
23
+
24
+ This installs two equivalent commands: `ccprofiles` (full) and `clp` (short). Use whichever you like.
25
+
26
+ <details><summary>Install from source instead</summary>
27
+
28
+ ```bash
29
+ git clone https://github.com/tmfadhlul/claude-code-profile.git && cd claude-code-profile
30
+ npm install && npm run build
31
+ cd packages/cli && npm link
32
+ ```
33
+
34
+ > Using nvm? `npm link` installs into the *active* node version — re-link after `nvm use <other>`.
35
+ </details>
36
+
37
+ ## Quickstart
38
+
39
+ ```bash
40
+ ccprofiles adopt --yes # REQUIRED FIRST: scan ~/.claude* and build the manifest
41
+ ccprofiles list # see all your profiles
42
+ ccprofiles doctor # find broken links & plaintext keys
43
+ ccprofiles secrets migrate # move API keys from .zshrc into the OS keychain
44
+ ```
45
+
46
+ Everything except `list`, `adopt`, and `doctor` needs the manifest, so `ccprofiles adopt --yes` is always step one — commands will remind you if you skip it.
47
+
48
+ ### Manage MCP servers
49
+
50
+ ```bash
51
+ ccprofiles mcp list # server × profile drift matrix
52
+ ccprofiles mcp add shadcn --all --command npx --args "shadcn@latest,mcp"
53
+ ccprofiles mcp sync --from oauth --to office,z # make profiles match
54
+ ```
55
+
56
+ ### New profile for a new account
57
+
58
+ ```bash
59
+ ccprofiles create work --from oauth # dir + launcher fn + copied MCP set
60
+ # restart shell, then:
61
+ cl-work # launches claude with CLAUDE_CONFIG_DIR=~/.claude-work
62
+ ```
63
+
64
+ ### Replicate to a second machine
65
+
66
+ ```bash
67
+ # machine A (source of truth)
68
+ ccprofiles serve --allow-secrets
69
+ # → ccprofiles sync server on port 51234
70
+ # → pairing PIN: 123456
71
+
72
+ # machine B
73
+ ccprofiles pair 192.168.1.10 --port 51234 --pin 123456 --name mac
74
+ ccprofiles sync --from mac --with-secrets
75
+ ```
76
+
77
+ Manifest, MCP servers, skills, commands, launcher functions, and (opt-in) secrets all arrive — rendered for the local OS: PowerShell profile functions and junctions on Windows, `.zshrc`/`.bashrc` functions and symlinks elsewhere.
78
+
79
+ Two things intentionally don't travel:
80
+
81
+ - **OAuth sessions** — you still run `/login` once per account on the new machine (Anthropic session state is machine-bound; syncing it would be wrong).
82
+ - The **`default` profile has no `cl-*` launcher** — it's what plain `claude` already launches; only the named profiles get launcher functions.
83
+
84
+ ## How it works
85
+
86
+ Three layers of state:
87
+
88
+ 1. **Live state** — your actual `.claude*` dirs and shell rc files. Claude Code owns these; ccprofiles edits only the keys it manages (`mcpServers`, its marked rc block, its links).
89
+ 2. **Manifest** — `~/.ccprofiles/manifest.yaml`, a platform-neutral declaration (paths templated as `{home}`, secrets referenced as `secret://name`). Versioned with local git commits; safe to share.
90
+ 3. **Secrets store** — per-machine keychain: macOS Keychain, Linux `secret-tool` (libsecret), or an AES-256-GCM encrypted file as fallback (native Windows and headless Linux — set `CCPROFILES_PASSPHRASE` in your environment for it). Values never appear in the manifest, bundles, or rc files; launchers resolve them at run time via `ccprofiles secrets get`.
91
+
92
+ > ⚠️ `ccprofiles secrets set <name> <value>` takes the value as an argument, which lands in your shell history — prefer `ccprofiles secrets migrate` (reads from rc files) or clear history after. Interactive prompting is on the roadmap.
93
+
94
+ `ccprofiles status` shows drift between manifest and live; `ccprofiles apply` reconciles (with backups under `~/.ccprofiles/backups/`); `ccprofiles snapshot` goes the other way (live → manifest).
95
+
96
+ ### Sync security model
97
+
98
+ Pairing performs an X25519 ECDH key exchange authenticated by the 6-digit PIN shown on the serving device (HMAC confirmation both ways — a MITM on your network cannot complete pairing without the PIN, and the client verifies the server too). All subsequent payloads are AES-256-GCM encrypted with the pairing key. Secrets transfer additionally requires the server to opt in with `--allow-secrets`, and values go straight into the receiving machine's keychain.
99
+
100
+ ## Commands
101
+
102
+ | Area | Commands |
103
+ |---|---|
104
+ | Profiles | `list` · `create <name> [--from p]` · `adopt [--yes]` · `doctor` |
105
+ | MCP | `mcp list` · `mcp add/rm <name> [--profile p\|--all]` · `mcp sync --from p --to p1,p2\|--all` |
106
+ | Secrets | `secrets set/get/list/rm` · `secrets migrate` |
107
+ | Manifest | `status` · `apply` · `snapshot` |
108
+ | Sync | `serve [--allow-secrets]` · `pair <host> --port n --pin p` · `devices` · `sync --from dev [--with-secrets]` |
109
+ | Bundle | `export <file>` · `import <file>` |
110
+
111
+ All mutating commands support `--dry-run`. Every mutation backs up the files it touches to `~/.ccprofiles/backups/<timestamp>/` first.
112
+
113
+ ## Troubleshooting
114
+
115
+ | Symptom | Fix |
116
+ |---|---|
117
+ | `error: no manifest yet` | Run `ccprofiles adopt --yes` first — it builds the manifest from your existing profiles |
118
+ | `zsh: command not found: ccp` | Not linked/installed — see Install; if just linked, run `rehash` |
119
+ | ``cannot reach <host> — is `ccprofiles serve` running?`` | Start `ccprofiles serve` on the other device; check you're on the same network and the port matches |
120
+ | `encrypted-file backend requires a passphrase` | Set `CCPROFILES_PASSPHRASE` (Windows / Linux without libsecret) |
121
+ | Profile shows account `-` after sync | Expected — run `/login` inside that profile once; OAuth sessions don't sync |
122
+ | Something went wrong after `apply` | Restore from `~/.ccprofiles/backups/<latest>/` |
123
+
124
+ ## Roadmap
125
+
126
+ - Web dashboard on top of `ccprofiles-core`
127
+ - mDNS auto-discovery for `ccprofiles devices`
128
+ - Windows Credential Manager (DPAPI) secrets backend
129
+ - Interactive prompts (`secrets set` without echoing, `adopt` confirmation)
130
+
131
+ ## Development
132
+
133
+ npm-workspaces monorepo: `packages/core` (library) + `packages/cli` (commander wrapper).
134
+
135
+ ```bash
136
+ npm install
137
+ npm test # vitest — unit + e2e (incl. an in-process two-machine sync test)
138
+ npm run build
139
+ ```
140
+
141
+ ## Related projects
142
+
143
+ Not to be confused with [samhvw8/claude-code-profile](https://github.com/samhvw8/claude-code-profile) (the `ccp` command) — a great tool for a **different** job: a central hub of reusable skills/agents/hooks/commands, with profiles for different *workflows*. It explicitly does **not** handle MCP servers, secrets, LAN sync, or multiple *accounts* — which are exactly this tool's focus. The two are complementary; this one deliberately uses the `ccprofiles`/`clp` commands to avoid clashing with its `ccp`.
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import { type CliContext } from '../context.js';
3
+ export declare function registerBundleCommands(program: Command, ctx: CliContext): void;
@@ -0,0 +1,39 @@
1
+ import { exportBundle, importBundle, collectAssets, writeAssets, parseManifest, serializeManifest, saveManifest, discoverProfiles, planApply, executeApply, backupFiles, } from 'ccprofiles-core';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { requireManifest } from '../context.js';
5
+ function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
6
+ export function registerBundleCommands(program, ctx) {
7
+ program.command('export <file>').description('export manifest + assets as a portable bundle (no secrets)')
8
+ .action(async (file) => {
9
+ const m = await requireManifest(ctx);
10
+ const assets = await collectAssets(m, ctx.platform);
11
+ await writeFile(file, exportBundle(serializeManifest(m), assets));
12
+ console.log(`exported ${m.profiles.length} profiles + ${Object.keys(assets).length} asset files to ${file}`);
13
+ });
14
+ program.command('import <file>').description('import a bundle and apply it')
15
+ .option('--dry-run')
16
+ .option('--yes', 'apply without the safety confirmation')
17
+ .action(async (file, opts) => {
18
+ const { manifestYaml, assets } = importBundle(await readFile(file));
19
+ const m = parseManifest(manifestYaml); // also runs assertSafeManifest — rejects injectable identifiers
20
+ console.log(`bundle: ${m.profiles.length} profiles, ${Object.keys(m.mcpServers).length} mcp servers, ${Object.keys(assets).length} asset files`);
21
+ if (!opts.dryRun && !opts.yes) {
22
+ console.log('');
23
+ console.log('⚠️ Applying a bundle writes shell launcher functions to your rc file and MCP server');
24
+ console.log(' commands to your Claude config — both execute code on your machine. Only import');
25
+ console.log(' bundles you created or trust. Re-run with --yes to proceed (or --dry-run to preview).');
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+ if (!opts.dryRun) {
30
+ await backupFiles([join(ctx.manifestRoot, 'manifest.yaml')], ctx.backupRoot, stamp());
31
+ await saveManifest(ctx.manifestRoot, m);
32
+ await writeAssets(assets, m, ctx.platform);
33
+ }
34
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
35
+ const res = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!opts.dryRun });
36
+ for (const line of res.performed)
37
+ console.log(`${opts.dryRun ? '[dry-run] ' : ''}${line}`);
38
+ });
39
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import { type CliContext } from '../context.js';
3
+ export declare function registerManifestCommands(program: Command, ctx: CliContext): void;
@@ -0,0 +1,59 @@
1
+ import { discoverProfiles, buildManifest, saveManifest, planApply, executeApply, } from 'ccprofiles-core';
2
+ import { requireManifest } from '../context.js';
3
+ function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
4
+ export function registerManifestCommands(program, ctx) {
5
+ program.command('status').description('show live-vs-manifest drift').action(async () => {
6
+ const m = await requireManifest(ctx);
7
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
8
+ if (actions.length === 0) {
9
+ console.log('in sync');
10
+ return;
11
+ }
12
+ const res = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: true });
13
+ for (const line of res.performed)
14
+ console.log(`pending: ${line}`);
15
+ });
16
+ program.command('apply').description('apply manifest to live configs')
17
+ .option('--dry-run')
18
+ .action(async (opts) => {
19
+ const m = await requireManifest(ctx);
20
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
21
+ if (actions.length === 0) {
22
+ console.log('in sync — nothing to do');
23
+ return;
24
+ }
25
+ const res = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!opts.dryRun });
26
+ for (const line of res.performed)
27
+ console.log(`${opts.dryRun ? '[dry-run] ' : ''}${line}`);
28
+ if (res.backupDir)
29
+ console.log(`backups: ${res.backupDir}`);
30
+ });
31
+ program.command('snapshot').description('overwrite manifest from live state').action(async () => {
32
+ const m = buildManifest(await discoverProfiles(ctx.home), ctx.platform);
33
+ await saveManifest(ctx.manifestRoot, m);
34
+ console.log(`snapshot: ${m.profiles.length} profiles, ${Object.keys(m.mcpServers).length} mcp servers`);
35
+ });
36
+ program.command('create <name>').description('create a new profile')
37
+ .option('--from <profile>', 'copy mcp list and links from an existing profile')
38
+ .action(async (name, opts) => {
39
+ const m = await requireManifest(ctx);
40
+ if (m.profiles.some(p => p.name === name))
41
+ throw new Error(`profile exists: ${name}`);
42
+ const src = opts.from ? m.profiles.find(p => p.name === opts.from) : null;
43
+ if (opts.from && !src)
44
+ throw new Error(`unknown profile: ${opts.from}`);
45
+ m.profiles.push({
46
+ name,
47
+ dir: `{home}/.claude-${name}`,
48
+ launcher: `cl-${name}`,
49
+ auth: 'env',
50
+ env: {},
51
+ links: src ? { ...src.links } : (m.hub ? { skills: 'hub', commands: 'hub' } : {}),
52
+ mcp: src ? [...src.mcp] : [],
53
+ });
54
+ await saveManifest(ctx.manifestRoot, m);
55
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
56
+ await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp() });
57
+ console.log(`profile "${name}" created — launcher: cl-${name} (restart your shell)`);
58
+ });
59
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import { type CliContext } from '../context.js';
3
+ export declare function registerMcpCommands(program: Command, ctx: CliContext): void;
@@ -0,0 +1,86 @@
1
+ import { discoverProfiles, saveManifest, planApply, executeApply, } from 'ccprofiles-core';
2
+ import { requireManifest } from '../context.js';
3
+ function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
4
+ async function applyNow(ctx, m, dryRun) {
5
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
6
+ const res = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun });
7
+ for (const line of res.performed)
8
+ console.log(`${dryRun ? '[dry-run] ' : ''}${line}`);
9
+ }
10
+ function targets(m, opts) {
11
+ if (opts.all)
12
+ return m.profiles.map(p => p.name);
13
+ if (!opts.profile)
14
+ throw new Error('specify --profile <name> or --all');
15
+ if (!m.profiles.some(p => p.name === opts.profile))
16
+ throw new Error(`unknown profile: ${opts.profile}`);
17
+ return [opts.profile];
18
+ }
19
+ export function registerMcpCommands(program, ctx) {
20
+ const mcp = program.command('mcp').description('manage MCP servers across profiles');
21
+ mcp.command('list').action(async () => {
22
+ const m = await requireManifest(ctx);
23
+ const names = Object.keys(m.mcpServers).sort();
24
+ const header = ' '.repeat(24) + m.profiles.map(p => p.name.padEnd(10)).join('');
25
+ console.log(header);
26
+ for (const n of names) {
27
+ const cells = m.profiles.map(p => (p.mcp.includes(n) ? 'x' : '.').padEnd(10)).join('');
28
+ console.log(n.padEnd(24) + cells);
29
+ }
30
+ });
31
+ mcp.command('add <name>')
32
+ .option('--profile <p>').option('--all').option('--dry-run')
33
+ .option('--command <cmd>').option('--args <csv>')
34
+ .action(async (name, opts) => {
35
+ const m = await requireManifest(ctx);
36
+ if (!m.mcpServers[name]) {
37
+ if (!opts.command)
38
+ throw new Error(`unknown server "${name}" — pass --command (and optionally --args) to define it`);
39
+ m.mcpServers[name] = { command: opts.command, ...(opts.args ? { args: String(opts.args).split(',') } : {}) };
40
+ }
41
+ for (const t of targets(m, opts)) {
42
+ const pr = m.profiles.find(p => p.name === t);
43
+ if (!pr.mcp.includes(name))
44
+ pr.mcp.push(name);
45
+ }
46
+ if (!opts.dryRun)
47
+ await saveManifest(ctx.manifestRoot, m);
48
+ await applyNow(ctx, m, !!opts.dryRun);
49
+ });
50
+ mcp.command('rm <name>')
51
+ .option('--profile <p>').option('--all').option('--dry-run')
52
+ .action(async (name, opts) => {
53
+ const m = await requireManifest(ctx);
54
+ for (const t of targets(m, opts)) {
55
+ const pr = m.profiles.find(p => p.name === t);
56
+ pr.mcp = pr.mcp.filter(x => x !== name);
57
+ }
58
+ if (!m.profiles.some(p => p.mcp.includes(name)))
59
+ delete m.mcpServers[name];
60
+ if (!opts.dryRun)
61
+ await saveManifest(ctx.manifestRoot, m);
62
+ await applyNow(ctx, m, !!opts.dryRun);
63
+ });
64
+ mcp.command('sync')
65
+ .requiredOption('--from <p>').option('--to <csv>').option('--all').option('--dry-run')
66
+ .action(async (opts) => {
67
+ const m = await requireManifest(ctx);
68
+ const src = m.profiles.find(p => p.name === opts.from);
69
+ if (!src)
70
+ throw new Error(`unknown profile: ${opts.from}`);
71
+ const to = opts.all
72
+ ? m.profiles.filter(p => p.name !== src.name).map(p => p.name)
73
+ : String(opts.to ?? '').split(',').filter(Boolean);
74
+ if (to.length === 0)
75
+ throw new Error('specify --to <p1,p2> or --all');
76
+ for (const t of to) {
77
+ const pr = m.profiles.find(p => p.name === t);
78
+ if (!pr)
79
+ throw new Error(`unknown profile: ${t}`);
80
+ pr.mcp = [...src.mcp];
81
+ }
82
+ if (!opts.dryRun)
83
+ await saveManifest(ctx.manifestRoot, m);
84
+ await applyNow(ctx, m, !!opts.dryRun);
85
+ });
86
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import type { CliContext } from '../context.js';
3
+ export declare function registerProfileCommands(program: Command, ctx: CliContext): void;
@@ -0,0 +1,71 @@
1
+ import { discoverProfiles, buildManifest, saveManifest, loadManifest, ensureRootGitignore } from 'ccprofiles-core';
2
+ import { existsSync, readFileSync, lstatSync, readlinkSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ export function registerProfileCommands(program, ctx) {
6
+ program.command('list').description('list Claude Code profiles').action(async () => {
7
+ const live = await discoverProfiles(ctx.home);
8
+ const rows = live.map(lp => ({
9
+ name: lp.dirName === '.claude' ? 'default' : lp.dirName.slice('.claude-'.length),
10
+ dir: lp.dir,
11
+ account: lp.account ?? '-',
12
+ mcp: Object.keys(lp.mcpServers).length,
13
+ }));
14
+ for (const r of rows)
15
+ console.log(`${r.name.padEnd(12)} ${String(r.mcp).padStart(3)} mcp ${r.account.padEnd(28)} ${r.dir}`);
16
+ });
17
+ program.command('adopt').description('build manifest from live profiles')
18
+ .option('--yes', 'write without confirmation')
19
+ .action(async (opts) => {
20
+ const live = await discoverProfiles(ctx.home);
21
+ const manifest = buildManifest(live, ctx.platform);
22
+ console.log(`Discovered ${manifest.profiles.length} profiles, ${Object.keys(manifest.mcpServers).length} mcp servers.`);
23
+ if (!opts.yes) {
24
+ console.log('Re-run with --yes to write the manifest.');
25
+ return;
26
+ }
27
+ if (!existsSync(join(ctx.manifestRoot, '.git'))) {
28
+ try {
29
+ execFileSync('git', ['init', ctx.manifestRoot], { stdio: 'ignore' });
30
+ }
31
+ catch { /* git optional */ }
32
+ }
33
+ await ensureRootGitignore(ctx.manifestRoot);
34
+ await saveManifest(ctx.manifestRoot, manifest);
35
+ console.log(`Manifest written to ${join(ctx.manifestRoot, 'manifest.yaml')}`);
36
+ });
37
+ program.command('doctor').description('check setup health').action(async () => {
38
+ const problems = [];
39
+ const live = await discoverProfiles(ctx.home);
40
+ for (const lp of live)
41
+ for (const name of readdirSync(lp.dir)) {
42
+ const f = join(lp.dir, name);
43
+ try {
44
+ if (lstatSync(f).isSymbolicLink() && !existsSync(f))
45
+ problems.push(`broken symlink: ${f} -> ${readlinkSync(f)}`);
46
+ }
47
+ catch { /* ignore */ }
48
+ }
49
+ if (existsSync(ctx.platform.rcFile)) {
50
+ const rc = readFileSync(ctx.platform.rcFile, 'utf8');
51
+ const outsideBlock = rc.split('# >>> ccprofiles managed >>>')[0] + (rc.split('# <<< ccprofiles managed <<<')[1] ?? '');
52
+ if (/sk-ant-/.test(outsideBlock))
53
+ problems.push(`plaintext Anthropic key found in ${ctx.platform.rcFile} — run: ccprofiles secrets migrate`);
54
+ }
55
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
56
+ const m = await loadManifest(ctx.manifestRoot);
57
+ for (const pr of m.profiles) {
58
+ const dir = pr.dir.replace('{home}', ctx.home);
59
+ if (!existsSync(dir))
60
+ problems.push(`manifest profile "${pr.name}" missing on disk: ${dir} — run: ccprofiles apply`);
61
+ }
62
+ }
63
+ if (problems.length === 0) {
64
+ console.log('ok: no problems found');
65
+ return;
66
+ }
67
+ for (const p of problems)
68
+ console.log(`warn: ${p}`);
69
+ process.exitCode = 1;
70
+ });
71
+ }
@@ -0,0 +1,5 @@
1
+ import type { Command } from 'commander';
2
+ import { SecretsStore } from 'ccprofiles-core';
3
+ import type { CliContext } from '../context.js';
4
+ export declare function secretsStore(ctx: CliContext): Promise<SecretsStore>;
5
+ export declare function registerSecretsCommands(program: Command, ctx: CliContext): void;
@@ -0,0 +1,84 @@
1
+ import { SecretsStore, FileBackend, defaultBackend, backupFiles, atomicWrite } from 'ccprofiles-core';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ export async function secretsStore(ctx) {
5
+ const pw = ctx.env.CCPROFILES_PASSPHRASE;
6
+ const backend = pw
7
+ ? new FileBackend(ctx.secretsFilePath, pw)
8
+ : await defaultBackend(ctx.platform, {
9
+ filePath: ctx.secretsFilePath,
10
+ passphrase: async () => { throw new Error('set CCPROFILES_PASSPHRASE for the encrypted-file backend'); },
11
+ });
12
+ return new SecretsStore(backend, ctx.secretsIndexPath);
13
+ }
14
+ const KEY_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN'];
15
+ // posix: export VAR="sk-..." powershell: $env:VAR = "sk-..."
16
+ const MIGRATE_RE_POSIX = new RegExp(`^(\\s*(?:export\\s+)?(${KEY_VARS.join('|')})\\s*=\\s*)"?(sk-[A-Za-z0-9_-]+)"?(.*)$`);
17
+ const MIGRATE_RE_PWSH = new RegExp(`^(\\s*\\$env:(${KEY_VARS.join('|')})\\s*=\\s*)"?(sk-[A-Za-z0-9_-]+)"?(.*)$`);
18
+ export function registerSecretsCommands(program, ctx) {
19
+ const sec = program.command('secrets').description('manage secrets (values never stored in configs)');
20
+ sec.command('set <name> [value]').action(async (name, value) => {
21
+ if (value === undefined)
22
+ throw new Error('value required (interactive prompt lands in Plan 2)');
23
+ const store = await secretsStore(ctx);
24
+ await store.set(name, value);
25
+ console.log(`stored ${name} (${store.backendName})`);
26
+ });
27
+ sec.command('get <name>').action(async (name) => {
28
+ const store = await secretsStore(ctx);
29
+ const v = await store.get(name);
30
+ if (v === null) {
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ console.log(v);
35
+ });
36
+ sec.command('list').action(async () => {
37
+ const store = await secretsStore(ctx);
38
+ for (const n of await store.list())
39
+ console.log(`${n} (${store.backendName})`);
40
+ });
41
+ sec.command('rm <name>').action(async (name) => {
42
+ const store = await secretsStore(ctx);
43
+ await store.delete(name);
44
+ console.log(`removed ${name}`);
45
+ });
46
+ sec.command('migrate').option('--dry-run').action(async (opts) => {
47
+ const rcFile = ctx.platform.rcFile;
48
+ if (!existsSync(rcFile)) {
49
+ console.log('no rc file found');
50
+ return;
51
+ }
52
+ const store = await secretsStore(ctx);
53
+ const lines = (await readFile(rcFile, 'utf8')).split('\n');
54
+ const migrated = [];
55
+ const out = [];
56
+ for (const line of lines) {
57
+ const pwsh = line.match(MIGRATE_RE_PWSH);
58
+ const posix = pwsh ? null : line.match(MIGRATE_RE_POSIX);
59
+ const match = pwsh ?? posix;
60
+ if (!match) {
61
+ out.push(line);
62
+ continue;
63
+ }
64
+ const [, prefix, varName, secretValue, suffix] = match;
65
+ const secretName = varName.toLowerCase().replaceAll('_', '-');
66
+ if (!opts.dryRun)
67
+ await store.set(secretName, secretValue);
68
+ out.push(pwsh
69
+ ? `${prefix}(ccprofiles secrets get ${secretName})${suffix}`
70
+ : `${prefix}"$(ccprofiles secrets get ${secretName})"${suffix}`);
71
+ migrated.push(secretName);
72
+ }
73
+ if (migrated.length === 0) {
74
+ console.log('no plaintext keys found');
75
+ return;
76
+ }
77
+ if (!opts.dryRun) {
78
+ await backupFiles([rcFile], ctx.backupRoot, new Date().toISOString().replace(/[:.]/g, '-'));
79
+ await atomicWrite(rcFile, out.join('\n'));
80
+ }
81
+ for (const n of migrated)
82
+ console.log(`${opts.dryRun ? '[dry-run] ' : ''}migrated ${n}`);
83
+ });
84
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import type { CliContext } from '../context.js';
3
+ export declare function registerSyncCommands(program: Command, ctx: CliContext): void;
@@ -0,0 +1,84 @@
1
+ import { startSyncServer, pairWithServer, fetchRemote, fetchSecrets, loadDevices, saveDevices, parseManifest, saveManifest, writeAssets, discoverProfiles, planApply, executeApply, backupFiles, } from 'ccprofiles-core';
2
+ import { join } from 'node:path';
3
+ import { secretsStore } from './secrets.js';
4
+ function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
5
+ export function registerSyncCommands(program, ctx) {
6
+ program.command('serve').description('serve this device for pairing/sync')
7
+ .option('--port <n>', 'port (default: random)', v => parseInt(v, 10))
8
+ .option('--host <ip>', 'interface to bind (default: 0.0.0.0 — all interfaces; set your LAN IP to restrict)')
9
+ .option('--allow-secrets', 'allow paired devices to pull secret values')
10
+ .action(async (opts) => {
11
+ const server = await startSyncServer({
12
+ manifestRoot: ctx.manifestRoot,
13
+ platform: ctx.platform,
14
+ port: opts.port,
15
+ host: opts.host,
16
+ allowSecrets: opts.allowSecrets,
17
+ secretValues: async (names) => {
18
+ const store = await secretsStore(ctx);
19
+ const wanted = names.length ? names : await store.list();
20
+ const out = {};
21
+ for (const n of wanted) {
22
+ const v = await store.get(n);
23
+ if (v !== null)
24
+ out[n] = v;
25
+ }
26
+ return out;
27
+ },
28
+ });
29
+ console.log(`ccprofiles sync server on ${server.host}:${server.port}`);
30
+ if (server.host === '0.0.0.0')
31
+ console.log('(reachable from all network interfaces — pass --host <lan-ip> to restrict)');
32
+ console.log(`pairing PIN: ${server.pin}`);
33
+ console.log('(pairing locks after 5 wrong PINs — restart serve to reset)');
34
+ if (opts.allowSecrets)
35
+ console.log('secrets transfer: ENABLED for paired devices');
36
+ console.log('Ctrl-C to stop');
37
+ await new Promise(() => { });
38
+ });
39
+ program.command('pair <host>').description('pair with a serving device')
40
+ .requiredOption('--pin <pin>', 'PIN shown on the serving device')
41
+ .requiredOption('--port <n>', 'port shown on the serving device', (v) => parseInt(v, 10))
42
+ .option('--name <name>', 'name for this peer', undefined)
43
+ .action(async (host, opts) => {
44
+ const device = await pairWithServer(host, opts.port, opts.pin, opts.name ?? host);
45
+ const devices = (await loadDevices(ctx.manifestRoot)).filter(d => d.name !== device.name);
46
+ devices.push(device);
47
+ await saveDevices(ctx.manifestRoot, devices);
48
+ console.log(`paired with ${device.name} (${host}:${opts.port})`);
49
+ });
50
+ program.command('devices').description('list paired devices').action(async () => {
51
+ for (const d of await loadDevices(ctx.manifestRoot))
52
+ console.log(`${d.name.padEnd(16)} ${d.host}:${d.port}`);
53
+ });
54
+ program.command('sync').description('pull manifest + assets from a paired device and apply')
55
+ .requiredOption('--from <device>')
56
+ .option('--with-secrets', 'also transfer secret values into the local store')
57
+ .option('--dry-run')
58
+ .action(async (opts) => {
59
+ const device = (await loadDevices(ctx.manifestRoot)).find(d => d.name === opts.from);
60
+ if (!device)
61
+ throw new Error(`unknown device: ${opts.from} — run: ccprofiles pair <host> --port <p> --pin <pin>`);
62
+ const { manifestYaml, assets } = await fetchRemote(device);
63
+ const m = parseManifest(manifestYaml); // validate before touching anything
64
+ console.log(`pulled manifest: ${m.profiles.length} profiles, ${Object.keys(m.mcpServers).length} mcp servers, ${Object.keys(assets).length} asset files`);
65
+ if (!opts.dryRun) {
66
+ await backupFiles([join(ctx.manifestRoot, 'manifest.yaml')], ctx.backupRoot, stamp());
67
+ await saveManifest(ctx.manifestRoot, m);
68
+ await writeAssets(assets, m, ctx.platform);
69
+ }
70
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
71
+ const res = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!opts.dryRun });
72
+ for (const line of res.performed)
73
+ console.log(`${opts.dryRun ? '[dry-run] ' : ''}${line}`);
74
+ if (opts.withSecrets) {
75
+ const values = await fetchSecrets(device, []);
76
+ if (!opts.dryRun) {
77
+ const store = await secretsStore(ctx);
78
+ for (const [k, v] of Object.entries(values))
79
+ await store.set(k, v);
80
+ }
81
+ console.log(`${opts.dryRun ? '[dry-run] ' : ''}secrets transferred: ${Object.keys(values).join(', ') || 'none'}`);
82
+ }
83
+ });
84
+ }
@@ -0,0 +1,15 @@
1
+ import { Command } from 'commander';
2
+ import { type Manifest, type Platform } from 'ccprofiles-core';
3
+ export interface CliContext {
4
+ home: string;
5
+ platform: Platform;
6
+ manifestRoot: string;
7
+ secretsIndexPath: string;
8
+ secretsFilePath: string;
9
+ backupRoot: string;
10
+ env: NodeJS.ProcessEnv;
11
+ }
12
+ export declare function makeContext(env?: NodeJS.ProcessEnv): CliContext;
13
+ /** Load the manifest, or explain how to create one — never a raw ENOENT. */
14
+ export declare function requireManifest(ctx: CliContext): Promise<Manifest>;
15
+ export declare function buildProgram(ctx: CliContext): Command;
@@ -0,0 +1,42 @@
1
+ import { Command } from 'commander';
2
+ import { detectPlatform, loadManifest } from 'ccprofiles-core';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { registerProfileCommands } from './commands/profiles.js';
6
+ import { registerMcpCommands } from './commands/mcp.js';
7
+ import { registerSecretsCommands } from './commands/secrets.js';
8
+ import { registerManifestCommands } from './commands/manifest.js';
9
+ import { registerSyncCommands } from './commands/sync.js';
10
+ import { registerBundleCommands } from './commands/bundle.js';
11
+ export function makeContext(env = process.env) {
12
+ const testHome = env.CCPROFILES_TEST_HOME;
13
+ const platform = detectPlatform(testHome ? { home: testHome, shell: env.SHELL } : {});
14
+ const manifestRoot = env.CCPROFILES_HOME ?? join(platform.home, '.ccprofiles');
15
+ return {
16
+ home: platform.home,
17
+ platform,
18
+ manifestRoot,
19
+ secretsIndexPath: join(manifestRoot, 'secret-names.json'),
20
+ secretsFilePath: join(manifestRoot, 'secrets.enc'),
21
+ backupRoot: join(manifestRoot, 'backups'),
22
+ env,
23
+ };
24
+ }
25
+ /** Load the manifest, or explain how to create one — never a raw ENOENT. */
26
+ export async function requireManifest(ctx) {
27
+ if (!existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
28
+ throw new Error('no manifest yet — run: ccprofiles adopt --yes (builds one from your existing .claude* profiles)');
29
+ }
30
+ return loadManifest(ctx.manifestRoot);
31
+ }
32
+ export function buildProgram(ctx) {
33
+ const program = new Command('ccprofiles').description('Manage multiple Claude Code accounts/profiles (alias: clp)');
34
+ program.exitOverride(); // throw instead of process.exit — required for tests
35
+ registerProfileCommands(program, ctx);
36
+ registerMcpCommands(program, ctx);
37
+ registerSecretsCommands(program, ctx);
38
+ registerManifestCommands(program, ctx);
39
+ registerSyncCommands(program, ctx);
40
+ registerBundleCommands(program, ctx);
41
+ return program;
42
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { buildProgram, makeContext } from './context.js';
3
+ buildProgram(makeContext()).parseAsync(process.argv).catch((e) => {
4
+ if (e.code?.startsWith?.('commander.'))
5
+ process.exit(1);
6
+ console.error(`error: ${e.message}`);
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "claude-account-sync",
3
+ "version": "0.1.0",
4
+ "description": "Manage multiple Claude Code accounts/profiles — MCP drift sync, keychain secrets, and encrypted LAN replication between machines. CLI: ccprofiles (alias clp).",
5
+ "type": "module",
6
+ "bin": { "ccprofiles": "./dist/index.js", "clp": "./dist/index.js" },
7
+ "files": ["dist", "README.md"],
8
+ "license": "MIT",
9
+ "repository": { "type": "git", "url": "git+https://github.com/tmfadhlul/claude-code-profile.git", "directory": "packages/cli" },
10
+ "homepage": "https://github.com/tmfadhlul/claude-code-profile#readme",
11
+ "bugs": "https://github.com/tmfadhlul/claude-code-profile/issues",
12
+ "keywords": ["claude-code", "claude", "profiles", "accounts", "mcp", "cli", "sync", "multi-account", "keychain"],
13
+ "engines": { "node": ">=20" },
14
+ "dependencies": { "ccprofiles-core": "^0.1.1", "commander": "^12.1.0" }
15
+ }