cchost 0.4.4 → 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/lib/actions.js ADDED
@@ -0,0 +1,290 @@
1
+ 'use strict';
2
+
3
+ // Structured versions of cchost's mutating commands, for `cchost ui`. Each
4
+ // action runs in two modes on the same code path the CLI uses: dryRun (the
5
+ // preview) and real (the apply). Both return per-resource lines:
6
+ // { n, resource, label, kind, text }
7
+ // where kind is 'change' | 'same' | 'warn' | 'error' | 'info', so the page can
8
+ // show exactly what will happen, then exactly what did.
9
+ //
10
+ // Nothing returned here ever contains an endpoint token, and emails are masked.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const profiles = require('./profiles');
16
+ const share = require('./share');
17
+ const copyLib = require('./copy');
18
+ const envLib = require('./env');
19
+
20
+ const GROUPS = {
21
+ projects: share.PROJECT_SUBDIRS,
22
+ tools: share.TOOL_SUBDIRS,
23
+ config: share.CONFIG_FILES,
24
+ };
25
+ const GROUP_NAMES = Object.keys(GROUPS);
26
+
27
+ function groupOf(resource) {
28
+ return GROUP_NAMES.find((g) => GROUPS[g].includes(resource));
29
+ }
30
+
31
+ const RESOURCES = share.SHARED_RESOURCES.map((id, i) => ({
32
+ id,
33
+ n: String(i + 1).padStart(2, '0'),
34
+ label: share.isFileResource(id) ? id : `${id}/`,
35
+ group: groupOf(id),
36
+ }));
37
+ const META = Object.fromEntries(RESOURCES.map((r) => [r.id, r]));
38
+
39
+ function actionError(message, status = 400) {
40
+ const e = new Error(message);
41
+ e.status = status;
42
+ return e;
43
+ }
44
+
45
+ // `label` names a profile-level line (no resource), e.g. 'profile', 'login'.
46
+ function line(resource, kind, text, label = null) {
47
+ if (!resource) return { n: null, resource: null, label, kind, text };
48
+ const m = META[resource];
49
+ return { n: m.n, resource, label: m.label, kind, text };
50
+ }
51
+
52
+ // ['tools', 'config'] -> { tools: true, config: true }. Empty means all 7.
53
+ function groupOpts(groups) {
54
+ const list = Array.isArray(groups) ? groups : [];
55
+ const opts = {};
56
+ for (const g of list) {
57
+ if (!GROUP_NAMES.includes(g)) throw actionError(`Unknown group "${g}". Use: ${GROUP_NAMES.join(', ')}`);
58
+ opts[g] = true;
59
+ }
60
+ return opts;
61
+ }
62
+
63
+ function existingProfile(name) {
64
+ if (typeof name !== 'string') throw actionError('profile is required');
65
+ profiles.validateProfileName(name);
66
+ const dir = profiles.profileDir(name);
67
+ if (!fs.existsSync(dir)) throw actionError(`Profile "${name}" does not exist.`, 404);
68
+ return dir;
69
+ }
70
+
71
+ function profileSummary(p) {
72
+ const resources = {};
73
+ for (const r of share.SHARED_RESOURCES) resources[r] = share.resourceState(p.dir, r);
74
+ let endpoint;
75
+ try {
76
+ endpoint = envLib.endpointSummary(p.dir);
77
+ } catch {
78
+ endpoint = '(invalid config)';
79
+ }
80
+ return {
81
+ name: p.name,
82
+ loggedIn: p.loggedIn,
83
+ credentialSource: p.credentialSource,
84
+ email: p.email ? envLib.maskEmail(p.email) : '',
85
+ kind: envLib.hasEnvConfig(p.dir) ? 'endpoint' : 'subscription',
86
+ endpoint,
87
+ resources,
88
+ shared: Object.values(resources).filter((s) => s === 'shared').length,
89
+ };
90
+ }
91
+
92
+ function state() {
93
+ return {
94
+ pool: '~/.claude',
95
+ profilesDir: '~/.claude-profiles',
96
+ groups: GROUPS,
97
+ resources: RESOURCES,
98
+ profiles: profiles.listProfiles().map(profileSummary),
99
+ };
100
+ }
101
+
102
+ // --- link / unlink ---------------------------------------------------------
103
+
104
+ function link({ profile, groups }, { dryRun }) {
105
+ existingProfile(profile);
106
+ const results = share.shareLink(profile, { ...groupOpts(groups), dryRun });
107
+ return results.map((r) => {
108
+ if (r.status === 'already-linked') return line(r.resource, 'same', 'already shared');
109
+ if (r.status === 'linked') {
110
+ const legacy = r.relinkedFromLegacy ? ' (was pointing at legacy ~/.claude-shared/)' : '';
111
+ return line(r.resource, 'change', `${dryRun ? 'link to' : 'linked to'} ~/.claude/${r.resource}${legacy}`);
112
+ }
113
+ if (r.status === 'refused') {
114
+ return line(r.resource, 'warn', 'has private content — move or delete it first; it is not merged into ~/.claude/');
115
+ }
116
+ if (r.status === 'foreign-link') return line(r.resource, 'warn', 'points outside ~/.claude/ — left alone');
117
+ return line(r.resource, 'error', r.message || r.status);
118
+ });
119
+ }
120
+
121
+ function unlink({ profile, groups }, { dryRun }) {
122
+ existingProfile(profile);
123
+ const results = share.shareUnlink(profile, { ...groupOpts(groups), dryRun });
124
+ return results.map((r) => {
125
+ if (r.status === 'not-linked') return line(r.resource, 'same', 'already private');
126
+ if (r.status === 'unlinked') {
127
+ if (!dryRun) return line(r.resource, 'change', 'unlinked; current content copied in');
128
+ if (r.items === 0) return line(r.resource, 'change', 'unlink (nothing to copy in — ~/.claude/ has no items here)');
129
+ const what = r.items === undefined ? 'the current content' : `${r.items} item${r.items === 1 ? '' : 's'}`;
130
+ return line(r.resource, 'change', `copy ${what} in from ~/.claude/, then unlink`);
131
+ }
132
+ return line(r.resource, 'error', r.message || r.status);
133
+ });
134
+ }
135
+
136
+ // --- copy ------------------------------------------------------------------
137
+
138
+ function copy({ profile, force }, { dryRun }) {
139
+ existingProfile(profile);
140
+ const { results } = copyLib.copy('default', profile, { force: Boolean(force), dryRun, log: () => {} });
141
+ const verb = dryRun ? 'copy' : 'copied';
142
+ return results.map((r) => {
143
+ if (r.status === 'shared') return line(r.resource, 'same', 'shared — nothing to copy');
144
+ if (r.status === 'absent') return line(r.resource, 'same', 'not present in ~/.claude/');
145
+ if (r.status === 'skipped') return line(r.resource, 'same', 'already present (overwrite to replace)');
146
+ if (r.status === 'copied') return line(r.resource, 'change', `${verb} from ~/.claude/`);
147
+ if (r.status === 'dir') {
148
+ if (r.copied === 0 && r.skipped === 0) return line(r.resource, 'same', 'empty in ~/.claude/');
149
+ const skipped = r.skipped ? `, ${r.skipped} already present` : '';
150
+ if (r.copied === 0) return line(r.resource, 'same', `all ${r.skipped} item(s) already present`);
151
+ return line(r.resource, 'change', `${verb} ${r.copied} item${r.copied === 1 ? '' : 's'}${skipped}`);
152
+ }
153
+ return line(r.resource, 'info', r.status);
154
+ });
155
+ }
156
+
157
+ // --- add -------------------------------------------------------------------
158
+
159
+ function add({ name, private: privateGroups, endpoint }, { dryRun }) {
160
+ if (typeof name !== 'string' || !name) throw actionError('name is required');
161
+ profiles.validateProfileName(name);
162
+ const dir = profiles.profileDir(name);
163
+ if (fs.existsSync(dir)) throw actionError(`Profile "${name}" already exists.`, 409);
164
+
165
+ const privateOpts = groupOpts(privateGroups);
166
+ const privateSet = new Set(Object.keys(privateOpts).length ? share.resolveSubset(privateOpts) : []);
167
+
168
+ let config = null;
169
+ if (endpoint) {
170
+ config = {};
171
+ for (const key of envLib.FIELDS) {
172
+ const v = endpoint[key];
173
+ if (typeof v === 'string' && v.trim()) config[key] = v.trim();
174
+ }
175
+ if (!config.base_url || !config.auth_token) {
176
+ throw actionError('An endpoint profile needs both a base URL and a token.');
177
+ }
178
+ const warnings = [];
179
+ for (const [key, value] of Object.entries(config)) envLib.validateField(key, value, (m) => warnings.push(m));
180
+ }
181
+
182
+ const lines = [line(null, 'change', `${dryRun ? 'create' : 'created'} ~/.claude-profiles/${name}`, 'profile')];
183
+ if (config) {
184
+ lines.push(line(null, 'change', `${dryRun ? 'use' : 'uses'} ${envLib.hostOf(config.base_url)} (token stored user-only)`, 'endpoint'));
185
+ } else {
186
+ lines.push(line(null, 'info', `Anthropic subscription: sign in on the first "cchost use ${name}"`, 'login'));
187
+ }
188
+
189
+ if (dryRun) {
190
+ for (const r of share.SHARED_RESOURCES) {
191
+ lines.push(privateSet.has(r)
192
+ ? line(r, 'change', 'start private and empty')
193
+ : line(r, 'change', `link to ~/.claude/${r}`));
194
+ }
195
+ return lines;
196
+ }
197
+
198
+ profiles.addProfile(name);
199
+ try {
200
+ const sharedOpts = {};
201
+ for (const g of GROUP_NAMES) if (!privateOpts[g]) sharedOpts[g] = true;
202
+ const linkResults = Object.keys(sharedOpts).length ? share.shareLink(name, sharedOpts) : [];
203
+ for (const r of share.SHARED_RESOURCES) {
204
+ if (privateSet.has(r)) {
205
+ if (!share.isFileResource(r)) fs.mkdirSync(path.join(dir, r), { recursive: true });
206
+ lines.push(line(r, 'change', 'private and empty'));
207
+ continue;
208
+ }
209
+ const res = linkResults.find((x) => x.resource === r);
210
+ lines.push(res && (res.status === 'linked' || res.status === 'already-linked')
211
+ ? line(r, 'change', `linked to ~/.claude/${r}`)
212
+ : line(r, 'warn', `not linked — ${(res && res.message) || 'unknown'}`));
213
+ }
214
+ share.markPrivate(dir, privateSet);
215
+ if (config) envLib.writeEnvConfig(dir, config);
216
+ } catch (err) {
217
+ // Same as the CLI: a half-created profile is removed, shared content kept.
218
+ try {
219
+ require('./cli').removeRecursivelyPreservingLinks(dir);
220
+ } catch { /* best effort */ }
221
+ throw err;
222
+ }
223
+ return lines;
224
+ }
225
+
226
+ // --- remove ----------------------------------------------------------------
227
+
228
+ function remove({ profile, confirm }, { dryRun }) {
229
+ const dir = existingProfile(profile);
230
+ const lines = [];
231
+ for (const r of share.SHARED_RESOURCES) {
232
+ const st = share.resourceState(dir, r);
233
+ if (st === 'shared') lines.push(line(r, 'same', 'shared — only the link is removed, ~/.claude/ keeps the content'));
234
+ else if (st === 'private') lines.push(line(r, 'warn', 'private content deleted permanently'));
235
+ else if (st === 'foreign-link') lines.push(line(r, 'same', 'link removed; its target is left alone'));
236
+ else lines.push(line(r, 'same', 'nothing to delete'));
237
+ }
238
+ lines.push(line(null, 'warn', `login, sessions and settings in ~/.claude-profiles/${profile} deleted`, 'login'));
239
+ if (dryRun) return lines;
240
+
241
+ if (confirm !== profile) throw actionError(`Type the profile name "${profile}" to confirm removal.`);
242
+ require('./cli').removeRecursivelyPreservingLinks(dir);
243
+ return [line(null, 'change', `removed ~/.claude-profiles/${profile}`, 'profile'), ...lines];
244
+ }
245
+
246
+ // --- doctor ----------------------------------------------------------------
247
+
248
+ function doctorChecks() {
249
+ const doctor = require('./doctor');
250
+ return doctor.runChecks().map((c) => ({
251
+ group: c.group,
252
+ name: c.name,
253
+ status: c.status,
254
+ message: c.message,
255
+ fixable: Boolean(c.fix && c.fix.apply && c.fix.safe),
256
+ // Has an automatic fix that `doctor --fix` only runs with --force.
257
+ forceable: Boolean(c.fix && c.fix.apply && !c.fix.safe),
258
+ fix: c.fix ? (c.fix.description || null) : null,
259
+ cmd: c.fix ? (c.fix.cmd || null) : null,
260
+ }));
261
+ }
262
+
263
+ // With no name: every safe fix, the same set `cchost doctor --fix` applies.
264
+ // With a name: just that check's fix; one that needs confirmation (the CLI's
265
+ // --force) runs only when `force` is true, i.e. the user confirmed it.
266
+ async function doctorFix({ name, force } = {}, { dryRun }) {
267
+ const doctor = require('./doctor');
268
+ const results = doctor.runChecks();
269
+ let fixable;
270
+ if (name) {
271
+ const target = results.find((r) => r.name === name && r.status !== 'ok');
272
+ if (!target) return [line(null, 'same', `"${name}" no longer needs fixing`, 'doctor')];
273
+ if (!target.fix || !target.fix.apply) throw actionError(`"${name}" has no automatic fix.`);
274
+ if (!target.fix.safe && !force) throw actionError(`"${name}" needs confirmation to fix.`);
275
+ fixable = [target];
276
+ } else {
277
+ fixable = results.filter((r) => r.fix && r.fix.apply && r.fix.safe);
278
+ }
279
+ if (fixable.length === 0) return [line(null, 'same', 'nothing auto-fixable', 'doctor')];
280
+ if (dryRun) return fixable.map((r) => line(null, 'change', `${r.name}: ${r.fix.description || 'fix'}`, 'fix'));
281
+ const out = [];
282
+ await doctor.applyFixes(fixable, Boolean(name && force), async () => 'y', (text) => {
283
+ for (const t of String(text).split('\n')) if (t.trim()) out.push(line(null, 'change', t.trim(), 'fix'));
284
+ });
285
+ return out;
286
+ }
287
+
288
+ const ACTIONS = { link, unlink, copy, add, remove, 'doctor-fix': doctorFix };
289
+
290
+ module.exports = { state, doctorChecks, ACTIONS, RESOURCES, GROUPS, actionError };
package/lib/banner.js CHANGED
@@ -14,9 +14,11 @@ function renderBanner() {
14
14
  try {
15
15
  version = require('../package.json').version;
16
16
  } catch {
17
+ // Not fatal — help still works if package.json is unreadable.
17
18
  }
18
19
  const tagline = 'Multi-profile launcher for Claude Code';
19
- return BANNER + ' ' + '\n\n';
20
+ const label = version ? `cchost ${version} — ${tagline}` : `cchost — ${tagline}`;
21
+ return BANNER + ' ' + label + '\n\n';
20
22
  }
