cchost 0.4.2 → 0.4.4

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/CHANGELOG.md CHANGED
@@ -5,6 +5,61 @@ All notable changes to `cchost` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.4.4] - 2026-06-19
9
+
10
+ ### Changed
11
+
12
+ - **macOS officially supported.** CI has been running the full test matrix on
13
+ `macos-latest` since v0.4.0; documentation now reflects that. README, package
14
+ description, and keywords updated. No code change — macOS users were already
15
+ on a verified path, the claim was just stale.
16
+ - Sharper `package.json` description — leads with the value prop
17
+ ("multi-account profile manager for Claude Code") instead of the implementation
18
+ detail. Improves discoverability on `npm search` and the npm package page.
19
+ - README tagline added under the H1 ("nvm, but for your Claude Code accounts")
20
+ and a platforms badge for at-a-glance OS support.
21
+
22
+ ## [0.4.3] - 2026-06-07
23
+
24
+ ### Fixed
25
+
26
+ - **`claude --resume` now finds older sessions after a v0.3 → v0.4 upgrade.**
27
+ The v0.4 migration's `__legacy` collision rename was applied to
28
+ `projects/` folders too, which hid their sessions from `claude --resume`
29
+ (resume filters by cwd-encoded folder name; a `__legacy` suffix never
30
+ matches a real cwd). Four changes:
31
+ - `migrateLegacyPool` now merges `projects/<base>/` children into the
32
+ existing canonical folder instead of renaming the directory.
33
+ Skills/agents/commands keep the directory-level `__legacy` rename —
34
+ only `projects/` changes.
35
+ - Same-UUID session-file collisions during merge reissue a fresh UUID v4
36
+ instead of appending `__legacy.jsonl`, so the file stays a valid Claude
37
+ session filename. Paired `<uuid>/` sub-uuid dirs travel under the same
38
+ new UUID.
39
+ - `cc doctor` detects pre-existing `projects/<base>__legacy*` folders and
40
+ `--fix` merges them into the canonical sibling (or renames the legacy
41
+ folder in place if no sibling exists). Case-insensitive sibling lookup
42
+ on Windows also collapses split `d--…` / `D--…` variants.
43
+ - `cc doctor` also detects damaged `<uuid>__legacy*.jsonl` filenames left
44
+ in `projects/` by earlier heal logic and `--fix` restores them to
45
+ `<uuid>.jsonl` (or reissues a fresh UUID when the canonical name is
46
+ taken).
47
+ - **Self-referencing legacy pool detection.** When `~/.claude/projects/` is
48
+ a junction/symlink pointing into `~/.claude-shared/projects/`
49
+ (a layout some pre-v0.4 setups produced), `migrateLegacyPool` aborts
50
+ cleanly instead of treating it as a real migration — what would otherwise
51
+ have moved every file onto itself and triggered the legacy-suffix path on
52
+ every entry. `cc doctor` surfaces this case as a manual-cleanup warning
53
+ instead of offering a self-move auto-fix.
54
+
55
+ ## [0.4.2] - 2026-06-04
56
+
57
+ ### Fixed
58
+
59
+ - ASCII banner on `cc help` no longer misaligns when the terminal is
60
+ narrower than the banner width. Banner is now top-anchored and stable
61
+ across PowerShell, cmd, bash, zsh, and fish.
62
+
8
63
  ## [0.4.1] - 2026-06-04
9
64
 
10
65
  ### Changed
package/README.md CHANGED
@@ -1,19 +1,23 @@
1
1
  # cchost
2
2
 
