cchost 0.1.3 → 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 CHANGED
@@ -16,13 +16,14 @@ Requires Node.js 18+ and Claude Code 2.1.140+ already installed. Get Claude Code
16
16
 
17
17
  | Command | What it does |
18
18
  | --- | --- |
19
- | `cc add <profile> [--email=] [--org=] [--name=]` | Create a profile directory. Optional alias flags (EXPERIMENTAL, see below). |
19
+ | `cc add <profile>` | Create a profile directory. |
20
20
  | `cc use <profile> [...args]` | Launch Claude under that profile. Profile must already exist. Args after the name are forwarded verbatim to `claude`. |
21
- | `cc list [--original] [--json]` | Show every profile: login state, account email, and storage mode (`shared` / `isolated`). |
22
- | `cc migrate <src> <dest>` | Copy projects from `default`\|`shared` to `shared`\|`<profile>`. Skip-on-collision; credential files never copied. |
23
- | `cc link <profile> [<profile>...]` | Link a profile's `projects/` to `~/.claude-shared/projects`. Migrates existing content; renames on collision. |
24
- | `cc unlink <profile>` | Restore a profile to its own private `projects/` (copies shared content back). |
21
+ | `cc list [--json]` | Show every profile: login state, account email, and how many artifact dirs are linked to shared (N/4). |
22
+ | `cc migrate <src> <dest> [--force]` | Copy artifacts from `default`\|`shared` to `shared`\|`<profile>`. Skip-on-collision by default; `--force` overwrites. |
23
+ | `cc link <profile> [<profile>...]` | Link a profile's artifact dirs to `~/.claude-shared/`. Migrates existing content; renames on collision. |
24
+ | `cc unlink <profile>` | Restore a profile to its own private artifact dirs (copies shared content back). |
25
25
  | `cc remove <profile>` | Delete a profile. Requires typing the profile name to confirm. |
26
+ | `cc doctor [--fix [--force]] [--json]` | Diagnose environment, storage, and platform issues. |
26
27
  | `cc help [command]` | Print usage (top-level, or for a single command). |
27
28
 
28
29
  ### Examples
@@ -39,58 +40,72 @@ cc remove old-profile
39
40
  `cc list` output:
40
41
 
41
42
  ```
42
- Profile LoggedIn Email Storage
43
- personal true personal@example.com isolated
44
- scratch false - isolated
45
- work true work@example.com shared
43
+ Profile LoggedIn Email Shared
44
+ personal true personal@example.com 4/4
45
+ scratch false - 0/4
46
+ work true work@example.com 0/4
46
47
  ```
47
48
 
48
- ### Migrating existing projects
49
+ ### What migrate copies and what link shares
49
50
 
50
- If you've been using Claude Code without profiles, your history lives in `~/.claude/projects/`. Pull it into a profile or the shared pool:
51
+ | Artifact | `cc migrate` copies it | `cc link` shares it | Notes |
52
+ | --- | :---: | :---: | --- |
53
+ | `projects/` | ✓ | ✓ | Resumable conversation transcripts |
54
+ | `skills/` | ✓ | ✓ | Custom slash-command scripts |
55
+ | `agents/` | ✓ | ✓ | Custom agent definitions |
56
+ | `commands/` | ✓ | ✓ | Custom command configs |
57
+ | `mcp.json` | ✓ (default→profile only) | — | Per-profile MCP server config; may carry auth tokens |
58
+ | `settings.json` | ✓ (default→profile only) | — | Per-profile UI/feature settings |
59
+ | `CLAUDE.md` | ✓ (default→profile only) | — | Per-profile project instructions |
60
+ | `sessions/` | — | — | Transient per-process state; sharing would corrupt bookkeeping |
61
+ | `plugins/` | — | — | Carries auth tokens; deferred to a future opt-in flag |
62
+ | `.credentials.json` | — | — | Never touched — each profile keeps its own auth |
63
+ | `.claude.json` | — | — | Never touched — re-synced from server on every launch |
64
+
65
+ `mcp.json`, `settings.json`, and `CLAUDE.md` are **not** copied to `shared` and are **not** linked because they typically carry auth tokens or per-profile configuration that should not leak across accounts.
66
+
67
+ ### Migrating existing artifacts
68
+
69
+ If you've been using Claude Code without profiles, your history lives in `~/.claude/`. Pull it into a profile or the shared pool:
51
70
 
52
71
  ```bash
53
- cc migrate default work # copy ~/.claude/projects → ~/.claude-profiles/work/projects
54
- cc migrate default shared # copy ~/.claude/projects → ~/.claude-shared/projects
55
- cc migrate shared personal # copy ~/.claude-shared/projects → ~/.claude-profiles/personal/projects
72
+ cc migrate default work # copy ~/.claude/* → ~/.claude-profiles/work/
73
+ cc migrate default shared # copy ~/.claude/projects,skills,agents,commands → ~/.claude-shared/
74
+ cc migrate shared personal # copy ~/.claude-shared/* → ~/.claude-profiles/personal/
75
+ cc migrate default work --force # same as first, overwriting anything that already exists
56
76
  ```
57
77
 
58
- This is a **copy** — the source is left untouched. If an entry already exists at the destination, the source entry is skipped (no overwrite). Credential files (`.credentials.json`, `.claude.json`) are never copied: each profile keeps its own auth state.
78
+ This is a **copy** — the source is left untouched. By default, if an entry already exists at the destination it is silently skipped. Pass `--force` to overwrite instead — existing destination content will be cleared.
79
+
80
+ Credential files (`.credentials.json`, `.claude.json`) are never copied: each profile keeps its own auth state.
59
81
 
60
- ### Shared history across profiles
82
+ ### Shared artifacts across profiles
61
83
 
62
- If you want each profile to keep separate credentials but share project history (e.g. rotate accounts on the same project when usage limits hit):
84
+ If you want each profile to keep separate credentials but share project history and skills (e.g. rotate accounts on the same project when usage limits hit):
63
85
 
64
86
  ```bash
65
87
  cc link work
66
88
  cc link personal
67
89
  ```
68
90
 
69
- `work/projects` and `personal/projects` become links to `~/.claude-shared/projects`. On Windows this uses directory junctions (no admin/Developer Mode required); on macOS/Linux, regular symlinks.
91
+ `work/` and `personal/` each get four linked dirs (`projects`, `skills`, `agents`, `commands`) pointing to `~/.claude-shared/`. On Windows this uses directory junctions (no admin/Developer Mode required); on macOS/Linux, regular symlinks.
70
92
 
71
- **Only `projects/` is shared.** That's the directory Claude Code uses for resumable conversation transcripts (`projects/<project>/<session>.jsonl`). The sibling `sessions/` folder holds transient per-process registry state — PID, working directory, status, timestamps — and is **never** linked, migrated, or copied across profiles. Sharing it would corrupt Claude's running-process bookkeeping. `sessions/` stays per-profile under `~/.claude-profiles/<name>/sessions/`.
93
+ `sessions/` is **never** linked. It holds transient per-process registry state — PID, working directory, status, timestamps — and sharing it would corrupt Claude's running-process bookkeeping. It stays per-profile under `~/.claude-profiles/<name>/sessions/`.
72
94
 
73
95
  `cc link a b c` links several profiles at once; per-profile failures are reported but don't abort the rest of the batch.
74
96
 
75
97
  If two profiles share history and you launch them against the same project at the same time, both will write to the same session files (last write wins). Don't do that.
76
98
 
