cchost 0.3.1 → 0.4.2

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/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 pp = path.join(prof.dir, 'projects');
161
- let isLinkNode = false;
162
- try { isLinkNode = fs.lstatSync(pp).isSymbolicLink(); } catch { return results; }
163
- if (!isLinkNode) return results;
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
- try {
166
- fs.statSync(pp);
167
- results.push(mk('Storage', `"${prof.name}" projects/ link`, 'ok', 'valid'));
168
- return results;
169
- } catch { /* broken */ }
170
-
171
- const expected = path.join(profiles.sharedDir(), 'projects');
172
- const targetExists = fs.existsSync(expected);
173
- results.push(mk('Storage', `"${prof.name}" projects/ link`, 'error', 'broken — target missing', {
174
- safe: targetExists,
175
- description: targetExists ? `Recreate junction/symlink to ${expected}` : 'Target missing',
176
- cmd: targetExists ? null : `cc link ${prof.name}`,
177
- apply: targetExists ? async () => {
178
- try { fs.unlinkSync(pp); } catch { try { fs.rmdirSync(pp); } catch { /* ignore */ } }
179
- fs.symlinkSync(expected, pp, process.platform === 'win32' ? 'junction' : 'dir');
180
- process.stdout.write(` Recreated: ${pp} -> ${expected}\n`);
181
- } : null,
182
- }));
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
@@ -194,6 +194,25 @@ function maskToken(token) {
194
194
  return `${token.slice(0, 6)}...${token.slice(-4)} (${n} chars)`;
195
195
  }
196
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
+
197
216
  function validateBaseUrl(url, warn) {
198
217
  if (!/^https?:\/\/.+/.test(url)) {
199
218
  throw usageError(`Invalid base_url "${url}": must start with http:// or https://`);
@@ -446,6 +465,7 @@ module.exports = {
446
465
  hostOf,
447
466
  endpointSummary,
448
467
  maskToken,
468
+ maskEmail,
449
469
  validateBaseUrl,
450
470
  validateToken,
451
471
  validateField,
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,