3
+ > **nvm, but for your Claude Code accounts.** Works on Windows, Linux, and macOS.
4
+
3
5
  [![npm version](https://img.shields.io/npm/v/cchost.svg)](https://www.npmjs.com/package/cchost)
4
6
  [![CI](https://github.com/allenhack638/cchost/actions/workflows/ci.yml/badge.svg)](https://github.com/allenhack638/cchost/actions/workflows/ci.yml)
5
7
  [![license](https://img.shields.io/npm/l/cchost.svg)](./LICENSE)
6
8
  [![node](https://img.shields.io/node/v/cchost.svg)](https://nodejs.org)
9
+ [![platforms](https://img.shields.io/badge/platforms-windows%20%7C%20linux%20%7C%20macos-blue)](#install)
7
10
 
8
- <!-- Demo: record a ~15s clip of `cc add` → `cc use` → `cc list`, save it as
9
- docs/demo.gif, and uncomment the line below. -->
10
- <!-- ![cchost in action](docs/demo.gif) -->
11
-
12
- Claude Code keeps one account's login, history, and settings in a single config directory. To use a second account you normally have to log out and back in, which loses your place. cchost gives each account its own directory so you can keep several side by side and pick one when you launch.
11
+ <p align="center">
12
+ <img src="https://raw.githubusercontent.com/allenhack638/cchost/master/docs/demo.png" alt="cchost CLI — cc list showing masked emails and shared resource counts" width="100%">
13
+ </p>
13
14
 
14
- Each profile is a complete, isolated `CLAUDE_CONFIG_DIR`: credentials, session history, project state, plugins, and settings. Create one with `cc add <name>`, launch under it with `cc use <name>`. Nothing is shared between profiles unless you ask for it.
15
+ `cchost` is a multi-account profile manager for [Claude Code](https://code.claude.com).
15
16
 
16
- `cc` is a plain Node CLI on your PATH, so it works the same from PowerShell, cmd, bash, zsh, or fish. No shell scripts, no rc-file edits. Tested on Windows, Linux, and macOS in CI.
17
+ - **Isolated profiles** — each profile is a complete `CLAUDE_CONFIG_DIR` with its own credentials, session history, and settings. Switch with `cc use <name>`.
18
+ - **Shared pool** — `skills/`, `agents/`, `commands/`, `projects/`, and shared config files (`mcp.json`, `settings.json`, `CLAUDE.md`) link across all profiles by default. Credentials never do.
19
+ - **Custom endpoints** — route a profile through any Anthropic-compatible API (Moonshot, OpenRouter, self-hosted vLLM, corporate proxies) instead of Anthropic OAuth.
20
+ - **Zero runtime dependencies** — single Node CLI on your `PATH`. Identical behavior across PowerShell, cmd, bash, zsh, and fish.
17
21
 
18
22
  ## Install
19
23
 
@@ -21,212 +25,117 @@ Each profile is a complete, isolated `CLAUDE_CONFIG_DIR`: credentials, session h
21
25
  npm install -g cchost
22
26
  ```
23
27
 
24
- Requires Node.js 18+ and Claude Code 2.1.140+ already installed. Get Claude Code from <https://code.claude.com> (`npm install -g @anthropic-ai/claude-code`).
28
+ Requires Node.js 18+ and Claude Code 2.1.140+ (`npm install -g @anthropic-ai/claude-code`).
25
29
 
26
- ## Commands
27
-
28
- | Command | What it does |
29
- | --- | --- |
30
- | `cc add <profile>` | Create a subscription profile (logs in with Anthropic OAuth on first use). Auto-symlinks artifact dirs into `~/.claude/`. |
31
- | `cc add <profile> --custom [...]` | Create — or, if it already exists, edit — a profile routed through a custom API endpoint. Opens a wizard, or takes `--base-url=URL --token=TOKEN [--model=…]`. See [Custom endpoints](#custom-endpoints-kimi-openrouter-etc). |
32
- | `cc use <profile> [...args]` | Launch Claude under that profile. Profile must already exist. Args after the name are forwarded verbatim to `claude`. |
33
- | `cc list [<profile>] [--json] [--reveal]` | List every profile (login state, email, shared count N/7, endpoint), or show one profile in detail. Emails and tokens are masked by default; `--reveal` shows both in full. |
34
- | `cc copy default <profile> [--force]` | Copy `~/.claude/` resources into a profile. Meaningful only when one or more resources have been unlinked via `cc unlink`; otherwise every resource resolves to the same on-disk path and is skipped. Skip-on-collision by default; `--force` overwrites. `cc migrate` is a deprecated alias. |
35
- | `cc link <profile> [<profile>...] [--projects\|--tools\|--config]` | (Re-)link a profile's resources to `~/.claude/`. No flag = all 7. Refuses to overwrite private content. |
36
- | `cc unlink <profile> [--projects\|--tools\|--config]` | Break the links and copy current `~/.claude/` content into the profile as private resources. Non-destructive. |
37
- | `cc remove <profile> [--force]` | Delete a profile. Requires typing the profile name to confirm. `--force` is required when private (unlinked) resources exist. |
38
- | `cc doctor [--fix [--force]] [--json]` | Diagnose environment, storage, and platform issues. |
39
- | `cc help [command]` | Print usage (top-level, or for a single command). |
40
-
41
- ### Examples
30
+ ## Quickstart
42
31
 
43
32
  ```bash
44
- cc add work # create the profile, do not launch
45
- cc use work # first launch: Claude prompts for login
46
- cc use work --resume # resume last conversation under "work"
47
- cc use personal -p "summarize" # one-shot prompt under "personal"
48
- cc list
49
- cc remove old-profile
33
+ cc add work # create a profile
34
+ cc use work # launch Claude under it (prompts for login on first run)
35
+ cc use work --resume # resume the last conversation
36
+ cc list # see all profiles
37
+ cc remove old-profile # delete a profile
50
38
  ```
51
39
 
52
- `cc list` output:
40
+ ## Commands
41
+
42
+ | Command | What it does |
43
+ | --- | --- |
44
+ | `cc add <profile>` | Create a subscription profile. Logs in via Anthropic OAuth on first `cc use`. Auto-shares all 7 resources with `~/.claude/`. |
45
+ | `cc add <profile> --custom [...]` | Create or edit a custom-endpoint profile. Interactive wizard, or `--base-url=URL --token=TOKEN [--model=…]`. See [Custom endpoints](#custom-endpoints). |
46
+ | `cc use <profile> [...args]` | Launch Claude under that profile. Extra args are forwarded to `claude`. |
47
+ | `cc list [<profile>] [--json] [--reveal]` | List all profiles or show one in detail. Emails and tokens are masked by default; `--reveal` prints them in full. |
48
+ | `cc copy default <profile> [--force]` | Seed an unlinked profile resource from `~/.claude/`. No-op for fully-shared profiles. |
49
+ | `cc link <profile> [--projects\|--tools\|--config]` | Link a profile's resources to `~/.claude/`. Defaults to all 7 when no flag is passed. |
50
+ | `cc unlink <profile> [--projects\|--tools\|--config]` | Break the link and make those resources private to the profile. Non-destructive. |
51
+ | `cc remove <profile> [--force]` | Delete a profile. Requires typing the profile name. `--force` required when private resources exist. |
52
+ | `cc doctor [--fix [--force]] [--json]` | Diagnose environment, storage, and platform issues. `--fix` applies safe auto-fixes; `--force` is required for destructive ones. |
53
+ | `cc help [command]` | Print usage. |
54
+
55
+ Sample `cc list` output:
53
56
 
54
57
  ```
55
58
  Profile LoggedIn Email Shared Endpoint
56
59
  kimi false - 7/7 api.moonshot.ai
57
60
  personal true p************.com 7/7 subscription
58
- scratch false - 7/7 subscription
59
61
  work true w************.com 7/7 subscription
60
62
  ```
61
63
 
62
- `7/7` is the steady state since v0.4.1 — every profile auto-shares 4 dirs (`projects`, `skills`, `agents`, `commands`) **and** 3 files (`mcp.json`, `settings.json`, `CLAUDE.md`) with `~/.claude/`. A lower number means one or more resources were unlinked via `cc unlink <profile> --projects|--tools|--config`.
64
+ The `Shared` column counts how many of the 7 shareable resources are linked into `~/.claude/`. `7/7` is the default; lower numbers mean one or more resources have been `cc unlink`ed and now hold private content.
63
65
 
64
- Emails are masked by default so the list is safe to paste in screenshots and issue reports. Pass `--reveal` to show them in full (same flag also reveals the API token on custom-endpoint profiles).
66
+ ## Sharing model
65
67
 
66
- The **Shared** column is a count: how many of the 7 shareable resources symlink into `~/.claude/`.
68
+ Anything that authenticates a profile stays per-profile; everything else is shared.
67
69
 
68
- - `7/7` — the default since v0.4.1. All four artifact dirs *and* the three
69
- config files point at the shared pool; a tool, server, or hook configured
70
- under any profile is immediately visible to every other profile.
71
- - `6/7`, `5/7`, … — one or more resources were `cc unlink`ed and now hold
72
- private content.
73
- - `0/7` — fully isolated; the profile keeps its own copy of everything.
74
- - `4/7` — typical of a profile created on v0.4.0 before the file resources
75
- were added. Run `cc doctor --fix` to backfill them.
70
+ | Resource | Shared by default | Flag group |
71
+ | --- | :---: | --- |
72
+ | `projects/` | ✓ | `--projects` |
73
+ | `skills/`, `agents/`, `commands/` | ✓ | `--tools` |
74
+ | `mcp.json`, `settings.json`, `CLAUDE.md` | ✓ | `--config` |
75
+ | `.credentials.json`, `.claude.json`, `.cc-env.json` | never | — |
76
+ | `sessions/`, `plugins/` | never | — |
76
77
 
77
- ### What's shared, what's per-profile
78
+ Sharing uses POSIX symlinks on Linux/macOS and directory junctions on Windows (no admin or Developer Mode required). File resources fall back to a hard link on Windows when symlink creation would require elevation. Note that `mcp.json` and `settings.json` can execute arbitrary code through MCP servers and hooks — if a profile needs an isolated copy of either, run `cc unlink <profile> --config`.
78
79
 
79
- Since v0.4.1, **`~/.claude/` is the shared pool**, and **all 7 resources** auto-link into it on `cc add`. The trust boundary is simple: anything that authenticates this profile's identity stays per-profile; everything else is global.
80
-
81
- | Resource | Location | Shared? | Flag group |
82
- | --- | --- | :---: | --- |
83
- | `projects/` | `~/.claude/projects/` | ✓ (symlink) | `--projects` |
84
- | `skills/` | `~/.claude/skills/` | ✓ (symlink) | `--tools` |
85
- | `agents/` | `~/.claude/agents/` | ✓ (symlink) | `--tools` |
86
- | `commands/` | `~/.claude/commands/` | ✓ (symlink) | `--tools` |
87
- | `mcp.json` | `~/.claude/mcp.json` | ✓ (file symlink / hard link) | `--config` |
88
- | `settings.json` | `~/.claude/settings.json` | ✓ (file symlink / hard link) | `--config` |
89
- | `CLAUDE.md` | `~/.claude/CLAUDE.md` | ✓ (file symlink / hard link) | `--config` |
90
- | `sessions/` | per-profile | — | (never shared) |
91
- | `plugins/` | per-profile | — | (never shared) |
92
- | `.credentials.json` | per-profile | — | Anthropic OAuth token. Never touched. |
93
- | `.claude.json` | per-profile | — | Account identity (re-synced on every launch). |
94
- | `.cc-env.json` | per-profile | — | Custom-endpoint API key. Never copied, never linked. |
95
-
96
- `mcp.json` carries MCP server tokens and `settings.json` carries hooks that can run arbitrary commands. Sharing them means every profile sees the same set — if you need per-profile separation for one of them, `cc unlink <profile> --config` breaks the link and makes that profile's copies private.
97
-
98
- ### Seeding a private profile from `~/.claude/`
99
-
100
- If a profile has unlinked resources (`cc unlink <p>`) and you want to seed them from the current `~/.claude/`:
101
-
102
- ```bash
103
- cc copy default work # copies anything unlinked from ~/.claude/ → work
104
- cc copy default work --force # same, overwriting if already present
105
- ```
106
-
107
- For a fresh `cc add` profile this is a no-op — every resource already resolves to `~/.claude/` via its symlink, so there's nothing to copy. `cc copy` becomes meaningful only after `cc unlink`. (`cc migrate` is a deprecated alias for `cc copy` and still works.)
108
-
109
- ### Opting out of sharing for a profile
80
+ To break sharing for a single profile:
110
81
 
111
82
  ```bash
112
- cc unlink work --tools # private skills/agents/commands for "work" only
113
- cc unlink work --projects # private projects/ for "work" only
114
- cc unlink work --config # private mcp.json/settings.json/CLAUDE.md
115
- cc unlink work # private everything (all 7 unlinked)
116
- cc link work --tools # re-share just the tools
117
- cc link work --config # re-share just the config files
83
+ cc unlink work --tools # private skills/agents/commands
84
+ cc unlink work --projects # private projects/
85
+ cc unlink work --config # private mcp.json/settings.json/CLAUDE.md
86
+ cc unlink work # all 7 private
87
+ cc link work --tools # re-share
118
88
  ```
119
89
 
120
- `cc unlink` is non-destructive — current pool content is copied into the profile as a private dir or file before the link is broken, so your view of the resource is preserved. `cc link` refuses to overwrite existing private content; move or remove it manually and re-run.
121
-
122
- Credential files (`.credentials.json`, `.claude.json`, `.cc-env.json`) are never touched: each profile keeps its own auth state.
90
+ `cc unlink` is non-destructive — pool content is copied into the profile before the link is broken. `cc link` refuses to overwrite existing private content; remove or relocate the private resource manually, then re-run.
123
91
 
124
- ### How sharing works under the hood
92
+ Use `cc copy default <profile>` to seed an unlinked resource from the current `~/.claude/`. For fully-shared profiles this is a no-op.
125
93
 
126
- Every profile created by `cc add` ships with 7 links pointing at `~/.claude/<resource>`:
94
+ ## Custom endpoints
127
95
 
128
- - Directories use Windows junctions (no admin / Developer Mode required) or POSIX symlinks.
129
- - Files (`mcp.json`, `settings.json`, `CLAUDE.md`) use file symlinks where possible. On Windows without Developer Mode, file symlinks require elevation — `cc link` falls back to a **hard link** there, which works without admin and behaves identically for edit-in-place flows.
96
+ A profile can route through any Anthropic-compatible API — Moonshot/Kimi, OpenRouter, Requesty, self-hosted vLLM, or a corporate proxy — instead of Anthropic's subscription OAuth.
130
97
 
131
- `sessions/` and `plugins/` are **never** linked. `sessions/` holds transient per-process registry state (PID, status, timestamps) and sharing it would corrupt Claude's running-process bookkeeping. `plugins/` carries auth tokens.
132
-
133
- If two profiles open the same project at the same time, both will write to the same conversation files in `~/.claude/projects/<cwd>/` (last write wins). Either avoid that or run `cc unlink <profile> --projects` on one of them.
134
-
135
- `cc remove` is safe with fully-shared profiles — it deletes only the symlinks, leaving `~/.claude/` untouched. If a profile has *private* (unlinked) resources, `cc remove` requires `--force` because that content is unique to the profile and will be lost.
136
-
137
- ## Custom endpoints (Kimi, OpenRouter, etc.)
138
-
139
- A profile can route through any Anthropic-compatible API endpoint instead of Anthropic's subscription OAuth — Moonshot/Kimi, OpenRouter, Requesty, a self-hosted vLLM, or a corporate proxy. You declare this when you create the profile, with `cc add <profile> --custom`.
140
-
141
- > **Billing:** a custom-endpoint profile bills through the **third-party provider**, using the API key you supply — **not** your Anthropic subscription. You are responsible for that provider's usage charges. A subscription profile is unaffected.
142
-
143
- A custom-endpoint profile and a subscription profile are mutually exclusive: a profile is born one or the other and stays that way. `cc add --custom` refuses to run on a profile that already has Anthropic OAuth credentials — create custom-endpoint profiles fresh.
144
-
145
- ### Worked example — Moonshot / Kimi
98
+ > **Billing:** custom-endpoint profiles bill through the **third-party provider**, not your Anthropic subscription.
146
99
 
147
100
  ```bash
148
101
  cc add kimi --custom --base-url=https://api.moonshot.ai/anthropic \
149
- --token=sk-your-moonshot-key \
102
+ --token=sk-your-key \
150
103
  --model=kimi-k2.5
151
104
  cc use kimi
152
105
  ```
153
106
 
154
- Moonshot's endpoint URL ends in `/anthropic` — Claude Code appends `/v1/messages` itself, so do **not** add `/v1`. Most third-party providers serve a single model name; pass it as `--model` and it is applied to every tier (opus/sonnet/haiku/subagent). To override a tier, pass `--opus=`, `--sonnet=`, `--haiku=`, or `--subagent=`.
155
-
156
- Run `cc add <profile> --custom` with no value flags for an interactive wizard instead — it prompts for every field and never echoes the token. `cc add --custom` is atomic: if you cancel the wizard, no profile is left behind.
157
-
158
- ### Editing a custom-endpoint profile
159
-
160
- Re-run `cc add <profile> --custom` on a profile that already has a custom endpoint and it switches to **edit mode**: the wizard prefills current values, and with flags only the flags you pass are changed.
107
+ Run `cc add <profile> --custom` with no value flags for an interactive wizard — it prompts for every field and never echoes the token. Re-running on an existing custom-endpoint profile enters **edit mode**: the wizard prefills current values, and with flags only the flags you pass are changed.
161
108
 
162
109
  ```bash
163
110
  cc add kimi --custom --model=kimi-k3 # change just the model
164
111
  cc add kimi --custom --token=sk-rotated # rotate the API key
165
112
  ```
166
113
 
167
- Inspect a config with `cc list <profile>` — the API token is masked:
114
+ Subscription and custom-endpoint profiles are mutually exclusive: a profile is either one or the other for its entire lifetime. To switch, remove the profile with `cc remove` and recreate it.
168
115
 
169
- ```
170
- Profile: kimi
171
- Directory: /home/you/.claude-profiles/kimi
172
- LoggedIn: false
173
- Email: -
174
- Shared: 7/7
175
- Endpoint: api.moonshot.ai
176
- Base URL: https://api.moonshot.ai/anthropic
177
- Token: sk-you...key9 (32 chars)
178
- Model: kimi-k2.5
179
- Opus: kimi-k2.5
180
- Sonnet: kimi-k2.5
181
- Haiku: kimi-k2.5
182
- Subagent: kimi-k2.5
183
- ```
184
-
185
- `cc list <profile> --reveal` prints the full API key and the full email — do not paste that output anywhere.
116
+ Inspect with `cc list <profile>` (token masked) or `cc list <profile> --reveal` (full). The endpoint URL should not include `/v1` — Claude Code appends `/v1/messages` itself. Most providers serve a single model; `--model` applies to every tier (`opus`/`sonnet`/`haiku`/`subagent`), or override per-tier with `--opus=`, `--sonnet=`, etc.
186
117
 
187
- There is no way to convert a custom-endpoint profile back to a subscription profile. To stop using an endpoint, `cc remove` the profile and recreate it.
188
-
189
- When you launch a custom-endpoint profile, `cc use` prints a one-line reminder to stderr:
118
+ On launch, `cc use` prints a one-line endpoint banner to stderr:
190
119
 
191
120
  ```
192
121
  [cchost] Profile 'kimi' → api.moonshot.ai (custom endpoint billing applies)
193
122
  ```
194
123
 
195
- The config lives at `~/.claude-profiles/<profile>/.cc-env.json` with user-only file permissions. It contains your API key — see [Sensitive data](#sensitive-data).
196
-
197
- ## How it works
198
-
199
- `cc` is a Node CLI with **zero runtime dependencies**. For `cc use` it:
200
-
201
- 1. Resolves the profile directory under `~/.claude-profiles/<name>/`
202
- 2. Sets `CLAUDE_CONFIG_DIR` in its **own** spawn environment
203
- 3. Spawns `claude` with `stdio: 'inherit'` so the TUI works correctly
204
- 4. Forwards the child's exit code
205
-
206
- The env var is set on the child process only — that's all `claude` needs.
207
-
208
- ## Sensitive data
209
-
210
- `~/.claude-profiles/<profile>/` holds real OAuth credentials. Treat it like SSH keys: don't commit it, don't share it, don't sync it to public cloud storage. The `.gitignore` shipped with this project excludes profile directories from anywhere they might land. `cc copy` and `cc link` never touch `.credentials.json` or `.claude.json`.
124
+ ## Security
211
125
 
212
- `.cc-env.json` (custom-endpoint config) contains a third-party **API key** — treat it exactly like `.credentials.json`. It is written with user-only file permissions, and `cc copy`/`cc link` never copy or link it across profiles. `cc list <profile>` masks the token; only `cc list <profile> --reveal` prints it in full.
126
+ `~/.claude-profiles/<profile>/` holds real OAuth credentials and API keys. Treat it like `~/.ssh`: don't commit it, don't share it, don't sync it to public cloud storage. The shipped `.gitignore` excludes profile directories. `cc copy` and `cc link` never touch credential files (`.credentials.json`, `.claude.json`, `.cc-env.json`); each profile keeps its own auth state.
213
127
 
214
128
  ## Troubleshooting
215
129
 
216
- **`Claude Code does not appear to be installed`**
217
- Install it (`npm install -g @anthropic-ai/claude-code`) and make sure `claude --version` runs in a fresh terminal.
130
+ Run `cc doctor` first — it checks Node, Claude Code version, profile integrity, symlinks, and the shared pool.
218
131
 
219
- **`warning: Claude Code <version> is older than 2.1.140`**
220
- Earlier versions had bugs where Windows read `.claude.json` from `%USERPROFILE%` regardless of `CLAUDE_CONFIG_DIR`, breaking isolation. Run `claude update`.
132
+ **`Claude Code does not appear to be installed`** — install with `npm install -g @anthropic-ai/claude-code` and verify `claude --version` runs in a fresh terminal.
221
133
 
222
- **`Profile "<name>" does not exist. Create it with: cc add <name>`**
223
- `cc use` does not auto-create profiles — that prevents typos like `cc use wokr` from silently creating a new, blank one. Run `cc add <name>` first.
134
+ **`warning: Claude Code <version> is older than 2.1.140`** — earlier versions on Windows read `.claude.json` from `%USERPROFILE%` regardless of `CLAUDE_CONFIG_DIR`, breaking isolation. Run `claude update`.
224
135
 
225
- **Profile name rejected**
226
- Names must be free of path separators, whitespace, `..`, leading dots, and reserved DOS names (CON, PRN, COM1…). Stick to letters, digits, dashes, underscores.
136
+ **`Profile "<name>" does not exist`** — `cc use` does not auto-create profiles. Run `cc add <name>` first.
227
137
 
228
- **Old shell-script version was leaking keystrokes / leaking `use work` into the prompt**
229
- That was a known bug in the cmd-based predecessor (`%*` ignored `shift`). This package forwards only the argv after `<profile>` to `claude`; the action and profile name themselves are consumed by `cc`.
138
+ **Profile name rejected** — names must not contain path separators, whitespace, `..`, leading dots, or DOS reserved names (`CON`, `PRN`, `COM1`…). Use letters, digits, dashes, and underscores.
230
139
 
231
140
  ## License
232
141
 
package/lib/doctor.js CHANGED
@@ -281,6 +281,27 @@ function subsetForResource(resource) {
281
281
  function checkLegacyPool() {
282
282
  const legacy = profiles.sharedDir();
283
283
  if (!fs.existsSync(legacy)) return null;
284
+ // Self-referencing pre-v0.4 setup: `~/.claude/projects/` was made a junction
285
+ // into `~/.claude-shared/projects/`. The two locations are the same physical
286
+ // dir on disk — no migration is possible (or needed). Flag it as a warning
287
+ // with a manual cleanup hint instead of a self-move auto-fix.
288
+ const pool = profiles.defaultClaudeDir();
289
+ for (const sub of share.SHARED_DIRS) {
290
+ const poolSub = path.join(pool, sub);
291
+ const legacySub = path.join(legacy, sub);
292
+ try {
293
+ if (fs.existsSync(poolSub) && fs.realpathSync(poolSub) === fs.realpathSync(legacySub)) {
294
+ return mk('Storage', '~/.claude-shared/ (legacy)', 'warn',
295
+ `~/.claude/${sub}/ is a junction into ~/.claude-shared/${sub}/ — same physical dir, safe to remove the junction and migrate by hand`,
296
+ {
297
+ safe: false,
298
+ description: 'Manually move ~/.claude-shared/<sub>/ contents to ~/.claude/<sub>/, remove the junction, then delete ~/.claude-shared/',
299
+ cmd: null,
300
+ apply: null,
301
+ });
302
+ }
303
+ } catch { /* fall through */ }
304
+ }
284
305
  let hasContent = false;
285
306
  try {
286
307
  hasContent = fs.readdirSync(legacy).length > 0;
@@ -314,6 +335,107 @@ function checkLegacyPool() {
314
335
  });
315
336
  }
316
337
 
338
+ // Damaged session filenames left inside projects/<X>/ by an earlier
339
+ // (v0.4.2-and-earlier) heal that injected `__legacy` into UUID filenames on
340
+ // collision. These break `claude --resume` because it expects strict
341
+ // <uuid>.jsonl. Safe auto-fix strips the suffix (or reissues a fresh UUID
342
+ // when the canonical name is taken).
343
+ function checkDamagedSessionFilenames() {
344
+ const projectsDir = path.join(profiles.defaultClaudeDir(), 'projects');
345
+ if (!fs.existsSync(projectsDir)) return null;
346
+ let folders;
347
+ try {
348
+ folders = fs.readdirSync(projectsDir);
349
+ } catch {
350
+ return null;
351
+ }
352
+ const damagedRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:__legacy(?:_\d+)?)+(\.jsonl)?$/i;
353
+ let count = 0;
354
+ const sampleFolders = new Set();
355
+ for (const folder of folders) {
356
+ const folderPath = path.join(projectsDir, folder);
357
+ let st;
358
+ try { st = fs.statSync(folderPath); } catch { continue; }
359
+ if (!st.isDirectory()) continue;
360
+ let children;
361
+ try { children = fs.readdirSync(folderPath); } catch { continue; }
362
+ for (const child of children) {
363
+ if (damagedRe.test(child)) {
364
+ count += 1;
365
+ if (sampleFolders.size < 3) sampleFolders.add(folder);
366
+ }
367
+ }
368
+ }
369
+ if (count === 0) return null;
370
+ const sample = Array.from(sampleFolders).join(', ') + (sampleFolders.size < 3 ? '' : ', ...');
371
+ return mk(
372
+ 'Storage',
373
+ '~/.claude/projects/ damaged session filenames',
374
+ 'warn',
375
+ `${count} session file${count === 1 ? '' : 's'} with __legacy in UUID — invisible to claude --resume (${sample})`,
376
+ {
377
+ safe: true,
378
+ description: 'Restore <uuid>.jsonl filenames (reissue a fresh UUID when canonical is taken)',
379
+ cmd: null,
380
+ apply: async () => {
381
+ const result = share.repairProjectFilenames({
382
+ log: (line) => process.stdout.write(`${line}\n`),
383
+ });
384
+ process.stdout.write(
385
+ ` Done: ${result.repaired.length} filename${result.repaired.length === 1 ? '' : 's'} restored.\n`,
386
+ );
387
+ if (result.failures.length) {
388
+ for (const f of result.failures) {
389
+ process.stdout.write(` Failure (${f.kind}): ${f.folder ? f.folder + '/' : ''}${f.name || ''} ${f.message}\n`);
390
+ }
391
+ }
392
+ },
393
+ },
394
+ );
395
+ }
396
+
397
+ // Stranded projects/<base>__legacy* folders left by older v0.4 migrations.
398
+ // Each one hides its session files from `claude --resume` because resume
399
+ // filters by cwd-encoded folder name. Safe auto-fix merges them into the
400
+ // canonical sibling (or renames in place if no sibling exists).
401
+ function checkLegacyProjectFolders() {
402
+ const projectsDir = path.join(profiles.defaultClaudeDir(), 'projects');
403
+ if (!fs.existsSync(projectsDir)) return null;
404
+ let entries;
405
+ try {
406
+ entries = fs.readdirSync(projectsDir);
407
+ } catch {
408
+ return null;
409
+ }
410
+ const legacy = entries.filter((e) => /__legacy(?:_\d+)?$/.test(e));
411
+ if (legacy.length === 0) return null;
412
+ const sample = legacy.slice(0, 3).join(', ') + (legacy.length > 3 ? ', ...' : '');
413
+ return mk(
414
+ 'Storage',
415
+ '~/.claude/projects/ legacy folders',
416
+ 'warn',
417
+ `${legacy.length} folder${legacy.length === 1 ? '' : 's'} with __legacy suffix hide sessions from --resume (${sample})`,
418
+ {
419
+ safe: true,
420
+ description: 'Merge each legacy projects/ folder into its canonical sibling',
421
+ cmd: null,
422
+ apply: async () => {
423
+ const result = share.healProjectsLegacy({
424
+ log: (line) => process.stdout.write(` ${line}\n`),
425
+ });
426
+ process.stdout.write(
427
+ ` Done: ${result.healed.length} folder${result.healed.length === 1 ? '' : 's'} healed.\n`,
428
+ );
429
+ if (result.failures.length) {
430
+ for (const f of result.failures) {
431
+ process.stdout.write(` Failure (${f.kind}): ${f.name || ''} ${f.message}\n`);
432
+ }
433
+ }
434
+ },
435
+ },
436
+ );
437
+ }
438
+
317
439
  // --- Platform ---
318
440
 
319
441
  function checkOneDrive() {
@@ -378,6 +500,8 @@ function runChecks() {
378
500
  checkProfilesDirWritable(),
379
501
  ];
380
502
  results.push(checkLegacyPool());
503
+ results.push(checkLegacyProjectFolders());
504
+ results.push(checkDamagedSessionFilenames());
381
505
  for (const prof of profiles.listProfiles()) {
382
506
  results.push(checkProfileClaudeJson(prof));
383
507
  results.push(...checkProfileLinks(prof));
package/lib/share.js CHANGED
@@ -2,9 +2,25 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const crypto = require('crypto');
5
6
 
6
7
  const profiles = require('./profiles');
7
8
 
9
+ // Strict UUID v4-ish pattern. Used to identify session files / sub-uuid dirs
10
+ // in projects/<X>/ so we can preserve a Claude-`--resume`-readable filename
11
+ // when merging two folders that hold the same session UUID.
12
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+ const UUID_FILE_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(\.jsonl)$/i;
14
+
15
+ function isUuidName(name) {
16
+ return UUID_RE.test(name);
17
+ }
18
+ function uuidStemOf(name) {
19
+ if (UUID_RE.test(name)) return name;
20
+ const m = name.match(UUID_FILE_RE);
21
+ return m ? m[1] : null;
22
+ }
23
+
8
24
  // v0.4.1: every profile shares 7 resources with ~/.claude/ (the pool).
9
25
  // - 4 directories: projects/, skills/, agents/, commands/
10
26
  // - 3 single files: mcp.json, settings.json, CLAUDE.md
@@ -316,10 +332,110 @@ function shareUnlink(profileName, opts = {}) {
316
332
  return results;
317
333
  }
318
334
 
335
+ // Merge children of src/ into dst/ in place. Used for `projects/` subfolder
336
+ // collisions, where the folder name encodes the cwd and renaming the *folder*
337
+ // would orphan its sessions from `claude --resume`.
338
+ //
339
+ // Collision strategy:
340
+ // - For UUID-keyed session files (`<uuid>.jsonl`) and their paired
341
+ // sub-uuid dirs: generate a fresh UUID v4 on collision so the file stays
342
+ // a valid Claude session filename. Pair the file and dir under the same
343
+ // new UUID so they travel together.
344
+ // - For other names (`memory/`, `sessions-index.json`, anything custom):
345
+ // fall back to a `__legacy[_i]` suffix on the file name. These aren't
346
+ // parsed by `--resume`, so the suffix is harmless.
347
+ function mergeFolderInto(src, dst, { log = () => {} } = {}) {
348
+ fs.mkdirSync(dst, { recursive: true });
349
+ let moved = 0;
350
+ const failures = [];
351
+ const entries = fs.readdirSync(src);
352
+
353
+ // First pass: group UUID-keyed entries by their stem so the .jsonl file
354
+ // and its paired sub-uuid dir can be renamed under the same new UUID on
355
+ // collision.
356
+ const groups = new Map(); // stem -> { dir?: name, file?: name }
357
+ const passthrough = [];
358
+ for (const ent of entries) {
359
+ const stem = uuidStemOf(ent);
360
+ if (!stem) { passthrough.push(ent); continue; }
361
+ if (!groups.has(stem)) groups.set(stem, {});
362
+ const g = groups.get(stem);
363
+ if (UUID_RE.test(ent)) g.dir = ent;
364
+ else g.file = ent;
365
+ }
366
+
367
+ for (const [stem, g] of groups) {
368
+ const dirCollides = g.dir && fs.existsSync(path.join(dst, g.dir));
369
+ const fileCollides = g.file && fs.existsSync(path.join(dst, g.file));
370
+ let newStem = stem;
371
+ if (dirCollides || fileCollides) {
372
+ newStem = crypto.randomUUID();
373
+ log(` ${stem} collides — reissuing as ${newStem}`);
374
+ }
375
+ if (g.dir) {
376
+ const from = path.join(src, g.dir);
377
+ const to = path.join(dst, newStem);
378
+ try {
379
+ fs.renameSync(from, to);
380
+ moved += 1;
381
+ log(` ${g.dir} → ${path.basename(to)}`);
382
+ } catch (err) {
383
+ failures.push({ name: g.dir, message: err.message });
384
+ }
385
+ }
386
+ if (g.file) {
387
+ const from = path.join(src, g.file);
388
+ const to = path.join(dst, `${newStem}.jsonl`);
389
+ try {
390
+ fs.renameSync(from, to);
391
+ moved += 1;
392
+ log(` ${g.file} → ${path.basename(to)}`);
393
+ } catch (err) {
394
+ failures.push({ name: g.file, message: err.message });
395
+ }
396
+ }
397
+ }
398
+
399
+ for (const ent of passthrough) {
400
+ const from = path.join(src, ent);
401
+ let to = path.join(dst, ent);
402
+ if (fs.existsSync(to)) {
403
+ const dot = ent.lastIndexOf('.');
404
+ const stem = dot > 0 ? ent.slice(0, dot) : ent;
405
+ const ext = dot > 0 ? ent.slice(dot) : '';
406
+ to = path.join(dst, `${stem}__legacy${ext}`);
407
+ let i = 2;
408
+ while (fs.existsSync(to)) {
409
+ to = path.join(dst, `${stem}__legacy_${i}${ext}`);
410
+ i += 1;
411
+ }
412
+ }
413
+ try {
414
+ fs.renameSync(from, to);
415
+ moved += 1;
416
+ log(` ${ent} → ${path.basename(to)}`);
417
+ } catch (err) {
418
+ failures.push({ name: ent, message: err.message });
419
+ }
420
+ }
421
+ return { moved, failures };
422
+ }
423
+
319
424
  // One-time migration from the v0.2/v0.3 legacy pool at ~/.claude-shared/ to
320
425
  // the v0.4 pool at ~/.claude/. Idempotent: a second call is a no-op once the
321
- // legacy dir is gone. Non-destructive: existing entries in ~/.claude/ survive
322
- // via the `__legacy` collision rename used here.
426
+ // legacy dir is gone.
427
+ //
428
+ // Collision strategy differs by sub:
429
+ // - skills/, agents/, commands/: named artifacts — rename the incoming
430
+ // folder with a `__legacy` suffix so both versions survive for the user
431
+ // to inspect.
432
+ // - projects/: opaque cwd-keyed buckets of *.jsonl sessions. Renaming the
433
+ // folder would make `claude --resume` invisible to the renamed sessions
434
+ // (it filters by cwd-encoded folder name). Merge children into the
435
+ // existing folder instead. UUID-keyed session-file collisions (rare —
436
+ // happens when the same session UUID was checkpointed across pool
437
+ // migrations) reissue a fresh UUID so the file stays a valid Claude
438
+ // session filename.
323
439
  //
324
440
  // Returns { migrated, moves: [...], relinked: [...], failures: [...] }
325
441
  function migrateLegacyPool({ log = () => {} } = {}) {
@@ -328,6 +444,21 @@ function migrateLegacyPool({ log = () => {} } = {}) {
328
444
  return { migrated: false, moves: [], relinked: [], failures: [] };
329
445
  }
330
446
  const pool = poolDir();
447
+ // Self-referencing setup: a pre-v0.4 install may have created
448
+ // `~/.claude/projects/` as a junction/symlink pointing into
449
+ // `~/.claude-shared/projects/`. Treating that as a "migration" would move
450
+ // files onto themselves and (worse) trigger merge-collision suffixing on
451
+ // every entry. Detect and abort cleanly.
452
+ for (const sub of SHARED_DIRS) {
453
+ const poolSub = path.join(pool, sub);
454
+ const legacySub = path.join(legacy, sub);
455
+ try {
456
+ if (fs.existsSync(poolSub) && fs.realpathSync(poolSub) === fs.realpathSync(legacySub)) {
457
+ log(` ${sub}/ in ~/.claude/ is a junction into ~/.claude-shared/ — nothing to migrate.`);
458
+ return { migrated: false, moves: [], relinked: [], failures: [], selfReferencing: true };
459
+ }
460
+ } catch { /* fall through and try the move */ }
461
+ }
331
462
  const moves = [];
332
463
  const relinked = [];
333
464
  const failures = [];
@@ -349,9 +480,29 @@ function migrateLegacyPool({ log = () => {} } = {}) {
349
480
  failures.push({ kind: 'readdir', sub, message: err.message });
350
481
  continue;
351
482
  }
483
+ const isProjects = PROJECT_SUBDIRS.includes(sub);
352
484
  for (const ent of entries) {
353
485
  const from = path.join(legacySub, ent);
354
- let to = path.join(pool, sub, ent);
486
+ const canonical = path.join(pool, sub, ent);
487
+
488
+ if (isProjects && fs.existsSync(canonical)) {
489
+ try {
490
+ const r = mergeFolderInto(from, canonical, { log });
491
+ moves.push({ sub, name: ent, to: canonical, merged: true, items: r.moved });
492
+ log(` ${sub}/${ent} → merged into existing (${r.moved} item${r.moved === 1 ? '' : 's'})`);
493
+ for (const f of r.failures) {
494
+ failures.push({ kind: 'move', sub, name: `${ent}/${f.name}`, message: f.message });
495
+ }
496
+ try {
497
+ if (fs.readdirSync(from).length === 0) fs.rmdirSync(from);
498
+ } catch { /* best-effort */ }
499
+ } catch (err) {
500
+ failures.push({ kind: 'merge', sub, name: ent, message: err.message });
501
+ }
502
+ continue;
503
+ }
504
+
505
+ let to = canonical;
355
506
  if (fs.existsSync(to)) {
356
507
  to = path.join(pool, sub, `${ent}__legacy`);
357
508
  let i = 2;
@@ -417,6 +568,211 @@ function migrateLegacyPool({ log = () => {} } = {}) {
417
568
  return { migrated: true, moves, relinked, failures };
418
569
  }
419
570
 
571
+ // Strip the __legacy / __legacy_N suffix(es) added by the v0.4 migration's
572
+ // collision handler. Repeated migrations could compound suffixes (e.g.
573
+ // `foo__legacy__legacy`, `foo__legacy_2`), so peel until stable.
574
+ function stripLegacySuffix(name) {
575
+ let s = name;
576
+ while (true) {
577
+ const m = s.match(/^(.+?)__legacy(?:_\d+)?$/);
578
+ if (!m) return s;
579
+ s = m[1];
580
+ }
581
+ }
582
+
583
+ // Find an entry in `entries` whose name matches `target` — exact match first,
584
+ // case-insensitive fallback on Windows (NTFS is case-insensitive by default).
585
+ function findSibling(entries, target) {
586
+ if (entries.includes(target)) return target;
587
+ if (process.platform === 'win32') {
588
+ const lower = target.toLowerCase();
589
+ for (const e of entries) {
590
+ if (e.toLowerCase() === lower) return e;
591
+ }
592
+ }
593
+ return null;
594
+ }
595
+
596
+ // Heal projects/<base>__legacy* folders left by older migrations. These were
597
+ // created when the v0.4 migration collision-renamed a `projects/<base>`
598
+ // folder, which orphans its sessions from `claude --resume` (filters by
599
+ // cwd-encoded folder name).
600
+ //
601
+ // For each `projects/<base>__legacy*`:
602
+ // - if a canonical sibling `projects/<base>` exists, merge .jsonl files and
603
+ // sub-uuid dirs into it (per-file __legacy suffix on rare collisions);
604
+ // - otherwise, rename the legacy folder to its canonical name in place.
605
+ //
606
+ // Returns { healed: [{ from, to, items, renamed }], failures: [...] }
607
+ function healProjectsLegacy({ log = () => {} } = {}) {
608
+ const projectsDir = path.join(poolDir(), 'projects');
609
+ if (!fs.existsSync(projectsDir)) {
610
+ return { healed: [], failures: [] };
611
+ }
612
+ let entries;
613
+ try {
614
+ entries = fs.readdirSync(projectsDir);
615
+ } catch (err) {
616
+ return { healed: [], failures: [{ kind: 'readdir', message: err.message }] };
617
+ }
618
+ const legacyEntries = entries.filter((e) => /__legacy(?:_\d+)?$/.test(e));
619
+ const healed = [];
620
+ const failures = [];
621
+
622
+ for (const ent of legacyEntries) {
623
+ const base = stripLegacySuffix(ent);
624
+ const from = path.join(projectsDir, ent);
625
+ if (!fs.statSync(from).isDirectory()) continue;
626
+ const canonical = findSibling(entries, base);
627
+ if (canonical) {
628
+ const to = path.join(projectsDir, canonical);
629
+ try {
630
+ const r = mergeFolderInto(from, to, { log });
631
+ for (const f of r.failures) {
632
+ failures.push({ kind: 'move', name: `${ent}/${f.name}`, message: f.message });
633
+ }
634
+ try {
635
+ if (fs.readdirSync(from).length === 0) {
636
+ fs.rmdirSync(from);
637
+ log(` removed empty ${ent}`);
638
+ }
639
+ } catch { /* best-effort */ }
640
+ healed.push({ from: ent, to: canonical, items: r.moved, renamed: false });
641
+ log(` ${ent} → merged into ${canonical} (${r.moved} item${r.moved === 1 ? '' : 's'})`);
642
+ } catch (err) {
643
+ failures.push({ kind: 'merge', name: ent, message: err.message });
644
+ }
645
+ } else {
646
+ const to = path.join(projectsDir, base);
647
+ try {
648
+ fs.renameSync(from, to);
649
+ entries.push(base);
650
+ healed.push({ from: ent, to: base, items: null, renamed: true });
651
+ log(` ${ent} → renamed to ${base}`);
652
+ } catch (err) {
653
+ failures.push({ kind: 'rename', name: ent, message: err.message });
654
+ }
655
+ }
656
+ }
657
+ return { healed, failures };
658
+ }
659
+
660
+ // Repair pass for pools already damaged by the v0.4.2-and-earlier heal
661
+ // behavior, which appended `__legacy` to UUID-keyed filenames on collision
662
+ // and made them invisible to `claude --resume`. Walks every projects/<X>/
663
+ // folder and, for each `<uuid>__legacy*.jsonl` file (and paired `<uuid>__legacy*`
664
+ // sub-uuid dir), restores a valid `<uuid>.jsonl` filename:
665
+ // - if the canonical `<uuid>.jsonl` is free, strip the suffix;
666
+ // - else reissue a fresh UUID v4.
667
+ //
668
+ // `memory__legacy` and `sessions-index__legacy.json` are also restored when
669
+ // the canonical name is free; otherwise left as-is (they're not parsed by
670
+ // `--resume`, so the harm is cosmetic).
671
+ //
672
+ // Returns { repaired: [{ folder, from, to }], failures: [...] }
673
+ function repairProjectFilenames({ log = () => {} } = {}) {
674
+ const projectsDir = path.join(poolDir(), 'projects');
675
+ if (!fs.existsSync(projectsDir)) {
676
+ return { repaired: [], failures: [] };
677
+ }
678
+ const repaired = [];
679
+ const failures = [];
680
+ let folders;
681
+ try {
682
+ folders = fs.readdirSync(projectsDir);
683
+ } catch (err) {
684
+ return { repaired: [], failures: [{ kind: 'readdir', message: err.message }] };
685
+ }
686
+
687
+ // Match a UUID stem optionally followed by `__legacy(_N)?` suffix(es),
688
+ // optionally followed by `.jsonl`. Captures the UUID stem and whether it's
689
+ // a .jsonl file.
690
+ const damaged = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:__legacy(?:_\d+)?)+(\.jsonl)?$/i;
691
+
692
+ for (const folder of folders) {
693
+ const folderPath = path.join(projectsDir, folder);
694
+ let st;
695
+ try { st = fs.statSync(folderPath); } catch { continue; }
696
+ if (!st.isDirectory()) continue;
697
+ let children;
698
+ try { children = fs.readdirSync(folderPath); } catch { continue; }
699
+
700
+ // Pair damaged file/dir by their exact damaged name (minus `.jsonl`).
701
+ // Different damaged names sharing the same UUID stem are *different*
702
+ // sessions that collided during the prior heal — each needs its own
703
+ // unique restored name. e.g. `<u>__legacy.jsonl` and `<u>__legacy_2.jsonl`
704
+ // both restore to *some* `<uuid>.jsonl`, but not the same one.
705
+ const pairs = new Map(); // damaged-key-without-.jsonl -> { stem, dir?: name, file?: name }
706
+ for (const ent of children) {
707
+ const m = ent.match(damaged);
708
+ if (!m) continue;
709
+ const stem = m[1];
710
+ const isFile = !!m[2];
711
+ const key = isFile ? ent.slice(0, -'.jsonl'.length) : ent;
712
+ if (!pairs.has(key)) pairs.set(key, { stem });
713
+ const p = pairs.get(key);
714
+ if (isFile) p.file = ent; else p.dir = ent;
715
+ }
716
+
717
+ const childSet = new Set(children);
718
+ for (const [, p] of pairs) {
719
+ const canonicalFile = `${p.stem}.jsonl`;
720
+ const dirFree = !childSet.has(p.stem);
721
+ const fileFree = !childSet.has(canonicalFile);
722
+ let newStem = p.stem;
723
+ if (!dirFree || !fileFree) {
724
+ newStem = crypto.randomUUID();
725
+ log(` ${folder}/${p.stem} occupied — reissuing as ${newStem}`);
726
+ }
727
+ if (p.dir) {
728
+ const from = path.join(folderPath, p.dir);
729
+ const to = path.join(folderPath, newStem);
730
+ try {
731
+ fs.renameSync(from, to);
732
+ childSet.delete(p.dir);
733
+ childSet.add(newStem);
734
+ repaired.push({ folder, from: p.dir, to: newStem });
735
+ log(` ${folder}/${p.dir} → ${newStem}`);
736
+ } catch (err) {
737
+ failures.push({ kind: 'rename', folder, name: p.dir, message: err.message });
738
+ }
739
+ }
740
+ if (p.file) {
741
+ const from = path.join(folderPath, p.file);
742
+ const to = path.join(folderPath, `${newStem}.jsonl`);
743
+ try {
744
+ fs.renameSync(from, to);
745
+ childSet.delete(p.file);
746
+ childSet.add(`${newStem}.jsonl`);
747
+ repaired.push({ folder, from: p.file, to: `${newStem}.jsonl` });
748
+ log(` ${folder}/${p.file} → ${newStem}.jsonl`);
749
+ } catch (err) {
750
+ failures.push({ kind: 'rename', folder, name: p.file, message: err.message });
751
+ }
752
+ }
753
+ }
754
+
755
+ // Restore memory/ and sessions-index.json names if the canonical is free.
756
+ for (const [damagedName, canonical] of [
757
+ ['memory__legacy', 'memory'],
758
+ ['sessions-index__legacy.json', 'sessions-index.json'],
759
+ ]) {
760
+ if (childSet.has(damagedName) && !childSet.has(canonical)) {
761
+ try {
762
+ fs.renameSync(path.join(folderPath, damagedName), path.join(folderPath, canonical));
763
+ childSet.delete(damagedName);
764
+ childSet.add(canonical);
765
+ repaired.push({ folder, from: damagedName, to: canonical });
766
+ log(` ${folder}/${damagedName} → ${canonical}`);
767
+ } catch (err) {
768
+ failures.push({ kind: 'rename', folder, name: damagedName, message: err.message });
769
+ }
770
+ }
771
+ }
772
+ }
773
+ return { repaired, failures };
774
+ }
775
+
420
776
  module.exports = {
421
777
  SHARED_RESOURCES,
422
778
  SHARED_DIRS,
@@ -434,4 +790,6 @@ module.exports = {
434
790
  poolDir,
435
791
  ensurePoolResources,
436
792
  migrateLegacyPool,
793
+ healProjectsLegacy,
794
+ repairProjectFilenames,
437
795
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cchost",
3
- "version": "0.4.2",
4
- "description": "Host multiple isolated Claude Code accounts on one machine via the `cc` command.",
3
+ "version": "0.4.4",
4
+ "description": "Multi-account profile manager for Claude Code — switch accounts, share skills/agents/projects, route to custom endpoints. Works on Windows, Linux, and macOS.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -10,7 +10,10 @@
10
10
  "profile",
11
11
  "multi-account",
12
12
  "credentials",
13
- "isolation"
13
+ "isolation",
14
+ "macos",
15
+ "windows",
16
+ "linux"
14
17
  ],
15
18
  "homepage": "https://github.com/allenhack638/cchost#readme",
16
19
  "bugs": {