claude-account-sync 0.1.0 → 0.2.1
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/README.md +41 -29
- package/dist/commands/secrets.js +33 -30
- package/dist/context.js +2 -0
- package/dist/index.js +0 -0
- package/dist/ui/api.js +255 -0
- package/dist/ui/command.js +42 -0
- package/dist/ui/http.js +42 -0
- package/dist/ui/server.js +36 -0
- package/dist/ui/static.js +22 -0
- package/dist/ui/token.js +15 -0
- package/dist/webui/assets/index-DZPGFb5T.css +1 -0
- package/dist/webui/assets/index-Dr2jwana.js +136 -0
- package/dist/webui/index.html +7 -0
- package/package.json +1 -1
- package/dist/commands/bundle.d.ts +0 -3
- package/dist/commands/manifest.d.ts +0 -3
- package/dist/commands/mcp.d.ts +0 -3
- package/dist/commands/profiles.d.ts +0 -3
- package/dist/commands/secrets.d.ts +0 -5
- package/dist/commands/sync.d.ts +0 -3
- package/dist/context.d.ts +0 -15
- package/dist/index.d.ts +0 -2
package/README.md
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
-
The `
|
|
7
|
+
The `clp` command (also available as `ccprofiles`) fixes that:
|
|
8
8
|
|
|
9
9
|
- 🔎 **Adopt** your existing `.claude*` directories into a declarative manifest — zero manual config
|
|
10
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 `
|
|
11
|
+
- 🔐 **Secrets out of your rc files** — macOS Keychain / libsecret / encrypted file, with `clp secrets migrate` to clean up existing plaintext keys
|
|
12
12
|
- 🖥️ **Replicate to another machine over LAN** — PIN pairing, end-to-end encrypted, no cloud, works macOS ↔ Windows ↔ Linux ↔ WSL
|
|
13
|
-
-
|
|
13
|
+
- 🖼️ **Web dashboard** — `clp ui` opens a local browser panel to manage everything visually
|
|
14
|
+
- 📦 **Offline bundles** for the no-network case (`clp export setup.ccb`)
|
|
14
15
|
- 🛟 **Safe by design**: surgical config edits only, automatic backups, `--dry-run` everywhere, never touches your sessions/history
|
|
15
16
|
|
|
16
17
|
## Install
|
|
@@ -21,7 +22,7 @@ Requires **Node ≥ 20** (you have it — Claude Code needs it too).
|
|
|
21
22
|
npm install -g claude-account-sync
|
|
22
23
|
```
|
|
23
24
|
|
|
24
|
-
This installs two equivalent commands: `
|
|
25
|
+
This installs two equivalent commands: `clp` (short) and `ccprofiles` (full). The docs below use `clp`.
|
|
25
26
|
|
|
26
27
|
<details><summary>Install from source instead</summary>
|
|
27
28
|
|
|
@@ -37,26 +38,28 @@ cd packages/cli && npm link
|
|
|
37
38
|
## Quickstart
|
|
38
39
|
|
|
39
40
|
```bash
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
clp adopt --yes # REQUIRED FIRST: scan ~/.claude* and build the manifest
|
|
42
|
+
clp list # see all your profiles
|
|
43
|
+
clp doctor # find broken links & plaintext keys
|
|
44
|
+
clp secrets migrate # move API keys from .zshrc into the OS keychain
|
|
44
45
|
```
|
|
45
46
|
|
|
46
|
-
Everything except `list`, `adopt`, and `doctor` needs the manifest, so `
|
|
47
|
+
Everything except `list`, `adopt`, and `doctor` needs the manifest, so `clp adopt --yes` is always step one — commands will remind you if you skip it.
|
|
48
|
+
|
|
49
|
+
Prefer a UI? `clp ui` opens the whole thing in your browser (see below).
|
|
47
50
|
|
|
48
51
|
### Manage MCP servers
|
|
49
52
|
|
|
50
53
|
```bash
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
clp mcp list # server × profile drift matrix
|
|
55
|
+
clp mcp add shadcn --all --command npx --args "shadcn@latest,mcp"
|
|
56
|
+
clp mcp sync --from oauth --to office,z # make profiles match
|
|
54
57
|
```
|
|
55
58
|
|
|
56
59
|
### New profile for a new account
|
|
57
60
|
|
|
58
61
|
```bash
|
|
59
|
-
|
|
62
|
+
clp create work --from oauth # dir + launcher fn + copied MCP set
|
|
60
63
|
# restart shell, then:
|
|
61
64
|
cl-work # launches claude with CLAUDE_CONFIG_DIR=~/.claude-work
|
|
62
65
|
```
|
|
@@ -65,13 +68,13 @@ cl-work # launches claude with CLAUDE_CONFIG_DIR=~/.cla
|
|
|
65
68
|
|
|
66
69
|
```bash
|
|
67
70
|
# machine A (source of truth)
|
|
68
|
-
|
|
71
|
+
clp serve --allow-secrets
|
|
69
72
|
# → ccprofiles sync server on port 51234
|
|
70
73
|
# → pairing PIN: 123456
|
|
71
74
|
|
|
72
75
|
# machine B
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
clp pair 192.168.1.10 --port 51234 --pin 123456 --name mac
|
|
77
|
+
clp sync --from mac --with-secrets
|
|
75
78
|
```
|
|
76
79
|
|
|
77
80
|
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.
|
|
@@ -81,21 +84,29 @@ Two things intentionally don't travel:
|
|
|
81
84
|
- **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
85
|
- The **`default` profile has no `cl-*` launcher** — it's what plain `claude` already launches; only the named profiles get launcher functions.
|
|
83
86
|
|
|
87
|
+
## Web dashboard
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
clp ui # opens http://127.0.0.1:<port>/?t=<token> in your browser
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A local panel to manage profiles, MCP servers (interactive drift matrix), secrets (add / reveal / delete / migrate), sync, and doctor — everything the CLI does. It's **localhost-only** and guarded by a per-launch session token plus an Origin check, so nothing off your machine (and no website in your browser) can reach the API. Pass `--no-open` to just print the URL, or `--port <n>` to pin the port.
|
|
94
|
+
|
|
84
95
|
## How it works
|
|
85
96
|
|
|
86
97
|
Three layers of state:
|
|
87
98
|
|
|
88
|
-
1. **Live state** — your actual `.claude*` dirs and shell rc files. Claude Code owns these;
|
|
99
|
+
1. **Live state** — your actual `.claude*` dirs and shell rc files. Claude Code owns these; the tool edits only the keys it manages (`mcpServers`, its marked rc block, its links).
|
|
89
100
|
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;
|
|
101
|
+
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; launcher functions resolve them at run time by calling the CLI.
|
|
91
102
|
|
|
92
|
-
> ⚠️ `
|
|
103
|
+
> ⚠️ `clp secrets set <name> <value>` takes the value as an argument, which lands in your shell history — prefer `clp secrets migrate` (reads from rc files) or clear history after. Interactive prompting is on the roadmap.
|
|
93
104
|
|
|
94
|
-
`
|
|
105
|
+
`clp status` shows drift between manifest and live; `clp apply` reconciles (with backups under `~/.ccprofiles/backups/`); `clp snapshot` goes the other way (live → manifest).
|
|
95
106
|
|
|
96
107
|
### Sync security model
|
|
97
108
|
|
|
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.
|
|
109
|
+
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. Pairing locks after 5 wrong PINs.
|
|
99
110
|
|
|
100
111
|
## Commands
|
|
101
112
|
|
|
@@ -107,6 +118,7 @@ Pairing performs an X25519 ECDH key exchange authenticated by the 6-digit PIN sh
|
|
|
107
118
|
| Manifest | `status` · `apply` · `snapshot` |
|
|
108
119
|
| Sync | `serve [--allow-secrets]` · `pair <host> --port n --pin p` · `devices` · `sync --from dev [--with-secrets]` |
|
|
109
120
|
| Bundle | `export <file>` · `import <file>` |
|
|
121
|
+
| Dashboard | `ui [--port n] [--no-open]` |
|
|
110
122
|
|
|
111
123
|
All mutating commands support `--dry-run`. Every mutation backs up the files it touches to `~/.ccprofiles/backups/<timestamp>/` first.
|
|
112
124
|
|
|
@@ -114,33 +126,33 @@ All mutating commands support `--dry-run`. Every mutation backs up the files it
|
|
|
114
126
|
|
|
115
127
|
| Symptom | Fix |
|
|
116
128
|
|---|---|
|
|
117
|
-
| `error: no manifest yet` | Run `
|
|
118
|
-
| `zsh: command not found:
|
|
119
|
-
| ``cannot reach <host> — is `ccprofiles serve` running?`` | Start `
|
|
129
|
+
| `error: no manifest yet` | Run `clp adopt --yes` first — it builds the manifest from your existing profiles |
|
|
130
|
+
| `zsh: command not found: clp` | Not linked/installed — see Install; if just linked, run `rehash` |
|
|
131
|
+
| ``cannot reach <host> — is `ccprofiles serve` running?`` | Start `clp serve` on the other device; check you're on the same network and the port matches |
|
|
120
132
|
| `encrypted-file backend requires a passphrase` | Set `CCPROFILES_PASSPHRASE` (Windows / Linux without libsecret) |
|
|
121
133
|
| Profile shows account `-` after sync | Expected — run `/login` inside that profile once; OAuth sessions don't sync |
|
|
122
134
|
| Something went wrong after `apply` | Restore from `~/.ccprofiles/backups/<latest>/` |
|
|
123
135
|
|
|
124
136
|
## Roadmap
|
|
125
137
|
|
|
126
|
-
-
|
|
127
|
-
-
|
|
138
|
+
- mDNS auto-discovery for `clp devices`
|
|
139
|
+
- Pair devices from the dashboard (currently CLI-only)
|
|
128
140
|
- Windows Credential Manager (DPAPI) secrets backend
|
|
129
141
|
- Interactive prompts (`secrets set` without echoing, `adopt` confirmation)
|
|
130
142
|
|
|
131
143
|
## Development
|
|
132
144
|
|
|
133
|
-
npm-workspaces monorepo: `packages/core` (library) + `packages/cli` (commander wrapper).
|
|
145
|
+
npm-workspaces monorepo: `packages/core` (library) + `packages/cli` (commander wrapper) + `packages/ui` (dashboard). The published CLI is [`claude-account-sync`](https://www.npmjs.com/package/claude-account-sync); the library is [`ccprofiles-core`](https://www.npmjs.com/package/ccprofiles-core).
|
|
134
146
|
|
|
135
147
|
```bash
|
|
136
148
|
npm install
|
|
137
149
|
npm test # vitest — unit + e2e (incl. an in-process two-machine sync test)
|
|
138
|
-
npm run build
|
|
150
|
+
npm run build # builds core + cli + the dashboard, bundled into the CLI
|
|
139
151
|
```
|
|
140
152
|
|
|
141
153
|
## Related projects
|
|
142
154
|
|
|
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 `
|
|
155
|
+
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 `clp` / `ccprofiles` commands to avoid clashing with its `ccp`.
|
|
144
156
|
|
|
145
157
|
## License
|
|
146
158
|
|
package/dist/commands/secrets.js
CHANGED
|
@@ -15,6 +15,38 @@ const KEY_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_AUT
|
|
|
15
15
|
// posix: export VAR="sk-..." powershell: $env:VAR = "sk-..."
|
|
16
16
|
const MIGRATE_RE_POSIX = new RegExp(`^(\\s*(?:export\\s+)?(${KEY_VARS.join('|')})\\s*=\\s*)"?(sk-[A-Za-z0-9_-]+)"?(.*)$`);
|
|
17
17
|
const MIGRATE_RE_PWSH = new RegExp(`^(\\s*\\$env:(${KEY_VARS.join('|')})\\s*=\\s*)"?(sk-[A-Za-z0-9_-]+)"?(.*)$`);
|
|
18
|
+
/** Move plaintext keys out of the shell rc file into the secrets store. Shared by the CLI and the UI API. */
|
|
19
|
+
export async function migrateRcSecrets(ctx, opts = {}) {
|
|
20
|
+
const rcFile = ctx.platform.rcFile;
|
|
21
|
+
if (!existsSync(rcFile))
|
|
22
|
+
return [];
|
|
23
|
+
const store = await secretsStore(ctx);
|
|
24
|
+
const lines = (await readFile(rcFile, 'utf8')).split('\n');
|
|
25
|
+
const migrated = [];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
const pwsh = line.match(MIGRATE_RE_PWSH);
|
|
29
|
+
const posix = pwsh ? null : line.match(MIGRATE_RE_POSIX);
|
|
30
|
+
const match = pwsh ?? posix;
|
|
31
|
+
if (!match) {
|
|
32
|
+
out.push(line);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const [, prefix, varName, secretValue, suffix] = match;
|
|
36
|
+
const secretName = varName.toLowerCase().replaceAll('_', '-');
|
|
37
|
+
if (!opts.dryRun)
|
|
38
|
+
await store.set(secretName, secretValue);
|
|
39
|
+
out.push(pwsh
|
|
40
|
+
? `${prefix}(ccprofiles secrets get ${secretName})${suffix}`
|
|
41
|
+
: `${prefix}"$(ccprofiles secrets get ${secretName})"${suffix}`);
|
|
42
|
+
migrated.push(secretName);
|
|
43
|
+
}
|
|
44
|
+
if (migrated.length && !opts.dryRun) {
|
|
45
|
+
await backupFiles([rcFile], ctx.backupRoot, new Date().toISOString().replace(/[:.]/g, '-'));
|
|
46
|
+
await atomicWrite(rcFile, out.join('\n'));
|
|
47
|
+
}
|
|
48
|
+
return migrated;
|
|
49
|
+
}
|
|
18
50
|
export function registerSecretsCommands(program, ctx) {
|
|
19
51
|
const sec = program.command('secrets').description('manage secrets (values never stored in configs)');
|
|
20
52
|
sec.command('set <name> [value]').action(async (name, value) => {
|
|
@@ -44,40 +76,11 @@ export function registerSecretsCommands(program, ctx) {
|
|
|
44
76
|
console.log(`removed ${name}`);
|
|
45
77
|
});
|
|
46
78
|
sec.command('migrate').option('--dry-run').action(async (opts) => {
|
|
47
|
-
const
|
|
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
|
-
}
|
|
79
|
+
const migrated = await migrateRcSecrets(ctx, opts);
|
|
73
80
|
if (migrated.length === 0) {
|
|
74
81
|
console.log('no plaintext keys found');
|
|
75
82
|
return;
|
|
76
83
|
}
|
|
77
|
-
if (!opts.dryRun) {
|
|
78
|
-
await backupFiles([rcFile], ctx.backupRoot, new Date().toISOString().replace(/[:.]/g, '-'));
|
|
79
|
-
await atomicWrite(rcFile, out.join('\n'));
|
|
80
|
-
}
|
|
81
84
|
for (const n of migrated)
|
|
82
85
|
console.log(`${opts.dryRun ? '[dry-run] ' : ''}migrated ${n}`);
|
|
83
86
|
});
|
package/dist/context.js
CHANGED
|
@@ -8,6 +8,7 @@ import { registerSecretsCommands } from './commands/secrets.js';
|
|
|
8
8
|
import { registerManifestCommands } from './commands/manifest.js';
|
|
9
9
|
import { registerSyncCommands } from './commands/sync.js';
|
|
10
10
|
import { registerBundleCommands } from './commands/bundle.js';
|
|
11
|
+
import { registerUiCommand } from './ui/command.js';
|
|
11
12
|
export function makeContext(env = process.env) {
|
|
12
13
|
const testHome = env.CCPROFILES_TEST_HOME;
|
|
13
14
|
const platform = detectPlatform(testHome ? { home: testHome, shell: env.SHELL } : {});
|
|
@@ -38,5 +39,6 @@ export function buildProgram(ctx) {
|
|
|
38
39
|
registerManifestCommands(program, ctx);
|
|
39
40
|
registerSyncCommands(program, ctx);
|
|
40
41
|
registerBundleCommands(program, ctx);
|
|
42
|
+
registerUiCommand(program, ctx);
|
|
41
43
|
return program;
|
|
42
44
|
}
|
package/dist/index.js
CHANGED
|
File without changes
|
package/dist/ui/api.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { discoverProfiles, buildManifest, saveManifest, planApply, executeApply, ensureRootGitignore, loadManifest, loadDevices, fetchRemote, fetchSecrets, writeAssets, parseManifest, backupFiles, } 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
|
+
import { requireManifest } from '../context.js';
|
|
6
|
+
import { secretsStore, migrateRcSecrets } from '../commands/secrets.js';
|
|
7
|
+
import { sendJson, readJson, HttpError } from './http.js';
|
|
8
|
+
function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
|
|
9
|
+
function profileName(dirName) {
|
|
10
|
+
return dirName === '.claude' ? 'default' : dirName.slice('.claude-'.length);
|
|
11
|
+
}
|
|
12
|
+
export function buildRoutes(ctx) {
|
|
13
|
+
const routes = [];
|
|
14
|
+
const add = (method, pattern, handler) => routes.push({ method, pattern, handler });
|
|
15
|
+
async function applyAndReport(m) {
|
|
16
|
+
const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
|
|
17
|
+
return executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp() });
|
|
18
|
+
}
|
|
19
|
+
// requireManifest throws a plain Error; surface "no manifest yet" as a 409 the UI can detect.
|
|
20
|
+
async function mustManifest() {
|
|
21
|
+
try {
|
|
22
|
+
return await requireManifest(ctx);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
throw new HttpError(409, e.message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function targetsOf(m, targets) {
|
|
29
|
+
if (targets === 'all')
|
|
30
|
+
return m.profiles.map(p => p.name);
|
|
31
|
+
for (const t of targets)
|
|
32
|
+
if (!m.profiles.some(p => p.name === t))
|
|
33
|
+
throw new HttpError(400, `unknown profile: ${t}`);
|
|
34
|
+
return targets;
|
|
35
|
+
}
|
|
36
|
+
// ── adopt / profiles / status / apply / doctor ──────────────────────────────
|
|
37
|
+
add('POST', /^\/api\/adopt$/, async (_m, _req, res) => {
|
|
38
|
+
const manifest = buildManifest(await discoverProfiles(ctx.home), ctx.platform);
|
|
39
|
+
if (!existsSync(join(ctx.manifestRoot, '.git'))) {
|
|
40
|
+
try {
|
|
41
|
+
execFileSync('git', ['init', ctx.manifestRoot], { stdio: 'ignore' });
|
|
42
|
+
}
|
|
43
|
+
catch { /* git optional */ }
|
|
44
|
+
}
|
|
45
|
+
await ensureRootGitignore(ctx.manifestRoot);
|
|
46
|
+
await saveManifest(ctx.manifestRoot, manifest);
|
|
47
|
+
sendJson(res, 200, { profiles: manifest.profiles.length, mcpServers: Object.keys(manifest.mcpServers).length });
|
|
48
|
+
});
|
|
49
|
+
add('GET', /^\/api\/profiles$/, async (_m, _req, res) => {
|
|
50
|
+
const live = await discoverProfiles(ctx.home);
|
|
51
|
+
let manifest = null;
|
|
52
|
+
if (existsSync(join(ctx.manifestRoot, 'manifest.yaml')))
|
|
53
|
+
manifest = await loadManifest(ctx.manifestRoot);
|
|
54
|
+
const rows = live.map(lp => {
|
|
55
|
+
const name = profileName(lp.dirName);
|
|
56
|
+
const decl = manifest?.profiles.find(p => p.name === name) ?? null;
|
|
57
|
+
return {
|
|
58
|
+
name, dir: lp.dir, auth: decl?.auth ?? (lp.account ? 'oauth' : 'env'),
|
|
59
|
+
account: lp.account, mcp: Object.keys(lp.mcpServers).length,
|
|
60
|
+
launcher: decl?.launcher ?? (name === 'default' ? null : `cl-${name}`),
|
|
61
|
+
adopted: !!decl,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
sendJson(res, 200, rows);
|
|
65
|
+
});
|
|
66
|
+
add('POST', /^\/api\/profiles$/, async (_m, req, res) => {
|
|
67
|
+
const { name, from } = await readJson(req);
|
|
68
|
+
if (!name)
|
|
69
|
+
throw new HttpError(400, 'name required');
|
|
70
|
+
const m = await mustManifest();
|
|
71
|
+
if (m.profiles.some(p => p.name === name))
|
|
72
|
+
throw new HttpError(409, `profile exists: ${name}`);
|
|
73
|
+
const src = from ? m.profiles.find(p => p.name === from) : null;
|
|
74
|
+
if (from && !src)
|
|
75
|
+
throw new HttpError(400, `unknown profile: ${from}`);
|
|
76
|
+
m.profiles.push({
|
|
77
|
+
name, dir: `{home}/.claude-${name}`, launcher: `cl-${name}`, auth: 'env', env: {},
|
|
78
|
+
links: src ? { ...src.links } : (m.hub ? { skills: 'hub', commands: 'hub' } : {}),
|
|
79
|
+
mcp: src ? [...src.mcp] : [],
|
|
80
|
+
});
|
|
81
|
+
await saveManifest(ctx.manifestRoot, m);
|
|
82
|
+
await applyAndReport(m);
|
|
83
|
+
sendJson(res, 200, { ok: true });
|
|
84
|
+
});
|
|
85
|
+
add('PATCH', /^\/api\/profiles\/([^/]+)$/, async (mtch, req, res) => {
|
|
86
|
+
const m = await mustManifest();
|
|
87
|
+
const pr = m.profiles.find(p => p.name === mtch[1]);
|
|
88
|
+
if (!pr)
|
|
89
|
+
throw new HttpError(404, `unknown profile: ${mtch[1]}`);
|
|
90
|
+
const body = await readJson(req);
|
|
91
|
+
if (body.env)
|
|
92
|
+
pr.env = body.env;
|
|
93
|
+
if (body.links)
|
|
94
|
+
pr.links = body.links;
|
|
95
|
+
if (body.launcher !== undefined)
|
|
96
|
+
pr.launcher = body.launcher;
|
|
97
|
+
await saveManifest(ctx.manifestRoot, m);
|
|
98
|
+
await applyAndReport(m);
|
|
99
|
+
sendJson(res, 200, { ok: true });
|
|
100
|
+
});
|
|
101
|
+
add('GET', /^\/api\/status$/, async (_m, _req, res) => {
|
|
102
|
+
const m = await mustManifest();
|
|
103
|
+
const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
|
|
104
|
+
const r = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: true });
|
|
105
|
+
sendJson(res, 200, { inSync: actions.length === 0, pending: r.performed });
|
|
106
|
+
});
|
|
107
|
+
add('POST', /^\/api\/apply$/, async (_m, _req, res) => {
|
|
108
|
+
const m = await mustManifest();
|
|
109
|
+
const r = await applyAndReport(m);
|
|
110
|
+
sendJson(res, 200, { performed: r.performed, backupDir: r.backupDir });
|
|
111
|
+
});
|
|
112
|
+
add('GET', /^\/api\/doctor$/, async (_m, _req, res) => {
|
|
113
|
+
const problems = [];
|
|
114
|
+
const live = await discoverProfiles(ctx.home);
|
|
115
|
+
for (const lp of live)
|
|
116
|
+
for (const nm of readdirSync(lp.dir)) {
|
|
117
|
+
const f = join(lp.dir, nm);
|
|
118
|
+
try {
|
|
119
|
+
if (lstatSync(f).isSymbolicLink() && !existsSync(f))
|
|
120
|
+
problems.push(`broken symlink: ${f} -> ${readlinkSync(f)}`);
|
|
121
|
+
}
|
|
122
|
+
catch { /* skip */ }
|
|
123
|
+
}
|
|
124
|
+
if (existsSync(ctx.platform.rcFile)) {
|
|
125
|
+
const rc = readFileSync(ctx.platform.rcFile, 'utf8');
|
|
126
|
+
const outside = rc.split('# >>> ccprofiles managed >>>')[0] + (rc.split('# <<< ccprofiles managed <<<')[1] ?? '');
|
|
127
|
+
if (/sk-ant-/.test(outside))
|
|
128
|
+
problems.push(`plaintext Anthropic key in ${ctx.platform.rcFile} — run secrets migrate`);
|
|
129
|
+
}
|
|
130
|
+
if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
|
|
131
|
+
const m = await loadManifest(ctx.manifestRoot);
|
|
132
|
+
for (const pr of m.profiles) {
|
|
133
|
+
const dir = pr.dir.replace('{home}', ctx.home);
|
|
134
|
+
if (!existsSync(dir))
|
|
135
|
+
problems.push(`manifest profile "${pr.name}" missing on disk: ${dir} — run apply`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
sendJson(res, 200, { problems });
|
|
139
|
+
});
|
|
140
|
+
// ── mcp ─────────────────────────────────────────────────────────────────────
|
|
141
|
+
add('GET', /^\/api\/mcp$/, async (_m, _req, res) => {
|
|
142
|
+
const m = await mustManifest();
|
|
143
|
+
sendJson(res, 200, {
|
|
144
|
+
servers: Object.keys(m.mcpServers).sort(),
|
|
145
|
+
profiles: m.profiles.map(p => ({ name: p.name, has: p.mcp })),
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
add('POST', /^\/api\/mcp$/, async (_m, req, res) => {
|
|
149
|
+
const { name, command, args, targets } = await readJson(req);
|
|
150
|
+
if (!name)
|
|
151
|
+
throw new HttpError(400, 'name required');
|
|
152
|
+
const m = await mustManifest();
|
|
153
|
+
if (!m.mcpServers[name]) {
|
|
154
|
+
if (!command)
|
|
155
|
+
throw new HttpError(400, `unknown server "${name}" — command required to define it`);
|
|
156
|
+
m.mcpServers[name] = { command, ...(args && args.length ? { args } : {}) };
|
|
157
|
+
}
|
|
158
|
+
for (const t of targetsOf(m, targets)) {
|
|
159
|
+
const pr = m.profiles.find(p => p.name === t);
|
|
160
|
+
if (!pr.mcp.includes(name))
|
|
161
|
+
pr.mcp.push(name);
|
|
162
|
+
}
|
|
163
|
+
await saveManifest(ctx.manifestRoot, m);
|
|
164
|
+
await applyAndReport(m);
|
|
165
|
+
sendJson(res, 200, { ok: true });
|
|
166
|
+
});
|
|
167
|
+
add('DELETE', /^\/api\/mcp\/([^/]+)$/, async (mtch, req, res) => {
|
|
168
|
+
const { targets } = await readJson(req);
|
|
169
|
+
const m = await mustManifest();
|
|
170
|
+
const name = decodeURIComponent(mtch[1]);
|
|
171
|
+
for (const t of targetsOf(m, targets)) {
|
|
172
|
+
const pr = m.profiles.find(p => p.name === t);
|
|
173
|
+
pr.mcp = pr.mcp.filter(x => x !== name);
|
|
174
|
+
}
|
|
175
|
+
if (!m.profiles.some(p => p.mcp.includes(name)))
|
|
176
|
+
delete m.mcpServers[name];
|
|
177
|
+
await saveManifest(ctx.manifestRoot, m);
|
|
178
|
+
await applyAndReport(m);
|
|
179
|
+
sendJson(res, 200, { ok: true });
|
|
180
|
+
});
|
|
181
|
+
add('POST', /^\/api\/mcp\/sync$/, async (_m, req, res) => {
|
|
182
|
+
const { from, to } = await readJson(req);
|
|
183
|
+
const m = await mustManifest();
|
|
184
|
+
const src = m.profiles.find(p => p.name === from);
|
|
185
|
+
if (!src)
|
|
186
|
+
throw new HttpError(400, `unknown profile: ${from}`);
|
|
187
|
+
for (const t of targetsOf(m, to)) {
|
|
188
|
+
if (t === src.name)
|
|
189
|
+
continue;
|
|
190
|
+
m.profiles.find(p => p.name === t).mcp = [...src.mcp];
|
|
191
|
+
}
|
|
192
|
+
await saveManifest(ctx.manifestRoot, m);
|
|
193
|
+
await applyAndReport(m);
|
|
194
|
+
sendJson(res, 200, { ok: true });
|
|
195
|
+
});
|
|
196
|
+
// ── secrets ─────────────────────────────────────────────────────────────────
|
|
197
|
+
add('GET', /^\/api\/secrets$/, async (_m, _req, res) => {
|
|
198
|
+
const store = await secretsStore(ctx);
|
|
199
|
+
sendJson(res, 200, { names: await store.list(), backend: store.backendName });
|
|
200
|
+
});
|
|
201
|
+
add('GET', /^\/api\/secrets\/([^/]+)$/, async (mtch, _req, res) => {
|
|
202
|
+
const store = await secretsStore(ctx);
|
|
203
|
+
const value = await store.get(decodeURIComponent(mtch[1]));
|
|
204
|
+
if (value === null)
|
|
205
|
+
throw new HttpError(404, 'not found');
|
|
206
|
+
sendJson(res, 200, { value });
|
|
207
|
+
});
|
|
208
|
+
add('PUT', /^\/api\/secrets\/([^/]+)$/, async (mtch, req, res) => {
|
|
209
|
+
const { value } = await readJson(req);
|
|
210
|
+
if (typeof value !== 'string')
|
|
211
|
+
throw new HttpError(400, 'value required');
|
|
212
|
+
const store = await secretsStore(ctx);
|
|
213
|
+
await store.set(decodeURIComponent(mtch[1]), value);
|
|
214
|
+
sendJson(res, 200, { ok: true });
|
|
215
|
+
});
|
|
216
|
+
add('DELETE', /^\/api\/secrets\/([^/]+)$/, async (mtch, _req, res) => {
|
|
217
|
+
const store = await secretsStore(ctx);
|
|
218
|
+
await store.delete(decodeURIComponent(mtch[1]));
|
|
219
|
+
sendJson(res, 200, { ok: true });
|
|
220
|
+
});
|
|
221
|
+
add('POST', /^\/api\/secrets\/migrate$/, async (_m, _req, res) => {
|
|
222
|
+
sendJson(res, 200, { migrated: await migrateRcSecrets(ctx) });
|
|
223
|
+
});
|
|
224
|
+
// ── devices / sync ──────────────────────────────────────────────────────────
|
|
225
|
+
add('GET', /^\/api\/devices$/, async (_m, _req, res) => {
|
|
226
|
+
sendJson(res, 200, await loadDevices(ctx.manifestRoot));
|
|
227
|
+
});
|
|
228
|
+
add('POST', /^\/api\/sync$/, async (_m, req, res) => {
|
|
229
|
+
const { from, withSecrets, dryRun } = await readJson(req);
|
|
230
|
+
const device = (await loadDevices(ctx.manifestRoot)).find(d => d.name === from);
|
|
231
|
+
if (!device)
|
|
232
|
+
throw new HttpError(400, `unknown device: ${from}`);
|
|
233
|
+
const { manifestYaml, assets } = await fetchRemote(device);
|
|
234
|
+
const m = parseManifest(manifestYaml);
|
|
235
|
+
if (!dryRun) {
|
|
236
|
+
await backupFiles([join(ctx.manifestRoot, 'manifest.yaml')], ctx.backupRoot, stamp());
|
|
237
|
+
await saveManifest(ctx.manifestRoot, m);
|
|
238
|
+
await writeAssets(assets, m, ctx.platform);
|
|
239
|
+
}
|
|
240
|
+
const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
|
|
241
|
+
const r = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!dryRun });
|
|
242
|
+
let secrets = [];
|
|
243
|
+
if (withSecrets) {
|
|
244
|
+
const values = await fetchSecrets(device, []);
|
|
245
|
+
if (!dryRun) {
|
|
246
|
+
const store = await secretsStore(ctx);
|
|
247
|
+
for (const [k, v] of Object.entries(values))
|
|
248
|
+
await store.set(k, v);
|
|
249
|
+
}
|
|
250
|
+
secrets = Object.keys(values);
|
|
251
|
+
}
|
|
252
|
+
sendJson(res, 200, { performed: r.performed, secrets });
|
|
253
|
+
});
|
|
254
|
+
return routes;
|
|
255
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { startUiServer } from './server.js';
|
|
6
|
+
import { newUiToken } from './token.js';
|
|
7
|
+
function defaultUiDir() {
|
|
8
|
+
// this file compiles to dist/ui/command.js; the built frontend is copied to dist/webui
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url)); // .../dist/ui
|
|
10
|
+
return join(dirname(here), 'webui'); // .../dist/webui
|
|
11
|
+
}
|
|
12
|
+
function openBrowser(url) {
|
|
13
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
14
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
15
|
+
try {
|
|
16
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
17
|
+
}
|
|
18
|
+
catch { /* non-fatal */ }
|
|
19
|
+
}
|
|
20
|
+
export function registerUiCommand(program, ctx) {
|
|
21
|
+
program.command('ui').description('open the web dashboard (localhost)')
|
|
22
|
+
.option('--port <n>', 'port (default: random)', v => parseInt(v, 10))
|
|
23
|
+
.option('--no-open', 'do not open the browser automatically')
|
|
24
|
+
.action(async (opts) => {
|
|
25
|
+
const uiDir = ctx.env.CCPROFILES_UI_DIR ?? defaultUiDir();
|
|
26
|
+
if (!existsSync(join(uiDir, 'index.html'))) {
|
|
27
|
+
console.log(`dashboard assets not found at ${uiDir} — build first: npm run build`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const token = newUiToken();
|
|
31
|
+
const srv = await startUiServer(ctx, { port: opts.port, token, uiDir });
|
|
32
|
+
const url = `http://127.0.0.1:${srv.port}/?t=${token}`;
|
|
33
|
+
console.log(`ccprofiles dashboard: ${url}`);
|
|
34
|
+
console.log('(localhost only · Ctrl-C to stop)');
|
|
35
|
+
if (opts.open !== false)
|
|
36
|
+
openBrowser(url);
|
|
37
|
+
globalThis.__uiServerClose = srv.close;
|
|
38
|
+
globalThis.__uiOnListening?.();
|
|
39
|
+
if (!ctx.env.CCPROFILES_UI_DIR)
|
|
40
|
+
await new Promise(() => { }); // run until Ctrl-C in real use
|
|
41
|
+
});
|
|
42
|
+
}
|
package/dist/ui/http.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export class HttpError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(status, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export class BadRequest extends HttpError {
|
|
9
|
+
constructor(message = 'invalid request') { super(400, message); }
|
|
10
|
+
}
|
|
11
|
+
export async function readBody(req) {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
for await (const c of req)
|
|
14
|
+
chunks.push(c);
|
|
15
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
16
|
+
}
|
|
17
|
+
export async function readJson(req) {
|
|
18
|
+
const raw = await readBody(req);
|
|
19
|
+
if (!raw)
|
|
20
|
+
return {};
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new BadRequest('invalid JSON body');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function sendJson(res, code, body) {
|
|
29
|
+
const text = JSON.stringify(body);
|
|
30
|
+
res.writeHead(code, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) });
|
|
31
|
+
res.end(text);
|
|
32
|
+
}
|
|
33
|
+
export function matchRoute(routes, method, path) {
|
|
34
|
+
for (const route of routes) {
|
|
35
|
+
if (route.method !== method)
|
|
36
|
+
continue;
|
|
37
|
+
const match = path.match(route.pattern);
|
|
38
|
+
if (match)
|
|
39
|
+
return { route, match };
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { buildRoutes } from './api.js';
|
|
3
|
+
import { matchRoute, sendJson, HttpError } from './http.js';
|
|
4
|
+
import { tokenOk, originOk } from './token.js';
|
|
5
|
+
import { serveStatic } from './static.js';
|
|
6
|
+
export async function startUiServer(ctx, opts) {
|
|
7
|
+
const routes = buildRoutes(ctx);
|
|
8
|
+
const server = createServer(async (req, res) => {
|
|
9
|
+
const url = req.url ?? '/';
|
|
10
|
+
const path = url.split('?')[0];
|
|
11
|
+
if (!path.startsWith('/api/'))
|
|
12
|
+
return serveStatic(res, url, opts.uiDir);
|
|
13
|
+
const address = server.address();
|
|
14
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
15
|
+
if (!originOk(req.headers.origin, port))
|
|
16
|
+
return sendJson(res, 403, { error: 'bad origin' });
|
|
17
|
+
if (!tokenOk(req.headers['x-ccp-token'], opts.token))
|
|
18
|
+
return sendJson(res, 401, { error: 'unauthorized' });
|
|
19
|
+
const m = matchRoute(routes, req.method ?? 'GET', path);
|
|
20
|
+
if (!m)
|
|
21
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
22
|
+
try {
|
|
23
|
+
await m.route.handler(m.match, req, res);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (e instanceof HttpError)
|
|
27
|
+
return sendJson(res, e.status, { error: e.message });
|
|
28
|
+
process.stderr.write(`ui api error: ${e.stack ?? e}\n`);
|
|
29
|
+
return sendJson(res, 500, { error: 'internal error' });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
await new Promise(resolve => server.listen(opts.port ?? 0, '127.0.0.1', resolve));
|
|
33
|
+
const address = server.address();
|
|
34
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
35
|
+
return { port, close: () => new Promise((resolve, reject) => server.close(e => (e ? reject(e) : resolve()))) };
|
|
36
|
+
}
|