cc-hub-cli 1.0.4 → 1.0.7

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.
Files changed (3) hide show
  1. package/README.md +65 -1
  2. package/dist/index.js +715 -42
  3. package/package.json +9 -3
package/README.md CHANGED
@@ -11,9 +11,12 @@ npm install -g cc-hub-cli
11
11
  ## Quick Start
12
12
 
13
13
  ```bash
14
- # Add a profile
14
+ # Add a profile with single model
15
15
  cc-hub profile add flow -m anthropic.claude-4-6-sonnet -t eyJ... -u https://example.com/api
16
16
 
17
+ # Add a profile with multiple models
18
+ cc-hub profile add multi -m kimi-k2.5 -m claude-sonnet-4-6 -m gpt-4 -t eyJ... -u https://api.example.com
19
+
17
20
  # Set as default
18
21
  cc-hub profile default flow
19
22
 
@@ -32,7 +35,10 @@ Manage multiple Claude API configurations (model, token, URL).
32
35
 
33
36
  ```bash
34
37
  cc-hub profile add <name> -m <model> -t <token> -u <url> # Add or update
38
+ cc-hub profile add <name> -m <m1> -m <m2> -t <token> # Add with multiple models
35
39
  cc-hub profile update <name> -m <model> # Update fields
40
+ cc-hub profile update <name> -m <m1> -m <m2> # Update with multiple models
41
+ cc-hub profile remove-model <name> -m <model> # Remove specific models
36
42
  cc-hub profile list # List all (tokens masked)
37
43
  cc-hub profile view <name> # View details (token visible)
38
44
  cc-hub profile view <name> -j # View as JSON
@@ -40,6 +46,24 @@ cc-hub profile remove <name> # Remove
40
46
  cc-hub profile default <name> # Set default
41
47
  ```
42
48
 
49
+ **Multi-Model Profiles:**
50
+
51
+ You can specify multiple models per profile using the `-m` flag multiple times:
52
+
53
+ ```bash
54
+ # Add a profile with multiple models
55
+ cc-hub profile add myprofile -m kimi-k2.5 -m claude-sonnet-4-6 -t <token> -u <url>
56
+
57
+ # Remove specific models
58
+ cc-hub profile remove-model myprofile -m kimi-k2.5
59
+ ```
60
+
61
+ When you launch Claude Code with `cc-hub run`, the models are automatically populated into Claude Code's `/model` picker via the `availableModels` setting in `~/.claude/settings.json`. The first model is used as the default.
62
+
63
+ **Non-Anthropic Models:**
64
+
65
+ If your profile includes non-Anthropic models (e.g., `kimi-k2.5`, `gpt-4`), the first such model is also exposed via the `ANTHROPIC_CUSTOM_MODEL_OPTION` environment variable, which adds it as a named entry in the `/model` picker.
66
+
43
67
  ### Run / Use
44
68
 
45
69
  Launch Claude Code with a profile's credentials injected as environment variables.
@@ -139,6 +163,8 @@ cc-hub reads from these paths (overridable via environment variables):
139
163
 
140
164
  ### Profile storage format
141
165
 
166
+ Single-model profile:
167
+
142
168
  ```json
