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/CHANGELOG.md +112 -0
- package/README.md +114 -44
- package/bin/cc.js +6 -1
- package/lib/actions.js +290 -0
- package/lib/banner.js +3 -1
- package/lib/ccforward.js +77 -0
- package/lib/cli.js +363 -104
- package/lib/color.js +3 -1
- package/lib/copy.js +43 -41
- package/lib/doctor.js +211 -68
- package/lib/env.js +65 -9
- package/lib/profiles.js +47 -14
- package/lib/share.js +124 -6
- package/lib/spawn.js +2 -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 +3 -2
package/lib/cli.js
CHANGED
|
@@ -13,19 +13,26 @@ const { renderTable } = require('./format');
|
|
|
13
13
|
const color = require('./color');
|
|
14
14
|
const { renderBanner } = require('./banner');
|
|
15
15
|
|
|
16
|
-
const HELP = `
|
|
16
|
+
const HELP = `cchost - Multi-profile launcher for Claude Code
|
|
17
|
+
|
|
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.
|
|
17
22
|
|
|
18
23
|
Usage:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
cchost add <profile> [--private] [--custom [...]] Create a profile (shares all 7 resources, or --private ones)
|
|
25
|
+
cchost use <profile> [...args] Launch Claude under a named profile
|
|
26
|
+
cchost list [<profile>] [--json] [--reveal] List profiles, or show one in detail
|
|
27
|
+
cchost copy default <profile> [--force] [--dry-run] Copy ~/.claude/ resources into a profile (only when unlinked)
|
|
28
|
+
cchost link <p>... [--projects|--tools|--config] (Re-)link a profile's resources to ~/.claude/
|
|
29
|
+
cchost unlink <profile> [--projects|--tools|--config] Restore a profile's private resources (non-destructive)
|
|
30
|
+
cchost remove <profile> [--force] Delete a profile and all its data
|
|
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)
|
|
33
|
+
cchost help [command] Show usage (optionally for a single command)
|
|
34
|
+
cchost help sharing Task guide: what is shared, and how to change it
|
|
35
|
+
cchost --version Print the installed cchost version
|
|
29
36
|
|
|
30
37
|
Shared resources (linked into ~/.claude/ by default):
|
|
31
38
|
--projects projects/ (session history)
|
|
@@ -35,35 +42,46 @@ Shared resources (linked into ~/.claude/ by default):
|
|
|
35
42
|
Never shared (stay per-profile):
|
|
36
43
|
.credentials.json .claude.json .cc-env.json (identity / auth)
|
|
37
44
|
|
|
45
|
+
link, unlink, and copy accept --dry-run to preview changes without making them.
|
|
46
|
+
|
|
38
47
|
Profiles live in ~/.claude-profiles/<profile>/ and become CLAUDE_CONFIG_DIR
|
|
39
48
|
for that launch. Args after the profile name are forwarded verbatim to claude.
|
|
40
49
|
`;
|
|
41
50
|
|
|
42
51
|
const HELP_BY_CMD = {
|
|
43
|
-
add: `
|
|
52
|
+
add: `cchost add <profile> [--private[=projects,tools,config]] [--custom [--base-url=URL --token=TOKEN ...]]
|
|
44
53
|
|
|
45
54
|
Create a new profile directory without launching Claude. All 7 shared
|
|
46
55
|
resources (projects/, skills/, agents/, commands/, mcp.json, settings.json,
|
|
47
56
|
CLAUDE.md) are auto-linked into ~/.claude/ so they're visible to every
|
|
48
57
|
profile (and to bare \`claude\`) from the moment the profile is created.
|
|
49
58
|
|
|
50
|
-
|
|
59
|
+
cchost add <profile>
|
|
51
60
|
Create a regular profile. It logs in with your Anthropic
|
|
52
|
-
subscription (OAuth) on the first '
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
subscription (OAuth) on the first 'cchost use'.
|
|
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
|
+
|
|
72
|
+
cchost add <profile> --custom
|
|
55
73
|
Create a profile routed through a custom, Anthropic-compatible API
|
|
56
74
|
endpoint — Moonshot/Kimi, OpenRouter, a self-hosted vLLM, a corporate
|
|
57
75
|
proxy, and so on. Opens an interactive wizard; the API token is never
|
|
58
76
|
echoed to the screen.
|
|
59
77
|
|
|
60
|
-
|
|
78
|
+
cchost add <profile> --custom --base-url=URL --token=TOKEN
|
|
61
79
|
[--model=NAME] [--opus=NAME] [--sonnet=NAME]
|
|
62
80
|
[--haiku=NAME] [--subagent=NAME]
|
|
63
81
|
Same, non-interactive. --base-url and --token are required.
|
|
64
82
|
|
|
65
|
-
Re-running '
|
|
66
|
-
its configuration. View any profile with '
|
|
83
|
+
Re-running 'cchost add <profile> --custom' on an existing endpoint profile EDITS
|
|
84
|
+
its configuration. View any profile with 'cchost list <profile>'.
|
|
67
85
|
|
|
68
86
|
Billing: a custom-endpoint profile bills through the third-party provider,
|
|
69
87
|
NOT your Anthropic subscription. A profile is born subscription or endpoint
|
|
@@ -73,31 +91,33 @@ The endpoint config lives at ~/.claude-profiles/<profile>/.cc-env.json with
|
|
|
73
91
|
user-only permissions. It is never linked and never copied.
|
|
74
92
|
|
|
75
93
|
Examples:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
cchost add work
|
|
95
|
+
cchost add client --private=projects,config
|
|
96
|
+
cchost add kimi --custom
|
|
97
|
+
cchost add kimi --custom --base-url=https://api.moonshot.ai/anthropic --token=sk-... --model=kimi-k2.5
|
|
79
98
|
`,
|
|
80
|
-
use: `
|
|
99
|
+
use: `cchost use <profile> [...args]
|
|
81
100
|
|
|
82
101
|
Launch Claude under a named profile. The profile must already exist (create
|
|
83
|
-
it with '
|
|
102
|
+
it with 'cchost add <profile>'). Its directory under ~/.claude-profiles/<profile>/
|
|
84
103
|
becomes CLAUDE_CONFIG_DIR for the spawned claude — credentials and per-
|
|
85
104
|
profile config are isolated; the 7 shared resources are visible via symlinks.
|
|
86
105
|
|
|
87
106
|
All args after the profile name are forwarded verbatim to claude.
|
|
88
107
|
|
|
89
108
|
Examples:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
109
|
+
cchost use work
|
|
110
|
+
cchost use work --resume
|
|
111
|
+
cchost use personal -p "summarize this codebase"
|
|
93
112
|
`,
|
|
94
|
-
list: `
|
|
113
|
+
list: `cchost list [<profile>] [--json] [--reveal]
|
|
95
114
|
|
|
96
115
|
Without a profile name: list every profile as a table.
|
|
97
116
|
|
|
98
117
|
Columns:
|
|
99
118
|
Profile - profile name
|
|
100
|
-
LoggedIn - true if
|
|
119
|
+
LoggedIn - true if credentials are present. On macOS the token lives in the
|
|
120
|
+
login Keychain and the login is inferred from .claude.json.
|
|
101
121
|
Email - account email from .claude.json (masked by default; pass
|
|
102
122
|
--reveal to show the full value); - if not yet logged in
|
|
103
123
|
Shared - N/7 resources linked to ~/.claude/. 7/7 = fully shared (the
|
|
@@ -105,23 +125,24 @@ Columns:
|
|
|
105
125
|
Endpoint - custom-endpoint host from .cc-env.json, or "subscription" if
|
|
106
126
|
the profile uses Anthropic's subscription OAuth
|
|
107
127
|
|
|
108
|
-
With a profile name: print that one profile's detail
|
|
109
|
-
|
|
128
|
+
With a profile name: print that one profile's detail: the state of each of
|
|
129
|
+
the 7 resources (shared, private, empty, missing, or foreign-link) and its
|
|
130
|
+
custom endpoint configuration if it has one. The API token is masked by default;
|
|
110
131
|
--reveal prints both the token and the full email — do not paste that output
|
|
111
132
|
anywhere.
|
|
112
133
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
134
|
+
cchost list Table of all profiles (emails masked)
|
|
135
|
+
cchost list --reveal Table with full emails shown
|
|
136
|
+
cchost list --json Same, as a JSON array (always masked)
|
|
137
|
+
cchost list <profile> Detail for a single profile
|
|
138
|
+
cchost list <profile> --reveal Detail with the full token + email shown
|
|
139
|
+
cchost list <profile> --json Detail as a JSON object (always masked)
|
|
119
140
|
`,
|
|
120
|
-
copy: `
|
|
141
|
+
copy: `cchost copy default <profile> [--force] [--dry-run]
|
|
121
142
|
|
|
122
143
|
Copy ~/.claude/ resources into a profile. Useful only when the profile has
|
|
123
144
|
'unlinked' one or more resources (private content); for fresh v0.4.1
|
|
124
|
-
profiles every resource is symlinked, so '
|
|
145
|
+
profiles every resource is symlinked, so 'cchost copy' is a no-op.
|
|
125
146
|
|
|
126
147
|
<src>: default (= ~/.claude/, Claude Code's own config dir)
|
|
127
148
|
<dest>: <profile-name>
|
|
@@ -135,32 +156,44 @@ For each resource:
|
|
|
135
156
|
- If the destination is shared via symlink/hard-link to the source → skipped
|
|
136
157
|
- Else, copy with skip-on-collision (entries already at the destination win)
|
|
137
158
|
|
|
138
|
-
--force
|
|
159
|
+
--force Overwrite collisions at the destination instead of skipping.
|
|
160
|
+
--dry-run Print what would be copied, skipped, or overwritten; change nothing.
|
|
139
161
|
|
|
140
162
|
Examples:
|
|
141
|
-
|
|
142
|
-
|
|
163
|
+
cchost copy default work --dry-run # preview first
|
|
164
|
+
cchost copy default work # only acts on unlinked resources
|
|
165
|
+
cchost copy default work --force # overwrite collisions
|
|
143
166
|
|
|
144
167
|
Removed in v0.4: 'shared' as src/dest. The shared pool IS ~/.claude/ in v0.4
|
|
145
|
-
and is reached via symlinks. Use '
|
|
146
|
-
Note: '
|
|
168
|
+
and is reached via symlinks. Use 'cchost link' / 'cchost unlink' to manage sharing.
|
|
169
|
+
Note: 'cchost migrate' is a deprecated alias for 'cchost copy' — it still works but
|
|
147
170
|
will be removed in a future version.
|
|
148
171
|
`,
|
|
149
|
-
|
|
172
|
+
migrate: `cchost migrate <src> <dest> [--force]
|
|
150
173
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
174
|
+
Deprecated alias for 'cchost copy'. It prints a deprecation warning and
|
|
175
|
+
forwards its arguments. Use 'cchost copy' instead; this alias will be removed
|
|
176
|
+
in a future version.
|
|
177
|
+
|
|
178
|
+
Examples:
|
|
179
|
+
cchost migrate default work
|
|
180
|
+
`,
|
|
181
|
+
link: `cchost link <profile> [<profile>...] [--projects|--tools|--config] [--dry-run]
|
|
182
|
+
|
|
183
|
+
(Re-)link a profile's resources into the shared pool at ~/.claude/. 'cchost add'
|
|
184
|
+
already does this for new profiles; 'cchost link' is for re-applying it after
|
|
185
|
+
'cchost unlink' or for older profiles that pre-date v0.4.1.
|
|
154
186
|
|
|
155
187
|
No flag link all 7 resources
|
|
156
188
|
--projects projects/
|
|
157
189
|
--tools skills/, agents/, commands/
|
|
158
190
|
--config mcp.json, settings.json, CLAUDE.md
|
|
159
191
|
(flags can be combined: --tools --config)
|
|
192
|
+
--dry-run print what would be linked or refused; change nothing
|
|
160
193
|
|
|
161
194
|
Refuses to overwrite private content: if the profile has a non-empty resource
|
|
162
195
|
that isn't already a symlink, that resource is reported as 'refused' and left
|
|
163
|
-
alone — move or remove it manually, then re-run '
|
|
196
|
+
alone — move or remove it manually, then re-run 'cchost link'. The other
|
|
164
197
|
resources are still linked.
|
|
165
198
|
|
|
166
199
|
On Windows, dirs use junctions (no admin/Developer Mode needed). File
|
|
@@ -168,12 +201,13 @@ symlinks may require Developer Mode; without it, files fall back to hard
|
|
|
168
201
|
links so editing-in-place still propagates between profiles.
|
|
169
202
|
|
|
170
203
|
Examples:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
204
|
+
cchost link work # link all 7
|
|
205
|
+
cchost link work --tools # only skills/, agents/, commands/
|
|
206
|
+
cchost link work --config # only mcp.json, settings.json, CLAUDE.md
|
|
207
|
+
cchost link a b c # link 3 profiles in one shot
|
|
208
|
+
cchost link work --dry-run # preview
|
|
175
209
|
`,
|
|
176
|
-
unlink: `
|
|
210
|
+
unlink: `cchost unlink <profile> [--projects|--tools|--config] [--dry-run]
|
|
177
211
|
|
|
178
212
|
Break the symlinks and copy current ~/.claude/ content into the profile as
|
|
179
213
|
private resources. Non-destructive: same-name items already private in the
|
|
@@ -183,16 +217,54 @@ No flag unlink all 7 resources
|
|
|
183
217
|
--projects projects/
|
|
184
218
|
--tools skills/, agents/, commands/
|
|
185
219
|
--config mcp.json, settings.json, CLAUDE.md
|
|
220
|
+
--dry-run print what would be unlinked and copied; change nothing
|
|
186
221
|
|
|
187
222
|
After unlinking, the profile's resource diverges from ~/.claude/. Other
|
|
188
223
|
profiles still see the shared pool.
|
|
189
224
|
|
|
190
225
|
Examples:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
226
|
+
cchost unlink work # full private profile
|
|
227
|
+
cchost unlink work --config # only mcp.json / settings.json / CLAUDE.md private
|
|
228
|
+
cchost unlink work --tools --config # skills, agents, commands AND config private
|
|
229
|
+
cchost unlink work --dry-run # preview
|
|
230
|
+
`,
|
|
231
|
+
sharing: `cchost help sharing — what profiles share, and how to change it
|
|
232
|
+
|
|
233
|
+
Every profile has 7 resources. By default each one is a link into ~/.claude/,
|
|
234
|
+
so all profiles (and bare \`claude\`) see the same content:
|
|
235
|
+
|
|
236
|
+
--projects projects/ session history
|
|
237
|
+
--tools skills/ agents/ commands/ user tooling
|
|
238
|
+
--config mcp.json settings.json CLAUDE.md MCP servers, settings, memory
|
|
239
|
+
|
|
240
|
+
Logins (.credentials.json, .claude.json, .cc-env.json) are never shared.
|
|
241
|
+
|
|
242
|
+
See where a profile stands:
|
|
243
|
+
cchost list Shared column: 7/7 = all shared
|
|
244
|
+
cchost list work state of each resource
|
|
245
|
+
|
|
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:
|
|
251
|
+
cchost unlink work --config own mcp.json / settings.json / CLAUDE.md
|
|
252
|
+
cchost unlink work all 7 private
|
|
253
|
+
The current shared content is copied in, so nothing disappears; from then
|
|
254
|
+
on, changes in that profile stay in that profile.
|
|
255
|
+
|
|
256
|
+
Share it again:
|
|
257
|
+
cchost link work --config back to the shared ~/.claude/ copy
|
|
258
|
+
link refuses to replace private content. Move or delete the private file or
|
|
259
|
+
folder first; it is not merged into ~/.claude/ automatically.
|
|
260
|
+
|
|
261
|
+
Copy shared content into a private profile (top-up, no overwrite):
|
|
262
|
+
cchost copy default work add what ~/.claude/ has and work lacks
|
|
263
|
+
cchost copy default work --force overwrite work's copies instead
|
|
264
|
+
|
|
265
|
+
Preview any of these first with --dry-run.
|
|
194
266
|
`,
|
|
195
|
-
remove: `
|
|
267
|
+
remove: `cchost remove <profile> [--force]
|
|
196
268
|
|
|
197
269
|
Delete a profile and everything inside its directory: credentials, sessions,
|
|
198
270
|
and any private resources. To confirm, you must type the exact profile name —
|
|
@@ -203,7 +275,7 @@ removed — the shared content in ~/.claude/ is preserved for other profiles.
|
|
|
203
275
|
If any resource is PRIVATE (unlinked), --force is required because its
|
|
204
276
|
content is unique to this profile and will be lost forever.
|
|
205
277
|
`,
|
|
206
|
-
doctor: `
|
|
278
|
+
doctor: `cchost doctor [--fix [--force]] [--json]
|
|
207
279
|
|
|
208
280
|
Run diagnostic checks across four groups:
|
|
209
281
|
|
|
@@ -213,7 +285,7 @@ Run diagnostic checks across four groups:
|
|
|
213
285
|
~/.claude-shared/ folded into ~/.claude/ if still present
|
|
214
286
|
Platform Windows: OneDrive home, WindowsApps stub, stale ~/bin/cc;
|
|
215
287
|
Git Bash: cygpath
|
|
216
|
-
Sanity Test-spawn claude --version through the same code path
|
|
288
|
+
Sanity Test-spawn claude --version through the same code path cchost use uses
|
|
217
289
|
|
|
218
290
|
Exit codes: 0 = all ok, 1 = warnings, 2 = errors.
|
|
219
291
|
|
|
@@ -223,7 +295,27 @@ Exit codes: 0 = all ok, 1 = warnings, 2 = errors.
|
|
|
223
295
|
.claude.json)
|
|
224
296
|
--json Machine-readable output for CI / scripting
|
|
225
297
|
`,
|
|
226
|
-
|
|
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)
|
|
317
|
+
`,
|
|
318
|
+
help: `cchost help [command]
|
|
227
319
|
|
|
228
320
|
Without an argument: print the summary of all commands.
|
|
229
321
|
With a command name: print detail for just that command.
|
|
@@ -247,11 +339,11 @@ function printHelp(cmd) {
|
|
|
247
339
|
process.stdout.write(HELP);
|
|
248
340
|
}
|
|
249
341
|
|
|
250
|
-
function prompt(question) {
|
|
342
|
+
function prompt(question, output = process.stdout) {
|
|
251
343
|
return new Promise((resolve) => {
|
|
252
344
|
const rl = readline.createInterface({
|
|
253
345
|
input: process.stdin,
|
|
254
|
-
output
|
|
346
|
+
output,
|
|
255
347
|
});
|
|
256
348
|
rl.question(question, (answer) => {
|
|
257
349
|
rl.close();
|
|
@@ -316,6 +408,18 @@ function parseFlagsStrict(args, flagNames, { allowPositional = false } = {}) {
|
|
|
316
408
|
return { flags, positional };
|
|
317
409
|
}
|
|
318
410
|
|
|
411
|
+
function resourceLabel(resource) {
|
|
412
|
+
return share.isFileResource(resource) ? resource : `${resource}/`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// One-line explanation per resource state for `cchost list <profile>`.
|
|
416
|
+
const RESOURCE_STATE_NOTES = {
|
|
417
|
+
private: 'only this profile sees it',
|
|
418
|
+
empty: 'private, but empty',
|
|
419
|
+
missing: 'not present; cchost link would share it',
|
|
420
|
+
'foreign-link': 'points outside ~/.claude/; cchost leaves it alone',
|
|
421
|
+
};
|
|
422
|
+
|
|
319
423
|
// Count how many of the 7 shared resources a profile currently has linked
|
|
320
424
|
// (symlink OR — on Windows file fallback — hard link).
|
|
321
425
|
function countSharedResources(profileDir) {
|
|
@@ -326,7 +430,7 @@ function countSharedResources(profileDir) {
|
|
|
326
430
|
return n;
|
|
327
431
|
}
|
|
328
432
|
|
|
329
|
-
// Flag name -> .cc-env.json field, for `
|
|
433
|
+
// Flag name -> .cc-env.json field, for `cchost add --custom`.
|
|
330
434
|
const ENDPOINT_VALUE_FLAGS = {
|
|
331
435
|
'--base-url': 'base_url',
|
|
332
436
|
'--token': 'auth_token',
|
|
@@ -337,19 +441,45 @@ const ENDPOINT_VALUE_FLAGS = {
|
|
|
337
441
|
'--subagent': 'subagent',
|
|
338
442
|
};
|
|
339
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
|
+
|
|
340
464
|
async function cmdAdd(rest) {
|
|
341
465
|
const positional = [];
|
|
342
466
|
const provided = {};
|
|
343
467
|
let custom = false;
|
|
468
|
+
let privateOpts = null;
|
|
344
469
|
|
|
345
470
|
for (const a of rest) {
|
|
346
471
|
if (a.startsWith('-')) {
|
|
347
472
|
const eq = a.indexOf('=');
|
|
348
473
|
const flagName = eq === -1 ? a : a.slice(0, eq);
|
|
349
474
|
if (flagName === '--custom') {
|
|
475
|
+
if (eq !== -1) throw usageError('--custom takes no value');
|
|
350
476
|
custom = true;
|
|
351
477
|
continue;
|
|
352
478
|
}
|
|
479
|
+
if (flagName === '--private') {
|
|
480
|
+
privateOpts = parsePrivateFlag(eq === -1 ? null : a.slice(eq + 1));
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
353
483
|
if (Object.prototype.hasOwnProperty.call(ENDPOINT_VALUE_FLAGS, flagName)) {
|
|
354
484
|
if (eq === -1) {
|
|
355
485
|
throw usageError(`Flag ${flagName} requires a value: ${flagName}=VALUE`);
|
|
@@ -364,15 +494,18 @@ async function cmdAdd(rest) {
|
|
|
364
494
|
|
|
365
495
|
const [name, ...extra] = positional;
|
|
366
496
|
if (!name) {
|
|
367
|
-
throw usageError(
|
|
497
|
+
throw usageError(
|
|
498
|
+
'Usage: cchost add <profile> [--private[=projects,tools,config]] ' +
|
|
499
|
+
'[--custom [--base-url=URL --token=TOKEN ...]]',
|
|
500
|
+
);
|
|
368
501
|
}
|
|
369
|
-
if (extra.length > 0) throw usageError(`
|
|
502
|
+
if (extra.length > 0) throw usageError(`cchost add takes no extra arguments: ${extra[0]}`);
|
|
370
503
|
profiles.validateProfileName(name);
|
|
371
504
|
|
|
372
505
|
if (Object.keys(provided).length > 0 && !custom) {
|
|
373
506
|
throw usageError(
|
|
374
507
|
'Endpoint flags (--base-url, --token, --model, ...) require --custom: ' +
|
|
375
|
-
`
|
|
508
|
+
`cchost add ${name} --custom ...`,
|
|
376
509
|
);
|
|
377
510
|
}
|
|
378
511
|
|
|
@@ -382,17 +515,23 @@ async function cmdAdd(rest) {
|
|
|
382
515
|
if (!custom) {
|
|
383
516
|
const { dir } = profiles.addProfile(name);
|
|
384
517
|
console.log(`Created profile "${name}" at ${dir}.`);
|
|
385
|
-
|
|
386
|
-
console.log(`Next:
|
|
518
|
+
setUpResources(name, dir, privateOpts);
|
|
519
|
+
console.log(`Next: cchost use ${name}`);
|
|
387
520
|
return 0;
|
|
388
521
|
}
|
|
389
522
|
|
|
390
523
|
// Custom endpoint profile.
|
|
391
524
|
const dir = profiles.profileDir(name);
|
|
392
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
|
+
}
|
|
393
532
|
if (!existed) {
|
|
394
533
|
profiles.addProfile(name);
|
|
395
|
-
|
|
534
|
+
setUpResources(name, dir, privateOpts);
|
|
396
535
|
}
|
|
397
536
|
|
|
398
537
|
let result;
|
|
@@ -417,7 +556,7 @@ async function cmdAdd(rest) {
|
|
|
417
556
|
return result.code;
|
|
418
557
|
}
|
|
419
558
|
|
|
420
|
-
if (!existed) console.log(`Next:
|
|
559
|
+
if (!existed) console.log(`Next: cchost use ${name}`);
|
|
421
560
|
return result.code;
|
|
422
561
|
}
|
|
423
562
|
|
|
@@ -447,13 +586,32 @@ function runLegacyPoolMigration() {
|
|
|
447
586
|
}
|
|
448
587
|
}
|
|
449
588
|
|
|
450
|
-
// On profile creation,
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
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 = {}) {
|
|
454
612
|
let results;
|
|
455
613
|
try {
|
|
456
|
-
results = share.shareLink(name);
|
|
614
|
+
results = share.shareLink(name, opts);
|
|
457
615
|
} catch (err) {
|
|
458
616
|
process.stderr.write(
|
|
459
617
|
color.stderr.yellow(
|
|
@@ -462,7 +620,9 @@ function autoLinkArtifacts(name) {
|
|
|
462
620
|
);
|
|
463
621
|
return;
|
|
464
622
|
}
|
|
465
|
-
const failed = results.filter(
|
|
623
|
+
const failed = results.filter(
|
|
624
|
+
(r) => r.status === 'failed' || r.status === 'refused' || r.status === 'foreign-link',
|
|
625
|
+
);
|
|
466
626
|
const linked = results.length - failed.length;
|
|
467
627
|
if (failed.length === 0) {
|
|
468
628
|
console.log(` Linked ${linked}/${results.length} resources to ~/.claude/.`);
|
|
@@ -476,35 +636,51 @@ function autoLinkArtifacts(name) {
|
|
|
476
636
|
}
|
|
477
637
|
process.stderr.write(
|
|
478
638
|
color.stderr.yellow(
|
|
479
|
-
` Re-run \`
|
|
639
|
+
` Re-run \`cchost link ${name}\` after resolving the above.`,
|
|
480
640
|
) + '\n',
|
|
481
641
|
);
|
|
482
642
|
}
|
|
483
643
|
|
|
484
644
|
async function cmdUse(rest) {
|
|
485
645
|
const [name, ...passthrough] = rest;
|
|
486
|
-
if (!name) throw usageError('Usage:
|
|
646
|
+
if (!name) throw usageError('Usage: cchost use <profile> [...args]');
|
|
487
647
|
profiles.validateProfileName(name);
|
|
488
648
|
const dir = profiles.profileDir(name);
|
|
489
649
|
if (!fs.existsSync(dir)) {
|
|
490
|
-
const err = new Error(`Profile "${name}" does not exist. Create it with:
|
|
650
|
+
const err = new Error(`Profile "${name}" does not exist. Create it with: cchost add ${name}`);
|
|
491
651
|
err.exitCode = 1;
|
|
492
652
|
throw err;
|
|
493
653
|
}
|
|
494
654
|
|
|
495
655
|
let extraEnv = {};
|
|
656
|
+
let unsetEnv = [];
|
|
496
657
|
const envConfig = envLib.readEnvConfig(dir);
|
|
497
658
|
if (envConfig) {
|
|
498
659
|
extraEnv = envLib.buildEnvVars(envConfig);
|
|
660
|
+
unsetEnv = envLib.endpointEnvToStrip(extraEnv);
|
|
499
661
|
const host = envConfig.base_url ? envLib.hostOf(envConfig.base_url) : '(unknown)';
|
|
500
662
|
process.stderr.write(
|
|
501
663
|
`[cchost] Profile '${name}' → ${host} (custom endpoint billing applies)\n`,
|
|
502
664
|
);
|
|
665
|
+
if (unsetEnv.length > 0) {
|
|
666
|
+
process.stderr.write(
|
|
667
|
+
`[cchost] notice: removed inherited ${unsetEnv.join(', ')} for endpoint profile '${name}'.\n`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
const conflicts = envLib.authEnvConflicts();
|
|
672
|
+
if (conflicts.length > 0) {
|
|
673
|
+
process.stderr.write(
|
|
674
|
+
`[cchost] warning: ${conflicts.join(', ')} set in the environment; ` +
|
|
675
|
+
`this may override the subscription login for '${name}'. ` +
|
|
676
|
+
`Unset ${conflicts.length > 1 ? 'them' : 'it'} to use the profile's login.\n`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
503
679
|
}
|
|
504
|
-
return spawnClaude({ args: passthrough, profileDir: dir, extraEnv });
|
|
680
|
+
return spawnClaude({ args: passthrough, profileDir: dir, extraEnv, unsetEnv });
|
|
505
681
|
}
|
|
506
682
|
|
|
507
|
-
// `
|
|
683
|
+
// `cchost list <profile>` — detail for a single profile, including its custom
|
|
508
684
|
// endpoint configuration if it has one.
|
|
509
685
|
function cmdListOne(name, { json, reveal }) {
|
|
510
686
|
if (json && reveal) {
|
|
@@ -513,15 +689,16 @@ function cmdListOne(name, { json, reveal }) {
|
|
|
513
689
|
profiles.validateProfileName(name);
|
|
514
690
|
const dir = profiles.profileDir(name);
|
|
515
691
|
if (!fs.existsSync(dir)) {
|
|
516
|
-
const e = new Error(`Profile "${name}" does not exist. Create it with:
|
|
692
|
+
const e = new Error(`Profile "${name}" does not exist. Create it with: cchost add ${name}`);
|
|
517
693
|
e.exitCode = 1;
|
|
518
694
|
throw e;
|
|
519
695
|
}
|
|
520
696
|
|
|
521
697
|
const total = share.SHARED_RESOURCES.length;
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
const
|
|
698
|
+
const resources = {};
|
|
699
|
+
for (const r of share.SHARED_RESOURCES) resources[r] = share.resourceState(dir, r);
|
|
700
|
+
const sharedCount = Object.values(resources).filter((st) => st === 'shared').length;
|
|
701
|
+
const { credentialSource, loggedIn, email } = profiles.readProfile(dir);
|
|
525
702
|
const displayEmail = reveal ? email : (email ? envLib.maskEmail(email) : email);
|
|
526
703
|
|
|
527
704
|
let config = null;
|
|
@@ -537,8 +714,10 @@ function cmdListOne(name, { json, reveal }) {
|
|
|
537
714
|
name,
|
|
538
715
|
dir,
|
|
539
716
|
loggedIn,
|
|
717
|
+
credentialSource,
|
|
540
718
|
email: displayEmail,
|
|
541
719
|
shared: sharedCount,
|
|
720
|
+
resources,
|
|
542
721
|
endpoint: envLib.endpointSummary(dir),
|
|
543
722
|
};
|
|
544
723
|
if (config) {
|
|
@@ -558,9 +737,18 @@ function cmdListOne(name, { json, reveal }) {
|
|
|
558
737
|
|
|
559
738
|
console.log(`Profile: ${name}`);
|
|
560
739
|
console.log(`Directory: ${dir}`);
|
|
561
|
-
console.log(
|
|
740
|
+
console.log(
|
|
741
|
+
`LoggedIn: ${loggedIn ? 'true' : 'false'}` +
|
|
742
|
+
(credentialSource === 'keychain' ? ' (macOS Keychain)' : ''),
|
|
743
|
+
);
|
|
562
744
|
console.log(`Email: ${displayEmail || '-'}`);
|
|
563
745
|
console.log(`Shared: ${sharedCount}/${total}`);
|
|
746
|
+
const width = Math.max(...share.SHARED_RESOURCES.map((r) => resourceLabel(r).length));
|
|
747
|
+
for (const r of share.SHARED_RESOURCES) {
|
|
748
|
+
const st = resources[r];
|
|
749
|
+
const note = st === 'shared' ? `→ ~/.claude/${r}` : (RESOURCE_STATE_NOTES[st] || '');
|
|
750
|
+
console.log(` ${resourceLabel(r).padEnd(width)} ${st.padEnd(12)} ${note}`.trimEnd());
|
|
751
|
+
}
|
|
564
752
|
|
|
565
753
|
if (configError) {
|
|
566
754
|
console.log('Endpoint: (invalid config — .cc-env.json is malformed)');
|
|
@@ -620,6 +808,7 @@ function cmdList(rest) {
|
|
|
620
808
|
name: p.name,
|
|
621
809
|
dir: p.dir,
|
|
622
810
|
loggedIn: p.loggedIn,
|
|
811
|
+
credentialSource: p.credentialSource,
|
|
623
812
|
email: reveal ? p.email : (p.email ? envLib.maskEmail(p.email) : p.email),
|
|
624
813
|
shared: sharedCount,
|
|
625
814
|
endpoint: envLib.endpointSummary(p.dir),
|
|
@@ -633,7 +822,7 @@ function cmdList(rest) {
|
|
|
633
822
|
|
|
634
823
|
if (enriched.length === 0) {
|
|
635
824
|
console.log(`No profiles yet in ${profiles.baseDir()}.`);
|
|
636
|
-
console.log('Create one with:
|
|
825
|
+
console.log('Create one with: cchost add <name>');
|
|
637
826
|
return 0;
|
|
638
827
|
}
|
|
639
828
|
|
|
@@ -674,7 +863,7 @@ function hasPrivateContent(dir) {
|
|
|
674
863
|
async function cmdRemove(rest) {
|
|
675
864
|
const { flags, positional } = parseFlagsStrict(rest, ['--force'], { allowPositional: true });
|
|
676
865
|
const [name, ...extra] = positional;
|
|
677
|
-
if (!name) throw usageError('Usage:
|
|
866
|
+
if (!name) throw usageError('Usage: cchost remove <profile> [--force]');
|
|
678
867
|
if (extra.length > 0) throw usageError(`Unexpected argument: ${extra[0]}`);
|
|
679
868
|
const force = flags.has('--force');
|
|
680
869
|
profiles.validateProfileName(name);
|
|
@@ -689,7 +878,7 @@ async function cmdRemove(rest) {
|
|
|
689
878
|
const err = new Error(
|
|
690
879
|
`Profile "${name}" has private (unlinked) resources whose content is not ` +
|
|
691
880
|
`in ~/.claude/. Removing the profile will delete that content permanently.\n` +
|
|
692
|
-
`Re-run with --force to confirm:
|
|
881
|
+
`Re-run with --force to confirm: cchost remove ${name} --force`,
|
|
693
882
|
);
|
|
694
883
|
err.exitCode = 1;
|
|
695
884
|
throw err;
|
|
@@ -721,19 +910,22 @@ async function cmdRemove(rest) {
|
|
|
721
910
|
async function cmdLink(rest) {
|
|
722
911
|
const { flags, positional } = parseFlagsStrict(
|
|
723
912
|
rest,
|
|
724
|
-
['--tools', '--projects', '--config'],
|
|
913
|
+
['--tools', '--projects', '--config', '--dry-run'],
|
|
725
914
|
{ allowPositional: true },
|
|
726
915
|
);
|
|
727
916
|
if (positional.length === 0) {
|
|
728
917
|
throw usageError(
|
|
729
|
-
'Usage:
|
|
918
|
+
'Usage: cchost link <profile> [<profile>...] [--projects|--tools|--config] [--dry-run]',
|
|
730
919
|
);
|
|
731
920
|
}
|
|
921
|
+
const dryRun = flags.has('--dry-run');
|
|
732
922
|
const opts = {
|
|
733
923
|
tools: flags.has('--tools'),
|
|
734
924
|
projects: flags.has('--projects'),
|
|
735
925
|
config: flags.has('--config'),
|
|
926
|
+
dryRun,
|
|
736
927
|
};
|
|
928
|
+
if (dryRun) console.log('Dry run — nothing will be changed.');
|
|
737
929
|
const multi = positional.length > 1;
|
|
738
930
|
const summary = [];
|
|
739
931
|
for (const name of positional) {
|
|
@@ -752,19 +944,27 @@ async function cmdLink(rest) {
|
|
|
752
944
|
process.stderr.write(color.stderr.red(` ${label}: failed — ${r.message}`) + '\n');
|
|
753
945
|
} else if (r.status === 'refused') {
|
|
754
946
|
process.stderr.write(color.stderr.yellow(` ${label}: refused — ${r.message}`) + '\n');
|
|
947
|
+
} else if (r.status === 'foreign-link') {
|
|
948
|
+
process.stderr.write(color.stderr.yellow(` ${label}: foreign link — ${r.message}`) + '\n');
|
|
755
949
|
} else if (r.status === 'already-linked') {
|
|
756
950
|
console.log(` ${label}: already linked`);
|
|
757
951
|
} else if (r.relinkedFromLegacy) {
|
|
758
|
-
console.log(
|
|
952
|
+
console.log(
|
|
953
|
+
` ${label}: ${dryRun ? 'would relink' : 'relinked'} (was pointing at legacy ~/.claude-shared/)`,
|
|
954
|
+
);
|
|
955
|
+
} else if (dryRun) {
|
|
956
|
+
console.log(` ${label}: would link → ~/.claude/${r.resource}`);
|
|
759
957
|
} else {
|
|
760
958
|
const note = r.kind === 'hardlink' ? ' (hard link — enable Windows Developer Mode for a true symlink)' : '';
|
|
761
959
|
console.log(` ${label}: linked${note}`);
|
|
762
960
|
}
|
|
763
961
|
}
|
|
764
|
-
const failed = results.filter(
|
|
962
|
+
const failed = results.filter(
|
|
963
|
+
(r) => r.status === 'failed' || r.status === 'refused' || r.status === 'foreign-link',
|
|
964
|
+
);
|
|
765
965
|
const linked = results.length - failed.length;
|
|
766
966
|
console.log(
|
|
767
|
-
` ${linked}/${results.length} linked` +
|
|
967
|
+
` ${dryRun ? 'would end with ' : ''}${linked}/${results.length} linked` +
|
|
768
968
|
(failed.length ? `, ${failed.length} not linked` : '') + '.',
|
|
769
969
|
);
|
|
770
970
|
summary.push({
|
|
@@ -794,19 +994,22 @@ async function cmdLink(rest) {
|
|
|
794
994
|
async function cmdUnlink(rest) {
|
|
795
995
|
const { flags, positional } = parseFlagsStrict(
|
|
796
996
|
rest,
|
|
797
|
-
['--tools', '--projects', '--config'],
|
|
997
|
+
['--tools', '--projects', '--config', '--dry-run'],
|
|
798
998
|
{ allowPositional: true },
|
|
799
999
|
);
|
|
800
1000
|
const [name, ...extra] = positional;
|
|
801
1001
|
if (!name) {
|
|
802
|
-
throw usageError('Usage:
|
|
1002
|
+
throw usageError('Usage: cchost unlink <profile> [--projects|--tools|--config] [--dry-run]');
|
|
803
1003
|
}
|
|
804
1004
|
if (extra.length > 0) throw usageError(`Unexpected argument: ${extra[0]}`);
|
|
1005
|
+
const dryRun = flags.has('--dry-run');
|
|
805
1006
|
const opts = {
|
|
806
1007
|
tools: flags.has('--tools'),
|
|
807
1008
|
projects: flags.has('--projects'),
|
|
808
1009
|
config: flags.has('--config'),
|
|
1010
|
+
dryRun,
|
|
809
1011
|
};
|
|
1012
|
+
if (dryRun) console.log('Dry run — nothing will be changed.');
|
|
810
1013
|
const results = share.shareUnlink(name, opts);
|
|
811
1014
|
let anyFailure = false;
|
|
812
1015
|
for (const r of results) {
|
|
@@ -816,6 +1019,9 @@ async function cmdUnlink(rest) {
|
|
|
816
1019
|
process.stderr.write(color.stderr.red(` ${label}: failed — ${r.message}`) + '\n');
|
|
817
1020
|
} else if (r.status === 'not-linked') {
|
|
818
1021
|
console.log(` ${label}: not linked, skipping`);
|
|
1022
|
+
} else if (dryRun) {
|
|
1023
|
+
const what = r.items === undefined ? 'its content' : `${r.items} item(s)`;
|
|
1024
|
+
console.log(` ${label}: would unlink and copy ${what} from ~/.claude/ into the profile`);
|
|
819
1025
|
} else {
|
|
820
1026
|
console.log(` ${label}: unlinked, content copied back`);
|
|
821
1027
|
}
|
|
@@ -823,6 +1029,51 @@ async function cmdUnlink(rest) {
|
|
|
823
1029
|
return anyFailure ? 1 : 0;
|
|
824
1030
|
}
|
|
825
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
|
+
|
|
826
1077
|
async function cmdDoctor(rest) {
|
|
827
1078
|
const { flags } = parseFlagsStrict(rest, ['--fix', '--force', '--json']);
|
|
828
1079
|
const fix = flags.has('--fix');
|
|
@@ -834,24 +1085,30 @@ async function cmdDoctor(rest) {
|
|
|
834
1085
|
}
|
|
835
1086
|
|
|
836
1087
|
async function cmdCopy(rest) {
|
|
837
|
-
const { flags, positional } = parseFlagsStrict(
|
|
1088
|
+
const { flags, positional } = parseFlagsStrict(
|
|
1089
|
+
rest,
|
|
1090
|
+
['--force', '--dry-run'],
|
|
1091
|
+
{ allowPositional: true },
|
|
1092
|
+
);
|
|
838
1093
|
const [src, dest, ...extra] = positional;
|
|
839
|
-
if (!src || !dest) throw usageError('Usage:
|
|
1094
|
+
if (!src || !dest) throw usageError('Usage: cchost copy <src> <dest> [--force] [--dry-run]');
|
|
840
1095
|
if (extra.length > 0) throw usageError(`Unexpected extra arguments: ${extra.join(' ')}`);
|
|
841
1096
|
const force = flags.has('--force');
|
|
1097
|
+
const dryRun = flags.has('--dry-run');
|
|
1098
|
+
if (dryRun) process.stdout.write('Dry run — nothing will be changed.\n');
|
|
842
1099
|
if (force) {
|
|
843
1100
|
process.stdout.write(
|
|
844
|
-
|
|
1101
|
+
`Warning: --force is set. Existing items at the destination ${dryRun ? 'would' : 'will'} be overwritten.\n`,
|
|
845
1102
|
);
|
|
846
1103
|
}
|
|
847
|
-
copyLib.copy(src, dest, { force });
|
|
1104
|
+
copyLib.copy(src, dest, { force, dryRun });
|
|
848
1105
|
return 0;
|
|
849
1106
|
}
|
|
850
1107
|
|
|
851
1108
|
async function cmdMigrate(rest) {
|
|
852
1109
|
process.stderr.write(
|
|
853
1110
|
color.stderr.yellow(
|
|
854
|
-
"[cchost] '
|
|
1111
|
+
"[cchost] 'cchost migrate' is deprecated — use 'cchost copy' instead. " +
|
|
855
1112
|
'This alias will be removed in a future version.',
|
|
856
1113
|
) + '\n',
|
|
857
1114
|
);
|
|
@@ -876,8 +1133,8 @@ async function run(argv) {
|
|
|
876
1133
|
if (action === 'env') {
|
|
877
1134
|
process.stderr.write(
|
|
878
1135
|
color.stderr.red(
|
|
879
|
-
"'
|
|
880
|
-
"'
|
|
1136
|
+
"'cchost env' was removed in v0.3.1. Configure a custom endpoint with " +
|
|
1137
|
+
"'cchost add <profile> --custom', and view it with 'cchost list <profile>'.",
|
|
881
1138
|
) + '\n',
|
|
882
1139
|
);
|
|
883
1140
|
return 2;
|
|
@@ -901,6 +1158,8 @@ async function run(argv) {
|
|
|
901
1158
|
return await cmdRemove(rest);
|
|
902
1159
|
case 'doctor':
|
|
903
1160
|
return await cmdDoctor(rest);
|
|
1161
|
+
case 'ui':
|
|
1162
|
+
return await cmdUi(rest);
|
|
904
1163
|
default: {
|
|
905
1164
|
process.stderr.write(color.stderr.red(`Unknown command: ${action}`) + '\n\n');
|
|
906
1165
|
printHelp();
|