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/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
@@ -1,12 +1,15 @@
1
1
  'use strict';
2
2
 
3
- // Per-profile custom API endpoints (v0.3).
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
- // `cc env <profile>` and `cc env <profile> --flags`.
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
- 'Endpoint profiles must be created on a fresh profile.\n' +
374
- 'Run:\n' +
375
- ' cc add <new-name>\n' +
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('cc env requires either flags or an interactive terminal.');
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
- console.log('Cancelled. No changes written.');
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,