143
169
  {
144
170
  "profiles": {
@@ -152,6 +178,44 @@ cc-hub reads from these paths (overridable via environment variables):
152
178
  }
153
179
  ```
154
180
 
181
+ Multi-model profile:
182
+
183
+ ```json
184
+ {
185
+ "profiles": {
186
+ "multi": {
187
+ "model": "kimi-k2.5",
188
+ "models": ["kimi-k2.5", "claude-sonnet-4-6", "gpt-4"],
189
+ "token": "eyJ...",
190
+ "url": "https://api.example.com"
191
+ }
192
+ },
193
+ "default": "multi"
194
+ }
195
+ ```
196
+
197
+ When launching with a multi-model profile, cc-hub automatically configures Claude Code's `availableModels` setting, populating the `/model` picker with all specified models.
198
+
199
+ ## Claude Code Skill
200
+
201
+ cc-hub includes a Claude Code skill for natural language profile management. The skill is located at `skills/cc-hub/SKILL.md`.
202
+
203
+ **Capabilities:**
204
+ - Add/remove models from profiles using natural language
205
+ - List models in a profile
206
+ - Set the default model for a profile
207
+
208
+ **Examples:**
209
+
210
+ ```
211
+ Add kimi-k2.5 and gpt-4 to my hy profile
212
+ Remove claude-sonnet from the flow profile
213
+ What models are in my hy profile?
214
+ Set the default model for hy to gpt-4
215
+ ```
216
+
217
+ The skill directly reads and writes `~/.claude/profiles.json` to make changes.
218
+
155
219
  ## License
156
220
 
157
221
  MIT
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command5 } from "commander";
4
+ import { Command as Command6 } from "commander";
5
5
 
6
6
  // src/profiles.ts
7
- import { Command } from "commander";
8
- import { spawnSync } from "child_process";
7
+ import { Command as Command2 } from "commander";
8
+ import { spawnSync, spawn } from "child_process";
9
9
 
10
10
  // src/config.ts
11
11
  import fs from "fs";
@@ -14,6 +14,7 @@ import os from "os";
14
14
  var CLAUDE_DIR = process.env.CLAUDE_DIR || path.join(os.homedir(), ".claude");
15
15
  var PROFILES_FILE = process.env.CLAUDE_PROFILES_FILE || path.join(CLAUDE_DIR, "profiles.json");
16
16
  var SETTINGS_FILE = process.env.CLAUDE_SETTINGS_FILE || path.join(CLAUDE_DIR, "settings.json");
17
+ var CLAUDE_JSON = path.join(os.homedir(), ".claude.json");
17
18
  var PROJECTS_DIR = path.join(CLAUDE_DIR, "projects");
18
19
  var SESSIONS_DIR = path.join(CLAUDE_DIR, "sessions");
19
20
  function ensureFile(filePath, defaultContent) {
@@ -34,6 +35,396 @@ function ensureProfilesFile() {
34
35
  function ensureSettingsFile() {
35
36
  ensureFile(SETTINGS_FILE, "{}\n");
36
37
  }
38
+ function fixJsonFile(filePath, fallback = {}) {
39
+ if (!fs.existsSync(filePath)) return;
40
+ const backupPath = path.join(CLAUDE_DIR, path.basename(filePath) + ".backup");
41
+ const raw = fs.readFileSync(filePath, "utf-8");
42
+ try {
43
+ JSON.parse(raw);
44
+ fs.copyFileSync(filePath, backupPath);
45
+ return;
46
+ } catch {
47
+ }
48
+ let text = raw.trim();
49
+ if (text.charCodeAt(0) === 65279) {
50
+ text = text.slice(1).trim();
51
+ }
52
+ text = text.replace(/,\s*$/, "");
53
+ text = text.replace(/,\s*([}\]])/g, "$1");
54
+ const lastBrace = Math.max(text.lastIndexOf("}"), text.lastIndexOf("]"));
55
+ if (lastBrace !== -1 && lastBrace < text.length - 1) {
56
+ text = text.slice(0, lastBrace + 1);
57
+ }
58
+ let openCurly = 0, openSquare = 0;
59
+ for (const ch of text) {
60
+ if (ch === "{") openCurly++;
61
+ else if (ch === "}") openCurly--;
62
+ else if (ch === "[") openSquare++;
63
+ else if (ch === "]") openSquare--;
64
+ }
65
+ if (openSquare > 0) text += "]".repeat(openSquare);
66
+ if (openCurly > 0) text += "}".repeat(openCurly);
67
+ try {
68
+ JSON.parse(text);
69
+ fs.writeFileSync(filePath, text + "\n", "utf-8");
70
+ console.error(`Fixed invalid JSON in ${path.basename(filePath)}.`);
71
+ } catch {
72
+ if (fs.existsSync(backupPath)) {
73
+ fs.copyFileSync(backupPath, filePath);
74
+ console.error(`Restored ${path.basename(filePath)} from backup.`);
75
+ } else {
76
+ writeJson(filePath, fallback);
77
+ console.error(`Could not fix ${path.basename(filePath)}, no backup found, reset to default.`);
78
+ }
79
+ }
80
+ }
81
+
82
+ // src/provider.ts
83
+ import http from "http";
84
+ import { Command } from "commander";
85
+ function sanitizeToolId(id) {
86
+ let sanitized = id.replace(/[^a-zA-Z0-9_-]/g, "_");
87
+ if (!/^[a-zA-Z]/.test(sanitized)) {
88
+ sanitized = "tc_" + sanitized;
89
+ }
90
+ return sanitized;
91
+ }
92
+ function transformAnthropicToOpenAI(body) {
93
+ const messages = [];
94
+ if (body.system) {
95
+ if (typeof body.system === "string") {
96
+ messages.push({ role: "system", content: body.system });
97
+ } else if (Array.isArray(body.system)) {
98
+ const text = body.system.filter((b) => b.type === "text" && b.text).map((b) => b.text).join("\n");
99
+ if (text) messages.push({ role: "system", content: text });
100
+ }
101
+ }
102
+ for (const msg of body.messages ?? []) {
103
+ if (typeof msg.content === "string") {
104
+ messages.push({ role: msg.role, content: msg.content });
105
+ continue;
106
+ }
107
+ if (!Array.isArray(msg.content)) {
108
+ messages.push({ role: msg.role, content: JSON.stringify(msg.content) });
109
+ continue;
110
+ }
111
+ if (msg.role === "user") {
112
+ const toolResults = msg.content.filter(
113
+ (b) => b.type === "tool_result" && b.tool_use_id
114
+ );
115
+ for (const tr of toolResults) {
116
+ messages.push({
117
+ role: "tool",
118
+ tool_call_id: sanitizeToolId(tr.tool_use_id),
119
+ content: typeof tr.content === "string" ? tr.content : Array.isArray(tr.content) ? tr.content.filter((b) => b.type === "text").map((b) => b.text).join("") : JSON.stringify(tr.content)
120
+ });
121
+ }
122
+ const contentParts = msg.content.filter(
123
+ (b) => b.type === "text" && b.text || b.type === "image" && b.source
124
+ );
125
+ if (contentParts.length > 0) {
126
+ const converted = contentParts.map((part) => {
127
+ if (part.type === "image") {
128
+ const url = part.source?.type === "base64" ? `data:${part.source.media_type};base64,${part.source.data}` : part.source?.url ?? "";
129
+ return { type: "image_url", image_url: { url } };
130
+ }
131
+ return { type: "text", text: part.text };
132
+ });
133
+ if (converted.every((p) => p.type === "text")) {
134
+ messages.push({
135
+ role: "user",
136
+ content: converted.map((p) => p.text).join("")
137
+ });
138
+ } else {
139
+ messages.push({ role: "user", content: converted });
140
+ }
141
+ }
142
+ } else if (msg.role === "assistant") {
143
+ const assistantMsg = { role: "assistant", content: null };
144
+ const textParts = msg.content.filter(
145
+ (b) => b.type === "text" && b.text
146
+ );
147
+ if (textParts.length > 0) {
148
+ assistantMsg.content = textParts.map((b) => b.text).join("\n");
149
+ }
150
+ const toolUseParts = msg.content.filter(
151
+ (b) => b.type === "tool_use" && b.id
152
+ );
153
+ if (toolUseParts.length > 0) {
154
+ assistantMsg.tool_calls = toolUseParts.map((b) => ({
155
+ id: sanitizeToolId(b.id),
156
+ type: "function",
157
+ function: {
158
+ name: b.name,
159
+ arguments: JSON.stringify(b.input ?? {})
160
+ }
161
+ }));
162
+ }
163
+ messages.push(assistantMsg);
164
+ }
165
+ }
166
+ const result = {
167
+ model: body.model,
168
+ messages,
169
+ stream: body.stream ?? false
170
+ };
171
+ if (body.max_tokens != null) result.max_tokens = body.max_tokens;
172
+ if (body.temperature != null) result.temperature = body.temperature;
173
+ if (body.tools?.length) {
174
+ result.tools = body.tools.map((t) => ({
175
+ type: "function",
176
+ function: {
177
+ name: t.name,
178
+ description: t.description ?? "",
179
+ parameters: t.input_schema
180
+ }
181
+ }));
182
+ if (body.tool_choice) {
183
+ const tc = body.tool_choice;
184
+ if (tc.type === "auto" || tc.type === "none" || tc.type === "required") {
185
+ result.tool_choice = tc.type;
186
+ } else if (tc.type === "tool") {
187
+ result.tool_choice = {
188
+ type: "function",
189
+ function: { name: tc.name }
190
+ };
191
+ }
192
+ }
193
+ }
194
+ return result;
195
+ }
196
+ function transformOpenAIResponseToAnthropic(openaiResponse, originalModel) {
197
+ const choice = openaiResponse.choices?.[0];
198
+ if (!choice) throw new Error("No choices in OpenAI response");
199
+ const content = [];
200
+ if (choice.message?.content) {
201
+ content.push({ type: "text", text: choice.message.content });
202
+ }
203
+ if (choice.message?.tool_calls?.length) {
204
+ for (const tc of choice.message.tool_calls) {
205
+ let input = {};
206
+ try {
207
+ input = typeof tc.function.arguments === "string" ? JSON.parse(tc.function.arguments) : tc.function.arguments;
208
+ } catch {
209
+ input = { text: tc.function.arguments ?? "" };
210
+ }
211
+ content.push({
212
+ type: "tool_use",
213
+ id: tc.id,
214
+ name: tc.function.name,
215
+ input
216
+ });
217
+ }
218
+ }
219
+ const finishMap = {
220
+ stop: "end_turn",
221
+ length: "max_tokens",
222
+ tool_calls: "tool_use",
223
+ content_filter: "stop_sequence"
224
+ };
225
+ return {
226
+ id: openaiResponse.id ?? `msg_${Date.now()}`,
227
+ type: "message",
228
+ role: "assistant",
229
+ model: openaiResponse.model ?? originalModel,
230
+ content,
231
+ stop_reason: finishMap[choice.finish_reason] ?? "end_turn",
232
+ stop_sequence: null,
233
+ usage: {
234
+ input_tokens: (openaiResponse.usage?.prompt_tokens ?? 0) - (openaiResponse.usage?.prompt_tokens_details?.cached_tokens ?? 0),
235
+ output_tokens: openaiResponse.usage?.completion_tokens ?? 0,
236
+ cache_read_input_tokens: openaiResponse.usage?.prompt_tokens_details?.cached_tokens ?? 0
237
+ }
238
+ };
239
+ }
240
+ function* synthesizeAnthropicSSE(anthropicResponse) {
241
+ const sse = (event, data) => `event: ${event}
242
+ data: ${JSON.stringify(data)}
243
+
244
+ `;
245
+ const usage = anthropicResponse.usage ?? {};
246
+ yield sse("message_start", {
247
+ type: "message_start",
248
+ message: {
249
+ id: anthropicResponse.id,
250
+ type: "message",
251
+ role: "assistant",
252
+ content: [],
253
+ model: anthropicResponse.model,
254
+ stop_reason: null,
255
+ stop_sequence: null,
256
+ usage: {
257
+ input_tokens: usage.input_tokens ?? 0,
258
+ output_tokens: 0,
259
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0
260
+ }
261
+ }
262
+ });
263
+ for (let i = 0; i < (anthropicResponse.content ?? []).length; i++) {
264
+ const block = anthropicResponse.content[i];
265
+ yield sse("content_block_start", {
266
+ type: "content_block_start",
267
+ index: i,
268
+ content_block: block.type === "tool_use" ? { type: "tool_use", id: block.id, name: block.name, input: {} } : { type: "text", text: "" }
269
+ });
270
+ if (block.type === "text" && block.text) {
271
+ yield sse("content_block_delta", {
272
+ type: "content_block_delta",
273
+ index: i,
274
+ delta: { type: "text_delta", text: block.text }
275
+ });
276
+ } else if (block.type === "tool_use" && block.input) {
277
+ yield sse("content_block_delta", {
278
+ type: "content_block_delta",
279
+ index: i,
280
+ delta: { type: "input_json_delta", partial_json: JSON.stringify(block.input) }
281
+ });
282
+ }
283
+ yield sse("content_block_stop", { type: "content_block_stop", index: i });
284
+ }
285
+ yield sse("message_delta", {
286
+ type: "message_delta",
287
+ delta: {
288
+ stop_reason: anthropicResponse.stop_reason ?? "end_turn",
289
+ stop_sequence: anthropicResponse.stop_sequence ?? null
290
+ },
291
+ usage: { output_tokens: usage.output_tokens ?? 0 }
292
+ });
293
+ yield sse("message_stop", { type: "message_stop" });
294
+ }
295
+ async function startOpenAIProxy(targetUrl, apiKey, model, models = []) {
296
+ const base = targetUrl.replace(/\/+$/, "");
297
+ const server = http.createServer(async (req, res) => {
298
+ try {
299
+ if (req.method === "GET" && req.url === "/v1/models") {
300
+ res.writeHead(200, { "Content-Type": "application/json" });
301
+ const modelList = models.length > 0 ? models : [model];
302
+ const response = {
303
+ object: "list",
304
+ data: modelList.map((m) => ({ id: m, object: "model" }))
305
+ };
306
+ res.end(JSON.stringify(response));
307
+ return;
308
+ }
309
+ if (req.method === "POST" && req.url?.startsWith("/v1/messages")) {
310
+ const body = await readBody(req);
311
+ let parsed;
312
+ try {
313
+ parsed = JSON.parse(body);
314
+ } catch {
315
+ res.writeHead(400, { "Content-Type": "application/json" });
316
+ res.end(JSON.stringify({ error: { type: "invalid_request_error", message: "invalid JSON" } }));
317
+ return;
318
+ }
319
+ const isStream = !!parsed.stream;
320
+ const openaiBody = transformAnthropicToOpenAI({ ...parsed, stream: false });
321
+ if (isStream) {
322
+ res.writeHead(200, {
323
+ "Content-Type": "text/event-stream",
324
+ "Cache-Control": "no-cache",
325
+ "Connection": "keep-alive"
326
+ });
327
+ const keepalive = setInterval(() => res.write(": keepalive\n\n"), 15e3);
328
+ try {
329
+ const upstream2 = await fetch(`${base}/v1/chat/completions`, {
330
+ method: "POST",
331
+ headers: {
332
+ "Content-Type": "application/json",
333
+ "Authorization": `Bearer ${apiKey}`
334
+ },
335
+ body: JSON.stringify(openaiBody)
336
+ });
337
+ if (!upstream2.ok) {
338
+ const errText = await upstream2.text();
339
+ res.write(`event: error
340
+ data: ${errText}
341
+
342
+ `);
343
+ res.end();
344
+ return;
345
+ }
346
+ const data2 = await upstream2.json();
347
+ const anthropicResponse2 = transformOpenAIResponseToAnthropic(data2, parsed.model ?? model);
348
+ for (const chunk of synthesizeAnthropicSSE(anthropicResponse2)) {
349
+ res.write(chunk);
350
+ }
351
+ res.end();
352
+ } finally {
353
+ clearInterval(keepalive);
354
+ }
355
+ return;
356
+ }
357
+ const upstream = await fetch(`${base}/v1/chat/completions`, {
358
+ method: "POST",
359
+ headers: {
360
+ "Content-Type": "application/json",
361
+ "Authorization": `Bearer ${apiKey}`
362
+ },
363
+ body: JSON.stringify(openaiBody)
364
+ });
365
+ if (!upstream.ok) {
366
+ const errText = await upstream.text();
367
+ res.writeHead(upstream.status, { "Content-Type": "application/json" });
368
+ res.end(errText);
369
+ return;
370
+ }
371
+ const data = await upstream.json();
372
+ const anthropicResponse = transformOpenAIResponseToAnthropic(data, parsed.model ?? model);
373
+ res.writeHead(200, { "Content-Type": "application/json" });
374
+ res.end(JSON.stringify(anthropicResponse));
375
+ return;
376
+ }
377
+ res.writeHead(404, { "Content-Type": "application/json" });
378
+ res.end(JSON.stringify({ error: { type: "not_found", message: "endpoint not found" } }));
379
+ } catch (err) {
380
+ if (!res.headersSent) {
381
+ res.writeHead(500, { "Content-Type": "application/json" });
382
+ res.end(JSON.stringify({ error: { type: "internal_error", message: String(err) } }));
383
+ }
384
+ }
385
+ });
386
+ return new Promise((resolve, reject) => {
387
+ server.listen(0, "127.0.0.1", () => {
388
+ const addr = server.address();
389
+ const baseUrl = `http://127.0.0.1:${addr.port}`;
390
+ resolve({
391
+ baseUrl,
392
+ stop: () => server.close()
393
+ });
394
+ });
395
+ server.on("error", reject);
396
+ });
397
+ }
398
+ function readBody(req) {
399
+ return new Promise((resolve, reject) => {
400
+ const chunks = [];
401
+ req.on("data", (c) => chunks.push(c));
402
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
403
+ req.on("error", reject);
404
+ });
405
+ }
406
+ var PROVIDERS = [
407
+ {
408
+ name: "anthropic",
409
+ description: "Default \u2014 sends Anthropic-format requests directly to the configured URL"
410
+ },
411
+ {
412
+ name: "openai",
413
+ description: "Embedded proxy \u2014 translates Anthropic requests to OpenAI Chat Completions format"
414
+ }
415
+ ];
416
+ function providerCommand() {
417
+ const cmd = new Command("provider").description("Manage provider types");
418
+ cmd.command("list").description("List available provider types").action(() => {
419
+ const fmt = (name, desc) => `${name.padEnd(12)} ${desc}`;
420
+ console.log(fmt("NAME", "DESCRIPTION"));
421
+ console.log(fmt("----", "-----------"));
422
+ for (const p of PROVIDERS) {
423
+ console.log(fmt(p.name, p.description));
424
+ }
425
+ });
426
+ return cmd;
427
+ }
37
428
 
