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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,54 @@ All notable changes to `cchost` are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [0.6.0] - 2026-11-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`cchost ui`: a local web view.** One page shows every profile, the state
|
|
13
|
+
of its 7 resources, its login and endpoint, the shared pool and doctor
|
|
14
|
+
results, and lets you add and remove profiles, share or unshare groups, copy
|
|
15
|
+
from `~/.claude/` and apply doctor fixes. Every change is previewed per
|
|
16
|
+
resource before it runs and reported step by step afterwards (Activity).
|
|
17
|
+
Zero dependencies; served on `127.0.0.1` with a Host-header check, strict
|
|
18
|
+
CSP, and no endpoint tokens ever sent to the page.
|
|
19
|
+
- **Web view sign-in, like rclone's web GUI.** `cchost ui` prints a username
|
|
20
|
+
and a per-run random password and opens a one-time sign-in link; the browser
|
|
21
|
+
gets an expiring HttpOnly session (`--session`, default 8h) and can sign
|
|
22
|
+
out. `--user` / `--pass` set your own credentials. Failed sign-ins are
|
|
23
|
+
rate-limited.
|
|
24
|
+
- **Fix doctor issues from the web view.** Per-issue Fix buttons with a
|
|
25
|
+
preview; fixes that need `--force` in the CLI ask for confirmation; manual
|
|
26
|
+
steps show a copyable command.
|
|
27
|
+
- **Web view polish.** Collapsible sidebar (remembered), share/private changes
|
|
28
|
+
can be reverted before applying by clicking the current side again, and
|
|
29
|
+
subtle motion (view and panel entrances, card hover, expanding Doctor
|
|
30
|
+
groups) that switches off under reduced motion.
|
|
31
|
+
|
|
32
|
+
- **`cchost add <profile> --private[=projects,tools,config]`** creates a
|
|
33
|
+
profile whose chosen resource groups (or all 7) start private and empty
|
|
34
|
+
instead of shared. Nothing from `~/.claude/` is copied in, unlike
|
|
35
|
+
`cchost unlink`. Works with `--custom`.
|
|
36
|
+
- **Private-by-choice record.** `add --private` and `cchost unlink` record
|
|
37
|
+
private resources in `<profile>/.cc-private.json`; `cchost link` removes
|
|
38
|
+
them. `cchost doctor` reports these as `private by choice` and `--fix` no
|
|
39
|
+
longer re-shares an empty or absent private resource. Profiles without the
|
|
40
|
+
file behave as before.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **`cc` passes compiler calls to the real C compiler.** When the legacy `cc`
|
|
45
|
+
alias is run with anything other than a cchost command (`cc -c foo.c`,
|
|
46
|
+
`cc --version`, …), it runs the next `cc` on `PATH` that isn't cchost, so
|
|
47
|
+
`make`, `node-gyp`, and configure scripts work even when npm's bin directory
|
|
48
|
+
comes before `/usr/bin`. cchost commands (`cc use work`) and bare `cc` are
|
|
49
|
+
unchanged. Use `cchost --version` for cchost's own version.
|
|
50
|
+
|
|
51
|
+
### CI
|
|
52
|
+
|
|
53
|
+
- Releases now create the `vX.Y.Z` tag and GitHub Release (notes from this
|
|
54
|
+
changelog) right after the npm publish. Actions updated to v7.
|
|
55
|
+
|
|
8
56
|
## [0.5.0] - 2026-11-07
|
|
9
57
|
|
|
10
58
|
### Added
|
package/README.md
CHANGED
|
@@ -28,8 +28,11 @@ npm install -g cchost
|
|
|
28
28
|
Requires Node.js 18+ and Claude Code 2.1.140+ (`npm install -g @anthropic-ai/claude-code`).
|
|
29
29
|
|
|
30
30
|
`cchost` is the primary command. The same binary is also installed as `cc` for
|
|
31
|
-
backwards compatibility
|
|
32
|
-
Linux
|
|
31
|
+
backwards compatibility. Because `cc` is also the C compiler on macOS and
|
|
32
|
+
Linux, `cc` only acts as cchost for cchost commands (`cc use work`, `cc list`,
|
|
33
|
+
…); anything else (`cc -c foo.c`, `cc --version`) is passed to the real
|
|
34
|
+
compiler further down `PATH`, so builds keep working. Prefer `cchost` in
|
|
35
|
+
scripts and daily use; `cchost --version` prints cchost's version.
|
|
33
36
|
|
|
34
37
|
## Quickstart
|
|
35
38
|
|
|
@@ -39,13 +42,32 @@ cchost use work # launch Claude under it (prompts for login on first
|
|
|
39
42
|
cchost use work --resume # resume the last conversation
|
|
40
43
|
cchost list # see all profiles
|
|
41
44
|
cchost remove old-profile # delete a profile
|
|
45
|
+
cchost ui # see and change everything in your browser
|
|
42
46
|
```
|
|
43
47
|
|
|
48
|
+
## Web view
|
|
49
|
+
|
|
50
|
+
`cchost ui` opens a local page that shows every profile, what each one shares with `~/.claude/`, its login and endpoint, and the doctor results. You can add and remove profiles, switch a group between shared and private, copy from `~/.claude/` and apply doctor fixes from the page. Every change shows a per-resource preview first (the same result as `--dry-run`) and, once applied, lists each step it took; the Activity view keeps the history for the session.
|
|
51
|
+
|
|
52
|
+
Sign-in works like rclone's web GUI. `cchost ui` prints a username (`cchost`) and a random password that is new every run, then opens your browser with a one-time sign-in link (valid 2 minutes). The browser keeps an HttpOnly session cookie that lasts 8 hours (`--session=30m|8h|2d`) or until `cchost ui` stops; the sidebar's Sign out ends it early. Wrong passwords are rate-limited.
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
cchost ui running at http://127.0.0.1:52134/
|
|
56
|
+
|
|
57
|
+
Username: cchost
|
|
58
|
+
Password: k7Qp-3mZx-Wb9t-Lr2v
|
|
59
|
+
Sessions last 8h. The password is new every run.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The page runs on `127.0.0.1` only. Endpoint tokens are never sent to the page and emails are masked. `--port=N` picks a port, `--no-open` prints the one-time link instead of opening a browser, and `--user=NAME` / `--pass=PASSWORD` set your own credentials (a random password is safer: `--pass` ends up in shell history). Ctrl+C stops it.
|
|
63
|
+
|
|
64
|
+
In Doctor, every issue with an automatic fix has its own **Fix** button with a preview; fixes the CLI only applies with `--fix --force` (such as resetting a corrupt `.claude.json`) are marked **Fix…** and ask you to confirm. Issues that need a manual step show the command with a copy button.
|
|
65
|
+
|
|
44
66
|
## Commands
|
|
45
67
|
|
|
46
68
|
| Command | What it does |
|
|
47
69
|
| --- | --- |
|
|
48
|
-
| `cchost add <profile
|
|
70
|
+
| `cchost add <profile> [--private[=groups]]` | Create a subscription profile. Logs in via Anthropic OAuth on first `cchost use`. Auto-shares all 7 resources with `~/.claude/`, or starts the `--private` ones empty and unshared. |
|
|
49
71
|
| `cchost add <profile> --custom [...]` | Create or edit a custom-endpoint profile. Interactive wizard, or `--base-url=URL --token=TOKEN [--model=…]`. See [Custom endpoints](#custom-endpoints). |
|
|
50
72
|
| `cchost use <profile> [...args]` | Launch Claude under that profile. Extra args are forwarded to `claude`. |
|
|
51
73
|
| `cchost list [<profile>] [--json] [--reveal]` | List all profiles, or show one in detail including the state of each of its 7 resources. Emails and tokens are masked by default; `--reveal` prints them in full. |
|
|
@@ -53,6 +75,7 @@ cchost remove old-profile # delete a profile
|
|
|
53
75
|
| `cchost link <profile> [--projects\|--tools\|--config] [--dry-run]` | Link a profile's resources to `~/.claude/`. Defaults to all 7 when no flag is passed. |
|
|
54
76
|
| `cchost unlink <profile> [--projects\|--tools\|--config] [--dry-run]` | Break the link and make those resources private to the profile. Non-destructive. |
|
|
55
77
|
| `cchost remove <profile> [--force]` | Delete a profile. Requires typing the profile name. `--force` required when private resources exist. |
|
|
78
|
+
| `cchost ui [--port=N] [--no-open] [--session=8h]` | Open the local web view: see every profile and change sharing, profiles and doctor fixes with a preview first. See [Web view](#web-view). |
|
|
56
79
|
| `cchost doctor [--fix [--force]] [--json]` | Diagnose environment, storage, and platform issues. `--fix` applies safe auto-fixes; `--force` is required for destructive ones. |
|
|
57
80
|
| `cchost help [command]` | Print usage. `cchost help sharing` is a task guide to linking, unlinking, and copying. |
|
|
58
81
|
|
|
@@ -76,7 +99,7 @@ On Windows and Linux, anything that authenticates a profile stays per-profile an
|
|
|
76
99
|
| `projects/` | ✓ | `--projects` |
|
|
77
100
|
| `skills/`, `agents/`, `commands/` | ✓ | `--tools` |
|
|
78
101
|
| `mcp.json`, `settings.json`, `CLAUDE.md` | ✓ | `--config` |
|
|
79
|
-
| `.credentials.json`, `.claude.json`, `.cc-env.json` | never | — |
|
|
102
|
+
| `.credentials.json`, `.claude.json`, `.cc-env.json`, `.cc-private.json` | never | — |
|
|
80
103
|
| `sessions/`, `plugins/` | never | — |
|
|
81
104
|
|
|
82
105
|
Sharing uses POSIX symlinks on Linux/macOS and directory junctions on Windows (no admin or Developer Mode required). File resources fall back to a hard link on Windows when symlink creation would require elevation. Note that `mcp.json` and `settings.json` can execute arbitrary code through MCP servers and hooks — if a profile needs an isolated copy of either, run `cchost unlink <profile> --config`.
|
|
@@ -86,6 +109,8 @@ Sharing uses POSIX symlinks on Linux/macOS and directory junctions on Windows (n
|
|
|
86
109
|
> execute hooks and MCP servers. For a work/personal split, unlink the resources
|
|
87
110
|
> that must not cross over before first use, or use a separate OS account.
|
|
88
111
|
|
|
112
|
+
To create a profile that starts private instead, use `cchost add <profile> --private` (all 7) or `--private=projects,tools,config` (just those groups). Private resources start empty — nothing from `~/.claude/` is copied in — and are recorded in `<profile>/.cc-private.json` so `cchost doctor --fix` never re-shares them.
|
|
113
|
+
|
|
89
114
|
To see exactly what a profile shares, run `cchost list <profile>` — it prints each resource as `shared`, `private`, `empty`, `missing`, or `foreign-link`. `cchost help sharing` walks through the common tasks.
|
|
90
115
|
|
|
91
116
|
To break sharing for a single profile (add `--dry-run` to any of these to preview first):
|
package/bin/cc.js
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const { run } = require('../lib/cli');
|
|
5
|
+
const { maybeForward } = require('../lib/ccforward');
|
|
5
6
|
|
|
6
7
|
(async () => {
|
|
7
8
|
try {
|
|
8
|
-
const
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
// Invoked as `cc` for a compile (`cc -c foo.c`)? Run the real compiler.
|
|
11
|
+
const forwarded = maybeForward(args);
|
|
12
|
+
if (forwarded !== null) process.exit(forwarded);
|
|
13
|
+
const code = await run(args);
|
|
9
14
|
process.exit(typeof code === 'number' ? code : 0);
|
|
10
15
|
} catch (err) {
|
|
11
16
|
const message = err && err.message ? err.message : String(err);
|
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/ccforward.js
ADDED
|
@@ -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 };
|