21
23
 
22
24
  module.exports = { renderBanner, BANNER };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ // The legacy `cc` alias shares its name with the POSIX C compiler, and npm's
4
+ // global bin dir often sits ahead of /usr/bin on PATH. Build tools (make,
5
+ // node-gyp, configure scripts) then run cchost instead of the compiler. When
6
+ // we are invoked as `cc` with arguments that are not a cchost command, hand
7
+ // the call to the next real `cc` on PATH so both uses keep working.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { spawnSync } = require('child_process');
12
+
13
+ // First arguments that mean "this is cchost". Everything else — `-c`, `-o`,
14
+ // `foo.c`, and `--version` / `-v`, which build tools use to probe the
15
+ // compiler — goes to the compiler. Use `cchost --version` for cchost itself.
16
+ const CCHOST_FIRST_ARGS = new Set([
17
+ 'add', 'use', 'list', 'copy', 'migrate', 'link', 'unlink', 'remove',
18
+ 'doctor', 'ui', 'help', 'env', '--help', '-h',
19
+ ]);
20
+
21
+ function invokedAsCc(argv1 = process.argv[1]) {
22
+ // npm installs `cc` as a symlink on POSIX and Node keeps the symlink's name
23
+ // in argv[1]. Windows .cmd shims call bin/cc.js directly, so this is false
24
+ // there — `cc` is rarely a compiler on Windows anyway.
25
+ return Boolean(argv1) && path.basename(argv1) === 'cc';
26
+ }
27
+
28
+ function shouldForward(args) {
29
+ // Bare `cc` keeps printing cchost help, as it always has.
30
+ return args.length > 0 && !CCHOST_FIRST_ARGS.has(args[0]);
31
+ }
32
+
33
+ function realpathOrNull(p) {
34
+ try {
35
+ return fs.realpathSync(p);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ // The first executable `cc` on PATH that is not cchost (this install or any
42
+ // other cchost install, e.g. under another nvm Node version).
43
+ function findCompiler({ env = process.env, self = process.argv[1] } = {}) {
44
+ const selfReal = realpathOrNull(self);
45
+ const dirs = (env.PATH || '').split(path.delimiter).filter(Boolean);
46
+ for (const dir of dirs) {
47
+ const candidate = path.join(dir, 'cc');
48
+ const real = realpathOrNull(candidate);
49
+ if (!real || real === selfReal) continue;
50
+ if (/[\\/]cchost[\\/]bin[\\/]cc\.js$/.test(real)) continue;
51
+ try {
52
+ if (!fs.statSync(real).isFile()) continue;
53
+ fs.accessSync(real, fs.constants.X_OK);
54
+ } catch {
55
+ continue;
56
+ }
57
+ return candidate;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // Returns the compiler's exit code, or null when the call is not forwarded
63
+ // (not invoked as `cc`, a cchost command, or no compiler found).
64
+ function maybeForward(args, { argv1 = process.argv[1], env = process.env } = {}) {
65
+ if (!invokedAsCc(argv1) || !shouldForward(args)) return null;
66
+ const compiler = findCompiler({ env, self: argv1 });
67
+ if (!compiler) return null;
68
+ const res = spawnSync(compiler, args, { stdio: 'inherit', env });
69
+ if (res.error) throw res.error;
70
+ if (res.signal) {
71
+ process.kill(process.pid, res.signal);
72
+ return 1;
73
+ }
74
+ return res.status === null ? 1 : res.status;
75
+ }
76
+
77
+ module.exports = { invokedAsCc, shouldForward, findCompiler, maybeForward, CCHOST_FIRST_ARGS };