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 +21 -0
- package/README.md +147 -0
- package/dist/commands/bundle.d.ts +3 -0
- package/dist/commands/bundle.js +39 -0
- package/dist/commands/manifest.d.ts +3 -0
- package/dist/commands/manifest.js +59 -0
- package/dist/commands/mcp.d.ts +3 -0
- package/dist/commands/mcp.js +86 -0
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +71 -0
- package/dist/commands/secrets.d.ts +5 -0
- package/dist/commands/secrets.js +84 -0
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.js +84 -0
- package/dist/context.d.ts +15 -0
- package/dist/context.js +42 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -0
- package/package.json +15 -0
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,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,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,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,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,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;
|
package/dist/context.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
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
|
+
}
|