77
- `cc remove` is safe with shared profiles — it deletes only the link, never recursing into the shared target.
78
-
79
- ### Aliases (`cc add` flags) — EXPERIMENTAL
80
-
81
- `cc add work --email=work@example.com --org=Acme --name="Work Account"` stores those values in `~/.claude-profiles/work/.cc-alias.json`. On every `cc use`, they're written into the profile's `.claude.json` `oauthAccount` so `cc list` and Claude's own UI display them instead of the real underlying account.
82
-
83
- **EXPERIMENTAL.** Claude Code may overwrite `oauthAccount` on server re-sync; we re-apply on the next `cc use`, but masking across all of Claude's UI surfaces has not been verified. Use `cc list --original` to see the real values from `.claude.json`.
99
+ `cc remove` is safe with shared profiles — it deletes only the links, never recursing into the shared target.
84
100
 
85
101
  ## How it works
86
102
 
87
103
  `cc` is a Node CLI with **zero runtime dependencies**. For `cc use` it:
88
104
 
89
105
  1. Resolves the profile directory under `~/.claude-profiles/<name>/`
90
- 2. Applies any stored alias to `.claude.json` `oauthAccount` (best-effort)
91
- 3. Sets `CLAUDE_CONFIG_DIR` in its **own** spawn environment
92
- 4. Spawns `claude` with `stdio: 'inherit'` so the TUI works correctly
93
- 5. Forwards the child's exit code
106
+ 2. Sets `CLAUDE_CONFIG_DIR` in its **own** spawn environment
107
+ 3. Spawns `claude` with `stdio: 'inherit'` so the TUI works correctly
108
+ 4. Forwards the child's exit code
94
109
 
95
110
  The env var is set on the child process only — that's all `claude` needs.
96
111
 
package/lib/cli.js CHANGED
@@ -14,13 +14,14 @@ const color = require('./color');
14
14
  const HELP = `cc - Multi-profile launcher for Claude Code
15
15
 
16
16
  Usage:
17
- cc add <profile> [--email=] [--org=] [--name=] Create a profile (EXPERIMENTAL aliases)
17
+ cc add <profile> Create a profile directory
18
18
  cc use <profile> [...args] Launch Claude under a named profile
19
- cc list [--original] [--json] Show all profiles: login state, email, storage (shared/isolated)
20
- cc migrate <src> <dest> Copy projects from default|shared to shared|<profile>
21
- cc link <p>... Replace each profile's projects/ with a link to shared
22
- cc unlink <profile> Restore a profile's private projects/
19
+ cc list [--json] Show all profiles: login state, email, shared-dir count (N/4)
20
+ cc migrate <src> <dest> [--force] Copy artifact dirs (and config files) between profiles
21
+ cc link <p>... Link each profile's artifact dirs to ~/.claude-shared/
22
+ cc unlink <profile> Restore a profile's private artifact dirs
23
23
  cc remove <profile> Delete a profile and all its data (typed confirmation)
24
+ cc doctor [--fix [--force]] [--json] Diagnose environment, storage, and platform issues
24
25
  cc help [command] Show usage (optionally for a single command)
25
26
  cc --version Print the installed cchost version
26
27
 
@@ -29,18 +30,13 @@ for that launch. Args after the profile name are forwarded verbatim to claude.
29
30
  `;
30
31
 
