cchost 0.5.0 → 0.6.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 +48 -0
- package/README.md +29 -4
- package/bin/cc.js +6 -1
- package/lib/actions.js +290 -0
- package/lib/ccforward.js +77 -0
- package/lib/cli.js +152 -15
- package/lib/copy.js +15 -15
- package/lib/doctor.js +11 -1
- package/lib/share.js +62 -1
- package/lib/ui/login.html +117 -0
- package/lib/ui/page.html +1171 -0
- package/lib/ui/server.js +384 -0
- package/lib/ui/tokens.css +80 -0
- package/package.json +1 -1
package/lib/cli.js
CHANGED
|
@@ -15,12 +15,13 @@ const { renderBanner } = require('./banner');
|
|
|
15
15
|
|
|
16
16
|
const HELP = `cchost - Multi-profile launcher for Claude Code
|
|
17
17
|
|
|
18
|
-
Installed as both 'cchost' (preferred) and 'cc' (legacy alias).
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
Installed as both 'cchost' (preferred) and 'cc' (legacy alias). 'cc' is also
|
|
19
|
+
the C compiler on macOS and Linux: when 'cc' is called with anything other than
|
|
20
|
+
a cchost command (e.g. 'cc -c foo.c', 'cc --version'), it runs the real
|
|
21
|
+
compiler further down PATH. Use 'cchost' in scripts.
|
|
21
22
|
|
|
22
23
|
Usage:
|
|
23
|
-
cchost add <profile> [--custom [...]]
|
|
24
|
+
cchost add <profile> [--private] [--custom [...]] Create a profile (shares all 7 resources, or --private ones)
|
|
24
25
|
cchost use <profile> [...args] Launch Claude under a named profile
|
|
25
26
|
cchost list [<profile>] [--json] [--reveal] List profiles, or show one in detail
|
|
26
27
|
cchost copy default <profile> [--force] [--dry-run] Copy ~/.claude/ resources into a profile (only when unlinked)
|
|
@@ -28,6 +29,7 @@ Usage:
|
|
|
28
29
|
cchost unlink <profile> [--projects|--tools|--config] Restore a profile's private resources (non-destructive)
|
|
29
30
|
cchost remove <profile> [--force] Delete a profile and all its data
|
|
30
31
|
cchost doctor [--fix [--force]] [--json] Diagnose environment, storage, and platform issues
|
|
32
|
+
cchost ui [--port=N] [--no-open] [--session=8h] Open the web view (prints a sign-in password, like rclone)
|
|
31
33
|
cchost help [command] Show usage (optionally for a single command)
|
|
32
34
|
cchost help sharing Task guide: what is shared, and how to change it
|
|
33
35
|
cchost --version Print the installed cchost version
|
|
@@ -47,7 +49,7 @@ for that launch. Args after the profile name are forwarded verbatim to claude.
|
|
|
47
49
|
`;
|
|
48
50
|
|
|
49
51
|
const HELP_BY_CMD = {
|
|
50
|
-
add: `cchost add <profile> [--custom [--base-url=URL --token=TOKEN ...]]
|
|
52
|
+
add: `cchost add <profile> [--private[=projects,tools,config]] [--custom [--base-url=URL --token=TOKEN ...]]
|
|
51
53
|
|
|
52
54
|
Create a new profile directory without launching Claude. All 7 shared
|
|
53
55
|
resources (projects/, skills/, agents/, commands/, mcp.json, settings.json,
|
|
@@ -58,6 +60,15 @@ profile (and to bare \`claude\`) from the moment the profile is created.
|
|
|
58
60
|
Create a regular profile. It logs in with your Anthropic
|
|
59
61
|
subscription (OAuth) on the first 'cchost use'.
|
|
60
62
|
|
|
63
|
+
cchost add <profile> --private
|
|
64
|
+
cchost add <profile> --private=projects,config
|
|
65
|
+
Start some or all resources private and empty instead of shared:
|
|
66
|
+
nothing from ~/.claude/ is copied in, and nothing this profile writes
|
|
67
|
+
there is seen by other profiles. Groups: projects (session history),
|
|
68
|
+
tools (skills/agents/commands), config (mcp.json/settings.json/CLAUDE.md).
|
|
69
|
+
Combine with --custom for an isolated endpoint profile. Share a group
|
|
70
|
+
later with 'cchost link <profile> --<group>'.
|
|
71
|
+
|
|
61
72
|
cchost add <profile> --custom
|
|
62
73
|
Create a profile routed through a custom, Anthropic-compatible API
|
|
63
74
|
endpoint — Moonshot/Kimi, OpenRouter, a self-hosted vLLM, a corporate
|
|
@@ -81,6 +92,7 @@ user-only permissions. It is never linked and never copied.
|
|
|
81
92
|
|
|
82
93
|
Examples:
|
|
83
94
|
cchost add work
|
|
95
|
+
cchost add client --private=projects,config
|
|
84
96
|
cchost add kimi --custom
|
|
85
97
|
cchost add kimi --custom --base-url=https://api.moonshot.ai/anthropic --token=sk-... --model=kimi-k2.5
|
|
86
98
|
`,
|
|
@@ -231,7 +243,11 @@ See where a profile stands:
|
|
|
231
243
|
cchost list Shared column: 7/7 = all shared
|
|
232
244
|
cchost list work state of each resource
|
|
233
245
|
|
|
234
|
-
|
|
246
|
+
Start a new profile private (empty, nothing copied in):
|
|
247
|
+
cchost add client --private all 7 private
|
|
248
|
+
cchost add client --private=projects own session history, shared tools/config
|
|
249
|
+
|
|
250
|
+
Make something private to an existing profile:
|
|
235
251
|
cchost unlink work --config own mcp.json / settings.json / CLAUDE.md
|
|
236
252
|
cchost unlink work all 7 private
|
|
237
253
|
The current shared content is copied in, so nothing disappears; from then
|
|
@@ -278,6 +294,26 @@ Exit codes: 0 = all ok, 1 = warnings, 2 = errors.
|
|
|
278
294
|
--fix --force Also apply confirmation-required fixes (e.g. reset corrupt
|
|
279
295
|
.claude.json)
|
|
280
296
|
--json Machine-readable output for CI / scripting
|
|
297
|
+
`,
|
|
298
|
+
ui: `cchost ui [--port=N] [--no-open] [--user=NAME] [--pass=PASSWORD] [--session=8h]
|
|
299
|
+
|
|
300
|
+
Open a local web page that shows every profile, what each one shares with
|
|
301
|
+
~/.claude/, logins, endpoints and doctor results, and lets you add and remove
|
|
302
|
+
profiles, share or unshare resources, copy from ~/.claude/ and apply doctor
|
|
303
|
+
fixes. Every change shows a preview first and lists each step it took.
|
|
304
|
+
|
|
305
|
+
Sign-in works like rclone's web GUI: the terminal prints a username and a
|
|
306
|
+
random password that is new every run, and your browser opens with a
|
|
307
|
+
one-time sign-in link (valid 2 minutes). The browser then keeps a session
|
|
308
|
+
that ends after --session or when cchost ui stops. The page is served on
|
|
309
|
+
127.0.0.1 only; endpoint tokens are never sent to it. Ctrl+C stops it.
|
|
310
|
+
|
|
311
|
+
--port=N Listen on port N (default: a free port)
|
|
312
|
+
--no-open Print the one-time link instead of opening a browser
|
|
313
|
+
--user=NAME Sign-in username (default: cchost)
|
|
314
|
+
--pass=PASSWORD Use this password instead of a random one (8+ characters;
|
|
315
|
+
visible in your shell history — prefer the random one)
|
|
316
|
+
--session=8h How long a sign-in lasts: 30m, 8h, 2d (default: 8h)
|
|
281
317
|
`,
|
|
282
318
|
help: `cchost help [command]
|
|
283
319
|
|
|
@@ -380,7 +416,7 @@ function resourceLabel(resource) {
|
|
|
380
416
|
const RESOURCE_STATE_NOTES = {
|
|
381
417
|
private: 'only this profile sees it',
|
|
382
418
|
empty: 'private, but empty',
|
|
383
|
-
missing: 'not
|
|
419
|
+
missing: 'not present; cchost link would share it',
|
|
384
420
|
'foreign-link': 'points outside ~/.claude/; cchost leaves it alone',
|
|
385
421
|
};
|
|
386
422
|
|
|
@@ -405,19 +441,45 @@ const ENDPOINT_VALUE_FLAGS = {
|
|
|
405
441
|
'--subagent': 'subagent',
|
|
406
442
|
};
|
|
407
443
|
|
|
444
|
+
// `--private` → all groups; `--private=projects,tools,config` → those groups.
|
|
445
|
+
// Returns the share.resolveSubset() options for the private groups.
|
|
446
|
+
const PRIVATE_GROUPS = ['projects', 'tools', 'config'];
|
|
447
|
+
|
|
448
|
+
function parsePrivateFlag(value) {
|
|
449
|
+
if (value === null) return { projects: true, tools: true, config: true };
|
|
450
|
+
const groups = value.split(',').map((g) => g.trim()).filter(Boolean);
|
|
451
|
+
if (groups.length === 0) {
|
|
452
|
+
throw usageError(`--private= needs at least one of: ${PRIVATE_GROUPS.join(', ')}`);
|
|
453
|
+
}
|
|
454
|
+
const opts = {};
|
|
455
|
+
for (const g of groups) {
|
|
456
|
+
if (!PRIVATE_GROUPS.includes(g)) {
|
|
457
|
+
throw usageError(`Unknown --private group "${g}". Use: ${PRIVATE_GROUPS.join(', ')}`);
|
|
458
|
+
}
|
|
459
|
+
opts[g] = true;
|
|
460
|
+
}
|
|
461
|
+
return opts;
|
|
462
|
+
}
|
|
463
|
+
|
|
408
464
|
async function cmdAdd(rest) {
|
|
409
465
|
const positional = [];
|
|
410
466
|
const provided = {};
|
|
411
467
|
let custom = false;
|
|
468
|
+
let privateOpts = null;
|
|
412
469
|
|
|
413
470
|
for (const a of rest) {
|
|
414
471
|
if (a.startsWith('-')) {
|
|
415
472
|
const eq = a.indexOf('=');
|
|
416
473
|
const flagName = eq === -1 ? a : a.slice(0, eq);
|
|
417
474
|
if (flagName === '--custom') {
|
|
475
|
+
if (eq !== -1) throw usageError('--custom takes no value');
|
|
418
476
|
custom = true;
|
|
419
477
|
continue;
|
|
420
478
|
}
|
|
479
|
+
if (flagName === '--private') {
|
|
480
|
+
privateOpts = parsePrivateFlag(eq === -1 ? null : a.slice(eq + 1));
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
421
483
|
if (Object.prototype.hasOwnProperty.call(ENDPOINT_VALUE_FLAGS, flagName)) {
|
|
422
484
|
if (eq === -1) {
|
|
423
485
|
throw usageError(`Flag ${flagName} requires a value: ${flagName}=VALUE`);
|
|
@@ -432,7 +494,10 @@ async function cmdAdd(rest) {
|
|
|
432
494
|
|
|
433
495
|
const [name, ...extra] = positional;
|
|
434
496
|
if (!name) {
|
|
435
|
-
throw usageError(
|
|
497
|
+
throw usageError(
|
|
498
|
+
'Usage: cchost add <profile> [--private[=projects,tools,config]] ' +
|
|
499
|
+
'[--custom [--base-url=URL --token=TOKEN ...]]',
|
|
500
|
+
);
|
|
436
501
|
}
|
|
437
502
|
if (extra.length > 0) throw usageError(`cchost add takes no extra arguments: ${extra[0]}`);
|
|
438
503
|
profiles.validateProfileName(name);
|
|
@@ -450,7 +515,7 @@ async function cmdAdd(rest) {
|
|
|
450
515
|
if (!custom) {
|
|
451
516
|
const { dir } = profiles.addProfile(name);
|
|
452
517
|
console.log(`Created profile "${name}" at ${dir}.`);
|
|
453
|
-
|
|
518
|
+
setUpResources(name, dir, privateOpts);
|
|
454
519
|
console.log(`Next: cchost use ${name}`);
|
|
455
520
|
return 0;
|
|
456
521
|
}
|
|
@@ -458,9 +523,15 @@ async function cmdAdd(rest) {
|
|
|
458
523
|
// Custom endpoint profile.
|
|
459
524
|
const dir = profiles.profileDir(name);
|
|
460
525
|
const existed = fs.existsSync(dir);
|
|
526
|
+
if (existed && privateOpts) {
|
|
527
|
+
throw usageError(
|
|
528
|
+
`--private only applies when creating a profile; "${name}" already exists. ` +
|
|
529
|
+
`Use: cchost unlink ${name} [--projects|--tools|--config]`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
461
532
|
if (!existed) {
|
|
462
533
|
profiles.addProfile(name);
|
|
463
|
-
|
|
534
|
+
setUpResources(name, dir, privateOpts);
|
|
464
535
|
}
|
|
465
536
|
|
|
466
537
|
let result;
|
|
@@ -515,13 +586,32 @@ function runLegacyPoolMigration() {
|
|
|
515
586
|
}
|
|
516
587
|
}
|
|
517
588
|
|
|
518
|
-
// On profile creation,
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
function
|
|
589
|
+
// On profile creation, link every resource that isn't private into
|
|
590
|
+
// ~/.claude/, and start each private dir empty. Private config files are left
|
|
591
|
+
// absent so Claude Code creates them on first use.
|
|
592
|
+
function setUpResources(name, dir, privateOpts) {
|
|
593
|
+
if (!privateOpts) {
|
|
594
|
+
autoLinkArtifacts(name);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const privateSet = share.resolveSubset(privateOpts);
|
|
598
|
+
const sharedGroups = {};
|
|
599
|
+
for (const g of PRIVATE_GROUPS) if (!privateOpts[g]) sharedGroups[g] = true;
|
|
600
|
+
if (Object.keys(sharedGroups).length > 0) autoLinkArtifacts(name, sharedGroups);
|
|
601
|
+
for (const r of privateSet) {
|
|
602
|
+
if (!share.isFileResource(r)) fs.mkdirSync(path.join(dir, r), { recursive: true });
|
|
603
|
+
}
|
|
604
|
+
share.markPrivate(dir, privateSet);
|
|
605
|
+
console.log(` Private (not shared with other profiles): ${privateSet.map(resourceLabel).join(', ')}`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Symlink resources into ~/.claude/ (all 7, or the groups in `opts`). Failures
|
|
609
|
+
// emit a warning but do NOT abort cchost add — the profile is still usable,
|
|
610
|
+
// and the user can re-run `cchost link <name>` to retry.
|
|
611
|
+
function autoLinkArtifacts(name, opts = {}) {
|
|
522
612
|
let results;
|
|
523
613
|
try {
|
|
524
|
-
results = share.shareLink(name);
|
|
614
|
+
results = share.shareLink(name, opts);
|
|
525
615
|
} catch (err) {
|
|
526
616
|
process.stderr.write(
|
|
527
617
|
color.stderr.yellow(
|
|
@@ -939,6 +1029,51 @@ async function cmdUnlink(rest) {
|
|
|
939
1029
|
return anyFailure ? 1 : 0;
|
|
940
1030
|
}
|
|
941
1031
|
|
|
1032
|
+
// `cchost ui` runs until Ctrl+C, so it never resolves.
|
|
1033
|
+
// '30m', '8h', '2d' -> milliseconds.
|
|
1034
|
+
function parseDuration(text) {
|
|
1035
|
+
const m = /^(\d+)(m|h|d)$/.exec(text);
|
|
1036
|
+
if (!m || Number(m[1]) === 0) return null;
|
|
1037
|
+
return Number(m[1]) * { m: 60e3, h: 3600e3, d: 86400e3 }[m[2]];
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function cmdUi(rest) {
|
|
1041
|
+
let port = 0;
|
|
1042
|
+
let open = true;
|
|
1043
|
+
let user = 'cchost';
|
|
1044
|
+
let pass = null;
|
|
1045
|
+
let sessionTtlMs;
|
|
1046
|
+
for (const a of rest) {
|
|
1047
|
+
if (a === '--no-open') { open = false; continue; }
|
|
1048
|
+
const eq = a.indexOf('=');
|
|
1049
|
+
const flag = eq === -1 ? a : a.slice(0, eq);
|
|
1050
|
+
const value = eq === -1 ? null : a.slice(eq + 1);
|
|
1051
|
+
if (['--port', '--user', '--pass', '--session'].includes(flag) && !value) {
|
|
1052
|
+
throw usageError(`Flag ${flag} requires a value: ${flag}=VALUE`);
|
|
1053
|
+
}
|
|
1054
|
+
if (flag === '--port') {
|
|
1055
|
+
port = Number(value);
|
|
1056
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw usageError(`Invalid port: ${a}`);
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
if (flag === '--user') { user = value; continue; }
|
|
1060
|
+
if (flag === '--pass') {
|
|
1061
|
+
if (value.length < 8) throw usageError('--pass must be at least 8 characters.');
|
|
1062
|
+
pass = value;
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
if (flag === '--session') {
|
|
1066
|
+
sessionTtlMs = parseDuration(value);
|
|
1067
|
+
if (!sessionTtlMs) throw usageError(`Invalid --session "${value}". Use e.g. 30m, 8h or 2d.`);
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
throw usageError(a.startsWith('-') ? `Unknown flag: ${flag}` : `Unexpected argument: ${a}`);
|
|
1071
|
+
}
|
|
1072
|
+
const { startUi } = require('./ui/server');
|
|
1073
|
+
await startUi({ port, open, user, pass, sessionTtlMs });
|
|
1074
|
+
return new Promise(() => {});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
942
1077
|
async function cmdDoctor(rest) {
|
|
943
1078
|
const { flags } = parseFlagsStrict(rest, ['--fix', '--force', '--json']);
|
|
944
1079
|
const fix = flags.has('--fix');
|
|
@@ -1023,6 +1158,8 @@ async function run(argv) {
|
|
|
1023
1158
|
return await cmdRemove(rest);
|
|
1024
1159
|
case 'doctor':
|
|
1025
1160
|
return await cmdDoctor(rest);
|
|
1161
|
+
case 'ui':
|
|
1162
|
+
return await cmdUi(rest);
|
|
1026
1163
|
default: {
|
|
1027
1164
|
process.stderr.write(color.stderr.red(`Unknown command: ${action}`) + '\n\n');
|
|
1028
1165
|
printHelp();
|
package/lib/copy.js
CHANGED
|
@@ -131,7 +131,7 @@ function formatResourceLabel(resource) {
|
|
|
131
131
|
// the v0.4 symlink semantics. Logs per-resource progress and a closing
|
|
132
132
|
// summary line. Returns a structured result for tests. With dryRun the same
|
|
133
133
|
// report is printed in the conditional ("would copy") and nothing is written.
|
|
134
|
-
function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
|
|
134
|
+
function copy(srcArg, destArg, { force = false, dryRun = false, log = console.log } = {}) {
|
|
135
135
|
const src = resolveSource(srcArg);
|
|
136
136
|
const dest = resolveDest(destArg);
|
|
137
137
|
|
|
@@ -142,7 +142,7 @@ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
|
|
|
142
142
|
if (!dryRun) profiles.ensureProfileDir(dest.name);
|
|
143
143
|
const copiedWord = dryRun ? 'would copy' : 'copied';
|
|
144
144
|
if (!fs.existsSync(src.dir)) {
|
|
145
|
-
|
|
145
|
+
log(`Source ${src.dir} does not exist. Nothing to copy.`);
|
|
146
146
|
return { copied: 0, skipped: 0, shared: 0, results: [] };
|
|
147
147
|
}
|
|
148
148
|
|
|
@@ -165,14 +165,14 @@ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
|
|
|
165
165
|
|
|
166
166
|
// Already-shared check: same on-disk target via symlink or hard link.
|
|
167
167
|
if (fs.existsSync(dstPath) && fs.existsSync(srcPath) && sameOnDisk(srcPath, dstPath)) {
|
|
168
|
-
|
|
168
|
+
log(` ${label} shared via symlink — skipped`);
|
|
169
169
|
results.push({ resource, status: 'shared' });
|
|
170
170
|
totalShared += 1;
|
|
171
171
|
continue;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
if (!fs.existsSync(srcPath)) {
|
|
175
|
-
|
|
175
|
+
log(` ${label} not present at source`);
|
|
176
176
|
results.push({ resource, status: 'absent' });
|
|
177
177
|
continue;
|
|
178
178
|
}
|
|
@@ -180,13 +180,13 @@ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
|
|
|
180
180
|
if (isFile) {
|
|
181
181
|
const r = copySingleFile(srcPath, dstPath, { force, dryRun });
|
|
182
182
|
if (r === 'copied') {
|
|
183
|
-
|
|
183
|
+
log(` ${label} ${copiedWord}`);
|
|
184
184
|
totalCopied += 1;
|
|
185
185
|
} else if (r === 'skipped') {
|
|
186
|
-
|
|
186
|
+
log(` ${label} skipped (already present — pass --force to overwrite)`);
|
|
187
187
|
totalSkipped += 1;
|
|
188
188
|
} else {
|
|
189
|
-
|
|
189
|
+
log(` ${label} not present at source`);
|
|
190
190
|
}
|
|
191
191
|
results.push({ resource, status: r });
|
|
192
192
|
continue;
|
|
@@ -194,12 +194,12 @@ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
|
|
|
194
194
|
|
|
195
195
|
const { copied, skipped } = copyDirContents(srcPath, dstPath, { force, dryRun });
|
|
196
196
|
if (copied === 0 && skipped === 0) {
|
|
197
|
-
|
|
197
|
+
log(` ${label} empty at source`);
|
|
198
198
|
} else if (copied > 0) {
|
|
199
199
|
const more = skipped > 0 ? `, skipped ${skipped} (already present)` : '';
|
|
200
|
-
|
|
200
|
+
log(` ${label} ${copiedWord} ${copied} item(s)${more}`);
|
|
201
201
|
} else {
|
|
202
|
-
|
|
202
|
+
log(
|
|
203
203
|
` ${label} skipped ${skipped} item(s) (all already present — pass --force to overwrite)`,
|
|
204
204
|
);
|
|
205
205
|
}
|
|
@@ -208,21 +208,21 @@ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
|
|
|
208
208
|
results.push({ resource, status: 'dir', copied, skipped });
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
log('');
|
|
212
212
|
if (totalCopied === 0 && totalShared === share.SHARED_RESOURCES.length) {
|
|
213
|
-
|
|
213
|
+
log(
|
|
214
214
|
`Nothing to copy — all ${share.SHARED_RESOURCES.length} resources are already shared via symlinks.`,
|
|
215
215
|
);
|
|
216
|
-
|
|
216
|
+
log(
|
|
217
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
|
+
log(
|
|
221
221
|
`${dryRun ? 'Nothing would be copied' : 'Nothing copied'}. ` +
|
|
222
222
|
`${totalShared} shared via symlink, ${totalSkipped} skipped (already present).`,
|
|
223
223
|
);
|
|
224
224
|
} else {
|
|
225
|
-
|
|
225
|
+
log(
|
|
226
226
|
`${dryRun ? 'Dry run: would copy' : 'Done. Copied'} ${totalCopied} item(s), ` +
|
|
227
227
|
`${totalShared} already shared via symlinks` +
|
|
228
228
|
(totalSkipped > 0 ? `, ${totalSkipped} skipped (already present)` : '') +
|
package/lib/doctor.js
CHANGED
|
@@ -250,6 +250,16 @@ function checkProfileLinks(prof) {
|
|
|
250
250
|
let stat;
|
|
251
251
|
try { stat = fs.lstatSync(pp); } catch { stat = null; }
|
|
252
252
|
|
|
253
|
+
// Private on purpose (add --private / unlink): never offer to re-share it.
|
|
254
|
+
if (
|
|
255
|
+
(!stat || !stat.isSymbolicLink()) &&
|
|
256
|
+
!share.isSharedResource(prof.dir, resource) &&
|
|
257
|
+
share.isMarkedPrivate(prof.dir, resource)
|
|
258
|
+
) {
|
|
259
|
+
results.push(mk('Storage', `"${prof.name}" ${label}`, 'ok', 'private by choice'));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
253
263
|
if (!stat) {
|
|
254
264
|
// Resource missing entirely — safe to auto-create the link.
|
|
255
265
|
results.push(
|
|
@@ -761,4 +771,4 @@ async function runDoctor({ fix, force, json, prompt: promptFn }) {
|
|
|
761
771
|
return doctorExitCode(results);
|
|
762
772
|
}
|
|
763
773
|
|
|
764
|
-
module.exports = { runChecks, runDoctor, checkCcShadow, checkMacKeychain };
|
|
774
|
+
module.exports = { runChecks, runDoctor, applyFixes, checkCcShadow, checkMacKeychain };
|
package/lib/share.js
CHANGED
|
@@ -180,9 +180,54 @@ function createResourceLink(target, link, resource) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// <profile>/.cc-private.json records resources the user made private on
|
|
184
|
+
// purpose (`cchost add --private`, `cchost unlink`), so an empty or absent
|
|
185
|
+
// private resource is not mistaken for a missing link and re-shared by
|
|
186
|
+
// `cchost doctor --fix`. `cchost link` removes entries. Never shared.
|
|
187
|
+
const PRIVATE_FILE = '.cc-private.json';
|
|
188
|
+
|
|
189
|
+
function readPrivate(profileDir) {
|
|
190
|
+
try {
|
|
191
|
+
const data = JSON.parse(fs.readFileSync(path.join(profileDir, PRIVATE_FILE), 'utf8'));
|
|
192
|
+
const list = data && Array.isArray(data.private) ? data.private : [];
|
|
193
|
+
return new Set(list.filter((r) => SHARED_RESOURCES.includes(r)));
|
|
194
|
+
} catch {
|
|
195
|
+
return new Set();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function writePrivate(profileDir, set) {
|
|
200
|
+
const p = path.join(profileDir, PRIVATE_FILE);
|
|
201
|
+
const list = SHARED_RESOURCES.filter((r) => set.has(r));
|
|
202
|
+
if (list.length === 0) {
|
|
203
|
+
fs.rmSync(p, { force: true });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
fs.writeFileSync(p, JSON.stringify({ private: list }, null, 2) + '\n');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function markPrivate(profileDir, resources) {
|
|
210
|
+
const set = readPrivate(profileDir);
|
|
211
|
+
const before = set.size;
|
|
212
|
+
for (const r of resources) set.add(r);
|
|
213
|
+
if (set.size !== before) writePrivate(profileDir, set);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function unmarkPrivate(profileDir, resources) {
|
|
217
|
+
const set = readPrivate(profileDir);
|
|
218
|
+
let changed = false;
|
|
219
|
+
for (const r of resources) changed = set.delete(r) || changed;
|
|
220
|
+
if (changed) writePrivate(profileDir, set);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isMarkedPrivate(profileDir, resource) {
|
|
224
|
+
return readPrivate(profileDir).has(resource);
|
|
225
|
+
}
|
|
226
|
+
|
|
183
227
|
// Where one resource of a profile currently stands. Returns one of:
|
|
184
228
|
// 'shared' — linked to ~/.claude/ (symlink, junction, or hard link)
|
|
185
|
-
// 'private' — a real file/dir with content,
|
|
229
|
+
// 'private' — a real file/dir with content, or one recorded as private
|
|
230
|
+
// on purpose in .cc-private.json (even if empty or absent)
|
|
186
231
|
// 'empty' — a real but empty file/dir (link would replace it)
|
|
187
232
|
// 'missing' — nothing at the path
|
|
188
233
|
// 'foreign-link' — a symlink pointing somewhere other than ~/.claude/
|
|
@@ -190,6 +235,7 @@ function resourceState(profileDir, resource) {
|
|
|
190
235
|
const p = path.join(profileDir, resource);
|
|
191
236
|
if (isSharedResource(profileDir, resource)) return 'shared';
|
|
192
237
|
if (isLink(p)) return 'foreign-link';
|
|
238
|
+
if (isMarkedPrivate(profileDir, resource)) return 'private';
|
|
193
239
|
let st;
|
|
194
240
|
try {
|
|
195
241
|
st = fs.statSync(p);
|
|
@@ -302,6 +348,14 @@ function shareLink(profileName, opts = {}) {
|
|
|
302
348
|
results.push({ resource, status: 'failed', message: err.message });
|
|
303
349
|
}
|
|
304
350
|
}
|
|
351
|
+
if (!dryRun) {
|
|
352
|
+
unmarkPrivate(
|
|
353
|
+
pdir,
|
|
354
|
+
results
|
|
355
|
+
.filter((r) => r.status === 'linked' || r.status === 'already-linked')
|
|
356
|
+
.map((r) => r.resource),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
305
359
|
return results;
|
|
306
360
|
}
|
|
307
361
|
|
|
@@ -385,6 +439,9 @@ function shareUnlink(profileName, opts = {}) {
|
|
|
385
439
|
results.push({ resource, status: 'failed', message: err.message });
|
|
386
440
|
}
|
|
387
441
|
}
|
|
442
|
+
if (!dryRun) {
|
|
443
|
+
markPrivate(pdir, results.filter((r) => r.status === 'unlinked').map((r) => r.resource));
|
|
444
|
+
}
|
|
388
445
|
return results;
|
|
389
446
|
}
|
|
390
447
|
|
|
@@ -840,6 +897,10 @@ module.exports = {
|
|
|
840
897
|
isLink,
|
|
841
898
|
isSharedResource,
|
|
842
899
|
resourceState,
|
|
900
|
+
PRIVATE_FILE,
|
|
901
|
+
markPrivate,
|
|
902
|
+
unmarkPrivate,
|
|
903
|
+
isMarkedPrivate,
|
|
843
904
|
removeLink,
|
|
844
905
|
shareLink,
|
|
845
906
|
shareUnlink,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
8
|
+
<title>Sign in · cchost</title>
|
|
9
|
+
<style nonce="__NONCE__">
|
|
10
|
+
/*__TOKENS__*/
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
html, body { margin: 0; min-height: 100%; }
|
|
13
|
+
body {
|
|
14
|
+
min-height: 100vh; display: grid; place-items: center; padding: 24px 16px;
|
|
15
|
+
background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans);
|
|
16
|
+
-webkit-font-smoothing: antialiased;
|
|
17
|
+
}
|
|
18
|
+
::selection { background: color-mix(in srgb, var(--primary) 28%, transparent); }
|
|
19
|
+
.card {
|
|
20
|
+
width: min(380px, 100%); display: grid; gap: 20px; padding: 28px;
|
|
21
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm);
|
|
22
|
+
animation: enter 420ms var(--ease) both;
|
|
23
|
+
}
|
|
24
|
+
@keyframes enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
25
|
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 15px; }
|
|
26
|
+
.brand svg { width: 22px; height: 22px; }
|
|
27
|
+
.brand .mark { fill: var(--primary); opacity: 0.9; }
|
|
28
|
+
.brand small { margin-left: auto; color: var(--text-3); font-weight: 500; font-size: 12px; }
|
|
29
|
+
h1 { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
|
|
30
|
+
.lede { margin: 4px 0 0; color: var(--text-2); font-size: 13px; }
|
|
31
|
+
form { display: grid; gap: 14px; }
|
|
32
|
+
label { display: grid; gap: 6px; font-size: 13px; font-weight: 550; }
|
|
33
|
+
.input {
|
|
34
|
+
height: 36px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--border-strong);
|
|
35
|
+
background: var(--surface); color: var(--text); font: inherit; font-weight: 400;
|
|
36
|
+
transition: border-color 120ms, box-shadow 120ms;
|
|
37
|
+
}
|
|
38
|
+
.input:focus-visible { outline: none; border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 25%, transparent); }
|
|
39
|
+
.input[aria-invalid="true"] { border-color: var(--err); }
|
|
40
|
+
.btn {
|
|
41
|
+
height: 36px; border-radius: 6px; border: 1px solid var(--primary); background: var(--primary); color: var(--primary-fg);
|
|
42
|
+
font: inherit; font-weight: 550; cursor: pointer; transition: background 120ms, transform 120ms;
|
|
43
|
+
}
|
|
44
|
+
.btn:hover { background: var(--primary-hover); }
|
|
45
|
+
.btn:active { transform: translateY(1px); }
|
|
46
|
+
.btn:disabled { opacity: 0.6; cursor: progress; }
|
|
47
|
+
.btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
48
|
+
.err { min-height: 20px; margin: 0; color: var(--err-fg); font-size: 13px; }
|
|
49
|
+
.err.shake { animation: shake 320ms var(--ease); }
|
|
50
|
+
@keyframes shake { 0%, 100% { transform: none; } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
|
|
51
|
+
.hint { margin: 0; padding-top: 16px; border-top: 1px solid var(--border); color: var(--text-3); font-size: 12.5px; }
|
|
52
|
+
code { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
|
|
53
|
+
@media (prefers-reduced-motion: reduce) {
|
|
54
|
+
.card, .err.shake { animation: none; }
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<main class="card">
|
|
60
|
+
<div class="brand">
|
|
61
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="6.5" width="12" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.75"/><rect class="mark" x="9.5" y="2.5" width="12" height="12" rx="3"/></svg>
|
|
62
|
+
cchost <small>v__VERSION__</small>
|
|
63
|
+
</div>
|
|
64
|
+
<div>
|
|
65
|
+
<h1>Sign in</h1>
|
|
66
|
+
<p class="lede" id="lede">Use the username and password printed in the terminal where you ran <code>cchost ui</code>.</p>
|
|
67
|
+
</div>
|
|
68
|
+
<form id="form" novalidate>
|
|
69
|
+
<label>Username<input class="input" id="user" name="user" autocomplete="username" spellcheck="false" value="cchost" required></label>
|
|
70
|
+
<label>Password<input class="input" id="pass" name="pass" type="password" autocomplete="current-password" spellcheck="false" required autofocus aria-describedby="err"></label>
|
|
71
|
+
<p class="err" id="err" role="alert"></p>
|
|
72
|
+
<button class="btn" id="go" type="submit">Sign in</button>
|
|
73
|
+
</form>
|
|
74
|
+
<p class="hint">The password changes every time <code>cchost ui</code> starts, and only this machine can reach this page.</p>
|
|
75
|
+
</main>
|
|
76
|
+
<script nonce="__NONCE__">
|
|
77
|
+
(() => {
|
|
78
|
+
'use strict';
|
|
79
|
+
const $ = (id) => document.getElementById(id);
|
|
80
|
+
try {
|
|
81
|
+
const t = localStorage.getItem('cchost-theme');
|
|
82
|
+
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
|
83
|
+
} catch { /* storage blocked */ }
|
|
84
|
+
if (new URLSearchParams(location.search).has('expired')) {
|
|
85
|
+
$('lede').textContent = 'That sign-in link was already used or has expired. Sign in with the password from the terminal.';
|
|
86
|
+
history.replaceState(null, '', '/');
|
|
87
|
+
}
|
|
88
|
+
$('form').addEventListener('submit', async (ev) => {
|
|
89
|
+
ev.preventDefault();
|
|
90
|
+
const err = $('err');
|
|
91
|
+
err.textContent = '';
|
|
92
|
+
err.classList.remove('shake');
|
|
93
|
+
$('go').disabled = true;
|
|
94
|
+
$('go').textContent = 'Signing in…';
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch('/api/login', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json', 'X-CCHost': '1' },
|
|
99
|
+
body: JSON.stringify({ user: $('user').value, pass: $('pass').value }),
|
|
100
|
+
});
|
|
101
|
+
if (res.ok) { location.replace('/'); return; }
|
|
102
|
+
const json = await res.json().catch(() => ({}));
|
|
103
|
+
err.textContent = json.error || `Sign-in failed (${res.status}).`;
|
|
104
|
+
} catch {
|
|
105
|
+
err.textContent = 'Cannot reach cchost ui — is it still running?';
|
|
106
|
+
}
|
|
107
|
+
void err.offsetWidth;
|
|
108
|
+
err.classList.add('shake');
|
|
109
|
+
$('pass').setAttribute('aria-invalid', 'true');
|
|
110
|
+
$('pass').select();
|
|
111
|
+
$('go').disabled = false;
|
|
112
|
+
$('go').textContent = 'Sign in';
|
|
113
|
+
});
|
|
114
|
+
})();
|
|
115
|
+
</script>
|
|
116
|
+
</body>
|
|
117
|
+
</html>
|