cchost 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/color.js CHANGED
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  function colorEnabled(stream) {
4
- if (process.env.NO_COLOR && process.env.NO_COLOR.length > 0) return false;
4
+ // Per no-color.org, NO_COLOR disables color only when set to a non-empty
5
+ // value; NO_COLOR="" leaves color enabled.
6
+ if (process.env.NO_COLOR) return false;
5
7
  if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') return true;
6
8
  const s = stream || process.stdout;
7
9
  return Boolean(s && s.isTTY);
package/lib/copy.js CHANGED
@@ -1,17 +1,17 @@
1
1
  'use strict';
2
2
 
3
- // `cc copy` — copy mcp.json / settings.json / CLAUDE.md / artifact dirs from
4
- // ~/.claude/ (default) into a profile. Renamed from `cc migrate` in v0.4.1;
5
- // `cc migrate` still works as a deprecation alias.
3
+ // `cchost copy` — copy mcp.json / settings.json / CLAUDE.md / artifact dirs from
4
+ // ~/.claude/ (default) into a profile. Renamed from `cchost migrate` in v0.4.1;
5
+ // `cchost migrate` still works as a deprecation alias.
6
6
  //
7
7
  // Only direction supported: default → <profile>. The reverse and profile→
8
8
  // profile were considered and dropped — they're rare enough that a manual
9
9
  // `cp -r` is fine and keeps the surface small.
10
10
  //
11
11
  // Most v0.4 profiles have all 7 resources symlinked into the pool, so
12
- // `cc copy default foo` is usually a no-op (every resource resolves to the
12
+ // `cchost copy default foo` is usually a no-op (every resource resolves to the
13
13
  // same path as the source). The command is meaningful only when a resource
14
- // has been unlinked via `cc unlink`.
14
+ // has been unlinked via `cchost unlink`.
15
15
 
16
16
  const fs = require('fs');
17
17
  const path = require('path');
@@ -35,7 +35,7 @@ function rejectSharedKeyword(role) {
35
35
  return usageError(
36
36
  `'shared' is not a valid copy ${role} in v0.4. ` +
37
37
  "The shared pool is now ~/.claude/ itself, reached via per-profile " +
38
- "symlinks. Use `cc link <profile>` / `cc unlink <profile>` to manage " +
38
+ "symlinks. Use `cchost link <profile>` / `cchost unlink <profile>` to manage " +
39
39
  "which resources are shared.",
40
40
  );
41
41
  }
@@ -86,10 +86,11 @@ function sameOnDisk(a, b) {
86
86
  }
87
87
 
88
88
  // Copy directory contents item-by-item with skip-on-collision (default) or
89
- // overwrite (force). Returns { copied, skipped }.
90
- function copyDirContents(srcRoot, dstRoot, { force = false } = {}) {
89
+ // overwrite (force). Returns { copied, skipped }. With dryRun the counts are
90
+ // what a real run would produce, and nothing is written.
91
+ function copyDirContents(srcRoot, dstRoot, { force = false, dryRun = false } = {}) {
91
92
  if (!fs.existsSync(srcRoot)) return { copied: 0, skipped: 0 };
92
- fs.mkdirSync(dstRoot, { recursive: true });
93
+ if (!dryRun) fs.mkdirSync(dstRoot, { recursive: true });
93
94
  let copied = 0;
94
95
  let skipped = 0;
95
96
  for (const ent of fs.readdirSync(srcRoot, { withFileTypes: true })) {
@@ -97,29 +98,26 @@ function copyDirContents(srcRoot, dstRoot, { force = false } = {}) {
97
98
  const from = path.join(srcRoot, ent.name);
98
99
  const to = path.join(dstRoot, ent.name);
99
100
  if (fs.existsSync(to)) {
100
- if (force) {
101
- fs.rmSync(to, { recursive: true, force: true });
102
- } else {
101
+ if (!force) {
103
102
  skipped += 1;
104
103
  continue;
105
104
  }
105
+ if (!dryRun) fs.rmSync(to, { recursive: true, force: true });
106
106
  }
107
- fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true });
107
+ if (!dryRun) fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true });
108
108
  copied += 1;
109
109
  }
110
110
  return { copied, skipped };
111
111
  }
