cc-hub-cli 1.0.3 → 1.0.6
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/README.md +65 -1
- package/dist/index.js +691 -42
- package/package.json +1 -1
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
|
|
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,372 @@ 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
|
+
const upstream = await fetch(`${base}/v1/chat/completions`, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: {
|
|
324
|
+
"Content-Type": "application/json",
|
|
325
|
+
"Authorization": `Bearer ${apiKey}`
|
|
326
|
+
},
|
|
327
|
+
body: JSON.stringify(openaiBody)
|
|
328
|
+
});
|
|
329
|
+
if (!upstream.ok) {
|
|
330
|
+
const errText = await upstream.text();
|
|
331
|
+
res.writeHead(upstream.status, { "Content-Type": "application/json" });
|
|
332
|
+
res.end(errText);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const data = await upstream.json();
|
|
336
|
+
const anthropicResponse = transformOpenAIResponseToAnthropic(data, parsed.model ?? model);
|
|
337
|
+
if (isStream) {
|
|
338
|
+
res.writeHead(200, {
|
|
339
|
+
"Content-Type": "text/event-stream",
|
|
340
|
+
"Cache-Control": "no-cache",
|
|
341
|
+
"Connection": "keep-alive"
|
|
342
|
+
});
|
|
343
|
+
for (const chunk of synthesizeAnthropicSSE(anthropicResponse)) {
|
|
344
|
+
res.write(chunk);
|
|
345
|
+
}
|
|
346
|
+
res.end();
|
|
347
|
+
} else {
|
|
348
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
349
|
+
res.end(JSON.stringify(anthropicResponse));
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
354
|
+
res.end(JSON.stringify({ error: { type: "not_found", message: "endpoint not found" } }));
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (!res.headersSent) {
|
|
357
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
358
|
+
res.end(JSON.stringify({ error: { type: "internal_error", message: String(err) } }));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
server.listen(0, "127.0.0.1", () => {
|
|
364
|
+
const addr = server.address();
|
|
365
|
+
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
366
|
+
resolve({
|
|
367
|
+
baseUrl,
|
|
368
|
+
stop: () => server.close()
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
server.on("error", reject);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
function readBody(req) {
|
|
375
|
+
return new Promise((resolve, reject) => {
|
|
376
|
+
const chunks = [];
|
|
377
|
+
req.on("data", (c) => chunks.push(c));
|
|
378
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
379
|
+
req.on("error", reject);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
var PROVIDERS = [
|
|
383
|
+
{
|
|
384
|
+
name: "anthropic",
|
|
385
|
+
description: "Default \u2014 sends Anthropic-format requests directly to the configured URL"
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: "openai",
|
|
389
|
+
description: "Embedded proxy \u2014 translates Anthropic requests to OpenAI Chat Completions format"
|
|
390
|
+
}
|
|
391
|
+
];
|
|
392
|
+
function providerCommand() {
|
|
393
|
+
const cmd = new Command("provider").description("Manage provider types");
|
|
394
|
+
cmd.command("list").description("List available provider types").action(() => {
|
|
395
|
+
const fmt = (name, desc) => `${name.padEnd(12)} ${desc}`;
|
|
396
|
+
console.log(fmt("NAME", "DESCRIPTION"));
|
|
397
|
+
console.log(fmt("----", "-----------"));
|
|
398
|
+
for (const p of PROVIDERS) {
|
|
399
|
+
console.log(fmt(p.name, p.description));
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
return cmd;
|
|
403
|
+
}
|
|
37
404
|
|
|
38
405
|
// src/profiles.ts
|
|
39
406
|
function maskToken(token) {
|
|
@@ -41,20 +408,94 @@ function maskToken(token) {
|
|
|
41
408
|
if (token.length <= 12) return token;
|
|
42
409
|
return token.slice(0, 8) + "..." + token.slice(-4);
|
|
43
410
|
}
|
|
411
|
+
function formatModels(p) {
|
|
412
|
+
if (p.models && p.models.length > 0) {
|
|
413
|
+
const nonAnthropicModels = p.models.filter((m) => !isAnthropicModel(m));
|
|
414
|
+
const parts = [];
|
|
415
|
+
p.models.forEach((m, i) => {
|
|
416
|
+
if (!isAnthropicModel(m)) {
|
|
417
|
+
const aliasIndex = nonAnthropicModels.indexOf(m);
|
|
418
|
+
if (aliasIndex === 0) parts.push(`${m} (opus)`);
|
|
419
|
+
else if (aliasIndex === 1) parts.push(`${m} (sonnet)`);
|
|
420
|
+
else if (aliasIndex === 2) parts.push(`${m} (haiku)`);
|
|
421
|
+
else parts.push(m);
|
|
422
|
+
} else {
|
|
423
|
+
parts.push(m);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
const joined = parts.join(", ");
|
|
427
|
+
if (joined.length > 28) {
|
|
428
|
+
return parts[0] + ", +" + (parts.length - 1) + " more";
|
|
429
|
+
}
|
|
430
|
+
return joined;
|
|
431
|
+
}
|
|
432
|
+
return p.model || "(unset)";
|
|
433
|
+
}
|
|
434
|
+
function isAnthropicModel(model) {
|
|
435
|
+
const anthropicAliases = ["opus", "sonnet", "haiku", "best", "default", "opusplan", "opus[1m]", "sonnet[1m]"];
|
|
436
|
+
const lower = model.toLowerCase();
|
|
437
|
+
if (anthropicAliases.includes(lower)) return true;
|
|
438
|
+
if (lower.startsWith("claude-")) return true;
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
function updateSettingsForProfile(p) {
|
|
442
|
+
ensureSettingsFile();
|
|
443
|
+
const settings = readJson(SETTINGS_FILE);
|
|
444
|
+
const models = p.models || (p.model ? [p.model] : []);
|
|
445
|
+
const nonAnthropicModels = models.filter((m) => !isAnthropicModel(m));
|
|
446
|
+
if (models.length > 0) {
|
|
447
|
+
settings.model = models[0];
|
|
448
|
+
if (nonAnthropicModels.length > 0) {
|
|
449
|
+
const aliases = [];
|
|
450
|
+
if (nonAnthropicModels[0]) aliases.push("opus");
|
|
451
|
+
if (nonAnthropicModels[1]) aliases.push("sonnet");
|
|
452
|
+
if (nonAnthropicModels[2]) aliases.push("haiku");
|
|
453
|
+
settings.availableModels = aliases;
|
|
454
|
+
} else {
|
|
455
|
+
settings.availableModels = models;
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
delete settings.model;
|
|
459
|
+
delete settings.availableModels;
|
|
460
|
+
}
|
|
461
|
+
const envVarsToClean = [
|
|
462
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
463
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
|
464
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION",
|
|
465
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
466
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
|
467
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION",
|
|
468
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
469
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
|
470
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION",
|
|
471
|
+
"ANTHROPIC_CUSTOM_MODEL_OPTION"
|
|
472
|
+
];
|
|
473
|
+
if (settings.env) {
|
|
474
|
+
for (const key of envVarsToClean) {
|
|
475
|
+
delete settings.env[key];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
writeJson(SETTINGS_FILE, settings);
|
|
479
|
+
}
|
|
44
480
|
function profileCommand() {
|
|
45
|
-
const profile = new
|
|
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) => {
|
|
481
|
+
const profile = new Command2("profile").description("Manage Claude CLI profiles");
|
|
482
|
+
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
483
|
ensureProfilesFile();
|
|
48
484
|
const data = readJson(PROFILES_FILE);
|
|
49
485
|
const profile2 = data.profiles[name] || {};
|
|
50
|
-
|
|
486
|
+
const models = opts.model && opts.model.length > 0 ? opts.model : void 0;
|
|
487
|
+
if (models) {
|
|
488
|
+
profile2.models = models;
|
|
489
|
+
profile2.model = models[0];
|
|
490
|
+
}
|
|
51
491
|
if (opts.token) profile2.token = opts.token;
|
|
52
492
|
if (opts.url) profile2.url = opts.url;
|
|
493
|
+
if (opts.provider) profile2.provider = opts.provider;
|
|
53
494
|
data.profiles[name] = profile2;
|
|
54
495
|
writeJson(PROFILES_FILE, data);
|
|
55
496
|
console.log(`Profile '${name}' saved.`);
|
|
56
497
|
});
|
|
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) => {
|
|
498
|
+
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
499
|
ensureProfilesFile();
|
|
59
500
|
const data = readJson(PROFILES_FILE);
|
|
60
501
|
if (!data.profiles[name]) {
|
|
@@ -62,12 +503,81 @@ function profileCommand() {
|
|
|
62
503
|
process.exit(1);
|
|
63
504
|
}
|
|
64
505
|
const p = data.profiles[name];
|
|
65
|
-
|
|
506
|
+
const providedModels = opts.model && opts.model.length > 0 ? opts.model : void 0;
|
|
507
|
+
const modelsToDelete = opts.deleteModel && opts.deleteModel.length > 0 ? opts.deleteModel : void 0;
|
|
508
|
+
if (modelsToDelete) {
|
|
509
|
+
const toRemove = new Set(modelsToDelete);
|
|
510
|
+
const currentModels = p.models || (p.model ? [p.model] : []);
|
|
511
|
+
const newModels = currentModels.filter((m) => !toRemove.has(m));
|
|
512
|
+
const removedCount = currentModels.length - newModels.length;
|
|
513
|
+
if (removedCount === 0) {
|
|
514
|
+
console.log(`No matching models to remove from profile '${name}'.`);
|
|
515
|
+
} else if (newModels.length === 0) {
|
|
516
|
+
delete p.models;
|
|
517
|
+
delete p.model;
|
|
518
|
+
console.log(`Removed all models from profile '${name}'.`);
|
|
519
|
+
} else {
|
|
520
|
+
p.models = newModels;
|
|
521
|
+
p.model = newModels[0];
|
|
522
|
+
console.log(`Removed ${removedCount} model(s) from profile '${name}'.`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (providedModels) {
|
|
526
|
+
if (providedModels.length === 1) {
|
|
527
|
+
const modelToSet = providedModels[0];
|
|
528
|
+
const currentModels = p.models || (p.model ? [p.model] : []);
|
|
529
|
+
const existingIndex = currentModels.indexOf(modelToSet);
|
|
530
|
+
if (existingIndex !== -1) {
|
|
531
|
+
currentModels.splice(existingIndex, 1);
|
|
532
|
+
currentModels.unshift(modelToSet);
|
|
533
|
+
p.models = currentModels;
|
|
534
|
+
p.model = modelToSet;
|
|
535
|
+
console.log(`Selected existing model '${modelToSet}' (position ${existingIndex + 1} -> 1).`);
|
|
536
|
+
} else {
|
|
537
|
+
currentModels.unshift(modelToSet);
|
|
538
|
+
p.models = currentModels;
|
|
539
|
+
p.model = modelToSet;
|
|
540
|
+
console.log(`Added and selected new model '${modelToSet}'.`);
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
p.models = providedModels;
|
|
544
|
+
p.model = providedModels[0];
|
|
545
|
+
}
|
|
546
|
+
}
|
|
66
547
|
if (opts.token) p.token = opts.token;
|
|
67
548
|
if (opts.url) p.url = opts.url;
|
|
549
|
+
if (opts.provider) p.provider = opts.provider;
|
|
68
550
|
writeJson(PROFILES_FILE, data);
|
|
69
551
|
console.log(`Profile '${name}' updated.`);
|
|
70
552
|
});
|
|
553
|
+
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) => {
|
|
554
|
+
ensureProfilesFile();
|
|
555
|
+
const data = readJson(PROFILES_FILE);
|
|
556
|
+
if (!data.profiles[name]) {
|
|
557
|
+
console.error(`Profile '${name}' not found.`);
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
560
|
+
const p = data.profiles[name];
|
|
561
|
+
const toRemove = new Set(opts.model);
|
|
562
|
+
if (toRemove.size === 0) {
|
|
563
|
+
console.error("No models specified to remove. Use -m <model> to specify models.");
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
const currentModels = p.models || (p.model ? [p.model] : []);
|
|
567
|
+
const newModels = currentModels.filter((m) => !toRemove.has(m));
|
|
568
|
+
if (newModels.length === 0) {
|
|
569
|
+
delete p.models;
|
|
570
|
+
delete p.model;
|
|
571
|
+
console.log(`Removed all models from profile '${name}'.`);
|
|
572
|
+
} else {
|
|
573
|
+
const removedCount = currentModels.length - newModels.length;
|
|
574
|
+
p.models = newModels;
|
|
575
|
+
p.model = newModels[0];
|
|
576
|
+
console.log(`Removed ${removedCount} model(s) from profile '${name}'.`);
|
|
577
|
+
console.log(`Remaining models: ${newModels.join(", ")}`);
|
|
578
|
+
}
|
|
579
|
+
writeJson(PROFILES_FILE, data);
|
|
580
|
+
});
|
|
71
581
|
profile.command("list").description("List all profiles").action(() => {
|
|
72
582
|
ensureProfilesFile();
|
|
73
583
|
const data = readJson(PROFILES_FILE);
|
|
@@ -78,17 +588,18 @@ function profileCommand() {
|
|
|
78
588
|
return;
|
|
79
589
|
}
|
|
80
590
|
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("", "----", "
|
|
591
|
+
const fmt = (marker, name, model, token, provider, url) => `${marker.padEnd(2)} ${name.padEnd(20)} ${model.padEnd(30)} ${token.padEnd(20)} ${provider.padEnd(12)} ${url}`;
|
|
592
|
+
console.log(fmt("", "NAME", "MODEL(S)", "TOKEN", "PROVIDER", "URL"));
|
|
593
|
+
console.log(fmt("", "----", "--------", "-----", "--------", "---"));
|
|
84
594
|
for (const name of names) {
|
|
85
595
|
const p = profiles[name];
|
|
86
596
|
const marker = name === def ? "* " : " ";
|
|
87
597
|
console.log(fmt(
|
|
88
598
|
marker,
|
|
89
599
|
name,
|
|
90
|
-
p
|
|
600
|
+
formatModels(p),
|
|
91
601
|
maskToken(p.token || ""),
|
|
602
|
+
p.provider || "anthropic",
|
|
92
603
|
p.url || "(default)"
|
|
93
604
|
));
|
|
94
605
|
}
|
|
@@ -104,10 +615,27 @@ function profileCommand() {
|
|
|
104
615
|
if (opts.json) {
|
|
105
616
|
console.log(JSON.stringify({ name, ...p }, null, 2));
|
|
106
617
|
} else {
|
|
107
|
-
console.log(`Name:
|
|
108
|
-
console.log(`Model:
|
|
109
|
-
|
|
110
|
-
|
|
618
|
+
console.log(`Name: ${name}`);
|
|
619
|
+
console.log(`Model: ${p.model || "(unset)"}`);
|
|
620
|
+
if (p.models && p.models.length > 0) {
|
|
621
|
+
const nonAnthropicModels = p.models.filter((m) => !isAnthropicModel(m));
|
|
622
|
+
console.log(`Models:`);
|
|
623
|
+
for (const m of p.models) {
|
|
624
|
+
if (!isAnthropicModel(m)) {
|
|
625
|
+
const aliasIndex = nonAnthropicModels.indexOf(m);
|
|
626
|
+
let alias = "";
|
|
627
|
+
if (aliasIndex === 0) alias = " (opus)";
|
|
628
|
+
else if (aliasIndex === 1) alias = " (sonnet)";
|
|
629
|
+
else if (aliasIndex === 2) alias = " (haiku)";
|
|
630
|
+
console.log(` - ${m}${alias}`);
|
|
631
|
+
} else {
|
|
632
|
+
console.log(` - ${m}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
console.log(`Token: ${p.token || "(unset)"}`);
|
|
637
|
+
console.log(`URL: ${p.url || "(default)"}`);
|
|
638
|
+
console.log(`Provider: ${p.provider || "anthropic"}`);
|
|
111
639
|
}
|
|
112
640
|
});
|
|
113
641
|
profile.command("remove").description("Remove a profile").argument("<name>", "Profile name").action((name) => {
|
|
@@ -134,25 +662,70 @@ function profileCommand() {
|
|
|
134
662
|
});
|
|
135
663
|
return profile;
|
|
136
664
|
}
|
|
665
|
+
function collect(value, previous) {
|
|
666
|
+
return previous.concat([value]);
|
|
667
|
+
}
|
|
137
668
|
function execClaude(profileName, p, extraArgs) {
|
|
669
|
+
updateSettingsForProfile(p);
|
|
670
|
+
const models = p.models || (p.model ? [p.model] : []);
|
|
671
|
+
const firstModel = models[0];
|
|
138
672
|
const cmd = ["claude"];
|
|
139
|
-
if (
|
|
673
|
+
if (firstModel) cmd.push("--model", firstModel);
|
|
140
674
|
cmd.push(...extraArgs);
|
|
141
675
|
const env = {
|
|
142
676
|
...process.env,
|
|
143
677
|
ANTHROPIC_AUTH_TOKEN: p.token || void 0,
|
|
144
678
|
ANTHROPIC_BASE_URL: p.url || void 0
|
|
145
679
|
};
|
|
680
|
+
const nonAnthropicModels = models.filter((m) => !isAnthropicModel(m));
|
|
681
|
+
if (nonAnthropicModels.length > 0) {
|
|
682
|
+
if (nonAnthropicModels[0]) {
|
|
683
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL = nonAnthropicModels[0];
|
|
684
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME = nonAnthropicModels[0];
|
|
685
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION = `Custom: ${nonAnthropicModels[0]}`;
|
|
686
|
+
}
|
|
687
|
+
if (nonAnthropicModels[1]) {
|
|
688
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL = nonAnthropicModels[1];
|
|
689
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME = nonAnthropicModels[1];
|
|
690
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION = `Custom: ${nonAnthropicModels[1]}`;
|
|
691
|
+
}
|
|
692
|
+
if (nonAnthropicModels[2]) {
|
|
693
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = nonAnthropicModels[2];
|
|
694
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME = nonAnthropicModels[2];
|
|
695
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION = `Custom: ${nonAnthropicModels[2]}`;
|
|
696
|
+
}
|
|
697
|
+
env.ANTHROPIC_CUSTOM_MODEL_OPTION = nonAnthropicModels[0];
|
|
698
|
+
}
|
|
146
699
|
delete env.ANTHROPIC_API_KEY;
|
|
147
|
-
console.error(`Using profile '${profileName}': model=${
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
700
|
+
console.error(`Using profile '${profileName}': model=${firstModel || "(default)"} url=${p.url || "(default)"} provider=${p.provider || "anthropic"}`);
|
|
701
|
+
if (p.provider === "openai") {
|
|
702
|
+
const allModels = p.models || (p.model ? [p.model] : []);
|
|
703
|
+
startOpenAIProxy(
|
|
704
|
+
p.url || "https://api.openai.com",
|
|
705
|
+
p.token || "",
|
|
706
|
+
firstModel || "gpt-4o",
|
|
707
|
+
allModels
|
|
708
|
+
).then(({ baseUrl, stop }) => {
|
|
709
|
+
env.ANTHROPIC_BASE_URL = baseUrl;
|
|
710
|
+
const child = spawn(cmd[0], cmd.slice(1), { stdio: "inherit", env });
|
|
711
|
+
child.on("close", (code) => {
|
|
712
|
+
stop();
|
|
713
|
+
process.exit(code ?? 1);
|
|
714
|
+
});
|
|
715
|
+
}).catch((err) => {
|
|
716
|
+
console.error("Failed to start OpenAI proxy:", err);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
});
|
|
719
|
+
} else {
|
|
720
|
+
const result = spawnSync(cmd[0], cmd.slice(1), {
|
|
721
|
+
stdio: "inherit",
|
|
722
|
+
env
|
|
723
|
+
});
|
|
724
|
+
process.exit(result.status ?? 1);
|
|
725
|
+
}
|
|
153
726
|
}
|
|
154
727
|
function useCommand() {
|
|
155
|
-
return new
|
|
728
|
+
return new Command2("use").description("Set a profile as the default").argument("<name>", "Profile name").action((name) => {
|
|
156
729
|
ensureProfilesFile();
|
|
157
730
|
const data = readJson(PROFILES_FILE);
|
|
158
731
|
if (!data.profiles[name]) {
|
|
@@ -165,7 +738,8 @@ function useCommand() {
|
|
|
165
738
|
});
|
|
166
739
|
}
|
|
167
740
|
function runCommand() {
|
|
168
|
-
return new
|
|
741
|
+
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) => {
|
|
742
|
+
fixJsonFile(CLAUDE_JSON);
|
|
169
743
|
ensureProfilesFile();
|
|
170
744
|
const data = readJson(PROFILES_FILE);
|
|
171
745
|
let profileName = "";
|
|
@@ -187,7 +761,7 @@ function runCommand() {
|
|
|
187
761
|
}
|
|
188
762
|
|
|
189
763
|
// src/hooks.ts
|
|
190
|
-
import { Command as
|
|
764
|
+
import { Command as Command3 } from "commander";
|
|
191
765
|
function buildFlat(data) {
|
|
192
766
|
const rows = [];
|
|
193
767
|
const hooksRoot = data.hooks || {};
|
|
@@ -227,7 +801,7 @@ function buildFlat(data) {
|
|
|
227
801
|
return rows;
|
|
228
802
|
}
|
|
229
803
|
function hooksCommand() {
|
|
230
|
-
const hooks = new
|
|
804
|
+
const hooks = new Command3("hook").description("Manage Claude Code hooks in settings.json");
|
|
231
805
|
hooks.command("list").description("List all hooks").action(() => {
|
|
232
806
|
ensureSettingsFile();
|
|
233
807
|
const data = readJson(SETTINGS_FILE);
|
|
@@ -379,7 +953,7 @@ function hooksCommand() {
|
|
|
379
953
|
}
|
|
380
954
|
|
|
381
955
|
// src/sessions.ts
|
|
382
|
-
import { Command as
|
|
956
|
+
import { Command as Command4 } from "commander";
|
|
383
957
|
import fs2 from "fs";
|
|
384
958
|
import path2 from "path";
|
|
385
959
|
import { execSync } from "child_process";
|
|
@@ -463,7 +1037,7 @@ function snippet(text, query, width = 150) {
|
|
|
463
1037
|
return prefix + text.slice(start, end) + suffix;
|
|
464
1038
|
}
|
|
465
1039
|
function sessionCommand() {
|
|
466
|
-
const session = new
|
|
1040
|
+
const session = new Command4("session").description("Manage Claude Code sessions");
|
|
467
1041
|
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
1042
|
const limit = parseInt(opts.limit, 10);
|
|
469
1043
|
let dirs;
|
|
@@ -755,7 +1329,7 @@ function sessionCommand() {
|
|
|
755
1329
|
}
|
|
756
1330
|
|
|
757
1331
|
// src/complete.ts
|
|
758
|
-
import { Command as
|
|
1332
|
+
import { Command as Command5 } from "commander";
|
|
759
1333
|
var ZSH_COMPLETION = `#compdef cc-hub
|
|
760
1334
|
|
|
761
1335
|
_cc-hub() {
|
|
@@ -766,14 +1340,22 @@ _cc-hub() {
|
|
|
766
1340
|
'run:Launch Claude Code using the default or a specified profile'
|
|
767
1341
|
'hook:Manage Claude Code hooks in settings.json'
|
|
768
1342
|
'session:Manage Claude Code sessions'
|
|
1343
|
+
'provider:Manage provider types'
|
|
769
1344
|
'complete:Print shell completion functions'
|
|
770
1345
|
'help:Display help for a command'
|
|
771
1346
|
)
|
|
772
1347
|
|
|
1348
|
+
local -a provider_subcmds
|
|
1349
|
+
provider_subcmds=(
|
|
1350
|
+
'list:List available provider types'
|
|
1351
|
+
)
|
|
1352
|
+
|
|
1353
|
+
|
|
773
1354
|
local -a profile_subcmds
|
|
774
1355
|
profile_subcmds=(
|
|
775
1356
|
'add:Add or update a profile'
|
|
776
1357
|
'update:Update fields of an existing profile'
|
|
1358
|
+
'remove-model:Remove specific models from a profile'
|
|
777
1359
|
'list:List all profiles'
|
|
778
1360
|
'view:View full details of a profile'
|
|
779
1361
|
'remove:Remove a profile'
|
|
@@ -803,16 +1385,21 @@ _cc-hub() {
|
|
|
803
1385
|
local profiles_file="\${CLAUDE_PROFILES_FILE:-$HOME/.claude/profiles.json}"
|
|
804
1386
|
if [[ -f "$profiles_file" ]]; then
|
|
805
1387
|
local -a names
|
|
806
|
-
names=(\${(f)"$(command
|
|
807
|
-
import json
|
|
808
|
-
data = json.load(open('$profiles_file'))
|
|
809
|
-
for name in data.get('profiles', {}):
|
|
810
|
-
print(name)
|
|
811
|
-
" 2>/dev/null)"})
|
|
1388
|
+
names=(\${(f)"$(command jq -r '.profiles | keys[]' "$profiles_file" 2>/dev/null)"})
|
|
812
1389
|
_describe -t profiles 'profile' names
|
|
813
1390
|
fi
|
|
814
1391
|
}
|
|
815
1392
|
|
|
1393
|
+
_cc_hub_models_for_profile() {
|
|
1394
|
+
local profile_name="$1"
|
|
1395
|
+
local profiles_file="\${CLAUDE_PROFILES_FILE:-$HOME/.claude/profiles.json}"
|
|
1396
|
+
if [[ -f "$profiles_file" && -n "$profile_name" ]]; then
|
|
1397
|
+
local -a models
|
|
1398
|
+
models=(\${(f)"$(command jq -r --arg p "$profile_name" '(.profiles[$p].models // [ .profiles[$p].model ] )[]? // empty' "$profiles_file" 2>/dev/null)"})
|
|
1399
|
+
_describe -t models 'model' models
|
|
1400
|
+
fi
|
|
1401
|
+
}
|
|
1402
|
+
|
|
816
1403
|
_arguments -C \\
|
|
817
1404
|
'1: :->command' \\
|
|
818
1405
|
'*::arg:->args'
|
|
@@ -826,8 +1413,26 @@ for name in data.get('profiles', {}):
|
|
|
826
1413
|
profile)
|
|
827
1414
|
if (( CURRENT == 2 )); then
|
|
828
1415
|
_describe -t profile-subcmds 'profile subcommand' profile_subcmds
|
|
829
|
-
elif [[ $words[2] == "view" || $words[2] == "remove" || $words[2] == "default" || $words[2] == "
|
|
1416
|
+
elif [[ $words[2] == "view" || $words[2] == "remove" || $words[2] == "default" || $words[2] == "remove-model" ]]; then
|
|
830
1417
|
_cc_hub_profiles
|
|
1418
|
+
elif [[ $words[2] == "update" ]]; then
|
|
1419
|
+
if (( CURRENT == 3 )); then
|
|
1420
|
+
_cc_hub_profiles
|
|
1421
|
+
else
|
|
1422
|
+
words=("stub" $words[3,-1])
|
|
1423
|
+
(( CURRENT-- ))
|
|
1424
|
+
_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)'
|
|
1425
|
+
case $state in
|
|
1426
|
+
profileModel)
|
|
1427
|
+
_cc_hub_models_for_profile $line[1]
|
|
1428
|
+
;;
|
|
1429
|
+
esac
|
|
1430
|
+
fi
|
|
1431
|
+
fi
|
|
1432
|
+
;;
|
|
1433
|
+
provider)
|
|
1434
|
+
if (( CURRENT == 2 )); then
|
|
1435
|
+
_describe -t provider-subcmds 'provider subcommand' provider_subcmds
|
|
831
1436
|
fi
|
|
832
1437
|
;;
|
|
833
1438
|
use|run)
|
|
@@ -864,14 +1469,39 @@ for name in data.get('profiles', {}):
|
|
|
864
1469
|
fi
|
|
865
1470
|
}
|
|
866
1471
|
|
|
1472
|
+
_cc-hub_models_for_profile() {
|
|
1473
|
+
local profile_name="$1"
|
|
1474
|
+
local profiles_file="\${CLAUDE_PROFILES_FILE:-$HOME/.claude/profiles.json}"
|
|
1475
|
+
if [[ -f "$profiles_file" && -n "$profile_name" ]]; then
|
|
1476
|
+
local models
|
|
1477
|
+
models=$(command python3 -c "
|
|
1478
|
+
import json
|
|
1479
|
+
data = json.load(open('$profiles_file'))
|
|
1480
|
+
p = data.get('profiles', {}).get('$profile_name', {})
|
|
1481
|
+
models = p.get('models')
|
|
1482
|
+
if isinstance(models, list):
|
|
1483
|
+
for m in models:
|
|
1484
|
+
if m:
|
|
1485
|
+
print(m)
|
|
1486
|
+
else:
|
|
1487
|
+
m = p.get('model')
|
|
1488
|
+
if m:
|
|
1489
|
+
print(m)
|
|
1490
|
+
" 2>/dev/null)
|
|
1491
|
+
COMPREPLY=($(compgen -W "$models" -- "\${cur}"))
|
|
1492
|
+
fi
|
|
1493
|
+
}
|
|
1494
|
+
|
|
867
1495
|
_cc-hub() {
|
|
868
1496
|
local cur prev commands
|
|
869
1497
|
COMPREPLY=()
|
|
870
1498
|
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
871
1499
|
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
872
|
-
commands="profile use run hook session complete help"
|
|
1500
|
+
commands="profile use run hook session provider complete help"
|
|
873
1501
|
|
|
874
|
-
local profile_subcmds="add update list view remove default"
|
|
1502
|
+
local profile_subcmds="add update remove-model list view remove default"
|
|
1503
|
+
local provider_subcmds="list"
|
|
1504
|
+
local provider_types="anthropic openai"
|
|
875
1505
|
local hooks_subcmds="list add remove enable disable"
|
|
876
1506
|
local session_subcmds="list show search ps stats clean"
|
|
877
1507
|
|
|
@@ -887,10 +1517,28 @@ _cc-hub() {
|
|
|
887
1517
|
profile)
|
|
888
1518
|
if [[ \${COMP_CWORD} -eq 2 ]]; then
|
|
889
1519
|
COMPREPLY=($(compgen -W "$profile_subcmds" -- "$cur"))
|
|
890
|
-
elif [[ "$prev" == "view" || "$prev" == "remove" || "$prev" == "default" || "$prev" == "
|
|
1520
|
+
elif [[ "$prev" == "view" || "$prev" == "remove" || "$prev" == "default" || "$prev" == "remove-model" ]]; then
|
|
891
1521
|
_cc-hub_profiles
|
|
892
1522
|
elif [[ "$prev" == "profile" ]]; then
|
|
893
1523
|
COMPREPLY=($(compgen -W "$profile_subcmds" -- "$cur"))
|
|
1524
|
+
elif [[ "\${COMP_WORDS[2]}" == "update" && \${COMP_CWORD} -eq 3 ]]; then
|
|
1525
|
+
_cc-hub_profiles
|
|
1526
|
+
elif [[ "\${COMP_WORDS[2]}" == "update" ]]; then
|
|
1527
|
+
if [[ "$prev" == "--provider" || "$prev" == "-p" ]]; then
|
|
1528
|
+
COMPREPLY=($(compgen -W "$provider_types" -- "$cur"))
|
|
1529
|
+
elif [[ "$prev" == "--model" || "$prev" == "-m" || "$prev" == "--delete-model" || "$prev" == "-d" ]]; then
|
|
1530
|
+
_cc-hub_models_for_profile "\${COMP_WORDS[3]}"
|
|
1531
|
+
else
|
|
1532
|
+
local update_opts="--model -m --delete-model -d --token -t --url -u --provider -p"
|
|
1533
|
+
COMPREPLY=($(compgen -W "$update_opts" -- "$cur"))
|
|
1534
|
+
fi
|
|
1535
|
+
fi
|
|
1536
|
+
;;
|
|
1537
|
+
provider)
|
|
1538
|
+
if [[ \${COMP_CWORD} -eq 2 ]]; then
|
|
1539
|
+
COMPREPLY=($(compgen -W "$provider_subcmds" -- "$cur"))
|
|
1540
|
+
elif [[ "$prev" == "provider" ]]; then
|
|
1541
|
+
COMPREPLY=($(compgen -W "$provider_subcmds" -- "$cur"))
|
|
894
1542
|
fi
|
|
895
1543
|
;;
|
|
896
1544
|
use|run)
|
|
@@ -914,7 +1562,7 @@ _cc-hub() {
|
|
|
914
1562
|
complete -F _cc-hub cc-hub
|
|
915
1563
|
`;
|
|
916
1564
|
function completeCommand() {
|
|
917
|
-
return new
|
|
1565
|
+
return new Command5("complete").description("Print shell completion script").argument("<shell>", "Shell type: bash or zsh").action((shell) => {
|
|
918
1566
|
switch (shell) {
|
|
919
1567
|
case "zsh":
|
|
920
1568
|
process.stdout.write(ZSH_COMPLETION);
|
|
@@ -930,7 +1578,7 @@ function completeCommand() {
|
|
|
930
1578
|
}
|
|
931
1579
|
|
|
932
1580
|
// src/index.ts
|
|
933
|
-
var program = new
|
|
1581
|
+
var program = new Command6();
|
|
934
1582
|
program.name("cc-hub").description("Manage Claude CLI profiles, hooks, and sessions").version("1.0.0");
|
|
935
1583
|
program.addCommand(profileCommand());
|
|
936
1584
|
program.addCommand(useCommand());
|
|
@@ -938,4 +1586,5 @@ program.addCommand(runCommand());
|
|
|
938
1586
|
program.addCommand(hooksCommand());
|
|
939
1587
|
program.addCommand(sessionCommand());
|
|
940
1588
|
program.addCommand(completeCommand());
|
|
1589
|
+
program.addCommand(providerCommand());
|
|
941
1590
|
program.parse();
|