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/CHANGELOG.md +64 -0
- package/README.md +88 -43
- package/lib/banner.js +3 -1
- package/lib/cli.js +220 -98
- package/lib/color.js +3 -1
- package/lib/copy.js +31 -29
- package/lib/doctor.js +201 -68
- package/lib/env.js +65 -9
- package/lib/profiles.js +47 -14
- package/lib/share.js +63 -6
- package/lib/spawn.js +2 -1
- package/package.json +3 -2
package/lib/color.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
function colorEnabled(stream) {
|
|
4
|
-
|
|
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
|
-
// `
|
|
4
|
-
// ~/.claude/ (default) into a profile. Renamed from `
|
|
5
|
-
// `
|
|
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
|
-
// `
|
|
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 `
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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 \`
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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 =
|
|
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
|
|
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 =
|
|
79
|
-
const exts =
|
|
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 =
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
+
write(` Linked: ${pp} -> ${expected}\n`);
|
|
181
264
|
} else if (r) {
|
|
182
|
-
|
|
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
|
-
|
|
289
|
+
write(` Recreated: ${pp} -> ${expected}\n`);
|
|
207
290
|
} else if (r) {
|
|
208
|
-
|
|
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
|
-
|
|
326
|
+
write(` Linked: ${pp} -> ${expected}\n`);
|
|
244
327
|
} else if (r) {
|
|
245
|
-
|
|
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
|
|
257
|
-
cmd: `
|
|
258
|
-
`or manually move ${pp} aside, then:
|
|
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);
|
|
320
|
-
|
|
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) =>
|
|
331
|
-
|
|
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) =>
|
|
465
|
+
log: (line) => write(`${line}\n`),
|
|
383
466
|
});
|
|
384
|
-
|
|
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
|
-
|
|
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) =>
|
|
507
|
+
log: (line) => write(` ${line}\n`),
|
|
425
508
|
});
|
|
426
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 ? '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
await applyFixes(results, force, promptFn);
|
|
609
|
-
|
|
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:
|
|
622
|
-
else if (hasForce) process.stdout.write('Run:
|
|
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
|
-
|
|
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 };
|