cchost 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/README.md +123 -63
- package/lib/banner.js +29 -0
- package/lib/cli.js +545 -207
- package/lib/copy.js +242 -0
- package/lib/doctor.js +154 -22
- package/lib/env.js +40 -45
- package/lib/profiles.js +10 -0
- package/lib/share.js +343 -57
- package/package.json +1 -1
- package/lib/migrate.js +0 -145
package/lib/copy.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
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.
|
|
6
|
+
//
|
|
7
|
+
// Only direction supported: default → <profile>. The reverse and profile→
|
|
8
|
+
// profile were considered and dropped — they're rare enough that a manual
|
|
9
|
+
// `cp -r` is fine and keeps the surface small.
|
|
10
|
+
//
|
|
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
|
|
13
|
+
// same path as the source). The command is meaningful only when a resource
|
|
14
|
+
// has been unlinked via `cc unlink`.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const profiles = require('./profiles');
|
|
20
|
+
const share = require('./share');
|
|
21
|
+
|
|
22
|
+
const EXCLUDED_FILES = new Set(['.credentials.json', '.claude.json', '.cc-env.json']);
|
|
23
|
+
|
|
24
|
+
function defaultClaudeDir() {
|
|
25
|
+
return profiles.defaultClaudeDir();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function usageError(msg) {
|
|
29
|
+
const e = new Error(msg);
|
|
30
|
+
e.exitCode = 2;
|
|
31
|
+
return e;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rejectSharedKeyword(role) {
|
|
35
|
+
return usageError(
|
|
36
|
+
`'shared' is not a valid copy ${role} in v0.4. ` +
|
|
37
|
+
"The shared pool is now ~/.claude/ itself, reached via per-profile " +
|
|
38
|
+
"symlinks. Use `cc link <profile>` / `cc unlink <profile>` to manage " +
|
|
39
|
+
"which resources are shared.",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveSource(src) {
|
|
44
|
+
if (src === 'default') return { kind: 'default', dir: defaultClaudeDir() };
|
|
45
|
+
if (src === 'shared') throw rejectSharedKeyword('source');
|
|
46
|
+
throw usageError(`Invalid copy source "${src}". Allowed: default.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveDest(dest) {
|
|
50
|
+
if (dest === 'shared') throw rejectSharedKeyword('destination');
|
|
51
|
+
if (dest === 'default') {
|
|
52
|
+
throw usageError('Cannot copy into "default". Allowed destination: <profile>.');
|
|
53
|
+
}
|
|
54
|
+
profiles.validateProfileName(dest);
|
|
55
|
+
return { kind: 'profile', name: dest, dir: profiles.profileDir(dest) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Return resolved real paths, or null if a path can't be realpath'd. Used to
|
|
59
|
+
// detect "src and dst are the same file/dir under symlinks/hardlinks".
|
|
60
|
+
function realOrNull(p) {
|
|
61
|
+
try {
|
|
62
|
+
return fs.realpathSync(p);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Compare two existing paths for "are these the same on-disk object?". Honors
|
|
69
|
+
// symlinks (via realpath) and hard links (via inode comparison for files).
|
|
70
|
+
function sameOnDisk(a, b) {
|
|
71
|
+
const ra = realOrNull(a);
|
|
72
|
+
const rb = realOrNull(b);
|
|
73
|
+
if (ra && rb && ra === rb) return true;
|
|
74
|
+
// Inode comparison — covers Windows hard links where realpath does not
|
|
75
|
+
// resolve to the same string but the files share an inode.
|
|
76
|
+
try {
|
|
77
|
+
const sa = fs.statSync(a);
|
|
78
|
+
const sb = fs.statSync(b);
|
|
79
|
+
if (sa.isFile() && sb.isFile() && sa.ino !== 0 && sa.ino === sb.ino && sa.dev === sb.dev) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// either side missing — not the same
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
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 } = {}) {
|
|
91
|
+
if (!fs.existsSync(srcRoot)) return { copied: 0, skipped: 0 };
|
|
92
|
+
fs.mkdirSync(dstRoot, { recursive: true });
|
|
93
|
+
let copied = 0;
|
|
94
|
+
let skipped = 0;
|
|
95
|
+
for (const ent of fs.readdirSync(srcRoot, { withFileTypes: true })) {
|
|
96
|
+
if (EXCLUDED_FILES.has(ent.name)) continue;
|
|
97
|
+
const from = path.join(srcRoot, ent.name);
|
|
98
|
+
const to = path.join(dstRoot, ent.name);
|
|
99
|
+
if (fs.existsSync(to)) {
|
|
100
|
+
if (force) {
|
|
101
|
+
fs.rmSync(to, { recursive: true, force: true });
|
|
102
|
+
} else {
|
|
103
|
+
skipped += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true });
|
|
108
|
+
copied += 1;
|
|
109
|
+
}
|
|
110
|
+
return { copied, skipped };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Copy a single file. Returns 'copied' | 'skipped' | 'absent'.
|
|
114
|
+
function copySingleFile(srcPath, dstPath, { force = false } = {}) {
|
|
115
|
+
if (!fs.existsSync(srcPath)) return 'absent';
|
|
116
|
+
if (fs.existsSync(dstPath)) {
|
|
117
|
+
if (force) {
|
|
118
|
+
fs.rmSync(dstPath, { recursive: true, force: true });
|
|
119
|
+
} else {
|
|
120
|
+
return 'skipped';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
fs.mkdirSync(path.dirname(dstPath), { recursive: true });
|
|
124
|
+
fs.copyFileSync(srcPath, dstPath);
|
|
125
|
+
return 'copied';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatResourceLabel(resource) {
|
|
129
|
+
return share.isFileResource(resource) ? resource : `${resource}/`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Main entry: copy each of the 7 shared resources from src → dest, honoring
|
|
133
|
+
// 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 } = {}) {
|
|
136
|
+
const src = resolveSource(srcArg);
|
|
137
|
+
const dest = resolveDest(destArg);
|
|
138
|
+
|
|
139
|
+
if (path.resolve(src.dir) === path.resolve(dest.dir)) {
|
|
140
|
+
throw usageError(`Source and destination resolve to the same directory (${src.dir}).`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
profiles.ensureProfileDir(dest.name);
|
|
144
|
+
if (!fs.existsSync(src.dir)) {
|
|
145
|
+
console.log(`Source ${src.dir} does not exist. Nothing to copy.`);
|
|
146
|
+
return { copied: 0, skipped: 0, shared: 0, results: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let totalCopied = 0;
|
|
150
|
+
let totalSkipped = 0;
|
|
151
|
+
let totalShared = 0;
|
|
152
|
+
const results = [];
|
|
153
|
+
|
|
154
|
+
// Compute the column width so the per-line output aligns.
|
|
155
|
+
const maxLabel = share.SHARED_RESOURCES.reduce(
|
|
156
|
+
(m, r) => Math.max(m, formatResourceLabel(r).length),
|
|
157
|
+
0,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
for (const resource of share.SHARED_RESOURCES) {
|
|
161
|
+
const label = formatResourceLabel(resource).padEnd(maxLabel + 1);
|
|
162
|
+
const srcPath = path.join(src.dir, resource);
|
|
163
|
+
const dstPath = path.join(dest.dir, resource);
|
|
164
|
+
const isFile = share.isFileResource(resource);
|
|
165
|
+
|
|
166
|
+
// Already-shared check: same on-disk target via symlink or hard link.
|
|
167
|
+
if (fs.existsSync(dstPath) && fs.existsSync(srcPath) && sameOnDisk(srcPath, dstPath)) {
|
|
168
|
+
console.log(` ${label} shared via symlink — skipped`);
|
|
169
|
+
results.push({ resource, status: 'shared' });
|
|
170
|
+
totalShared += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!fs.existsSync(srcPath)) {
|
|
175
|
+
console.log(` ${label} not present at source`);
|
|
176
|
+
results.push({ resource, status: 'absent' });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (isFile) {
|
|
181
|
+
const r = copySingleFile(srcPath, dstPath, { force });
|
|
182
|
+
if (r === 'copied') {
|
|
183
|
+
console.log(` ${label} copied`);
|
|
184
|
+
totalCopied += 1;
|
|
185
|
+
} else if (r === 'skipped') {
|
|
186
|
+
console.log(` ${label} skipped (already present — pass --force to overwrite)`);
|
|
187
|
+
totalSkipped += 1;
|
|
188
|
+
} else {
|
|
189
|
+
console.log(` ${label} not present at source`);
|
|
190
|
+
}
|
|
191
|
+
results.push({ resource, status: r });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { copied, skipped } = copyDirContents(srcPath, dstPath, { force });
|
|
196
|
+
if (copied === 0 && skipped === 0) {
|
|
197
|
+
console.log(` ${label} empty at source`);
|
|
198
|
+
} else if (copied > 0) {
|
|
199
|
+
const more = skipped > 0 ? `, skipped ${skipped} (already present)` : '';
|
|
200
|
+
console.log(` ${label} copied ${copied} item(s)${more}`);
|
|
201
|
+
} else {
|
|
202
|
+
console.log(
|
|
203
|
+
` ${label} skipped ${skipped} item(s) (all already present — pass --force to overwrite)`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
totalCopied += copied;
|
|
207
|
+
totalSkipped += skipped;
|
|
208
|
+
results.push({ resource, status: 'dir', copied, skipped });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log('');
|
|
212
|
+
if (totalCopied === 0 && totalShared === share.SHARED_RESOURCES.length) {
|
|
213
|
+
console.log(
|
|
214
|
+
`Nothing to copy — all ${share.SHARED_RESOURCES.length} resources are already shared via symlinks.`,
|
|
215
|
+
);
|
|
216
|
+
console.log(
|
|
217
|
+
`Tip: run \`cc unlink ${dest.name}\` first if you want private copies in this profile.`,
|
|
218
|
+
);
|
|
219
|
+
} else if (totalCopied === 0) {
|
|
220
|
+
console.log(
|
|
221
|
+
`Nothing copied. ${totalShared} shared via symlink, ${totalSkipped} skipped (already present).`,
|
|
222
|
+
);
|
|
223
|
+
} else {
|
|
224
|
+
console.log(
|
|
225
|
+
`Done. Copied ${totalCopied} item(s), ${totalShared} already shared via symlinks` +
|
|
226
|
+
(totalSkipped > 0 ? `, ${totalSkipped} skipped (already present)` : '') +
|
|
227
|
+
'.',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { copied: totalCopied, skipped: totalSkipped, shared: totalShared, results };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
copy,
|
|
236
|
+
defaultClaudeDir,
|
|
237
|
+
resolveSource,
|
|
238
|
+
resolveDest,
|
|
239
|
+
copyDirContents,
|
|
240
|
+
copySingleFile,
|
|
241
|
+
EXCLUDED_FILES,
|
|
242
|
+
};
|
package/lib/doctor.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
|
|
7
7
|
const profiles = require('./profiles');
|
|
8
|
+
const share = require('./share');
|
|
8
9
|
const { findClaude, getClaudeVersion, compareVersion, MIN_VERSION } = require('./spawn');
|
|
9
10
|
const color = require('./color');
|
|
10
11
|
|
|
@@ -157,32 +158,162 @@ function checkProfileClaudeJson(prof) {
|
|
|
157
158
|
|
|
158
159
|
function checkProfileLinks(prof) {
|
|
159
160
|
const results = [];
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
for (const resource of share.SHARED_RESOURCES) {
|
|
162
|
+
const pp = path.join(prof.dir, resource);
|
|
163
|
+
const isFile = share.isFileResource(resource);
|
|
164
|
+
const label = isFile ? resource : `${resource}/`;
|
|
165
|
+
const expected = path.join(profiles.defaultClaudeDir(), resource);
|
|
166
|
+
|
|
167
|
+
let stat;
|
|
168
|
+
try { stat = fs.lstatSync(pp); } catch { stat = null; }
|
|
169
|
+
|
|
170
|
+
if (!stat) {
|
|
171
|
+
// Resource missing entirely — safe to auto-create the link.
|
|
172
|
+
results.push(
|
|
173
|
+
mk('Storage', `"${prof.name}" ${label}`, 'warn', 'missing — not linked', {
|
|
174
|
+
safe: true,
|
|
175
|
+
description: `Create link to ~/.claude/${resource}`,
|
|
176
|
+
cmd: null,
|
|
177
|
+
apply: async () => {
|
|
178
|
+
const r = share.shareLink(prof.name, subsetForResource(resource))[0];
|
|
179
|
+
if (r && r.status === 'linked') {
|
|
180
|
+
process.stdout.write(` Linked: ${pp} -> ${expected}\n`);
|
|
181
|
+
} else if (r) {
|
|
182
|
+
process.stdout.write(` Could not link ${pp}: ${r.message || r.status}\n`);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
164
189
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
190
|
+
if (stat.isSymbolicLink()) {
|
|
191
|
+
// Symlink — check it resolves correctly.
|
|
192
|
+
if (share.isSharedResource(prof.dir, resource)) {
|
|
193
|
+
results.push(mk('Storage', `"${prof.name}" ${label} link`, 'ok', 'valid'));
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// Broken or points elsewhere.
|
|
197
|
+
results.push(
|
|
198
|
+
mk('Storage', `"${prof.name}" ${label} link`, 'error', 'broken — target missing or wrong', {
|
|
199
|
+
safe: true,
|
|
200
|
+
description: `Recreate link to ${expected}`,
|
|
201
|
+
cmd: null,
|
|
202
|
+
apply: async () => {
|
|
203
|
+
share.removeLink(pp);
|
|
204
|
+
const r = share.shareLink(prof.name, subsetForResource(resource))[0];
|
|
205
|
+
if (r && (r.status === 'linked' || r.status === 'already-linked')) {
|
|
206
|
+
process.stdout.write(` Recreated: ${pp} -> ${expected}\n`);
|
|
207
|
+
} else if (r) {
|
|
208
|
+
process.stdout.write(` Could not recreate ${pp}: ${r.message || r.status}\n`);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Not a symlink. Could be (a) hard link sharing (Windows file fallback)
|
|
217
|
+
// → ok, or (b) genuinely private content → warn (no auto-fix).
|
|
218
|
+
if (share.isSharedResource(prof.dir, resource)) {
|
|
219
|
+
results.push(mk('Storage', `"${prof.name}" ${label} link`, 'ok', 'valid (hard link)'));
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Private — distinguish empty (safe to auto-link) from has-content (warn).
|
|
224
|
+
let hasContent = false;
|
|
225
|
+
try {
|
|
226
|
+
if (isFile) hasContent = stat.size > 0;
|
|
227
|
+
else hasContent = fs.readdirSync(pp).length > 0;
|
|
228
|
+
} catch { hasContent = true; }
|
|
229
|
+
|
|
230
|
+
if (!hasContent) {
|
|
231
|
+
results.push(
|
|
232
|
+
mk('Storage', `"${prof.name}" ${label}`, 'warn', 'private (empty) — not linked', {
|
|
233
|
+
safe: true,
|
|
234
|
+
description: `Replace with link to ~/.claude/${resource}`,
|
|
235
|
+
cmd: null,
|
|
236
|
+
apply: async () => {
|
|
237
|
+
try {
|
|
238
|
+
if (isFile) fs.unlinkSync(pp);
|
|
239
|
+
else fs.rmdirSync(pp);
|
|
240
|
+
} catch { /* fall through */ }
|
|
241
|
+
const r = share.shareLink(prof.name, subsetForResource(resource))[0];
|
|
242
|
+
if (r && r.status === 'linked') {
|
|
243
|
+
process.stdout.write(` Linked: ${pp} -> ${expected}\n`);
|
|
244
|
+
} else if (r) {
|
|
245
|
+
process.stdout.write(` Could not link ${pp}: ${r.message || r.status}\n`);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
results.push(
|
|
254
|
+
mk('Storage', `"${prof.name}" ${label}`, 'warn', 'private (has content) — not linked', {
|
|
255
|
+
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}`,
|
|
259
|
+
apply: null,
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
183
263
|
return results;
|
|
184
264
|
}
|
|
185
265
|
|
|
266
|
+
// Build the share.shareLink opts to target exactly one resource.
|
|
267
|
+
function subsetForResource(resource) {
|
|
268
|
+
if (share.PROJECT_SUBDIRS.includes(resource)) return { projects: true };
|
|
269
|
+
if (share.TOOL_SUBDIRS.includes(resource)) {
|
|
270
|
+
// shareLink with {tools:true} targets all 3 tool dirs; that's fine for
|
|
271
|
+
// backfilling, the others are no-ops if already linked.
|
|
272
|
+
return { tools: true };
|
|
273
|
+
}
|
|
274
|
+
if (share.CONFIG_FILES.includes(resource)) return { config: true };
|
|
275
|
+
return {};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// v0.3 → v0.4 migration nudge. If the legacy ~/.claude-shared/ pool still
|
|
279
|
+
// exists, surface it as a warning with a safe auto-fix that runs the
|
|
280
|
+
// one-time migration.
|
|
281
|
+
function checkLegacyPool() {
|
|
282
|
+
const legacy = profiles.sharedDir();
|
|
283
|
+
if (!fs.existsSync(legacy)) return null;
|
|
284
|
+
let hasContent = false;
|
|
285
|
+
try {
|
|
286
|
+
hasContent = fs.readdirSync(legacy).length > 0;
|
|
287
|
+
} catch {
|
|
288
|
+
// Unreadable — still flag it.
|
|
289
|
+
hasContent = true;
|
|
290
|
+
}
|
|
291
|
+
if (!hasContent) {
|
|
292
|
+
// Empty dir — safe to remove silently.
|
|
293
|
+
return mk('Storage', '~/.claude-shared/ (legacy)', 'warn', 'empty dir from a prior cchost version', {
|
|
294
|
+
safe: true,
|
|
295
|
+
description: 'Remove the empty legacy pool',
|
|
296
|
+
cmd: null,
|
|
297
|
+
apply: async () => {
|
|
298
|
+
try { fs.rmdirSync(legacy); process.stdout.write(` Removed ${legacy}\n`); } catch (e) {
|
|
299
|
+
process.stdout.write(` Could not remove ${legacy}: ${e.message}\n`);
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return mk('Storage', '~/.claude-shared/ (legacy)', 'warn', 'pre-v0.4 pool with content — migrate into ~/.claude/', {
|
|
305
|
+
safe: true,
|
|
306
|
+
description: 'Move legacy pool contents into ~/.claude/ and re-point profile symlinks',
|
|
307
|
+
cmd: null,
|
|
308
|
+
apply: async () => {
|
|
309
|
+
const result = share.migrateLegacyPool({ log: (line) => process.stdout.write(` ${line}\n`) });
|
|
310
|
+
process.stdout.write(
|
|
311
|
+
` Done: ${result.moves.length} item(s) moved, ${result.relinked.length} link(s) repointed.\n`,
|
|
312
|
+
);
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
186
317
|
// --- Platform ---
|
|
187
318
|
|
|
188
319
|
function checkOneDrive() {
|
|
@@ -246,6 +377,7 @@ function runChecks() {
|
|
|
246
377
|
checkCcShadow(),
|
|
247
378
|
checkProfilesDirWritable(),
|
|
248
379
|
];
|
|
380
|
+
results.push(checkLegacyPool());
|
|
249
381
|
for (const prof of profiles.listProfiles()) {
|
|
250
382
|
results.push(checkProfileClaudeJson(prof));
|
|
251
383
|
results.push(...checkProfileLinks(prof));
|
package/lib/env.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Per-profile custom API endpoints
|
|
3
|
+
// Per-profile custom API endpoints.
|
|
4
4
|
//
|
|
5
5
|
// A profile may carry a `.cc-env.json` file that routes Claude Code through an
|
|
6
6
|
// Anthropic-compatible third-party provider (Moonshot/Kimi, OpenRouter, vLLM,
|
|
7
7
|
// corporate proxies) instead of Anthropic's subscription OAuth. The file holds
|
|
8
8
|
// an API key and is treated with the same care as `.credentials.json`: it is
|
|
9
9
|
// never migrated, never linked, and written with user-only file permissions.
|
|
10
|
+
//
|
|
11
|
+
// Endpoint profiles are created and edited through `cc add <profile> --custom`;
|
|
12
|
+
// this module supplies the configuration logic that command delegates to.
|
|
10
13
|
|
|
11
14
|
const fs = require('fs');
|
|
12
15
|
const path = require('path');
|
|
@@ -191,6 +194,25 @@ function maskToken(token) {
|
|
|
191
194
|
return `${token.slice(0, 6)}...${token.slice(-4)} (${n} chars)`;
|
|
192
195
|
}
|
|
193
196
|
|
|
197
|
+
// Mask an email so it's safe to paste in screenshots/logs.
|
|
198
|
+
// allen@cyber.co.in → a*************.in
|
|
199
|
+
// Keeps the first character and the final TLD (everything after the last dot);
|
|
200
|
+
// everything in between is replaced with `*` of the original length so the
|
|
201
|
+
// width matches the original. If there's no usable TLD, mask everything after
|
|
202
|
+
// the first char.
|
|
203
|
+
function maskEmail(email) {
|
|
204
|
+
if (!email || typeof email !== 'string') return email;
|
|
205
|
+
if (email.length < 2) return '***';
|
|
206
|
+
const lastDot = email.lastIndexOf('.');
|
|
207
|
+
const first = email[0];
|
|
208
|
+
if (lastDot < 1 || lastDot === email.length - 1) {
|
|
209
|
+
return first + '*'.repeat(email.length - 1);
|
|
210
|
+
}
|
|
211
|
+
const tld = email.slice(lastDot); // includes the leading dot
|
|
212
|
+
const middleLen = lastDot - 1;
|
|
213
|
+
return first + '*'.repeat(middleLen) + tld;
|
|
214
|
+
}
|
|
215
|
+
|
|
194
216
|
function validateBaseUrl(url, warn) {
|
|
195
217
|
if (!/^https?:\/\/.+/.test(url)) {
|
|
196
218
|
throw usageError(`Invalid base_url "${url}": must start with http:// or https://`);
|
|
@@ -338,7 +360,7 @@ async function runWizard(name, existing, io) {
|
|
|
338
360
|
}
|
|
339
361
|
}
|
|
340
362
|
|
|
341
|
-
io.write(`\ncchost endpoint configuration for profile '${name}'\n\n`);
|
|
363
|
+
io.write(`\ncchost endpoint configuration for profile '${name}'${isEdit ? ' (editing)' : ''}\n\n`);
|
|
342
364
|
for (const field of WIZARD_FIELDS) {
|
|
343
365
|
await promptOne(field, config, io, isEdit);
|
|
344
366
|
}
|
|
@@ -360,20 +382,23 @@ async function runWizard(name, existing, io) {
|
|
|
360
382
|
// Command orchestration (called from cli.js)
|
|
361
383
|
// ---------------------------------------------------------------------------
|
|
362
384
|
|
|
363
|
-
//
|
|
385
|
+
// Configures a profile's custom endpoint. Called by `cc add <profile> --custom`
|
|
386
|
+
// (both for first-time creation and for editing an existing endpoint profile).
|
|
364
387
|
// `provided` is a partial config of fields supplied via flags.
|
|
365
388
|
// `opts.io` injects wizard IO (tests); `opts.isTTY` overrides TTY detection.
|
|
389
|
+
//
|
|
390
|
+
// Returns `{ code, written }`: `written` is false only when the interactive
|
|
391
|
+
// wizard was cancelled, so the caller can clean up a freshly-created profile.
|
|
366
392
|
async function runConfigure(name, profileDir, provided, opts = {}) {
|
|
367
393
|
if (!fs.existsSync(profileDir)) {
|
|
368
394
|
throw stateError(`Profile "${name}" does not exist. Create it with: cc add ${name}`);
|
|
369
395
|
}
|
|
370
396
|
if (hasOAuthState(profileDir)) {
|
|
371
397
|
throw stateError(
|
|
372
|
-
`Profile '${name}' has OAuth credentials.\n` +
|
|
373
|
-
'
|
|
374
|
-
'
|
|
375
|
-
' cc add <new-name
|
|
376
|
-
' cc env <new-name>',
|
|
398
|
+
`Profile '${name}' has OAuth credentials and cannot be converted to a custom endpoint.\n` +
|
|
399
|
+
'A profile is born subscription or endpoint and stays that way.\n' +
|
|
400
|
+
'Create a fresh endpoint profile instead:\n' +
|
|
401
|
+
' cc add <new-name> --custom',
|
|
377
402
|
);
|
|
378
403
|
}
|
|
379
404
|
|
|
@@ -400,7 +425,7 @@ async function runConfigure(name, profileDir, provided, opts = {}) {
|
|
|
400
425
|
console.log(
|
|
401
426
|
`${editing ? 'Updated' : 'Created'} endpoint config for profile '${name}' → ${hostOf(merged.base_url)}.`,
|
|
402
427
|
);
|
|
403
|
-
return 0;
|
|
428
|
+
return { code: 0, written: true };
|
|
404
429
|
}
|
|
405
430
|
|
|
406
431
|
// Interactive wizard.
|
|
@@ -408,52 +433,22 @@ async function runConfigure(name, profileDir, provided, opts = {}) {
|
|
|
408
433
|
if (!io) {
|
|
409
434
|
const isTTY = opts.isTTY != null ? opts.isTTY : process.stdin.isTTY;
|
|
410
435
|
if (!isTTY) {
|
|
411
|
-
throw usageError(
|
|
436
|
+
throw usageError(
|
|
437
|
+
'cc add --custom requires either endpoint flags or an interactive terminal.',
|
|
438
|
+
);
|
|
412
439
|
}
|
|
413
440
|
io = defaultWizardIo();
|
|
414
441
|
}
|
|
415
442
|
|
|
416
443
|
const result = await runWizard(name, existing, io);
|
|
417
444
|
if (result == null) {
|
|
418
|
-
|
|
419
|
-
return 0;
|
|
445
|
+
return { code: 0, written: false };
|
|
420
446
|
}
|
|
421
447
|
writeEnvConfig(profileDir, result);
|
|
422
448
|
console.log(
|
|
423
449
|
`${editing ? 'Updated' : 'Created'} endpoint config for profile '${name}' → ${hostOf(result.base_url)}.`,
|
|
424
450
|
);
|
|
425
|
-
return 0;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// `cc env <profile> show [--reveal]`.
|
|
429
|
-
function runShow(name, profileDir, { reveal = false, write } = {}) {
|
|
430
|
-
const w = write || ((s) => process.stdout.write(s + '\n'));
|
|
431
|
-
if (!fs.existsSync(profileDir)) {
|
|
432
|
-
throw stateError(`Profile "${name}" does not exist.`);
|
|
433
|
-
}
|
|
434
|
-
const config = readEnvConfig(profileDir); // throws on malformed file
|
|
435
|
-
if (config == null) {
|
|
436
|
-
throw stateError(`Profile '${name}' is not an endpoint profile (no ${ENV_FILE}).`);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
w(`Endpoint configuration for profile '${name}':`);
|
|
440
|
-
w(` Base URL: ${config.base_url || '-'}`);
|
|
441
|
-
if (reveal) {
|
|
442
|
-
w(` Token: ${config.auth_token || '-'}`);
|
|
443
|
-
} else {
|
|
444
|
-
w(` Token: ${config.auth_token ? maskToken(config.auth_token) : '-'}`);
|
|
445
|
-
}
|
|
446
|
-
w(` Main model: ${config.model || '-'}`);
|
|
447
|
-
w(` Opus: ${config.opus || config.model || '-'}`);
|
|
448
|
-
w(` Sonnet: ${config.sonnet || config.model || '-'}`);
|
|
449
|
-
w(` Haiku: ${config.haiku || config.model || '-'}`);
|
|
450
|
-
w(` Subagent: ${config.subagent || config.model || '-'}`);
|
|
451
|
-
if (reveal) {
|
|
452
|
-
w('');
|
|
453
|
-
w('WARNING: the full API key is printed above. Do not paste this output');
|
|
454
|
-
w('into chats, issues, screenshots, or logs.');
|
|
455
|
-
}
|
|
456
|
-
return 0;
|
|
451
|
+
return { code: 0, written: true };
|
|
457
452
|
}
|
|
458
453
|
|
|
459
454
|
module.exports = {
|
|
@@ -470,11 +465,11 @@ module.exports = {
|
|
|
470
465
|
hostOf,
|
|
471
466
|
endpointSummary,
|
|
472
467
|
maskToken,
|
|
468
|
+
maskEmail,
|
|
473
469
|
validateBaseUrl,
|
|
474
470
|
validateToken,
|
|
475
471
|
validateField,
|
|
476
472
|
runWizard,
|
|
477
473
|
runConfigure,
|
|
478
|
-
runShow,
|
|
479
474
|
defaultWizardIo,
|
|
480
475
|
};
|
package/lib/profiles.js
CHANGED
|
@@ -17,6 +17,15 @@ function baseDir() {
|
|
|
17
17
|
return path.join(os.homedir(), '.claude-profiles');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// ~/.claude/ — Claude Code's own default config dir. Since v0.4 this is also
|
|
21
|
+
// the shared artifact pool: every profile's projects/skills/agents/commands
|
|
22
|
+
// symlink back here, so one set of tools is visible to every profile.
|
|
23
|
+
function defaultClaudeDir() {
|
|
24
|
+
return path.join(os.homedir(), '.claude');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ~/.claude-shared/ — legacy pool from v0.2/0.3. Kept for migration detection
|
|
28
|
+
// only; new symlinks go to defaultClaudeDir(). Removed once migration runs.
|
|
20
29
|
function sharedDir() {
|
|
21
30
|
return path.join(os.homedir(), '.claude-shared');
|
|
22
31
|
}
|
|
@@ -143,6 +152,7 @@ function listProfiles() {
|
|
|
143
152
|
module.exports = {
|
|
144
153
|
baseDir,
|
|
145
154
|
sharedDir,
|
|
155
|
+
defaultClaudeDir,
|
|
146
156
|
profileDir,
|
|
147
157
|
validateProfileName,
|
|
148
158
|
ensureBaseDir,
|