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.
- package/README.md +65 -1
- package/dist/index.js +715 -42
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
108
|
-
console.log(`Model:
|
|
109
|
-
|
|
110
|
-
|
|
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 (
|
|
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=${
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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] == "
|
|
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" == "
|
|
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
|
|
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
|
|
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.
|
|
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"
|