112
112
 
113
113
  // Copy a single file. Returns 'copied' | 'skipped' | 'absent'.
114
- function copySingleFile(srcPath, dstPath, { force = false } = {}) {
114
+ function copySingleFile(srcPath, dstPath, { force = false, dryRun = false } = {}) {
115
115
  if (!fs.existsSync(srcPath)) return 'absent';
116
116
  if (fs.existsSync(dstPath)) {
117
- if (force) {
118
- fs.rmSync(dstPath, { recursive: true, force: true });
119
- } else {
120
- return 'skipped';
121
- }
117
+ if (!force) return 'skipped';
118
+ if (!dryRun) fs.rmSync(dstPath, { recursive: true, force: true });
122
119
  }
120
+ if (dryRun) return 'copied';
123
121
  fs.mkdirSync(path.dirname(dstPath), { recursive: true });
124
122
  fs.copyFileSync(srcPath, dstPath);
125
123
  return 'copied';
@@ -131,8 +129,9 @@ function formatResourceLabel(resource) {
131
129
 
132
130
  // Main entry: copy each of the 7 shared resources from src → dest, honoring
133
131
  // the v0.4 symlink semantics. Logs per-resource progress and a closing
134
- // summary line. Returns a structured result for tests.
135
- function copy(srcArg, destArg, { force = false } = {}) {
132
+ // summary line. Returns a structured result for tests. With dryRun the same
133
+ // report is printed in the conditional ("would copy") and nothing is written.
134
+ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
136
135
  const src = resolveSource(srcArg);
137
136
  const dest = resolveDest(destArg);
138
137
 
@@ -140,7 +139,8 @@ function copy(srcArg, destArg, { force = false } = {}) {
140
139
  throw usageError(`Source and destination resolve to the same directory (${src.dir}).`);
141
140
  }
142
141
 
143
- profiles.ensureProfileDir(dest.name);
142
+ if (!dryRun) profiles.ensureProfileDir(dest.name);
143
+ const copiedWord = dryRun ? 'would copy' : 'copied';
144
144
  if (!fs.existsSync(src.dir)) {
145
145
  console.log(`Source ${src.dir} does not exist. Nothing to copy.`);
146
146
  return { copied: 0, skipped: 0, shared: 0, results: [] };
@@ -178,9 +178,9 @@ function copy(srcArg, destArg, { force = false } = {}) {
178
178
  }
179
179
 
180
180
  if (isFile) {
181
- const r = copySingleFile(srcPath, dstPath, { force });
181
+ const r = copySingleFile(srcPath, dstPath, { force, dryRun });
182
182
  if (r === 'copied') {
183
- console.log(` ${label} copied`);
183
+ console.log(` ${label} ${copiedWord}`);
184
184
  totalCopied += 1;
185
185
  } else if (r === 'skipped') {
186
186
  console.log(` ${label} skipped (already present — pass --force to overwrite)`);
@@ -192,12 +192,12 @@ function copy(srcArg, destArg, { force = false } = {}) {
192
192
  continue;
193
193
  }
194
194
 
195
- const { copied, skipped } = copyDirContents(srcPath, dstPath, { force });
195
+ const { copied, skipped } = copyDirContents(srcPath, dstPath, { force, dryRun });
196
196
  if (copied === 0 && skipped === 0) {
197
197
  console.log(` ${label} empty at source`);
198
198
  } else if (copied > 0) {
199
199
  const more = skipped > 0 ? `, skipped ${skipped} (already present)` : '';
200
- console.log(` ${label} copied ${copied} item(s)${more}`);
200
+ console.log(` ${label} ${copiedWord} ${copied} item(s)${more}`);
201
201
  } else {
202
202
  console.log(
203
203
  ` ${label} skipped ${skipped} item(s) (all already present — pass --force to overwrite)`,
@@ -214,15 +214,17 @@ function copy(srcArg, destArg, { force = false } = {}) {
214
214
  `Nothing to copy — all ${share.SHARED_RESOURCES.length} resources are already shared via symlinks.`,
215
215
  );
216
216
  console.log(
217
- `Tip: run \`cc unlink ${dest.name}\` first if you want private copies in this profile.`,
217
+ `Tip: run \`cchost unlink ${dest.name}\` first if you want private copies in this profile.`,
218
218
  );
219
219
  } else if (totalCopied === 0) {
220
220
  console.log(
221
- `Nothing copied. ${totalShared} shared via symlink, ${totalSkipped} skipped (already present).`,
221
+ `${dryRun ? 'Nothing would be copied' : 'Nothing copied'}. ` +
222
+ `${totalShared} shared via symlink, ${totalSkipped} skipped (already present).`,
222
223
  );
223
224
  } else {
224
225
  console.log(
225
- `Done. Copied ${totalCopied} item(s), ${totalShared} already shared via symlinks` +
226
+ `${dryRun ? 'Dry run: would copy' : 'Done. Copied'} ${totalCopied} item(s), ` +
227
+ `${totalShared} already shared via symlinks` +
226
228
  (totalSkipped > 0 ? `, ${totalSkipped} skipped (already present)` : '') +
227
229
  '.',
228
230
  );
package/lib/doctor.js CHANGED
@@ -6,16 +6,20 @@ const os = require('os');
6
6
 
7
7
  const profiles = require('./profiles');
8
8
  const share = require('./share');
9
+ const envLib = require('./env');
9
10
  const { findClaude, getClaudeVersion, compareVersion, MIN_VERSION } = require('./spawn');
10
11
  const color = require('./color');
11
12
 
12
13
  const GROUP_ORDER = ['Environment', 'Storage', 'Platform', 'Sanity'];
13
14
 
15
+ // Default sink for fix progress. JSON mode passes a stderr writer instead.
16
+ const stdoutWrite = (text) => process.stdout.write(text);
17
+
14
18
  // fix: { safe, description, cmd, apply }
15
19
  // safe=true → auto-applied by --fix
16
- // safe=false → needs --fix --force, prompted y/n
20
+ // safe=false → needs --fix --force, prompted y/N
17
21
  // cmd → printed when fix cannot be auto-applied
18
- // apply → async fn to execute
22
+ // apply → async fn to execute, receives the progress writer
19
23
 
20
24
  function mk(group, name, status, message, fix) {
21
25
  return { group, name, status, message, fix: fix || null };
@@ -60,23 +64,21 @@ function checkClaudeVersion() {
60
64
  });
61
65
  }
62
66
 
63
- const CONFLICT_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
64
-
65
67
  function checkConflictingEnv() {
66
- const set = CONFLICT_VARS.filter(k => process.env[k]);
68
+ const set = envLib.authEnvConflicts();
67
69
  if (set.length === 0) return mk('Environment', 'conflicting env vars', 'ok', 'none set');
68
70
  const cmd = process.platform === 'win32'
69
71
  ? set.map(k => `Remove-Item Env:${k}`).join('; ')
70
72
  : set.map(k => `unset ${k}`).join('; ');
71
73
  return mk('Environment', 'conflicting env vars', 'warn', `set: ${set.join(', ')}`, {
72
- safe: false, description: 'Unset to avoid auth interference with cc use', cmd, apply: null,
74
+ safe: false, description: 'Unset to avoid auth interference with cchost use', cmd, apply: null,
73
75
  });
74
76
  }
75
77
 
76
- function findAllOnPath(name) {
78
+ function findAllOnPath(name, platform = process.platform) {
77
79
  const PATH = process.env.PATH || process.env.Path || '';
78
- const sep = process.platform === 'win32' ? ';' : ':';
79
- const exts = process.platform === 'win32'
80
+ const sep = platform === 'win32' ? ';' : ':';
81
+ const exts = platform === 'win32'
80
82
  ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map(s => s.trim()).filter(Boolean)
81
83
  : [''];
82
84
  const found = [];
@@ -86,7 +88,7 @@ function findAllOnPath(name) {
86
88
  try {
87
89
  const full = path.join(dir, name + ext);
88
90
  if (fs.statSync(full).isFile()) {
89
- const key = process.platform === 'win32' ? full.toLowerCase() : full;
91
+ const key = platform === 'win32' ? full.toLowerCase() : full;
90
92
  if (!seen.has(key)) { seen.add(key); found.push(full); }
91
93
  break;
92
94
  }
@@ -96,13 +98,94 @@ function findAllOnPath(name) {
96
98
  return found;
97
99
  }
98
100
 
99
- function checkCcShadow() {
100
- const all = findAllOnPath('cc');
101
- if (all.length <= 1) {
102
- return mk('Environment', 'cc on PATH', 'ok', all[0] || 'not found yet (expected after npm install -g)');
101
+ // Resolve a path to the object it points at.
102
+ function pathIdentity(p) {
103
+ try {
104
+ return fs.realpathSync(p);
105
+ } catch {
106
+ return path.resolve(p);
103
107
  }
104
- return mk('Environment', 'cc on PATH', 'warn', `multiple: ${all.join(', ')}`, {
105
- safe: false, description: 'Remove duplicate cc entries to avoid version confusion', cmd: null, apply: null,
108
+ }
109
+
110
+ // True only when `p` really is the cchost CLI. A bare `cc` may be a compiler
111
+ // (clang, gcc, ccache, conda) and must not be mistaken for the legacy alias.
112
+ function isCchostBinary(p) {
113
+ const target = pathIdentity(p);
114
+ if (/[\\/]cchost[\\/]bin[\\/]cc\.js$/i.test(target)) return true;
115
+ try {
116
+ const st = fs.statSync(target);
117
+ if (!st.isFile() || st.size > 256 * 1024) return false;
118
+ const text = fs.readFileSync(target, 'utf8');
119
+ return /cchost/i.test(text) && /(node_modules|cc\.js)/i.test(text);
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ // Windows npm writes separate cc.cmd and cchost.cmd shims. Extract the
126
+ // bin/cc.js they invoke so shims from the same install share an identity while
127
+ // a stale shim pointing elsewhere still counts as a separate install.
128
+ function shimTarget(p) {
129
+ if (!/\.(cmd|bat|ps1)$/i.test(p)) return null;
130
+ try {
131
+ const text = fs.readFileSync(p, 'utf8');
132
+ const m = text.match(
133
+ /(?:[A-Za-z]:[\\/]|%dp0%[\\/]|%~dp0[\\/]|(?:\.\.?[\\/]))[^"\r\n]*?node_modules[\\/]+cchost[\\/]+bin[\\/]+cc\.js/i,
134
+ );
135
+ if (!m) return null;
136
+ const raw = m[0];
137
+ if (/^[A-Za-z]:[\\/]/.test(raw)) {
138
+ return raw.replace(/[\\/]/g, '\\').toLowerCase();
139
+ }
140
+ const relative = raw
141
+ .replace(/^%dp0%|^%~dp0/i, '')
142
+ .replace(/^[\\/]+/, '')
143
+ .replace(/[\\/]/g, path.sep);
144
+ return path.resolve(path.dirname(p), relative).replace(/[\\/]/g, '\\').toLowerCase();
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ function shadowIdentity(p, platform = process.platform) {
151
+ if (platform === 'win32') {
152
+ const target = shimTarget(p);
153
+ if (target) return `shim:${target}`;
154
+ }
155
+ return pathIdentity(p);
156
+ }
157
+
158
+ function checkCcShadow(platform = process.platform) {
159
+ const found = [];
160
+ for (const name of ['cchost', 'cc']) {
161
+ for (const p of findAllOnPath(name, platform)) {
162
+ if (name === 'cc' && !isCchostBinary(p)) continue;
163
+ found.push({ name, path: p });
164
+ }
165
+ }
166
+
167
+ const unique = [];
168
+ const seen = new Set();
169
+ for (const entry of found) {
170
+ const key = shadowIdentity(entry.path, platform);
171
+ if (seen.has(key)) continue;
172
+ seen.add(key);
173
+ unique.push(entry);
174
+ }
175
+
176
+ if (unique.length <= 1) {
177
+ return mk(
178
+ 'Environment',
179
+ 'cchost on PATH',
180
+ 'ok',
181
+ unique.length ? unique[0].path : 'not found yet (expected after npm install -g cchost)',
182
+ );
183
+ }
184
+ return mk('Environment', 'cchost on PATH', 'warn', `multiple: ${unique.map((e) => e.path).join(', ')}`, {
185
+ safe: false,
186
+ description: 'Remove duplicate cchost/cc entries to avoid version confusion',
187
+ cmd: null,
188
+ apply: null,
106
189
  });
107
190
  }
108
191
 
@@ -115,9 +198,9 @@ function checkProfilesDirWritable() {
115
198
  safe: true,
116
199
  description: `Create ${base}`,
117
200
  cmd: null,
118
- apply: async () => {
201
+ apply: async (write = stdoutWrite) => {
119
202
  fs.mkdirSync(base, { recursive: true });
120
- process.stdout.write(` Created ${base}\n`);
203
+ write(` Created ${base}\n`);
121
204
  },
122
205
  });
123
206
  }
@@ -146,11 +229,11 @@ function checkProfileClaudeJson(prof) {
146
229
  safe: false,
147
230
  description: `Back up ${path.basename(p)} and reset to {}`,
148
231
  cmd: null,
149
- apply: async () => {
232
+ apply: async (write = stdoutWrite) => {
150
233
  const bak = `${p}.bak.${Date.now()}`;
151
234
  fs.renameSync(p, bak);
152
235
  fs.writeFileSync(p, '{}');
153
- process.stdout.write(` Backed up to ${path.basename(bak)}, reset to {}\n`);
236
+ write(` Backed up to ${path.basename(bak)}, reset to {}\n`);
154
237
  },
155
238
  });
156
239
  }
@@ -174,12 +257,12 @@ function checkProfileLinks(prof) {
174
257
  safe: true,
175
258
  description: `Create link to ~/.claude/${resource}`,
176
259
  cmd: null,
177
- apply: async () => {
260
+ apply: async (write = stdoutWrite) => {
178
261
  const r = share.shareLink(prof.name, subsetForResource(resource))[0];
179
262
  if (r && r.status === 'linked') {
180
- process.stdout.write(` Linked: ${pp} -> ${expected}\n`);
263
+ write(` Linked: ${pp} -> ${expected}\n`);
181
264
  } else if (r) {
182
- process.stdout.write(` Could not link ${pp}: ${r.message || r.status}\n`);
265
+ write(` Could not link ${pp}: ${r.message || r.status}\n`);
183
266
  }
184
267
  },
185
268
  }),
@@ -199,13 +282,13 @@ function checkProfileLinks(prof) {
199
282
  safe: true,
200
283
  description: `Recreate link to ${expected}`,
201
284
  cmd: null,
202
- apply: async () => {
285
+ apply: async (write = stdoutWrite) => {
203
286
  share.removeLink(pp);
204
287
  const r = share.shareLink(prof.name, subsetForResource(resource))[0];
205
288
  if (r && (r.status === 'linked' || r.status === 'already-linked')) {
206
- process.stdout.write(` Recreated: ${pp} -> ${expected}\n`);
289
+ write(` Recreated: ${pp} -> ${expected}\n`);
207
290
  } else if (r) {
208
- process.stdout.write(` Could not recreate ${pp}: ${r.message || r.status}\n`);
291
+ write(` Could not recreate ${pp}: ${r.message || r.status}\n`);
209
292
  }
210
293
  },
211
294
  }),
@@ -233,16 +316,16 @@ function checkProfileLinks(prof) {
233
316
  safe: true,
234
317
  description: `Replace with link to ~/.claude/${resource}`,
235
318
  cmd: null,
236
- apply: async () => {
319
+ apply: async (write = stdoutWrite) => {
237
320
  try {
238
321
  if (isFile) fs.unlinkSync(pp);
239
322
  else fs.rmdirSync(pp);
240
323
  } catch { /* fall through */ }
241
324
  const r = share.shareLink(prof.name, subsetForResource(resource))[0];
242
325
  if (r && r.status === 'linked') {
243
- process.stdout.write(` Linked: ${pp} -> ${expected}\n`);
326
+ write(` Linked: ${pp} -> ${expected}\n`);
244
327
  } else if (r) {
245
- process.stdout.write(` Could not link ${pp}: ${r.message || r.status}\n`);
328
+ write(` Could not link ${pp}: ${r.message || r.status}\n`);
246
329
  }
247
330
  },
248
331
  }),
@@ -253,9 +336,9 @@ function checkProfileLinks(prof) {
253
336
  results.push(
254
337
  mk('Storage', `"${prof.name}" ${label}`, 'warn', 'private (has content) — not linked', {
255
338
  safe: false,
256
- description: 'Move or remove the private content, then re-run cc link',
257
- cmd: `cc copy default ${prof.name} # if you want to merge it the other way; ` +
258
- `or manually move ${pp} aside, then: cc link ${prof.name}`,
339
+ description: 'Move or remove the private content, then re-run cchost link',
340
+ cmd: `cchost copy default ${prof.name} # if you want to merge it the other way; ` +
341
+ `or manually move ${pp} aside, then: cchost link ${prof.name}`,
259
342
  apply: null,
260
343
  }),
261
344
  );
@@ -315,9 +398,9 @@ function checkLegacyPool() {
315
398
  safe: true,
316
399
  description: 'Remove the empty legacy pool',
317
400
  cmd: null,
318
- apply: async () => {
319
- try { fs.rmdirSync(legacy); process.stdout.write(` Removed ${legacy}\n`); } catch (e) {
320
- process.stdout.write(` Could not remove ${legacy}: ${e.message}\n`);
401
+ apply: async (write = stdoutWrite) => {
402
+ try { fs.rmdirSync(legacy); write(` Removed ${legacy}\n`); } catch (e) {
403
+ write(` Could not remove ${legacy}: ${e.message}\n`);
321
404
  }
322
405
  },
323
406
  });
@@ -326,9 +409,9 @@ function checkLegacyPool() {
326
409
  safe: true,
327
410
  description: 'Move legacy pool contents into ~/.claude/ and re-point profile symlinks',
328
411
  cmd: null,
329
- apply: async () => {
330
- const result = share.migrateLegacyPool({ log: (line) => process.stdout.write(` ${line}\n`) });
331
- process.stdout.write(
412
+ apply: async (write = stdoutWrite) => {
413
+ const result = share.migrateLegacyPool({ log: (line) => write(` ${line}\n`) });
414
+ write(
332
415
  ` Done: ${result.moves.length} item(s) moved, ${result.relinked.length} link(s) repointed.\n`,
333
416
  );
334
417
  },
@@ -377,16 +460,16 @@ function checkDamagedSessionFilenames() {
377
460
  safe: true,
378
461
  description: 'Restore <uuid>.jsonl filenames (reissue a fresh UUID when canonical is taken)',
379
462
  cmd: null,
380
- apply: async () => {
463
+ apply: async (write = stdoutWrite) => {
381
464
  const result = share.repairProjectFilenames({
382
- log: (line) => process.stdout.write(`${line}\n`),
465
+ log: (line) => write(`${line}\n`),
383
466
  });
384
- process.stdout.write(
467
+ write(
385
468
  ` Done: ${result.repaired.length} filename${result.repaired.length === 1 ? '' : 's'} restored.\n`,
386
469
  );
387
470
  if (result.failures.length) {
388
471
  for (const f of result.failures) {
389
- process.stdout.write(` Failure (${f.kind}): ${f.folder ? f.folder + '/' : ''}${f.name || ''} ${f.message}\n`);
472
+ write(` Failure (${f.kind}): ${f.folder ? f.folder + '/' : ''}${f.name || ''} ${f.message}\n`);
390
473
  }
391
474
  }
392
475
  },
@@ -419,16 +502,16 @@ function checkLegacyProjectFolders() {
419
502
  safe: true,
420
503
  description: 'Merge each legacy projects/ folder into its canonical sibling',
421
504
  cmd: null,
422
- apply: async () => {
505
+ apply: async (write = stdoutWrite) => {
423
506
  const result = share.healProjectsLegacy({
424
- log: (line) => process.stdout.write(` ${line}\n`),
507
+ log: (line) => write(` ${line}\n`),
425
508
  });
426
- process.stdout.write(
509
+ write(
427
510
  ` Done: ${result.healed.length} folder${result.healed.length === 1 ? '' : 's'} healed.\n`,
428
511
  );
429
512
  if (result.failures.length) {
430
513
  for (const f of result.failures) {
431
- process.stdout.write(` Failure (${f.kind}): ${f.name || ''} ${f.message}\n`);
514
+ write(` Failure (${f.kind}): ${f.name || ''} ${f.message}\n`);
432
515
  }
433
516
  }
434
517
  },
@@ -453,7 +536,7 @@ function checkWindowsAppsCc() {
453
536
  if (process.platform !== 'win32') return null;
454
537
  const p = path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'cc.cmd');
455
538
  if (!fs.existsSync(p)) return mk('Platform', 'WindowsApps cc.cmd', 'ok', 'not present');
456
- return mk('Platform', 'WindowsApps cc.cmd', 'warn', `${p} may shadow npm-installed cc`, {
539
+ return mk('Platform', 'WindowsApps cc.cmd', 'warn', `${p} may shadow npm-installed cchost`, {
457
540
  safe: false, description: 'Delete the stale stub', cmd: `del "${p}"`, apply: null,
458
541
  });
459
542
  }
@@ -462,7 +545,7 @@ function checkStaleBinCc() {
462
545
  if (process.platform !== 'win32') return null;
463
546
  const p = path.join(os.homedir(), 'bin', 'cc');
464
547
  if (!fs.existsSync(p)) return null;
465
- return mk('Platform', '~/bin/cc', 'warn', `${p} may be an old version shadowing npm-installed cc`, {
548
+ return mk('Platform', '~/bin/cc', 'warn', `${p} may be an old version shadowing npm-installed cchost`, {
466
549
  safe: false, description: 'Remove the old script', cmd: `Remove-Item "${p}"`, apply: null,
467
550
  });
468
551
  }
@@ -476,6 +559,31 @@ function checkCygpath() {
476
559
  });
477
560
  }
478
561
 
562
+ // macOS keeps OAuth tokens in the login Keychain rather than in the profile
563
+ // directory. Current Claude Code versions namespace the entry by the exact
564
+ // CLAUDE_CONFIG_DIR path, so profiles get separate logins; renaming or moving
565
+ // a profile changes the path and requires a fresh login.
566
+ function checkMacKeychain(platform = process.platform) {
567
+ if (platform !== 'darwin') return null;
568
+ let profilesList = [];
569
+ try {
570
+ profilesList = profiles.listProfiles(platform);
571
+ } catch {
572
+ return null;
573
+ }
574
+ const keychainProfiles = profilesList.filter(
575
+ (p) => !envLib.hasEnvConfig(p.dir) && p.credentialSource === 'keychain',
576
+ );
577
+ // Nothing to explain until some profile actually logs in through the Keychain.
578
+ if (keychainProfiles.length === 0) return null;
579
+ return mk(
580
+ 'Platform',
581
+ 'macOS credentials (Keychain)',
582
+ 'ok',
583
+ `${keychainProfiles.length} profile(s) with a Keychain login; entries are namespaced by CLAUDE_CONFIG_DIR path (renaming or moving a profile requires a new login)`,
584
+ );
585
+ }
586
+
479
587
  // --- Sanity ---
480
588
 
481
589
  function checkSpawnClaude() {
@@ -507,6 +615,7 @@ function runChecks() {
507
615
  results.push(...checkProfileLinks(prof));
508
616
  }
509
617
  results.push(
618
+ checkMacKeychain(),
510
619
  checkOneDrive(),
511
620
  checkWindowsAppsCc(),
512
621
  checkStaleBinCc(),
@@ -543,7 +652,7 @@ function formatText(results) {
543
652
  if (r.fix.cmd) {
544
653
  lines.push(` → Run: ${r.fix.cmd}`);
545
654
  } else if (r.fix.apply) {
546
- lines.push(` → Run: ${r.fix.safe ? 'cc doctor --fix' : 'cc doctor --fix --force'}`);
655
+ lines.push(` → Run: ${r.fix.safe ? 'cchost doctor --fix' : 'cchost doctor --fix --force'}`);
547
656
  }
548
657
  }
549
658
  }
@@ -571,42 +680,68 @@ function formatJson(results) {
571
680
 
572
681
  // --- Fix runner ---
573
682
 
574
- async function applyFixes(results, force, promptFn) {
683
+ async function applyFixes(results, force, promptFn, write = stdoutWrite) {
575
684
  const toFix = results.filter(r => r.fix && r.fix.apply && (r.fix.safe || force));
576
685
  if (toFix.length === 0) {
577
- process.stdout.write('Nothing auto-fixable found.\n');
578
- return;
686
+ write('Nothing auto-fixable found.\n');
687
+ return 0;
579
688
  }
689
+ let applied = 0;
580
690
  for (const r of toFix) {
581
691
  if (r.fix.safe) {
582
- process.stdout.write(`Fixing: ${r.name}\n`);
583
- await r.fix.apply();
692
+ write(`Fixing: ${r.name}\n`);
693
+ await r.fix.apply(write);
694
+ applied += 1;
584
695
  } else {
585
696
  const ans = await promptFn(`Fix "${r.name}" — ${r.fix.description || r.name}? [y/N]: `);
586
697
  if (ans.trim().toLowerCase() === 'y') {
587
- await r.fix.apply();
698
+ await r.fix.apply(write);
699
+ applied += 1;
588
700
  } else {
589
- process.stdout.write(' Skipped.\n');
701
+ write(' Skipped.\n');
590
702
  }
591
703
  }
592
704
  }
705
+ return applied;
706
+ }
707
+
708
+ function doctorExitCode(results) {
709
+ if (results.some(r => r.status === 'error')) return 2;
710
+ if (results.some(r => r.status === 'warn')) return 1;
711
+ return 0;
593
712
  }
594
713
 
595
714
  // --- Entry point ---
596
715
 
597
716
  async function runDoctor({ fix, force, json, prompt: promptFn }) {
598
- const results = runChecks();
717
+ let results = runChecks();
718
+
719
+ if (json && fix) {
720
+ // Fix progress must not corrupt the JSON document: send it (and any
721
+ // prompts) to stderr, then emit one report of the post-fix state.
722
+ const stderrWrite = (text) => process.stderr.write(text);
723
+ const promptOnStderr = (question) => promptFn(question, process.stderr);
724
+ const applied = await applyFixes(results, force, promptOnStderr, stderrWrite);
725
+ if (applied > 0) results = runChecks();
726
+ process.stdout.write(formatJson(results) + '\n');
727
+ return doctorExitCode(results);
728
+ }
599
729
 
600
730
  if (json) {
601
731
  process.stdout.write(formatJson(results) + '\n');
602
- } else {
603
- process.stdout.write(formatText(results));
732
+ return doctorExitCode(results);
604
733
  }
605
734
 
735
+ process.stdout.write(formatText(results));
736
+
606
737
  if (fix) {
607
- if (!json) process.stdout.write('Applying fixes...\n');
608
- await applyFixes(results, force, promptFn);
609
- } else if (!json) {
738
+ process.stdout.write('Applying fixes...\n');
739
+ const applied = await applyFixes(results, force, promptFn);
740
+ if (applied > 0) {
741
+ results = runChecks();
742
+ process.stdout.write('\nAfter fixes:\n' + formatText(results));
743
+ }
744
+ } else {
610
745
  const errors = results.filter(r => r.status === 'error');
611
746
  const warns = results.filter(r => r.status === 'warn');
612
747
  if (errors.length === 0 && warns.length === 0) {
@@ -618,14 +753,12 @@ async function runDoctor({ fix, force, json, prompt: promptFn }) {
618
753
  process.stdout.write(`${parts.join(', ')}.\n`);
619
754
  const hasAutoFix = results.some(r => r.fix && r.fix.apply && r.fix.safe);
620
755
  const hasForce = results.some(r => r.fix && r.fix.apply && !r.fix.safe);
621
- if (hasAutoFix) process.stdout.write('Run: cc doctor --fix\n');
622
- else if (hasForce) process.stdout.write('Run: cc doctor --fix --force\n');
756
+ if (hasAutoFix) process.stdout.write('Run: cchost doctor --fix\n');
757
+ else if (hasForce) process.stdout.write('Run: cchost doctor --fix --force\n');
623
758
  }
624
759
  }
625
760
 
626
- if (results.some(r => r.status === 'error')) return 2;
627
- if (results.some(r => r.status === 'warn')) return 1;
628
- return 0;
761
+ return doctorExitCode(results);
629
762
  }
630
763
 
631
- module.exports = { runChecks, runDoctor };
764
+ module.exports = { runChecks, runDoctor, checkCcShadow, checkMacKeychain };