31
32
  const HELP_BY_CMD = {
32
- add: `cc add <profile> [--email=...] [--org=...] [--name=...]
33
+ add: `cc add <profile>
33
34
 
34
- Create a new profile directory without launching Claude. Optional alias flags
35
- record account values shown in 'cc list' and written into the profile's
36
- .claude.json oauthAccount on subsequent 'cc use' launches.
37
-
38
- EXPERIMENTAL: Claude Code may overwrite these on server re-sync. Masking
39
- behavior across all UI surfaces has not been verified.
35
+ Create a new profile directory without launching Claude.
40
36
 
41
37
  Examples:
42
38
  cc add work
43
- cc add work --email=work@example.com --org=Acme --name="Work Account"
39
+ cc add personal
44
40
  `,
45
41
  use: `cc use <profile> [...args]
46
42
 
@@ -56,56 +52,70 @@ Examples:
56
52
  cc use work --resume
57
53
  cc use personal -p "summarize this codebase"
58
54
  `,
59
- list: `cc list [--original] [--json]
55
+ list: `cc list [--json]
60
56
 
61
57
  Columns:
62
58
  Profile - profile name
63
59
  LoggedIn - true if .credentials.json exists
64
- Email - account email (alias-aware; - if not yet logged in)
65
- Storage - 'shared' if projects/ is linked to ~/.claude-shared, 'isolated' otherwise
60
+ Email - account email from .claude.json; - if not yet logged in
61
+ Shared - N/4 linked to ~/.claude-shared/ (projects, skills, agents, commands)
62
+ 4/4 = fully shared, 0/4 = fully isolated
66
63
  (sessions/ is transient per-process state and is never shared)
67
64
 
68
- By default, the displayed email reflects any alias set via 'cc add'.
69
- With --original, shows the real value from .claude.json.
70
-
71
- With --json, emits an array of {name, dir, loggedIn, email, storage, alias}.
65
+ With --json, emits an array of {name, dir, loggedIn, email, shared}.
72
66
  `,
73
- migrate: `cc migrate <src> <dest>
67
+ migrate: `cc migrate <src> <dest> [--force]
74
68
 
75
- Copy projects/ from a source to a destination. sessions/ (transient per-process
76
- registry state) is never migrated. This is a COPY — the source is left untouched.
69
+ Copy artifact directories from a source to a destination. This is a COPY — the
70
+ source is left untouched. sessions/ is never migrated (transient per-process state).
77
71
 
78
72
  <src>: default | shared
79
73
  default = ~/.claude/ (Claude's built-in non-profile data)
80
74
  shared = ~/.claude-shared/ (the cc shared pool)
81
75
  <dest>: shared | <profile-name>
82
76
 
83
- On collisions, existing entries at the destination are left alone (the source
84
- entry is skipped). Credential files (.credentials.json, .claude.json) are
85
- never copied — the destination keeps its own auth state.
77
+ Directories copied in all cases:
78
+ projects/, skills/, agents/, commands/
79
+
80
+ Extra files copied only when src=default and dest=<profile>:
81
+ mcp.json, settings.json, CLAUDE.md
82
+ (These carry per-profile config and are NOT copied to shared.)
83
+
84
+ Never copied: .credentials.json, .claude.json, sessions/, plugins/
85
+
86
+ Collision behavior (default): existing entries at the destination are silently
87
+ skipped — the source entry is left alone.
88
+
89
+ --force Overwrite existing entries instead of skipping.
90
+ Warning: existing content at the destination will be cleared.
86
91
 
87
92
  Examples:
88
- cc migrate default work # ~/.claude/projects -> ~/.claude-profiles/work/projects
89
- cc migrate default shared # ~/.claude/projects -> ~/.claude-shared/projects
90
- cc migrate shared work # ~/.claude-shared/projects -> ~/.claude-profiles/work/projects
93
+ cc migrate default work # copy ~/.claude/* to ~/.claude-profiles/work/
94
+ cc migrate default shared # copy dirs to ~/.claude-shared/
95
+ cc migrate shared work # copy shared dirs to ~/.claude-profiles/work/
96
+ cc migrate default work --force # same, overwriting any existing items
91
97
  `,
92
98
  link: `cc link <profile> [<profile>...]
93
99
 
94
- Replace each profile's projects/ with a link to ~/.claude-shared/projects/.
95
- Existing content is migrated into shared first; collisions are renamed with a
96
- __<profile> suffix (never overwritten). sessions/ (transient per-process state)
97
- is intentionally never linked — it stays per-profile.
100
+ Replace each profile's artifact directories with links to ~/.claude-shared/:
101
+ projects/, skills/, agents/, commands/
102
+
103
+ Existing content in each directory is migrated into shared first; collisions
104
+ are renamed with a __<profile> suffix (never overwritten). sessions/ (transient
105
+ per-process state) is intentionally never linked — it stays per-profile.
98
106
 
99
107
  On Windows, link uses directory junctions (no admin/Developer Mode needed).
100
108
  On macOS/Linux, regular symlinks.
101
109
 
102
- Multiple profiles can be linked at once: 'cc link a b c'. Per-profile failures
103
- are reported but don't abort the rest of the batch.
110
+ Multiple profiles can be linked at once: 'cc link a b c'. Failures are handled
111
+ per directory and per profile: one directory failing does not stop the other
112
+ three, and one profile failing does not stop the rest of the batch. A
113
+ per-directory summary is printed; exit code is 1 if anything failed.
104
114
  `,
105
115
  unlink: `cc unlink <profile>
106
116
 
107
- Remove a profile's projects/ link to ~/.claude-shared/ and copy shared content
108
- back into the profile as a private dir.
117
+ Remove each linked artifact directory (projects/, skills/, agents/, commands/)
118
+ and copy the shared content back into the profile as private dirs.
109
119
  `,
110
120
  remove: `cc remove <profile>
111
121
 
@@ -115,6 +125,21 @@ name — y/n is intentionally not accepted because credential loss is unrecovera
115
125
 
116
126
  If the profile's projects/ is a link to ~/.claude-shared/, only the link is
117
127
  removed — the shared target is preserved.
128
+ `,
129
+ doctor: `cc doctor [--fix [--force]] [--json]
130
+
131
+ Run diagnostic checks across four groups:
132
+
133
+ Environment Node >=18, claude on PATH, claude >=2.1.140, no conflicting env vars, no cc shadow
134
+ Storage ~/.claude-profiles/ writable, .claude.json parseable, junctions/symlinks valid
135
+ Platform Windows: OneDrive home, WindowsApps stub, stale ~/bin/cc; Git Bash: cygpath
136
+ Sanity Test-spawn claude --version through the same code path cc use uses
137
+
138
+ Exit codes: 0 = all ok, 1 = warnings, 2 = errors.
139
+
140
+ --fix Auto-apply safe fixes: create missing dirs, recreate broken junctions
141
+ --fix --force Also apply confirmation-required fixes (e.g. reset a corrupt .claude.json)
142
+ --json Machine-readable output for CI / scripting
118
143
  `,
119
144
  help: `cc help [command]
120
145
 
@@ -206,41 +231,12 @@ function parseFlagsStrict(args, flagNames, { allowPositional = false } = {}) {
206
231
  return { flags, positional };
207
232
  }
208
233
 
209
- function parseAliasFlags(args) {
210
- const out = {};
211
- for (const a of args) {
212
- if (!a.startsWith('--')) {
213
- throw usageError(`Unexpected positional argument: ${a}`);
214
- }
215
- const eq = a.indexOf('=');
216
- if (eq === -1) {
217
- throw usageError(`Flag must be in --key=value form: ${a}`);
218
- }
219
- const key = a.slice(2, eq);
220
- const value = a.slice(eq + 1);
221
- if (!profiles.ALLOWED_ALIAS_KEYS.includes(key)) {
222
- throw usageError(`Unknown flag: --${key}. Allowed: --email, --org, --name.`);
223
- }
224
- if (value.length === 0) {
225
- throw usageError(`Flag --${key} requires a non-empty value.`);
226
- }
227
- out[key] = value;
228
- }
229
- return out;
230
- }
231
-
232
234
  async function cmdAdd(rest) {
233
- const [name, ...flagArgs] = rest;
234
- if (!name) throw usageError('Usage: cc add <profile> [--email=...] [--org=...] [--name=...]');
235
- const aliasFlags = parseAliasFlags(flagArgs);
236
- const { dir } = profiles.addProfile(name, aliasFlags);
235
+ const [name, ...extra] = rest;
236
+ if (!name) throw usageError('Usage: cc add <profile>');
237
+ if (extra.length > 0) throw usageError(`cc add takes no extra arguments: ${extra[0]}`);
238
+ const { dir } = profiles.addProfile(name);
237
239
  console.log(`Created profile "${name}" at ${dir}.`);
238
- if (Object.keys(aliasFlags).length > 0) {
239
- process.stderr.write(
240
- color.stderr.yellow('Aliases are EXPERIMENTAL — Claude may overwrite them on server re-sync.') + '\n'
241
- );
242
- console.log(` Stored aliases: ${JSON.stringify(aliasFlags)}`);
243
- }
244
240
  console.log(`Next: cc use ${name}`);
245
241
  return 0;
246
242
  }
@@ -255,33 +251,24 @@ async function cmdUse(rest) {
255
251
  err.exitCode = 1;
256
252
  throw err;
257
253
  }
258
- try {
259
- profiles.applyAlias(dir);
260
- } catch {
261
- // best-effort; never block the launch
262
- }
263
- return spawnClaude({
264
- args: passthrough,
265
- profileDir: dir,
266
- });
254
+ return spawnClaude({ args: passthrough, profileDir: dir });
267
255
  }
268
256
 
269
257
  function cmdList(rest) {
270
- const { flags } = parseFlagsStrict(rest, ['--json', '--original']);
258
+ const { flags } = parseFlagsStrict(rest, ['--json']);
271
259
  const list = profiles.listProfiles();
272
- const useOriginal = flags.has('--original');
260
+ const total = share.SHARED_SUBDIRS.length;
273
261
 
274
262
  const enriched = list.map((p) => {
275
- const alias = p.alias || {};
276
- const displayEmail = useOriginal ? p.email : (alias.email || p.email);
277
- const storage = isLinkLike(path.join(p.dir, 'projects')) ? 'shared' : 'isolated';
263
+ const sharedCount = share.SHARED_SUBDIRS.filter(sub =>
264
+ isLinkLike(path.join(p.dir, sub))
265
+ ).length;
278
266
  return {
279
267
  name: p.name,
280
268
  dir: p.dir,
281
269
  loggedIn: p.loggedIn,
282
- email: displayEmail,
283
- storage,
284
- alias,
270
+ email: p.email,
271
+ shared: sharedCount,
285
272
  };
286
273
  });
287
274
 
@@ -296,13 +283,13 @@ function cmdList(rest) {
296
283
  return 0;
297
284
  }
298
285
 
299
- const rows = [['Profile', 'LoggedIn', 'Email', 'Storage']];
286
+ const rows = [['Profile', 'LoggedIn', 'Email', 'Shared']];
300
287
  for (const p of enriched) {
301
288
  rows.push([
302
289
  p.name,
303
290
  p.loggedIn ? 'true' : 'false',
304
291
  p.email ? p.email : '-',
305
- p.storage,
292
+ `${p.shared}/${total}`,
306
293
  ]);
307
294
  }
308
295
  console.log(renderTable(rows));
@@ -344,36 +331,89 @@ async function cmdRemove(rest) {
344
331
 
345
332
  async function cmdLink(rest) {
346
333
  if (rest.length === 0) throw usageError('Usage: cc link <profile> [<profile>...]');
347
- const failures = [];
334
+ const multi = rest.length > 1;
335
+ const summary = [];
348
336
  for (const name of rest) {
349
- if (rest.length > 1) console.log(`\n[${name}]`);
337
+ if (multi) console.log(`\n[${name}]`);
338
+ let results;
350
339
  try {
351
- share.shareLink(name);
340
+ results = share.shareLink(name);
352
341
  } catch (err) {
342
+ // Profile-level failure (invalid name, profile missing) — nothing linked.
353
343
  process.stderr.write(color.stderr.red(` error: ${err.message}`) + '\n');
354
- failures.push({ name, message: err.message });
344
+ summary.push({ name, linked: 0, total: share.SHARED_SUBDIRS.length, error: err.message });
345
+ continue;
346
+ }
347
+ for (const r of results) {
348
+ if (r.status === 'failed') {
349
+ process.stderr.write(color.stderr.red(` ${r.sub}: failed — ${r.message}`) + '\n');
350
+ } else if (r.status === 'already-linked') {
351
+ console.log(` ${r.sub}: already linked`);
352
+ } else {
353
+ const note = r.moved ? ` (migrated ${r.moved} item(s) into shared)` : '';
354
+ console.log(` ${r.sub}: linked${note}`);
355
+ }
355
356
  }
357
+ const failed = results.filter((r) => r.status === 'failed');
358
+ const linked = results.length - failed.length;
359
+ console.log(` ${linked}/${results.length} directories linked${failed.length ? `, ${failed.length} failed` : ''}.`);
360
+ summary.push({ name, linked, total: results.length, failed: failed.map((f) => f.sub) });
356
361
  }
357
- if (rest.length > 1) {
358
- const ok = rest.length - failures.length;
359
- console.log(`\nSummary: ${ok} succeeded, ${failures.length} failed.`);
360
- for (const f of failures) console.log(` ${f.name}: ${f.message}`);
362
+
363
+ const anyFailure = summary.some((s) => s.error || (s.failed && s.failed.length));
364
+ if (multi) {
365
+ console.log('\nSummary:');
366
+ for (const s of summary) {
367
+ if (s.error) {
368
+ console.log(` ${s.name}: error — ${s.error}`);
369
+ } else if (s.failed && s.failed.length) {
370
+ console.log(` ${s.name}: ${s.linked}/${s.total} linked (failed: ${s.failed.join(', ')})`);
371
+ } else {
372
+ console.log(` ${s.name}: ${s.linked}/${s.total} linked`);
373
+ }
374
+ }
361
375
  }
362
- return failures.length === 0 ? 0 : 1;
376
+ return anyFailure ? 1 : 0;
363
377
  }
364
378
 
365
379
  async function cmdUnlink(rest) {
366
380
  const [name] = rest;
367
381
  if (!name) throw usageError('Usage: cc unlink <profile>');
368
- share.shareUnlink(name);
369
- return 0;
382
+ const results = share.shareUnlink(name);
383
+ let anyFailure = false;
384
+ for (const r of results) {
385
+ if (r.status === 'failed') {
386
+ anyFailure = true;
387
+ process.stderr.write(color.stderr.red(` ${r.sub}: failed — ${r.message}`) + '\n');
388
+ } else if (r.status === 'not-linked') {
389
+ console.log(` ${r.sub}: not linked, skipping`);
390
+ } else {
391
+ console.log(` ${r.sub}: unlinked, content copied back`);
392
+ }
393
+ }
394
+ return anyFailure ? 1 : 0;
395
+ }
396
+
397
+ async function cmdDoctor(rest) {
398
+ const { flags } = parseFlagsStrict(rest, ['--fix', '--force', '--json']);
399
+ const fix = flags.has('--fix');
400
+ const force = flags.has('--force');
401
+ if (force && !fix) throw usageError('--force requires --fix');
402
+ const json = flags.has('--json');
403
+ const doctor = require('./doctor');
404
+ return doctor.runDoctor({ fix, force, json, prompt });
370
405
  }
371
406
 
372
407
  async function cmdMigrate(rest) {
373
- const [src, dest, ...extra] = rest;
374
- if (!src || !dest) throw usageError('Usage: cc migrate <src> <dest>');
408
+ const { flags, positional } = parseFlagsStrict(rest, ['--force'], { allowPositional: true });
409
+ const [src, dest, ...extra] = positional;
410
+ if (!src || !dest) throw usageError('Usage: cc migrate <src> <dest> [--force]');
375
411
  if (extra.length > 0) throw usageError(`Unexpected extra arguments: ${extra.join(' ')}`);
376
- migrateLib.migrate(src, dest);
412
+ const force = flags.has('--force');
413
+ if (force) {
414
+ process.stdout.write('Warning: --force is set. Existing items at the destination will be overwritten and their current content will be cleared.\n');
415
+ }
416
+ migrateLib.migrate(src, dest, { force });
377
417
  return 0;
378
418
  }
379
419
 
@@ -407,6 +447,8 @@ async function run(argv) {
407
447
  return await cmdUnlink(rest);
408
448
  case 'remove':
409
449
  return await cmdRemove(rest);
450
+ case 'doctor':
451
+ return await cmdDoctor(rest);
410
452
  default: {
411
453
  process.stderr.write(color.stderr.red(`Unknown command: ${action}`) + '\n\n');
412
454
  printHelp();
package/lib/doctor.js ADDED
@@ -0,0 +1,375 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const profiles = require('./profiles');
8
+ const { findClaude, getClaudeVersion, compareVersion, MIN_VERSION } = require('./spawn');
9
+ const color = require('./color');
10
+
11
+ const GROUP_ORDER = ['Environment', 'Storage', 'Platform', 'Sanity'];
12
+
13
+ // fix: { safe, description, cmd, apply }
14
+ // safe=true → auto-applied by --fix
15
+ // safe=false → needs --fix --force, prompted y/n
16
+ // cmd → printed when fix cannot be auto-applied
17
+ // apply → async fn to execute
18
+
19
+ function mk(group, name, status, message, fix) {
20
+ return { group, name, status, message, fix: fix || null };
21
+ }
22
+
23
+ // --- Environment ---
24
+
25
+ function checkNodeVersion() {
26
+ const v = `v${process.versions.node}`;
27
+ if (parseInt(process.versions.node.split('.')[0], 10) >= 18) {
28
+ return mk('Environment', 'Node.js version', 'ok', `${v} (>=18)`);
29
+ }
30
+ return mk('Environment', 'Node.js version', 'error', `${v} — requires >=18`, {
31
+ safe: false, description: 'Install Node.js 18+', cmd: 'https://nodejs.org', apply: null,
32
+ });
33
+ }
34
+
35
+ function checkClaudeOnPath() {
36
+ const bin = findClaude();
37
+ if (bin) return mk('Environment', 'claude on PATH', 'ok', bin);
38
+ return mk('Environment', 'claude on PATH', 'error', 'not found', {
39
+ safe: false,
40
+ description: 'Install Claude Code',
41
+ cmd: 'npm install -g @anthropic-ai/claude-code',
42
+ apply: null,
43
+ });
44
+ }
45
+
46
+ function checkClaudeVersion() {
47
+ const bin = findClaude();
48
+ if (!bin) return null;
49
+ const v = getClaudeVersion(bin);
50
+ const minStr = MIN_VERSION.join('.');
51
+ if (!v) {
52
+ return mk('Environment', 'claude version', 'warn', 'could not determine', {
53
+ safe: false, description: 'Update Claude Code', cmd: 'claude update', apply: null,
54
+ });
55
+ }
56
+ if (compareVersion(v, MIN_VERSION) >= 0) return mk('Environment', 'claude version', 'ok', `${v.raw} (>=${minStr})`);
57
+ return mk('Environment', 'claude version', 'warn', `${v.raw} < ${minStr} — CLAUDE_CONFIG_DIR isolation unreliable`, {
58
+ safe: false, description: 'Update Claude Code', cmd: 'claude update', apply: null,
59
+ });
60
+ }
61
+
62
+ const CONFLICT_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
63
+
64
+ function checkConflictingEnv() {
65
+ const set = CONFLICT_VARS.filter(k => process.env[k]);
66
+ if (set.length === 0) return mk('Environment', 'conflicting env vars', 'ok', 'none set');
67
+ const cmd = process.platform === 'win32'
68
+ ? set.map(k => `Remove-Item Env:${k}`).join('; ')
69
+ : set.map(k => `unset ${k}`).join('; ');
70
+ return mk('Environment', 'conflicting env vars', 'warn', `set: ${set.join(', ')}`, {
71
+ safe: false, description: 'Unset to avoid auth interference with cc use', cmd, apply: null,
72
+ });
73
+ }
74
+
75
+ function findAllOnPath(name) {
76
+ const PATH = process.env.PATH || process.env.Path || '';
77
+ const sep = process.platform === 'win32' ? ';' : ':';
78
+ const exts = process.platform === 'win32'
79
+ ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map(s => s.trim()).filter(Boolean)
80
+ : [''];
81
+ const found = [];
82
+ const seen = new Set();
83
+ for (const dir of PATH.split(sep).filter(Boolean)) {
84
+ for (const ext of exts) {
85
+ try {
86
+ const full = path.join(dir, name + ext);
87
+ if (fs.statSync(full).isFile()) {
88
+ const key = process.platform === 'win32' ? full.toLowerCase() : full;
89
+ if (!seen.has(key)) { seen.add(key); found.push(full); }
90
+ break;
91
+ }
92
+ } catch { /* skip */ }
93
+ }
94
+ }
95
+ return found;
96
+ }
97
+
98
+ function checkCcShadow() {
99
+ const all = findAllOnPath('cc');
100
+ if (all.length <= 1) {
101
+ return mk('Environment', 'cc on PATH', 'ok', all[0] || 'not found yet (expected after npm install -g)');
102
+ }
103
+ return mk('Environment', 'cc on PATH', 'warn', `multiple: ${all.join(', ')}`, {
104
+ safe: false, description: 'Remove duplicate cc entries to avoid version confusion', cmd: null, apply: null,
105
+ });
106
+ }
107
+
108
+ // --- Storage ---
109
+
110
+ function checkProfilesDirWritable() {
111
+ const base = profiles.baseDir();
112
+ if (!fs.existsSync(base)) {
113
+ return mk('Storage', '~/.claude-profiles/', 'warn', 'does not exist', {
114
+ safe: true,
115
+ description: `Create ${base}`,
116
+ cmd: null,
117
+ apply: async () => {
118
+ fs.mkdirSync(base, { recursive: true });
119
+ process.stdout.write(` Created ${base}\n`);
120
+ },
121
+ });
122
+ }
123
+ try {
124
+ fs.accessSync(base, fs.constants.W_OK);
125
+ return mk('Storage', '~/.claude-profiles/', 'ok', 'writable');
126
+ } catch {
127
+ const cmd = process.platform === 'win32'
128
+ ? `icacls "${base}" /grant "%USERNAME%:F"`
129
+ : `chmod u+w "${base}"`;
130
+ return mk('Storage', '~/.claude-profiles/', 'error', `not writable: ${base}`, {
131
+ safe: false, description: 'Fix directory permissions', cmd, apply: null,
132
+ });
133
+ }
134
+ }
135
+
136
+ function checkProfileClaudeJson(prof) {
137
+ const p = path.join(prof.dir, '.claude.json');
138
+ if (!fs.existsSync(p)) return mk('Storage', `"${prof.name}" .claude.json`, 'ok', 'not yet logged in');
139
+ try {
140
+ const raw = fs.readFileSync(p, 'utf8');
141
+ if (raw.trim()) JSON.parse(raw);
142
+ return mk('Storage', `"${prof.name}" .claude.json`, 'ok', 'valid JSON');
143
+ } catch (e) {
144
+ return mk('Storage', `"${prof.name}" .claude.json`, 'error', `parse error: ${e.message}`, {
145
+ safe: false,
146
+ description: `Back up ${path.basename(p)} and reset to {}`,
147
+ cmd: null,
148
+ apply: async () => {
149
+ const bak = `${p}.bak.${Date.now()}`;
150
+ fs.renameSync(p, bak);
151
+ fs.writeFileSync(p, '{}');
152
+ process.stdout.write(` Backed up to ${path.basename(bak)}, reset to {}\n`);
153
+ },
154
+ });
155
+ }
156
+ }
157
+
158
+ function checkProfileLinks(prof) {
159
+ const results = [];
160
+ const pp = path.join(prof.dir, 'projects');
161
+ let isLinkNode = false;
162
+ try { isLinkNode = fs.lstatSync(pp).isSymbolicLink(); } catch { return results; }
163
+ if (!isLinkNode) return results;
164
+
165
+ try {
166
+ fs.statSync(pp);
167
+ results.push(mk('Storage', `"${prof.name}" projects/ link`, 'ok', 'valid'));
168
+ return results;
169
+ } catch { /* broken */ }
170
+
171
+ const expected = path.join(profiles.sharedDir(), 'projects');
172
+ const targetExists = fs.existsSync(expected);
173
+ results.push(mk('Storage', `"${prof.name}" projects/ link`, 'error', 'broken — target missing', {
174
+ safe: targetExists,
175
+ description: targetExists ? `Recreate junction/symlink to ${expected}` : 'Target missing',
176
+ cmd: targetExists ? null : `cc link ${prof.name}`,
177
+ apply: targetExists ? async () => {
178
+ try { fs.unlinkSync(pp); } catch { try { fs.rmdirSync(pp); } catch { /* ignore */ } }
179
+ fs.symlinkSync(expected, pp, process.platform === 'win32' ? 'junction' : 'dir');
180
+ process.stdout.write(` Recreated: ${pp} -> ${expected}\n`);
181
+ } : null,
182
+ }));
183
+ return results;
184
+ }
185
+
186
+ // --- Platform ---
187
+
188
+ function checkOneDrive() {
189
+ if (process.platform !== 'win32') return null;
190
+ if (/onedrive/i.test(os.homedir()) || /onedrive/i.test(process.env.USERPROFILE || '')) {
191
+ return mk('Platform', 'OneDrive home folder', 'warn',
192
+ 'home is OneDrive-synced — can corrupt directory junctions', {
193
+ safe: false, description: 'Exclude ~/.claude-profiles from OneDrive sync', cmd: null, apply: null,
194
+ });
195
+ }
196
+ return mk('Platform', 'OneDrive home folder', 'ok', 'not OneDrive-redirected');
197
+ }
198
+
199
+ function checkWindowsAppsCc() {
200
+ if (process.platform !== 'win32') return null;
201
+ const p = path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'cc.cmd');
202
+ if (!fs.existsSync(p)) return mk('Platform', 'WindowsApps cc.cmd', 'ok', 'not present');
203
+ return mk('Platform', 'WindowsApps cc.cmd', 'warn', `${p} may shadow npm-installed cc`, {
204
+ safe: false, description: 'Delete the stale stub', cmd: `del "${p}"`, apply: null,
205
+ });
206
+ }
207
+
208
+ function checkStaleBinCc() {
209
+ if (process.platform !== 'win32') return null;
210
+ const p = path.join(os.homedir(), 'bin', 'cc');
211
+ if (!fs.existsSync(p)) return null;
212
+ return mk('Platform', '~/bin/cc', 'warn', `${p} may be an old version shadowing npm-installed cc`, {
213
+ safe: false, description: 'Remove the old script', cmd: `Remove-Item "${p}"`, apply: null,
214
+ });
215
+ }
216
+
217
+ function checkCygpath() {
218
+ if (process.platform !== 'win32') return null;
219
+ if (!process.env.MSYSTEM && !process.env.MINGW_PREFIX) return null;
220
+ if (findAllOnPath('cygpath').length > 0) return mk('Platform', 'cygpath (Git Bash)', 'ok', 'available');
221
+ return mk('Platform', 'cygpath (Git Bash)', 'warn', 'not found — path conversion may fail', {
222
+ safe: false, description: 'Use Git for Windows which bundles cygpath', cmd: null, apply: null,
223
+ });
224
+ }
225
+
226
+ // --- Sanity ---
227
+
228
+ function checkSpawnClaude() {
229
+ const bin = findClaude();
230
+ if (!bin) return null;
231
+ const v = getClaudeVersion(bin);
232
+ if (v) return mk('Sanity', 'test-spawn claude --version', 'ok', v.raw);
233
+ return mk('Sanity', 'test-spawn claude --version', 'error', 'no version output', {
234
+ safe: false, description: 'Reinstall Claude Code', cmd: 'npm install -g @anthropic-ai/claude-code', apply: null,
235
+ });
236
+ }
237
+
238
+ // --- Collect all ---
239
+
240
+ function runChecks() {
241
+ const results = [
242
+ checkNodeVersion(),
243
+ checkClaudeOnPath(),
244
+ checkClaudeVersion(),
245
+ checkConflictingEnv(),
246
+ checkCcShadow(),
247
+ checkProfilesDirWritable(),
248
+ ];
249
+ for (const prof of profiles.listProfiles()) {
250
+ results.push(checkProfileClaudeJson(prof));
251
+ results.push(...checkProfileLinks(prof));
252
+ }
253
+ results.push(
254
+ checkOneDrive(),
255
+ checkWindowsAppsCc(),
256
+ checkStaleBinCc(),
257
+ checkCygpath(),
258
+ checkSpawnClaude(),
259
+ );
260
+ return results.filter(Boolean);
261
+ }
262
+
263
+ // --- Output formatting ---
264
+
265
+ function colorIcon(status) {
266
+ const icon = status === 'ok' ? '✓' : status === 'warn' ? '⚠' : '✗';
267
+ if (!color.colorEnabled(process.stdout)) return icon;
268
+ if (status === 'ok') return `\x1b[32m${icon}\x1b[39m`;
269
+ if (status === 'warn') return `\x1b[33m${icon}\x1b[39m`;
270
+ return `\x1b[31m${icon}\x1b[39m`;
271
+ }
272
+
273
+ function formatText(results) {
274
+ const groups = {};
275
+ for (const g of GROUP_ORDER) groups[g] = [];
276
+ for (const r of results) {
277
+ if (groups[r.group]) groups[r.group].push(r);
278
+ }
279
+
280
+ const lines = [];
281
+ for (const g of GROUP_ORDER) {
282
+ if (groups[g].length === 0) continue;
283
+ lines.push(g);
284
+ for (const r of groups[g]) {
285
+ lines.push(` ${colorIcon(r.status)} ${r.name}: ${r.message}`);
286
+ if (r.status !== 'ok' && r.fix) {
287
+ if (r.fix.cmd) {
288
+ lines.push(` → Run: ${r.fix.cmd}`);
289
+ } else if (r.fix.apply) {
290
+ lines.push(` → Run: ${r.fix.safe ? 'cc doctor --fix' : 'cc doctor --fix --force'}`);
291
+ }
292
+ }
293
+ }
294
+ lines.push('');
295
+ }
296
+ return lines.join('\n');
297
+ }
298
+
299
+ function serializeFix(fix) {
300
+ if (!fix) return null;
301
+ return { safe: fix.safe, description: fix.description || null, cmd: fix.cmd || null };
302
+ }
303
+
304
+ function formatJson(results) {
305
+ const counts = { ok: 0, warn: 0, error: 0 };
306
+ for (const r of results) counts[r.status] = (counts[r.status] || 0) + 1;
307
+ return JSON.stringify({
308
+ summary: counts,
309
+ checks: results.map(r => ({
310
+ group: r.group, name: r.name, status: r.status, message: r.message,
311
+ fix: serializeFix(r.fix),
312
+ })),
313
+ }, null, 2);
314
+ }
315
+
316
+ // --- Fix runner ---
317
+
318
+ async function applyFixes(results, force, promptFn) {
319
+ const toFix = results.filter(r => r.fix && r.fix.apply && (r.fix.safe || force));
320
+ if (toFix.length === 0) {
321
+ process.stdout.write('Nothing auto-fixable found.\n');
322
+ return;
323
+ }
324
+ for (const r of toFix) {
325
+ if (r.fix.safe) {
326
+ process.stdout.write(`Fixing: ${r.name}\n`);
327
+ await r.fix.apply();
328
+ } else {
329
+ const ans = await promptFn(`Fix "${r.name}" — ${r.fix.description || r.name}? [y/N]: `);
330
+ if (ans.trim().toLowerCase() === 'y') {
331
+ await r.fix.apply();
332
+ } else {
333
+ process.stdout.write(' Skipped.\n');
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ // --- Entry point ---
340
+
341
+ async function runDoctor({ fix, force, json, prompt: promptFn }) {
342
+ const results = runChecks();
343
+
344
+ if (json) {
345
+ process.stdout.write(formatJson(results) + '\n');
346
+ } else {
347
+ process.stdout.write(formatText(results));
348
+ }
349
+
350
+ if (fix) {
351
+ if (!json) process.stdout.write('Applying fixes...\n');
352
+ await applyFixes(results, force, promptFn);
353
+ } else if (!json) {
354
+ const errors = results.filter(r => r.status === 'error');
355
+ const warns = results.filter(r => r.status === 'warn');
356
+ if (errors.length === 0 && warns.length === 0) {
357
+ process.stdout.write('All checks passed.\n');
358
+ } else {
359
+ const parts = [];
360
+ if (errors.length) parts.push(`${errors.length} error${errors.length !== 1 ? 's' : ''}`);
361
+ if (warns.length) parts.push(`${warns.length} warning${warns.length !== 1 ? 's' : ''}`);
362
+ process.stdout.write(`${parts.join(', ')}.\n`);
363
+ const hasAutoFix = results.some(r => r.fix && r.fix.apply && r.fix.safe);
364
+ const hasForce = results.some(r => r.fix && r.fix.apply && !r.fix.safe);
365
+ if (hasAutoFix) process.stdout.write('Run: cc doctor --fix\n');
366
+ else if (hasForce) process.stdout.write('Run: cc doctor --fix --force\n');
367
+ }
368
+ }
369
+
370
+ if (results.some(r => r.status === 'error')) return 2;
371
+ if (results.some(r => r.status === 'warn')) return 1;
372
+ return 0;
373
+ }
374
+
375
+ module.exports = { runChecks, runDoctor };
package/lib/migrate.js CHANGED
@@ -6,9 +6,14 @@ const os = require('os');
6
6
 
7
7
  const profiles = require('./profiles');
8
8
 
9
- // Only projects/ holds the resumable conversation transcripts. sessions/ is transient
10
- // per-process state and is intentionally not migrated.
11
- const SUBDIRS = ['projects'];
9
+ // sessions/ is transient per-process state — intentionally not migrated.
10
+ // plugins/ carries auth tokens — excluded until an explicit opt-in flag is added.
11
+ const SUBDIRS = ['projects', 'skills', 'agents', 'commands'];
12
+
13
+ // Single files copied only when src=default and dest=<profile>.
14
+ // Not copied to shared: these carry per-profile config / auth tokens.
15
+ const SINGLE_FILES = ['mcp.json', 'settings.json', 'CLAUDE.md'];
16
+
12
17
  const EXCLUDED_FILES = new Set(['.credentials.json', '.claude.json']);
13
18
 
14
19
  function defaultClaudeDir() {
@@ -36,7 +41,7 @@ function resolveDest(dest) {
36
41
  return { kind: 'profile', name: dest, dir: profiles.profileDir(dest) };
37
42
  }
38
43
 
39
- function copyTreeSkipCollisions(srcRoot, dstRoot) {
44
+ function copyTreeSkipCollisions(srcRoot, dstRoot, { force = false } = {}) {
40
45
  if (!fs.existsSync(srcRoot)) return { copied: 0, skipped: 0 };
41
46
  fs.mkdirSync(dstRoot, { recursive: true });
42
47
  let copied = 0;
@@ -46,8 +51,12 @@ function copyTreeSkipCollisions(srcRoot, dstRoot) {
46
51
  const from = path.join(srcRoot, ent.name);
47
52
  const to = path.join(dstRoot, ent.name);
48
53
  if (fs.existsSync(to)) {
49
- skipped += 1;
50
- continue;
54
+ if (force) {
55
+ fs.rmSync(to, { recursive: true, force: true });
56
+ } else {
57
+ skipped += 1;
58
+ continue;
59
+ }
51
60
  }
52
61
  fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true });
53
62
  copied += 1;
@@ -55,7 +64,23 @@ function copyTreeSkipCollisions(srcRoot, dstRoot) {
55
64
  return { copied, skipped };
56
65
  }
57
66
 
58
- function migrate(srcArg, destArg) {
67
+ // Returns 'copied', 'skipped', or 'absent'.
68
+ function copySingleFile(srcDir, dstDir, fileName, { force = false } = {}) {
69
+ const from = path.join(srcDir, fileName);
70
+ if (!fs.existsSync(from)) return 'absent';
71
+ const to = path.join(dstDir, fileName);
72
+ if (fs.existsSync(to)) {
73
+ if (force) {
74
+ fs.rmSync(to, { recursive: true, force: true });
75
+ } else {
76
+ return 'skipped';
77
+ }
78
+ }
79
+ fs.copyFileSync(from, to);
80
+ return 'copied';
81
+ }
82
+
83
+ function migrate(srcArg, destArg, { force = false } = {}) {
59
84
  const src = resolveSource(srcArg);
60
85
  const dest = resolveDest(destArg);
61
86
 
@@ -71,7 +96,7 @@ function migrate(srcArg, destArg) {
71
96
 
72
97
  if (!fs.existsSync(src.dir)) {
73
98
  console.log(`Source ${src.dir} does not exist. Nothing to migrate.`);
74
- return { copied: 0, skipped: 0 };
99
+ return { copied: 0, skipped: 0, files: {} };
75
100
  }
76
101
 
77
102
  let totalCopied = 0;
@@ -80,13 +105,27 @@ function migrate(srcArg, destArg) {
80
105
  const srcSub = path.join(src.dir, sub);
81
106
  const dstSub = path.join(dest.dir, sub);
82
107
  if (!fs.existsSync(srcSub)) continue;
83
- const { copied, skipped } = copyTreeSkipCollisions(srcSub, dstSub);
108
+ const { copied, skipped } = copyTreeSkipCollisions(srcSub, dstSub, { force });
84
109
  console.log(` ${sub}: copied ${copied}, skipped ${skipped}`);
85
110
  totalCopied += copied;
86
111
  totalSkipped += skipped;
87
112
  }
113
+
114
+ // Single config files: only when migrating from default into a profile.
115
+ const fileResults = {};
116
+ if (src.kind === 'default' && dest.kind === 'profile') {
117
+ for (const f of SINGLE_FILES) {
118
+ const result = copySingleFile(src.dir, dest.dir, f, { force });
119
+ fileResults[f] = result;
120
+ const label = result === 'absent' ? 'not present at source' : result;
121
+ console.log(` ${f}: ${label}`);
122
+ if (result === 'copied') totalCopied += 1;
123
+ else if (result === 'skipped') totalSkipped += 1;
124
+ }
125
+ }
126
+
88
127
  console.log(`Done. Total copied: ${totalCopied}, total skipped (already present): ${totalSkipped}.`);
89
- return { copied: totalCopied, skipped: totalSkipped };
128
+ return { copied: totalCopied, skipped: totalSkipped, files: fileResults };
90
129
  }
91
130
 
92
131
  module.exports = {
@@ -95,6 +134,8 @@ module.exports = {
95
134
  resolveSource,
96
135
  resolveDest,
97
136
  copyTreeSkipCollisions,
137
+ copySingleFile,
98
138
  SUBDIRS,
139
+ SINGLE_FILES,
99
140
  EXCLUDED_FILES,
100
141
  };
package/lib/profiles.js CHANGED
@@ -13,14 +13,6 @@ const RESERVED_WIN = new Set([
13
13
  const MAX_NAME_LEN = 64;
14
14
  const INVALID_CHARS_RE = /[\\/:*?"<>|\x00-\x1f]/;
15
15
 
16
- const ALIAS_FILE = '.cc-alias.json';
17
- const ALLOWED_ALIAS_KEYS = ['email', 'org', 'name'];
18
- const OAUTH_FIELD_MAP = {
19
- email: 'emailAddress',
20
- org: 'organizationName',
21
- name: 'displayName',
22
- };
23
-
24
16
  function baseDir() {
25
17
  return path.join(os.homedir(), '.claude-profiles');
26
18
  }
@@ -88,31 +80,7 @@ function ensureProfileDir(name) {
88
80
  return { dir, created };
89
81
  }
90
82
 
91
- function aliasPath(dir) {
92
- return path.join(dir, ALIAS_FILE);
93
- }
94
-
95
- function readAlias(dir) {
96
- try {
97
- const raw = fs.readFileSync(aliasPath(dir), 'utf8');
98
- if (!raw.trim()) return {};
99
- const data = JSON.parse(raw);
100
- if (!data || typeof data !== 'object') return {};
101
- const out = {};
102
- for (const k of ALLOWED_ALIAS_KEYS) {
103
- if (typeof data[k] === 'string' && data[k].length > 0) out[k] = data[k];
104
- }
105
- return out;
106
- } catch {
107
- return {};
108
- }
109
- }
110
-
111
- function writeAlias(dir, data) {
112
- fs.writeFileSync(aliasPath(dir), JSON.stringify(data, null, 2));
113
- }
114
-
115
- function addProfile(name, aliasFlags) {
83
+ function addProfile(name) {
116
84
  validateProfileName(name);
117
85
  const dir = profileDir(name);
118
86
  if (fs.existsSync(dir)) {
@@ -120,57 +88,9 @@ function addProfile(name, aliasFlags) {
120
88
  e.exitCode = 1;
121
89
  throw e;
122
90
  }
123
- const alias = {};
124
- for (const [k, v] of Object.entries(aliasFlags || {})) {
125
- if (!ALLOWED_ALIAS_KEYS.includes(k)) {
126
- throw usageError(`Unknown flag: --${k}. Allowed: --email, --org, --name.`);
127
- }
128
- if (typeof v !== 'string' || v.length === 0) {
129
- throw usageError(`Flag --${k} requires a non-empty value.`);
130
- }
131
- alias[k] = v;
132
- }
133
91
  ensureBaseDir();
134
92
  fs.mkdirSync(dir);
135
- if (Object.keys(alias).length > 0) {
136
- writeAlias(dir, alias);
137
- }
138
- return { dir, alias };
139
- }
140
-
141
- function applyAlias(dir) {
142
- const alias = readAlias(dir);
143
- if (Object.keys(alias).length === 0) return false;
144
- const claudeJsonPath = path.join(dir, '.claude.json');
145
- if (!fs.existsSync(claudeJsonPath)) return false;
146
- let data;
147
- try {
148
- const raw = fs.readFileSync(claudeJsonPath, 'utf8');
149
- if (!raw.trim()) return false;
150
- data = JSON.parse(raw);
151
- } catch {
152
- return false;
153
- }
154
- if (!data || typeof data !== 'object') return false;
155
- if (!data.oauthAccount || typeof data.oauthAccount !== 'object') return false;
156
- let mutated = false;
157
- for (const [k, v] of Object.entries(alias)) {
158
- const oauthKey = OAUTH_FIELD_MAP[k];
159
- if (!oauthKey) continue;
160
- if (data.oauthAccount[oauthKey] !== v) {
161
- data.oauthAccount[oauthKey] = v;
162
- mutated = true;
163
- }
164
- }
165
- if (mutated) {
166
- try {
167
- fs.writeFileSync(claudeJsonPath, JSON.stringify(data, null, 2));
168
- return true;
169
- } catch {
170
- return false;
171
- }
172
- }
173
- return false;
93
+ return { dir };
174
94
  }
175
95
 
176
96
  function readEmail(dir) {
@@ -214,7 +134,6 @@ function listProfiles() {
214
134
  dir,
215
135
  loggedIn: hasCredentials(dir),
216
136
  email: readEmail(dir),
217
- alias: readAlias(dir),
218
137
  });
219
138
  }
220
139
  out.sort((a, b) => a.name.localeCompare(b.name));
@@ -232,9 +151,4 @@ module.exports = {
232
151
  hasCredentials,
233
152
  listProfiles,
234
153
  addProfile,
235
- readAlias,
236
- writeAlias,
237
- applyAlias,
238
- ALLOWED_ALIAS_KEYS,
239
- OAUTH_FIELD_MAP,
240
154
  };
package/lib/share.js CHANGED
@@ -5,10 +5,11 @@ const path = require('path');
5
5
 
6
6
  const profiles = require('./profiles');
7
7
 
8
- // Only projects/ holds the resumable conversation transcripts (projects/<proj>/<session>.jsonl).
8
+ // Artifact directories that are safe to share across profiles via junctions/symlinks.
9
9
  // sessions/ is transient per-process registry state (PID, cwd, status, timestamps) and
10
10
  // must stay per-profile — sharing it would corrupt Claude's running-process bookkeeping.
11
- const SHARED_SUBDIRS = ['projects'];
11
+ // plugins/ carries auth tokens and is intentionally excluded (future opt-in).
12
+ const SHARED_SUBDIRS = ['projects', 'skills', 'agents', 'commands'];
12
13
 
13
14
  function symlinkType() {
14
15
  return process.platform === 'win32' ? 'junction' : 'dir';
@@ -70,6 +71,11 @@ function migrateIntoShared(profileName, src, sharedTarget) {
70
71
  return moved;
71
72
  }
72
73
 
74
+ // Links each of the four artifact dirs independently. A failure on one
75
+ // directory is recorded and the remaining directories are still attempted —
76
+ // callers get a per-directory result and decide how to report it.
77
+ // Returns [{ sub, status, moved?, message? }]; status is one of:
78
+ // 'linked' | 'already-linked' | 'failed'
73
79
  function shareLink(profileName) {
74
80
  profiles.validateProfileName(profileName);
75
81
  const pdir = profiles.profileDir(profileName);
@@ -79,26 +85,31 @@ function shareLink(profileName) {
79
85
  throw err;
80
86
  }
81
87
  ensureSharedDirs();
88
+ const results = [];
82
89
  for (const sub of SHARED_SUBDIRS) {
83
90
  const target = path.join(profiles.sharedDir(), sub);
84
91
  const link = path.join(pdir, sub);
85
- if (isLink(link)) {
86
- console.log(` ${sub}: already linked`);
87
- continue;
88
- }
89
- if (fs.existsSync(link)) {
90
- const moved = migrateIntoShared(profileName, link, target);
91
- console.log(` ${sub}: migrated ${moved} item(s) into shared`);
92
- }
93
92
  try {
93
+ if (isLink(link)) {
94
+ results.push({ sub, status: 'already-linked' });
95
+ continue;
96
+ }
97
+ let moved = 0;
98
+ if (fs.existsSync(link)) {
99
+ moved = migrateIntoShared(profileName, link, target);
100
+ }
94
101
  fs.symlinkSync(target, link, symlinkType());
102
+ results.push({ sub, status: 'linked', moved });
95
103
  } catch (err) {
96
- throw new Error(`Cannot link ${link} -> ${target}: ${err.message}`);
104
+ results.push({ sub, status: 'failed', message: err.message });
97
105
  }
98
- console.log(` ${sub}: linked -> ${target}`);
99
106
  }
107
+ return results;
100
108
  }
101
109
 
110
+ // Mirror of shareLink: each directory is restored independently.
111
+ // Returns [{ sub, status, message? }]; status is one of:
112
+ // 'unlinked' | 'not-linked' | 'failed'
102
113
  function shareUnlink(profileName) {
103
114
  profiles.validateProfileName(profileName);
104
115
  const pdir = profiles.profileDir(profileName);
@@ -107,22 +118,28 @@ function shareUnlink(profileName) {
107
118
  err.exitCode = 1;
108
119
  throw err;
109
120
  }
121
+ const results = [];
110
122
  for (const sub of SHARED_SUBDIRS) {
111
123
  const link = path.join(pdir, sub);
112
124
  const target = path.join(profiles.sharedDir(), sub);
113
- if (!isLink(link)) {
114
- console.log(` ${sub}: not linked, skipping`);
115
- continue;
116
- }
117
- removeLink(link);
118
- fs.mkdirSync(link, { recursive: true });
119
- if (fs.existsSync(target)) {
120
- for (const ent of fs.readdirSync(target)) {
121
- copyRecursive(path.join(target, ent), path.join(link, ent));
125
+ try {
126
+ if (!isLink(link)) {
127
+ results.push({ sub, status: 'not-linked' });
128
+ continue;
122
129
  }
130
+ removeLink(link);
131
+ fs.mkdirSync(link, { recursive: true });
132
+ if (fs.existsSync(target)) {
133
+ for (const ent of fs.readdirSync(target)) {
134
+ copyRecursive(path.join(target, ent), path.join(link, ent));
135
+ }
136
+ }
137
+ results.push({ sub, status: 'unlinked' });
138
+ } catch (err) {
139
+ results.push({ sub, status: 'failed', message: err.message });
123
140
  }
124
- console.log(` ${sub}: unlinked, content copied back`);
125
141
  }
142
+ return results;
126
143
  }
127
144
 
128
145
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchost",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Host multiple isolated Claude Code accounts on one machine via the `cc` command.",
5
5
  "keywords": [
6
6
  "claude",
@@ -43,4 +43,4 @@
43
43
  "devDependencies": {
44
44
  "vitest": "^2.1.0"
45
45
  }
46
- }
46
+ }