38
429
  // src/profiles.ts
39
430
  function maskToken(token) {
@@ -41,20 +432,94 @@ function maskToken(token) {
41
432
  if (token.length <= 12) return token;
42
433
  return token.slice(0, 8) + "..." + token.slice(-4);
43
434
  }
435
+ function formatModels(p) {
436
+ if (p.models && p.models.length > 0) {
437
+ const nonAnthropicModels = p.models.filter((m) => !isAnthropicModel(m));
438
+ const parts = [];
439
+ p.models.forEach((m, i) => {
440
+ if (!isAnthropicModel(m)) {
441
+ const aliasIndex = nonAnthropicModels.indexOf(m);
442
+ if (aliasIndex === 0) parts.push(`${m} (opus)`);
443
+ else if (aliasIndex === 1) parts.push(`${m} (sonnet)`);
444
+ else if (aliasIndex === 2) parts.push(`${m} (haiku)`);
445
+ else parts.push(m);
446
+ } else {
447
+ parts.push(m);
448
+ }
449
+ });
450
+ const joined = parts.join(", ");
451
+ if (joined.length > 28) {
452
+ return parts[0] + ", +" + (parts.length - 1) + " more";
453
+ }
454
+ return joined;
455
+ }
456
+ return p.model || "(unset)";
457
+ }
458
+ function isAnthropicModel(model) {
459
+ const anthropicAliases = ["opus", "sonnet", "haiku", "best", "default", "opusplan", "opus[1m]", "sonnet[1m]"];
460
+ const lower = model.toLowerCase();
461
+ if (anthropicAliases.includes(lower)) return true;
462
+ if (lower.startsWith("claude-")) return true;
463
+ return false;
464
+ }
465
+ function updateSettingsForProfile(p) {
466
+ ensureSettingsFile();
467
+ const settings = readJson(SETTINGS_FILE);
468
+ const models = p.models || (p.model ? [p.model] : []);
469
+ const nonAnthropicModels = models.filter((m) => !isAnthropicModel(m));
470
+ if (models.length > 0) {
471
+ settings.model = models[0];
472
+ if (nonAnthropicModels.length > 0) {
473
+ const aliases = [];
474
+ if (nonAnthropicModels[0]) aliases.push("opus");
475
+ if (nonAnthropicModels[1]) aliases.push("sonnet");
476
+ if (nonAnthropicModels[2]) aliases.push("haiku");
477
+ settings.availableModels = aliases;
478
+ } else {
479
+ settings.availableModels = models;
480
+ }
481
+ } else {
482
+ delete settings.model;
483
+ delete settings.availableModels;
484
+ }
485
+ const envVarsToClean = [
486
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
487
+ "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
488
+ "ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION",
489
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
490
+ "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
491
+ "ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION",
492
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
493
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
494
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION",
495
+ "ANTHROPIC_CUSTOM_MODEL_OPTION"
496
+ ];
497
+ if (settings.env) {
498
+ for (const key of envVarsToClean) {
499
+ delete settings.env[key];
500
+ }
501
+ }
502
+ writeJson(SETTINGS_FILE, settings);
503
+ }
44
504
  function profileCommand() {
45
- const profile = new Command("profile").description("Manage Claude CLI profiles");
46
- profile.command("add").description("Add or update a profile").argument("<name>", "Profile name").option("-m, --model <model>", "Model ID (e.g. claude-opus-4-6)").option("-t, --token <token>", "API key / token").option("-u, --url <url>", "Base URL (e.g. https://api.anthropic.com)").action((name, opts) => {
505
+ const profile = new Command2("profile").description("Manage Claude CLI profiles");
506
+ profile.command("add").description("Add or update a profile").argument("<name>", "Profile name").option("-m, --model <model>", "Model ID (e.g. claude-opus-4-6) - can be used multiple times", collect, []).option("-t, --token <token>", "API key / token").option("-u, --url <url>", "Base URL (e.g. https://api.anthropic.com)").option("-p, --provider <provider>", "Provider type: anthropic (default) or openai").action((name, opts) => {
47
507
  ensureProfilesFile();
48
508
  const data = readJson(PROFILES_FILE);
49
509
  const profile2 = data.profiles[name] || {};
50
- if (opts.model) profile2.model = opts.model;
510
+ const models = opts.model && opts.model.length > 0 ? opts.model : void 0;
511
+ if (models) {
512
+ profile2.models = models;
513
+ profile2.model = models[0];
514
+ }
51
515
  if (opts.token) profile2.token = opts.token;
52
516
  if (opts.url) profile2.url = opts.url;
517
+ if (opts.provider) profile2.provider = opts.provider;
53
518
  data.profiles[name] = profile2;
54
519
  writeJson(PROFILES_FILE, data);
55
520
  console.log(`Profile '${name}' saved.`);
56
521
  });
57
- profile.command("update").description("Update fields of an existing profile").argument("<name>", "Profile name (must already exist)").option("-m, --model <model>", "Model ID").option("-t, --token <token>", "API key / token").option("-u, --url <url>", "Base URL").action((name, opts) => {
522
+ profile.command("update").description("Update fields of an existing profile").argument("<name>", "Profile name (must already exist)").option("-m, --model <model>", "Model ID - can be used multiple times to set multiple models", collect, []).option("-d, --delete-model <model>", "Remove model ID - can be used multiple times", collect, []).option("-t, --token <token>", "API key / token").option("-u, --url <url>", "Base URL").option("-p, --provider <provider>", "Provider type: anthropic (default) or openai").action((name, opts) => {
58
523
  ensureProfilesFile();
59
524
  const data = readJson(PROFILES_FILE);
60
525
  if (!data.profiles[name]) {
@@ -62,12 +527,81 @@ function profileCommand() {
62
527
  process.exit(1);
63
528
  }
64
529
  const p = data.profiles[name];
65
- if (opts.model) p.model = opts.model;
530
+ const providedModels = opts.model && opts.model.length > 0 ? opts.model : void 0;
531
+ const modelsToDelete = opts.deleteModel && opts.deleteModel.length > 0 ? opts.deleteModel : void 0;
532
+ if (modelsToDelete) {
533
+ const toRemove = new Set(modelsToDelete);
534
+ const currentModels = p.models || (p.model ? [p.model] : []);
535
+ const newModels = currentModels.filter((m) => !toRemove.has(m));
536
+ const removedCount = currentModels.length - newModels.length;
537
+ if (removedCount === 0) {
538
+ console.log(`No matching models to remove from profile '${name}'.`);
539
+ } else if (newModels.length === 0) {
540
+ delete p.models;
541
+ delete p.model;
542
+ console.log(`Removed all models from profile '${name}'.`);
543
+ } else {
544
+ p.models = newModels;
545
+ p.model = newModels[0];
546
+ console.log(`Removed ${removedCount} model(s) from profile '${name}'.`);
547
+ }
548
+ }
549
+ if (providedModels) {
550
+ if (providedModels.length === 1) {
551
+ const modelToSet = providedModels[0];
552
+ const currentModels = p.models || (p.model ? [p.model] : []);
553
+ const existingIndex = currentModels.indexOf(modelToSet);
554
+ if (existingIndex !== -1) {
555
+ currentModels.splice(existingIndex, 1);
556
+ currentModels.unshift(modelToSet);
557
+ p.models = currentModels;
558
+ p.model = modelToSet;
559
+ console.log(`Selected existing model '${modelToSet}' (position ${existingIndex + 1} -> 1).`);
560
+ } else {
561
+ currentModels.unshift(modelToSet);
562
+ p.models = currentModels;
563
+ p.model = modelToSet;
564
+ console.log(`Added and selected new model '${modelToSet}'.`);
565
+ }
566
+ } else {
567
+ p.models = providedModels;
568
+ p.model = providedModels[0];
569
+ }
570
+ }
66
571
  if (opts.token) p.token = opts.token;
67
572
  if (opts.url) p.url = opts.url;
573
+ if (opts.provider) p.provider = opts.provider;
68
574
  writeJson(PROFILES_FILE, data);
69
575
  console.log(`Profile '${name}' updated.`);
70
576
  });
577
+ profile.command("remove-model").description("Remove specific models from a profile").argument("<name>", "Profile name").option("-m, --model <model>", "Model ID to remove - can be used multiple times", collect, []).action((name, opts) => {
578
+ ensureProfilesFile();
579
+ const data = readJson(PROFILES_FILE);
580
+ if (!data.profiles[name]) {
581
+ console.error(`Profile '${name}' not found.`);
582
+ process.exit(1);
583
+ }
584
+ const p = data.profiles[name];
585
+ const toRemove = new Set(opts.model);
586
+ if (toRemove.size === 0) {
587
+ console.error("No models specified to remove. Use -m <model> to specify models.");
588
+ process.exit(1);
589
+ }
590
+ const currentModels = p.models || (p.model ? [p.model] : []);
591
+ const newModels = currentModels.filter((m) => !toRemove.has(m));
592
+ if (newModels.length === 0) {
593
+ delete p.models;
594
+ delete p.model;
595
+ console.log(`Removed all models from profile '${name}'.`);
596
+ } else {
597
+ const removedCount = currentModels.length - newModels.length;
598
+ p.models = newModels;
599
+ p.model = newModels[0];
600
+ console.log(`Removed ${removedCount} model(s) from profile '${name}'.`);
601
+ console.log(`Remaining models: ${newModels.join(", ")}`);
602
+ }
603
+ writeJson(PROFILES_FILE, data);
604
+ });
71
605
  profile.command("list").description("List all profiles").action(() => {
72
606
  ensureProfilesFile();
73
607
  const data = readJson(PROFILES_FILE);
@@ -78,17 +612,18 @@ function profileCommand() {
78
612
  return;
79
613
  }
80
614
  const def = data.default || "";
81
- const fmt = (marker, name, model, token, url) => `${marker.padEnd(2)} ${name.padEnd(20)} ${model.padEnd(30)} ${token.padEnd(20)} ${url}`;
82
- console.log(fmt("", "NAME", "MODEL", "TOKEN", "URL"));
83
- console.log(fmt("", "----", "-----", "-----", "---"));
615
+ const fmt = (marker, name, model, token, provider, url) => `${marker.padEnd(2)} ${name.padEnd(20)} ${model.padEnd(30)} ${token.padEnd(20)} ${provider.padEnd(12)} ${url}`;
616
+ console.log(fmt("", "NAME", "MODEL(S)", "TOKEN", "PROVIDER", "URL"));
617
+ console.log(fmt("", "----", "--------", "-----", "--------", "---"));
84
618
  for (const name of names) {
85
619
  const p = profiles[name];
86
620
  const marker = name === def ? "* " : " ";
87
621
  console.log(fmt(
88
622
  marker,
89
623
  name,
90
- p.model || "(unset)",
624
+ formatModels(p),
91
625
  maskToken(p.token || ""),
626
+ p.provider || "anthropic",
92
627
  p.url || "(default)"
93
628
  ));
94
629
  }
@@ -104,10 +639,27 @@ function profileCommand() {
104
639
  if (opts.json) {
105
640
  console.log(JSON.stringify({ name, ...p }, null, 2));
106
641
  } else {
107
- console.log(`Name: ${name}`);
108
- console.log(`Model: ${p.model || "(unset)"}`);
109
- console.log(`Token: ${p.token || "(unset)"}`);
110
- console.log(`URL: ${p.url || "(default)"}`);
642
+ console.log(`Name: ${name}`);
643
+ console.log(`Model: ${p.model || "(unset)"}`);
644
+ if (p.models && p.models.length > 0) {
645
+ const nonAnthropicModels = p.models.filter((m) => !isAnthropicModel(m));
646
+ console.log(`Models:`);
647
+ for (const m of p.models) {
648
+ if (!isAnthropicModel(m)) {
649
+ const aliasIndex = nonAnthropicModels.indexOf(m);
650
+ let alias = "";
651
+ if (aliasIndex === 0) alias = " (opus)";
652
+ else if (aliasIndex === 1) alias = " (sonnet)";
653
+ else if (aliasIndex === 2) alias = " (haiku)";
654
+ console.log(` - ${m}${alias}`);
655
+ } else {
656
+ console.log(` - ${m}`);
657
+ }
658
+ }
659
+ }
660
+ console.log(`Token: ${p.token || "(unset)"}`);
661
+ console.log(`URL: ${p.url || "(default)"}`);
662
+ console.log(`Provider: ${p.provider || "anthropic"}`);
111
663
  }
112
664
  });
113
665
  profile.command("remove").description("Remove a profile").argument("<name>", "Profile name").action((name) => {
@@ -134,25 +686,70 @@ function profileCommand() {
134
686
  });
135
687
  return profile;
136
688
  }
689
+ function collect(value, previous) {
690
+ return previous.concat([value]);
691
+ }
137
692
  function execClaude(profileName, p, extraArgs) {
693
+ updateSettingsForProfile(p);
694
+ const models = p.models || (p.model ? [p.model] : []);
695
+ const firstModel = models[0];
138
696
  const cmd = ["claude"];
139
- if (p.model) cmd.push("--model", p.model);
697
+ if (firstModel) cmd.push("--model", firstModel);
140
698
  cmd.push(...extraArgs);
141
699
  const env = {
142
700
  ...process.env,
143
701
  ANTHROPIC_AUTH_TOKEN: p.token || void 0,
144
702
  ANTHROPIC_BASE_URL: p.url || void 0
145
703
  };
704
+ const nonAnthropicModels = models.filter((m) => !isAnthropicModel(m));
705
+ if (nonAnthropicModels.length > 0) {
706
+ if (nonAnthropicModels[0]) {
707
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = nonAnthropicModels[0];
708
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME = nonAnthropicModels[0];
709
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION = `Custom: ${nonAnthropicModels[0]}`;
710
+ }
711
+ if (nonAnthropicModels[1]) {
712
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = nonAnthropicModels[1];
713
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME = nonAnthropicModels[1];
714
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION = `Custom: ${nonAnthropicModels[1]}`;
715
+ }
716
+ if (nonAnthropicModels[2]) {
717
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = nonAnthropicModels[2];
718
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME = nonAnthropicModels[2];
719
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION = `Custom: ${nonAnthropicModels[2]}`;
720
+ }
721
+ env.ANTHROPIC_CUSTOM_MODEL_OPTION = nonAnthropicModels[0];
722
+ }
146
723
  delete env.ANTHROPIC_API_KEY;
147
- console.error(`Using profile '${profileName}': model=${p.model || "(default)"} url=${p.url || "(default)"}`);
148
- const result = spawnSync(cmd[0], cmd.slice(1), {
149
- stdio: "inherit",
150
- env
151
- });
152
- process.exit(result.status ?? 1);
724
+ console.error(`Using profile '${profileName}': model=${firstModel || "(default)"} url=${p.url || "(default)"} provider=${p.provider || "anthropic"}`);
725
+ if (p.provider === "openai") {
726
+ const allModels = p.models || (p.model ? [p.model] : []);
727
+ startOpenAIProxy(
728
+ p.url || "https://api.openai.com",
729
+ p.token || "",
730
+ firstModel || "gpt-4o",
731
+ allModels
732
+ ).then(({ baseUrl, stop }) => {
733
+ env.ANTHROPIC_BASE_URL = baseUrl;
734
+ const child = spawn(cmd[0], cmd.slice(1), { stdio: "inherit", env });
735
+ child.on("close", (code) => {
736
+ stop();
737
+ process.exit(code ?? 1);
738
+ });
739
+ }).catch((err) => {
740
+ console.error("Failed to start OpenAI proxy:", err);
741
+ process.exit(1);
742
+ });
743
+ } else {
744
+ const result = spawnSync(cmd[0], cmd.slice(1), {
745
+ stdio: "inherit",
746
+ env
747
+ });
748
+ process.exit(result.status ?? 1);
749
+ }
153
750
  }
154
751
  function useCommand() {
155
- return new Command("use").description("Set a profile as the default").argument("<name>", "Profile name").action((name) => {
752
+ return new Command2("use").description("Set a profile as the default").argument("<name>", "Profile name").action((name) => {
156
753
  ensureProfilesFile();
157
754
  const data = readJson(PROFILES_FILE);
158
755
  if (!data.profiles[name]) {
@@ -165,7 +762,8 @@ function useCommand() {
165
762
  });
166
763
  }
167
764
  function runCommand() {
168
- return new Command("run").description("Launch Claude Code using the default or a specified profile").allowUnknownOption().argument("[args...]", "Optional profile name followed by extra arguments").action((args) => {
765
+ return new Command2("run").description("Launch Claude Code using the default or a specified profile").allowUnknownOption().argument("[args...]", "Optional profile name followed by extra arguments").action((args) => {
766
+ fixJsonFile(CLAUDE_JSON);
169
767
  ensureProfilesFile();
170
768
  const data = readJson(PROFILES_FILE);
171
769
  let profileName = "";
@@ -187,7 +785,7 @@ function runCommand() {
187
785
  }
188
786
 
189
787
  // src/hooks.ts
190
- import { Command as Command2 } from "commander";
788
+ import { Command as Command3 } from "commander";
191
789
  function buildFlat(data) {
192
790
  const rows = [];
193
791
  const hooksRoot = data.hooks || {};
@@ -227,7 +825,7 @@ function buildFlat(data) {
227
825
  return rows;
228
826
  }
229
827
  function hooksCommand() {
230
- const hooks = new Command2("hook").description("Manage Claude Code hooks in settings.json");
828
+ const hooks = new Command3("hook").description("Manage Claude Code hooks in settings.json");
231
829
  hooks.command("list").description("List all hooks").action(() => {
232
830
  ensureSettingsFile();
233
831
  const data = readJson(SETTINGS_FILE);
@@ -379,7 +977,7 @@ function hooksCommand() {
379
977
  }
380
978
 
381
979
  // src/sessions.ts
382
- import { Command as Command3 } from "commander";
980
+ import { Command as Command4 } from "commander";
383
981
  import fs2 from "fs";
384
982
  import path2 from "path";
385
983
  import { execSync } from "child_process";
@@ -463,7 +1061,7 @@ function snippet(text, query, width = 150) {
463
1061
  return prefix + text.slice(start, end) + suffix;
464
1062
  }
465
1063
  function sessionCommand() {
466
- const session = new Command3("session").description("Manage Claude Code sessions");
1064
+ const session = new Command4("session").description("Manage Claude Code sessions");
467
1065
  session.command("list").description("List all Claude Code project sessions").option("-n, --limit <n>", "Max number of projects to show", "30").option("-s, --short", "Show encoded names only (no decoding)").option("-j, --json", "Output as JSON lines").action((opts) => {
468
1066
  const limit = parseInt(opts.limit, 10);
469
1067
  let dirs;
@@ -755,7 +1353,7 @@ function sessionCommand() {
755
1353
  }
756
1354
 
757
1355
  // src/complete.ts
758
- import { Command as Command4 } from "commander";
1356
+ import { Command as Command5 } from "commander";
759
1357
  var ZSH_COMPLETION = `#compdef cc-hub
760
1358
 
761
1359
  _cc-hub() {
@@ -766,14 +1364,22 @@ _cc-hub() {
766
1364
  'run:Launch Claude Code using the default or a specified profile'
767
1365
  'hook:Manage Claude Code hooks in settings.json'
768
1366
  'session:Manage Claude Code sessions'
1367
+ 'provider:Manage provider types'
769
1368
  'complete:Print shell completion functions'
770
1369
  'help:Display help for a command'
771
1370
  )
772
1371
 
1372
+ local -a provider_subcmds
1373
+ provider_subcmds=(
1374
+ 'list:List available provider types'
1375
+ )
1376
+
1377
+
773
1378
  local -a profile_subcmds
774
1379
  profile_subcmds=(
775
1380
  'add:Add or update a profile'
776
1381
  'update:Update fields of an existing profile'
1382
+ 'remove-model:Remove specific models from a profile'
777
1383
  'list:List all profiles'
778
1384
  'view:View full details of a profile'
779
1385
  'remove:Remove a profile'
@@ -803,16 +1409,21 @@ _cc-hub() {
803
1409
  local profiles_file="\${CLAUDE_PROFILES_FILE:-$HOME/.claude/profiles.json}"
804
1410
  if [[ -f "$profiles_file" ]]; then
805
1411
  local -a names
806
- names=(\${(f)"$(command python3 -c "
807
- import json
808
- data = json.load(open('$profiles_file'))
809
- for name in data.get('profiles', {}):
810
- print(name)
811
- " 2>/dev/null)"})
1412
+ names=(\${(f)"$(command jq -r '.profiles | keys[]' "$profiles_file" 2>/dev/null)"})
812
1413
  _describe -t profiles 'profile' names
813
1414
  fi
814
1415
  }
815
1416
 
1417
+ _cc_hub_models_for_profile() {
1418
+ local profile_name="$1"
1419
+ local profiles_file="\${CLAUDE_PROFILES_FILE:-$HOME/.claude/profiles.json}"
1420
+ if [[ -f "$profiles_file" && -n "$profile_name" ]]; then
1421
+ local -a models
1422
+ models=(\${(f)"$(command jq -r --arg p "$profile_name" '(.profiles[$p].models // [ .profiles[$p].model ] )[]? // empty' "$profiles_file" 2>/dev/null)"})
1423
+ _describe -t models 'model' models
1424
+ fi
1425
+ }
1426
+
816
1427
  _arguments -C \\
817
1428
  '1: :->command' \\
818
1429
  '*::arg:->args'
@@ -826,8 +1437,26 @@ for name in data.get('profiles', {}):
826
1437
  profile)
827
1438
  if (( CURRENT == 2 )); then
828
1439
  _describe -t profile-subcmds 'profile subcommand' profile_subcmds
829
- elif [[ $words[2] == "view" || $words[2] == "remove" || $words[2] == "default" || $words[2] == "update" ]]; then
1440
+ elif [[ $words[2] == "view" || $words[2] == "remove" || $words[2] == "default" || $words[2] == "remove-model" ]]; then
830
1441
  _cc_hub_profiles
1442
+ elif [[ $words[2] == "update" ]]; then
1443
+ if (( CURRENT == 3 )); then
1444
+ _cc_hub_profiles
1445
+ else
1446
+ words=("stub" $words[3,-1])
1447
+ (( CURRENT-- ))
1448
+ _arguments -C -S '1:profile:_cc_hub_profiles' '(-m --model)*'{-m,--model}'[Model ID]:model:->profileModel' '(-d --delete-model)*'{-d,--delete-model}'[Remove model ID]:model:->profileModel' '(-t --token)'{-t,--token}'[API key / token]:token:' '(-u --url)'{-u,--url}'[Base URL]:url:' '(-p --provider)'{-p,--provider}'[Provider type]:provider:(anthropic openai)'
1449
+ case $state in
1450
+ profileModel)
1451
+ _cc_hub_models_for_profile $line[1]
1452
+ ;;
1453
+ esac
1454
+ fi
1455
+ fi
1456
+ ;;
1457
+ provider)
1458
+ if (( CURRENT == 2 )); then
1459
+ _describe -t provider-subcmds 'provider subcommand' provider_subcmds
831
1460
  fi
832
1461
  ;;
833
1462
  use|run)
@@ -864,14 +1493,39 @@ for name in data.get('profiles', {}):
864
1493
  fi
865
1494
  }
866
1495
 
1496
+ _cc-hub_models_for_profile() {
1497
+ local profile_name="$1"
1498
+ local profiles_file="\${CLAUDE_PROFILES_FILE:-$HOME/.claude/profiles.json}"
1499
+ if [[ -f "$profiles_file" && -n "$profile_name" ]]; then
1500
+ local models
1501
+ models=$(command python3 -c "
1502
+ import json
1503
+ data = json.load(open('$profiles_file'))
1504
+ p = data.get('profiles', {}).get('$profile_name', {})
1505
+ models = p.get('models')
1506
+ if isinstance(models, list):
1507
+ for m in models:
1508
+ if m:
1509
+ print(m)
1510
+ else:
1511
+ m = p.get('model')
1512
+ if m:
1513
+ print(m)
1514
+ " 2>/dev/null)
1515
+ COMPREPLY=($(compgen -W "$models" -- "\${cur}"))
1516
+ fi
1517
+ }
1518
+
867
1519
  _cc-hub() {
868
1520
  local cur prev commands
869
1521
  COMPREPLY=()
870
1522
  cur="\${COMP_WORDS[COMP_CWORD]}"
871
1523
  prev="\${COMP_WORDS[COMP_CWORD-1]}"
872
- commands="profile use run hook session complete help"
1524
+ commands="profile use run hook session provider complete help"
873
1525
 
874
- local profile_subcmds="add update list view remove default"
1526
+ local profile_subcmds="add update remove-model list view remove default"
1527
+ local provider_subcmds="list"
1528
+ local provider_types="anthropic openai"
875
1529
  local hooks_subcmds="list add remove enable disable"
876
1530
  local session_subcmds="list show search ps stats clean"
877
1531
 
@@ -887,10 +1541,28 @@ _cc-hub() {
887
1541
  profile)
888
1542
  if [[ \${COMP_CWORD} -eq 2 ]]; then
889
1543
  COMPREPLY=($(compgen -W "$profile_subcmds" -- "$cur"))
890
- elif [[ "$prev" == "view" || "$prev" == "remove" || "$prev" == "default" || "$prev" == "update" ]]; then
1544
+ elif [[ "$prev" == "view" || "$prev" == "remove" || "$prev" == "default" || "$prev" == "remove-model" ]]; then
891
1545
  _cc-hub_profiles
892
1546
  elif [[ "$prev" == "profile" ]]; then
893
1547
  COMPREPLY=($(compgen -W "$profile_subcmds" -- "$cur"))
1548
+ elif [[ "\${COMP_WORDS[2]}" == "update" && \${COMP_CWORD} -eq 3 ]]; then
1549
+ _cc-hub_profiles
1550
+ elif [[ "\${COMP_WORDS[2]}" == "update" ]]; then
1551
+ if [[ "$prev" == "--provider" || "$prev" == "-p" ]]; then
1552
+ COMPREPLY=($(compgen -W "$provider_types" -- "$cur"))
1553
+ elif [[ "$prev" == "--model" || "$prev" == "-m" || "$prev" == "--delete-model" || "$prev" == "-d" ]]; then
1554
+ _cc-hub_models_for_profile "\${COMP_WORDS[3]}"
1555
+ else
1556
+ local update_opts="--model -m --delete-model -d --token -t --url -u --provider -p"
1557
+ COMPREPLY=($(compgen -W "$update_opts" -- "$cur"))
1558
+ fi
1559
+ fi
1560
+ ;;
1561
+ provider)
1562
+ if [[ \${COMP_CWORD} -eq 2 ]]; then
1563
+ COMPREPLY=($(compgen -W "$provider_subcmds" -- "$cur"))
1564
+ elif [[ "$prev" == "provider" ]]; then
1565
+ COMPREPLY=($(compgen -W "$provider_subcmds" -- "$cur"))
894
1566
  fi
895
1567
  ;;
896
1568
  use|run)
@@ -914,7 +1586,7 @@ _cc-hub() {
914
1586
  complete -F _cc-hub cc-hub
915
1587
  `;
916
1588
  function completeCommand() {
917
- return new Command4("complete").description("Print shell completion script").argument("<shell>", "Shell type: bash or zsh").action((shell) => {
1589
+ return new Command5("complete").description("Print shell completion script").argument("<shell>", "Shell type: bash or zsh").action((shell) => {
918
1590
  switch (shell) {
919
1591
  case "zsh":
920
1592
  process.stdout.write(ZSH_COMPLETION);
@@ -930,7 +1602,7 @@ function completeCommand() {
930
1602
  }
931
1603
 
932
1604
  // src/index.ts
933
- var program = new Command5();
1605
+ var program = new Command6();
934
1606
  program.name("cc-hub").description("Manage Claude CLI profiles, hooks, and sessions").version("1.0.0");
935
1607
  program.addCommand(profileCommand());
936
1608
  program.addCommand(useCommand());
@@ -938,4 +1610,5 @@ program.addCommand(runCommand());
938
1610
  program.addCommand(hooksCommand());
939
1611
  program.addCommand(sessionCommand());
940
1612
  program.addCommand(completeCommand());
1613
+ program.addCommand(providerCommand());
941
1614
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-hub-cli",
3
- "version": "1.0.4",
3
+ "version": "1.0.7",
4
4
  "description": "Manage Claude CLI profiles, hooks, and sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,11 @@
10
10
  "build": "tsup",
11
11
  "dev": "tsup --watch",
12
12
  "start": "node dist/index.js",
13
- "prepublishOnly": "npm run build"
13
+ "prepublishOnly": "npm run build",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "test:coverage": "vitest run --coverage",
17
+ "test:build": "npm test && npm run build"
14
18
  },
15
19
  "repository": {
16
20
  "type": "git",
@@ -29,7 +33,9 @@
29
33
  },
30
34
  "devDependencies": {
31
35
  "tsup": "^8.4.0",
32
- "typescript": "^5.7.0"
36
+ "typescript": "^5.7.0",
37
+ "vitest": "^3.0.0",
38
+ "@vitest/coverage-v8": "^3.0.0"
33
39
  },
34
40
  "engines": {
35
41
  "node": ">=18"