cc-hub 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -0
- package/README.zh-CN.md +92 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +6 -0
- package/dist/components/ui/modal.d.ts +10 -0
- package/dist/components/ui/modal.js +11 -0
- package/dist/components/ui/status-bar.d.ts +15 -0
- package/dist/components/ui/status-bar.js +6 -0
- package/dist/components/ui/tab-bar.d.ts +14 -0
- package/dist/components/ui/tab-bar.js +10 -0
- package/dist/components/ui/table.d.ts +36 -0
- package/dist/components/ui/table.js +79 -0
- package/dist/config/presets.d.ts +2 -0
- package/dist/config/presets.js +57 -0
- package/dist/store/claude-config.d.ts +5 -0
- package/dist/store/claude-config.js +79 -0
- package/dist/store/config-store.d.ts +15 -0
- package/dist/store/config-store.js +138 -0
- package/dist/store/model-store.d.ts +8 -0
- package/dist/store/model-store.js +76 -0
- package/dist/test-escape-compiled.d.ts +1 -0
- package/dist/test-escape-compiled.js +17 -0
- package/dist/tui/add-model.d.ts +7 -0
- package/dist/tui/add-model.js +102 -0
- package/dist/tui/add-provider.d.ts +13 -0
- package/dist/tui/add-provider.js +185 -0
- package/dist/tui/app.d.ts +1 -0
- package/dist/tui/app.js +163 -0
- package/dist/tui/confirm.d.ts +7 -0
- package/dist/tui/confirm.js +5 -0
- package/dist/tui/dashboard.d.ts +10 -0
- package/dist/tui/dashboard.js +45 -0
- package/dist/tui/edit-model.d.ts +8 -0
- package/dist/tui/edit-model.js +54 -0
- package/dist/tui/edit-provider.d.ts +12 -0
- package/dist/tui/edit-provider.js +140 -0
- package/dist/tui/scenario-config.d.ts +8 -0
- package/dist/tui/scenario-config.js +87 -0
- package/dist/types.d.ts +19 -0
- package/dist/types.js +2 -0
- package/package.json +45 -0
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// src/tui/app.tsx
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { useApp, useInput, Box, Text } from "ink";
|
|
5
|
+
import { loadConfig, saveConfig, setActiveModel, findModel, updateScenarioModels, removeModelFromProvider, getConfigError, clearConfigError, } from "../store/config-store.js";
|
|
6
|
+
import { activateModel } from "../store/claude-config.js";
|
|
7
|
+
import { Dashboard } from "./dashboard.js";
|
|
8
|
+
import { ScenarioConfig } from "./scenario-config.js";
|
|
9
|
+
import { Modal } from "../components/ui/modal.js";
|
|
10
|
+
export function App() {
|
|
11
|
+
const { exit } = useApp();
|
|
12
|
+
const [store, setStore] = React.useState(() => loadConfig());
|
|
13
|
+
const [screen, setScreen] = React.useState({ type: "dashboard" });
|
|
14
|
+
const [statusMessage, setStatusMessage] = React.useState(() => getConfigError());
|
|
15
|
+
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
16
|
+
// Auto-clear status message
|
|
17
|
+
React.useEffect(() => {
|
|
18
|
+
if (!statusMessage)
|
|
19
|
+
return;
|
|
20
|
+
const timer = setTimeout(() => setStatusMessage(null), 3000);
|
|
21
|
+
return () => clearTimeout(timer);
|
|
22
|
+
}, [statusMessage]);
|
|
23
|
+
React.useEffect(() => {
|
|
24
|
+
clearConfigError();
|
|
25
|
+
}, []);
|
|
26
|
+
// Model-only rows for navigation (no headers)
|
|
27
|
+
function getModelRows() {
|
|
28
|
+
return store.providers.flatMap((p) => p.models.map((m) => ({ providerId: p.id, modelId: m })));
|
|
29
|
+
}
|
|
30
|
+
React.useEffect(() => {
|
|
31
|
+
const n = getModelRows().length;
|
|
32
|
+
if (n === 0)
|
|
33
|
+
return;
|
|
34
|
+
setSelectedIndex((prev) => Math.max(0, Math.min(prev, n - 1)));
|
|
35
|
+
}, [store.providers.length]);
|
|
36
|
+
// --- Action handlers ---
|
|
37
|
+
const handleSelect = React.useCallback((modelId) => {
|
|
38
|
+
const updated = setActiveModel(store, modelId);
|
|
39
|
+
// Always sync scenarioModels to the selected model
|
|
40
|
+
const withScenarios = updateScenarioModels(updated, {
|
|
41
|
+
opusModelId: modelId,
|
|
42
|
+
sonnetModelId: modelId,
|
|
43
|
+
haikuModelId: modelId,
|
|
44
|
+
subagentModelId: modelId,
|
|
45
|
+
});
|
|
46
|
+
setStore(withScenarios);
|
|
47
|
+
saveConfig(withScenarios);
|
|
48
|
+
const resolved = findModel(withScenarios, modelId);
|
|
49
|
+
if (resolved) {
|
|
50
|
+
activateModel(withScenarios, modelId, withScenarios.scenarioModels);
|
|
51
|
+
}
|
|
52
|
+
}, [store]);
|
|
53
|
+
const handleDelete = React.useCallback((providerId, modelId) => {
|
|
54
|
+
const provider = store.providers.find((p) => p.id === providerId);
|
|
55
|
+
if (!provider || !provider.models.includes(modelId))
|
|
56
|
+
return;
|
|
57
|
+
setScreen({
|
|
58
|
+
type: "confirm",
|
|
59
|
+
message: `Delete model "${modelId}"?`,
|
|
60
|
+
onConfirm: () => {
|
|
61
|
+
let updated = removeModelFromProvider(store, providerId, modelId);
|
|
62
|
+
// Clean up scenarioModels references to the deleted model
|
|
63
|
+
const sm = updated.scenarioModels;
|
|
64
|
+
if (sm.opusModelId === modelId ||
|
|
65
|
+
sm.sonnetModelId === modelId ||
|
|
66
|
+
sm.haikuModelId === modelId ||
|
|
67
|
+
sm.subagentModelId === modelId) {
|
|
68
|
+
updated = {
|
|
69
|
+
...updated,
|
|
70
|
+
scenarioModels: {
|
|
71
|
+
opusModelId: sm.opusModelId === modelId ? undefined : sm.opusModelId,
|
|
72
|
+
sonnetModelId: sm.sonnetModelId === modelId ? undefined : sm.sonnetModelId,
|
|
73
|
+
haikuModelId: sm.haikuModelId === modelId ? undefined : sm.haikuModelId,
|
|
74
|
+
subagentModelId: sm.subagentModelId === modelId
|
|
75
|
+
? undefined
|
|
76
|
+
: sm.subagentModelId,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
setStore(updated);
|
|
81
|
+
saveConfig(updated);
|
|
82
|
+
setScreen({ type: "dashboard" });
|
|
83
|
+
setStatusMessage(`Deleted ${modelId}`);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}, [store]);
|
|
87
|
+
const handleConfirmCancel = React.useCallback(() => setScreen({ type: "dashboard" }), []);
|
|
88
|
+
const handleScenarioSave = React.useCallback((updates) => {
|
|
89
|
+
const updated = {
|
|
90
|
+
...store,
|
|
91
|
+
scenarioModels: updates,
|
|
92
|
+
};
|
|
93
|
+
setStore(updated);
|
|
94
|
+
saveConfig(updated);
|
|
95
|
+
// Stay on scenario page, only Esc goes back to dashboard
|
|
96
|
+
if (updated.activeModelId) {
|
|
97
|
+
activateModel(updated, updated.activeModelId, updated.scenarioModels);
|
|
98
|
+
}
|
|
99
|
+
}, [store]);
|
|
100
|
+
const handleScenarioCancel = React.useCallback(() => setScreen({ type: "dashboard" }), []);
|
|
101
|
+
// --- Centralized keyboard handling ---
|
|
102
|
+
useInput((input, key) => {
|
|
103
|
+
if (input === "q") {
|
|
104
|
+
exit();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
switch (screen.type) {
|
|
108
|
+
case "dashboard": {
|
|
109
|
+
if (input === "s") {
|
|
110
|
+
setScreen({ type: "scenario" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key.escape) {
|
|
114
|
+
exit();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (key.upArrow || key.downArrow) {
|
|
118
|
+
const models = getModelRows();
|
|
119
|
+
if (models.length === 0)
|
|
120
|
+
return;
|
|
121
|
+
const nextPos = key.upArrow
|
|
122
|
+
? (selectedIndex - 1 + models.length) % models.length
|
|
123
|
+
: (selectedIndex + 1) % models.length;
|
|
124
|
+
setSelectedIndex(nextPos);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (key.return) {
|
|
128
|
+
const models = getModelRows();
|
|
129
|
+
const row = models[selectedIndex];
|
|
130
|
+
if (row)
|
|
131
|
+
handleSelect(row.modelId);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (input === "d") {
|
|
135
|
+
const models = getModelRows();
|
|
136
|
+
const row = models[selectedIndex];
|
|
137
|
+
if (row) {
|
|
138
|
+
handleDelete(row.providerId, row.modelId);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
case "scenario": {
|
|
145
|
+
// ScenarioConfig handles its own keyboard input (↑↓←→Enter Esc)
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
case "confirm": {
|
|
149
|
+
if (input === "y" || input === "Y") {
|
|
150
|
+
screen.onConfirm();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (input === "n" || input === "N" || input === "escape") {
|
|
154
|
+
setScreen({ type: "dashboard" });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
// --- Render ---
|
|
162
|
+
return (_jsxs(Box, { flexDirection: "column", children: [statusMessage && (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: "green", children: ["\u2713 ", statusMessage] }) })), screen.type === "dashboard" && (_jsx(Dashboard, { store: store, selectedIndex: selectedIndex, onSelect: handleSelect, onDelete: handleDelete, onScenario: () => setScreen({ type: "scenario" }) })), screen.type === "scenario" && (_jsx(ScenarioConfig, { store: store, onSave: handleScenarioSave, onCancel: handleScenarioCancel })), screen.type === "confirm" && (_jsx(Modal, { title: "Confirm", borderColor: "yellow", borderStyle: "round", onClose: () => setScreen({ type: "dashboard" }), children: _jsxs(Box, { paddingX: 2, paddingY: 1, flexDirection: "column", children: [_jsx(Text, { children: screen.message }), _jsxs(Box, { marginTop: 1, gap: 2, children: [_jsxs(Text, { children: [_jsxs(Text, { inverse: true, bold: true, children: [" ", "Y", " "] }), _jsx(Text, { dimColor: true, children: " Yes" })] }), _jsxs(Text, { children: [_jsxs(Text, { inverse: true, bold: true, children: [" ", "N", " "] }), _jsx(Text, { dimColor: true, children: " No" })] }), _jsxs(Text, { children: [_jsxs(Text, { inverse: true, bold: true, children: [" ", "Esc", " "] }), _jsx(Text, { dimColor: true, children: " Cancel" })] })] })] }) }))] }));
|
|
163
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
export function Confirm({ message, onConfirm, onCancel }) {
|
|
4
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { children: message }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "[Y]" }), _jsx(Text, { children: "es " }), _jsx(Text, { color: "red", children: "[N]" }), _jsx(Text, { children: "o" })] })] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ConfigStore } from "../types.js";
|
|
2
|
+
interface DashboardProps {
|
|
3
|
+
store: ConfigStore;
|
|
4
|
+
selectedIndex: number;
|
|
5
|
+
onSelect: (modelId: string) => void;
|
|
6
|
+
onDelete: (providerId: string, modelId: string) => void;
|
|
7
|
+
onScenario: () => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function Dashboard({ store, selectedIndex }: DashboardProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// src/tui/dashboard.tsx
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { Table } from "../components/ui/table.js";
|
|
5
|
+
import { StatusBar } from "../components/ui/status-bar.js";
|
|
6
|
+
export function Dashboard({ store, selectedIndex }) {
|
|
7
|
+
const rows = [];
|
|
8
|
+
for (const p of store.providers) {
|
|
9
|
+
for (const m of p.models) {
|
|
10
|
+
rows.push({
|
|
11
|
+
providerName: p.name,
|
|
12
|
+
modelId: m,
|
|
13
|
+
active: m === store.activeModelId,
|
|
14
|
+
isSelected: rows.length === selectedIndex,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: "cc-hub" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "dim", children: "Select a model to switch your active Claude Code configuration." }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: rows.length === 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "dim", children: "No providers configured." }), _jsxs(Text, { color: "dim", children: ["Edit ", _jsx(Text, { color: "cyan", children: "~/.cc-hub/config.json" }), " to add providers, then restart."] })] })) : (_jsx(Table, { columns: [
|
|
19
|
+
{ header: "Provider", minWidth: 10 },
|
|
20
|
+
{ header: "Model", minWidth: 24 },
|
|
21
|
+
{ header: "Active", width: 8 },
|
|
22
|
+
], rows: rows.map((row) => [
|
|
23
|
+
{
|
|
24
|
+
text: row.providerName,
|
|
25
|
+
color: row.isSelected ? "green" : undefined,
|
|
26
|
+
bold: row.isSelected,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
text: row.modelId,
|
|
30
|
+
color: row.isSelected ? "green" : undefined,
|
|
31
|
+
bold: row.isSelected,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
text: row.active ? "●" : "",
|
|
35
|
+
color: row.active ? "green" : undefined,
|
|
36
|
+
bold: row.active,
|
|
37
|
+
},
|
|
38
|
+
]) })) }), _jsx(Box, { marginTop: 1, children: _jsx(StatusBar, { items: [
|
|
39
|
+
{ key: "Enter", label: "Switch" },
|
|
40
|
+
{ key: "↑↓", label: "Navigate" },
|
|
41
|
+
{ key: "d", label: "Delete" },
|
|
42
|
+
{ key: "s", label: "Scenarios" },
|
|
43
|
+
{ key: "q", label: "Quit" },
|
|
44
|
+
] }) })] }));
|
|
45
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ModelConfig } from "../types.js";
|
|
2
|
+
interface EditModelProps {
|
|
3
|
+
model: ModelConfig;
|
|
4
|
+
onSave: (updates: Partial<ModelConfig>) => void;
|
|
5
|
+
onCancel: () => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function EditModel({ model, onSave, onCancel }: EditModelProps): import("react/jsx-runtime").JSX.Element | null;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// src/tui/edit-model.tsx
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Box, Text, useInput } from "ink";
|
|
5
|
+
import TextInput from "ink-text-input";
|
|
6
|
+
export function EditModel({ model, onSave, onCancel }) {
|
|
7
|
+
const [field, setField] = React.useState("name");
|
|
8
|
+
const [name, setName] = React.useState(model.name);
|
|
9
|
+
const [baseUrl, setBaseUrl] = React.useState(model.baseUrl);
|
|
10
|
+
const [apiKey, setApiKey] = React.useState(model.apiKey);
|
|
11
|
+
const [modelId, setModelId] = React.useState(model.modelId || "");
|
|
12
|
+
// Handle escape BEFORE TextInput's useInput can process it
|
|
13
|
+
useInput((_, key) => {
|
|
14
|
+
if (key.escape) {
|
|
15
|
+
onCancel();
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
if (field === "done") {
|
|
19
|
+
onSave({ name, baseUrl, apiKey, modelId: modelId || undefined });
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const fields = [
|
|
23
|
+
{ key: "name", label: "Name" },
|
|
24
|
+
{ key: "baseUrl", label: "Base URL" },
|
|
25
|
+
{ key: "apiKey", label: "API Key" },
|
|
26
|
+
{ key: "modelId", label: "Model ID (optional)" },
|
|
27
|
+
];
|
|
28
|
+
const valueMap = { name, baseUrl, apiKey, modelId };
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["Edit Model: ", model.id] }), _jsx(Text, { children: "─".repeat(50) }), fields.map((f) => (_jsxs(Box, { children: [_jsx(Text, { children: f.key === field ? "> " : " " }), _jsx(Text, { color: "dim", children: f.label.padEnd(16) }), f.key === field ? (_jsx(Text, { color: "green", children: maskIf(f.key === "apiKey", valueMap[f.key]) })) : (_jsx(Text, { color: "dim", children: maskIf(f.key === "apiKey", valueMap[f.key]) }))] }, f.key))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(TextInput, { value: valueMap[field], onChange: (v) => {
|
|
30
|
+
if (field === "name")
|
|
31
|
+
setName(v);
|
|
32
|
+
else if (field === "baseUrl")
|
|
33
|
+
setBaseUrl(v);
|
|
34
|
+
else if (field === "apiKey")
|
|
35
|
+
setApiKey(v);
|
|
36
|
+
else if (field === "modelId")
|
|
37
|
+
setModelId(v);
|
|
38
|
+
}, onSubmit: () => {
|
|
39
|
+
const idx = fields.findIndex((f) => f.key === field);
|
|
40
|
+
if (idx < fields.length - 1) {
|
|
41
|
+
setField(fields[idx + 1].key);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
setField("done");
|
|
45
|
+
}
|
|
46
|
+
}, focus: false }), _jsx(Text, { color: "dim", children: "Press Enter for next field, Esc to cancel" })] })] }));
|
|
47
|
+
}
|
|
48
|
+
function maskIf(shouldMask, value) {
|
|
49
|
+
if (!shouldMask)
|
|
50
|
+
return value;
|
|
51
|
+
if (value.length <= 6)
|
|
52
|
+
return "••••••";
|
|
53
|
+
return value.slice(0, 3) + "••••••" + value.slice(-3);
|
|
54
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Provider } from "../types.js";
|
|
2
|
+
interface EditProviderProps {
|
|
3
|
+
provider: Provider;
|
|
4
|
+
onSave: (providerId: string, updates: {
|
|
5
|
+
name?: string;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
}) => void;
|
|
9
|
+
onCancel: () => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function EditProvider({ provider, onSave, onCancel }: EditProviderProps): import("react/jsx-runtime").JSX.Element | null;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// src/tui/edit-provider.tsx
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Box, Text, useInput } from "ink";
|
|
5
|
+
import TextInput from "ink-text-input";
|
|
6
|
+
export function EditProvider({ provider, onSave, onCancel }) {
|
|
7
|
+
const [step, setStep] = React.useState("provider-name");
|
|
8
|
+
const [name, setName] = React.useState(provider.name);
|
|
9
|
+
const [baseUrl, setBaseUrl] = React.useState(provider.baseUrl);
|
|
10
|
+
const [apiKey, setApiKey] = React.useState(provider.apiKey);
|
|
11
|
+
const [modelList, setModelList] = React.useState(provider.models);
|
|
12
|
+
const [modelIndex, setModelIndex] = React.useState(0);
|
|
13
|
+
const [pendingModel, setPendingModel] = React.useState({ name: "", modelId: "", id: "" });
|
|
14
|
+
const [addName, setAddName] = React.useState("");
|
|
15
|
+
const [addModelId, setAddModelId] = React.useState("");
|
|
16
|
+
// Global keyboard handling
|
|
17
|
+
useInput((input, key) => {
|
|
18
|
+
if (key.escape) {
|
|
19
|
+
if (step.startsWith("edit-model-")) {
|
|
20
|
+
setStep("models");
|
|
21
|
+
}
|
|
22
|
+
else if (step.startsWith("add-model-")) {
|
|
23
|
+
setStep("models");
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
onCancel();
|
|
27
|
+
}
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
switch (step) {
|
|
31
|
+
case "models": {
|
|
32
|
+
if (key.upArrow) {
|
|
33
|
+
setModelIndex((i) => Math.max(0, i - 1));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (key.downArrow) {
|
|
37
|
+
setModelIndex((i) => Math.min(modelList.length - 1, i + 1));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (input === "e" && modelList.length > 0) {
|
|
41
|
+
const m = modelList[modelIndex];
|
|
42
|
+
setPendingModel({ name: m.name, modelId: m.modelId, id: m.id });
|
|
43
|
+
setStep("edit-model-name");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (input === "a") {
|
|
47
|
+
setAddName("");
|
|
48
|
+
setAddModelId("");
|
|
49
|
+
setStep("add-model-name");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (input === "d" && modelList.length > 0) {
|
|
53
|
+
setModelList((list) => list.filter((_, i) => i !== modelIndex));
|
|
54
|
+
setModelIndex((i) => Math.max(0, Math.min(i, modelList.length - 2)));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (key.return) {
|
|
58
|
+
setStep("done");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
if (step === "done") {
|
|
66
|
+
onSave(provider.id, { name, baseUrl, apiKey });
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
// Provider field editing steps
|
|
70
|
+
if (step === "provider-name" || step === "provider-baseurl" || step === "provider-apikey") {
|
|
71
|
+
const fieldMap = {
|
|
72
|
+
"provider-name": { label: "Provider name", value: name, set: setName, next: "provider-baseurl" },
|
|
73
|
+
"provider-baseurl": { label: "Base URL", value: baseUrl, set: setBaseUrl, next: "provider-apikey" },
|
|
74
|
+
"provider-apikey": { label: "API Key", value: apiKey, set: setApiKey, next: "models" },
|
|
75
|
+
};
|
|
76
|
+
const f = fieldMap[step];
|
|
77
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["Edit Provider: ", provider.id] }), _jsx(Text, { children: "─".repeat(40) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "> " }), _jsxs(Text, { bold: true, children: [f.label, ": "] }), _jsx(Text, { color: "green", children: step === "provider-apikey" ? maskValue(f.value) : f.value })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(TextInput, { value: f.value, onChange: f.set, onSubmit: () => setStep(f.next), focus: false }), _jsx(Text, { color: "dim", children: "Press Enter for next field" })] })] }));
|
|
78
|
+
}
|
|
79
|
+
// Models list
|
|
80
|
+
if (step === "models") {
|
|
81
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["Edit Provider: ", name] }), _jsx(Text, { children: "─".repeat(50) }), modelList.length === 0 ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "dim", children: "No models. Press " }), _jsx(Text, { color: "green", bold: true, children: "a" }), _jsx(Text, { color: "dim", children: " to add one." })] })) : (modelList.map((m, i) => (_jsxs(Box, { children: [_jsx(Text, { children: i === modelIndex ? "> " : " " }), _jsx(Text, { color: "dim", children: m.id.padEnd(20) }), _jsx(Text, { children: m.name }), _jsxs(Text, { color: "dim", children: [" (", m.modelId, ")"] })] }, m.id)))), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "dim", children: [_jsx(Text, { color: "green", bold: true, children: "e" }), ": Edit", " ", _jsx(Text, { color: "green", bold: true, children: "a" }), ": Add", " ", _jsx(Text, { color: "green", bold: true, children: "d" }), ": Delete", " ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), ": Done", " ", _jsx(Text, { color: "green", bold: true, children: "Esc" }), ": Cancel"] }) })] }));
|
|
82
|
+
}
|
|
83
|
+
// Edit model fields
|
|
84
|
+
if (step.startsWith("edit-model-")) {
|
|
85
|
+
const editFields = [
|
|
86
|
+
{ key: "edit-model-name", label: "Name", getValue: () => pendingModel.name, setValue: (v) => setPendingModel((p) => ({ ...p, name: v })), next: "edit-model-modelId" },
|
|
87
|
+
{ key: "edit-model-modelId", label: "Model ID", getValue: () => pendingModel.modelId, setValue: (v) => setPendingModel((p) => ({ ...p, modelId: v })), next: "edit-model-id" },
|
|
88
|
+
{ key: "edit-model-id", label: "Local ID", getValue: () => pendingModel.id, setValue: (v) => setPendingModel((p) => ({ ...p, id: v })), next: "models" },
|
|
89
|
+
];
|
|
90
|
+
const current = editFields.find((f) => f.key === step);
|
|
91
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Edit Model" }), _jsx(Text, { children: "─".repeat(40) }), editFields.map((f) => (_jsxs(Box, { children: [_jsx(Text, { children: f.key === step ? "> " : " " }), _jsx(Text, { color: "dim", children: f.label.padEnd(12) }), _jsx(Text, { color: f.key === step ? "green" : "dim", children: f.getValue() })] }, f.key))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(TextInput, { value: current.getValue(), onChange: current.setValue, onSubmit: () => {
|
|
92
|
+
const idx = editFields.findIndex((f) => f.key === step);
|
|
93
|
+
if (idx < editFields.length - 1) {
|
|
94
|
+
setStep(editFields[idx + 1].key);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
setModelList((list) => list.map((m) => m.id === pendingModel.id
|
|
98
|
+
? { id: pendingModel.id, name: pendingModel.name, modelId: pendingModel.modelId }
|
|
99
|
+
: m));
|
|
100
|
+
setStep("models");
|
|
101
|
+
}
|
|
102
|
+
}, focus: false }), _jsx(Text, { color: "dim", children: "Press Enter for next field" })] })] }));
|
|
103
|
+
}
|
|
104
|
+
// Add model fields
|
|
105
|
+
if (step.startsWith("add-model-")) {
|
|
106
|
+
const addFields = [
|
|
107
|
+
{ key: "add-model-name", label: "Name", value: addName, set: setAddName, next: "add-model-modelId" },
|
|
108
|
+
{ key: "add-model-modelId", label: "Model ID", value: addModelId, set: setAddModelId, next: "models" },
|
|
109
|
+
];
|
|
110
|
+
const current = addFields.find((f) => f.key === step);
|
|
111
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Add Model" }), _jsx(Text, { children: "─".repeat(40) }), addFields.map((f) => (_jsxs(Box, { children: [_jsx(Text, { children: f.key === step ? "> " : " " }), _jsx(Text, { color: "dim", children: f.label.padEnd(12) }), _jsx(Text, { color: f.key === step ? "green" : "dim", children: f.value || "(empty)" })] }, f.key))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(TextInput, { value: current.value, onChange: current.set, onSubmit: () => {
|
|
112
|
+
const idx = addFields.findIndex((f) => f.key === step);
|
|
113
|
+
if (idx < addFields.length - 1) {
|
|
114
|
+
setStep(addFields[idx + 1].key);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
if (addName.trim()) {
|
|
118
|
+
const id = toKebabCase(addName);
|
|
119
|
+
setModelList((list) => [
|
|
120
|
+
...list,
|
|
121
|
+
{ id, name: addName.trim(), modelId: addModelId.trim() || id },
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
setStep("models");
|
|
125
|
+
}
|
|
126
|
+
}, focus: false }), _jsx(Text, { color: "dim", children: "Press Enter for next field" })] })] }));
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
function maskValue(value) {
|
|
131
|
+
if (value.length <= 6)
|
|
132
|
+
return "••••••";
|
|
133
|
+
return value.slice(0, 3) + "••••••" + value.slice(-3);
|
|
134
|
+
}
|
|
135
|
+
function toKebabCase(str) {
|
|
136
|
+
return str
|
|
137
|
+
.toLowerCase()
|
|
138
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
139
|
+
.replace(/^-|-$/g, "");
|
|
140
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ConfigStore, ScenarioModels } from "../types.js";
|
|
2
|
+
interface ScenarioConfigProps {
|
|
3
|
+
store: ConfigStore;
|
|
4
|
+
onSave: (updates: ScenarioModels) => void;
|
|
5
|
+
onCancel: () => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function ScenarioConfig({ store, onSave, onCancel, }: ScenarioConfigProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// src/tui/scenario-config.tsx
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Box, Text, useInput } from "ink";
|
|
5
|
+
import { Table } from "../components/ui/table.js";
|
|
6
|
+
import { StatusBar } from "../components/ui/status-bar.js";
|
|
7
|
+
const FIELDS = [
|
|
8
|
+
{ key: "opusModelId", label: "Opus", envVar: "ANTHROPIC_DEFAULT_OPUS_MODEL" },
|
|
9
|
+
{
|
|
10
|
+
key: "sonnetModelId",
|
|
11
|
+
label: "Sonnet",
|
|
12
|
+
envVar: "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
key: "haikuModelId",
|
|
16
|
+
label: "Haiku",
|
|
17
|
+
envVar: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
key: "subagentModelId",
|
|
21
|
+
label: "Subagent",
|
|
22
|
+
envVar: "CLAUDE_CODE_SUBAGENT_MODEL",
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
export function ScenarioConfig({ store, onSave, onCancel, }) {
|
|
26
|
+
const activeProvider = store.providers.find((p) => p.id === store.activeProviderId);
|
|
27
|
+
const allModels = activeProvider ? activeProvider.models : [];
|
|
28
|
+
const [index, setIndex] = React.useState(0);
|
|
29
|
+
const [values, setValues] = React.useState({
|
|
30
|
+
opusModelId: store.scenarioModels.opusModelId,
|
|
31
|
+
sonnetModelId: store.scenarioModels.sonnetModelId,
|
|
32
|
+
haikuModelId: store.scenarioModels.haikuModelId,
|
|
33
|
+
subagentModelId: store.scenarioModels.subagentModelId,
|
|
34
|
+
});
|
|
35
|
+
useInput((input, key) => {
|
|
36
|
+
if (key.escape) {
|
|
37
|
+
onCancel();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (key.upArrow) {
|
|
41
|
+
setIndex((i) => Math.max(0, i - 1));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (key.downArrow) {
|
|
45
|
+
setIndex((i) => Math.min(FIELDS.length - 1, i + 1));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (key.leftArrow || key.rightArrow) {
|
|
49
|
+
const field = FIELDS[index].key;
|
|
50
|
+
const currentIdx = allModels.findIndex((m) => m === values[field]);
|
|
51
|
+
const dir = key.leftArrow ? -1 : 1;
|
|
52
|
+
const nextIdx = (currentIdx + dir + allModels.length + 1) % (allModels.length + 1);
|
|
53
|
+
const nextValue = nextIdx === allModels.length ? undefined : allModels[nextIdx];
|
|
54
|
+
setValues((v) => ({ ...v, [field]: nextValue }));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (key.return) {
|
|
58
|
+
onSave(values);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Scenario Mappings" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "dim", children: "Map Claude Code's sonnet/opus/haiku/subagent aliases to your models." }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(Table, { columns: [
|
|
63
|
+
{ header: "Role", minWidth: 10 },
|
|
64
|
+
{ header: "Model", minWidth: 24 },
|
|
65
|
+
{ header: "Env Var", minWidth: 20 },
|
|
66
|
+
], rows: FIELDS.map((f, i) => [
|
|
67
|
+
{
|
|
68
|
+
text: f.label,
|
|
69
|
+
color: i === index ? "green" : undefined,
|
|
70
|
+
bold: i === index,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
text: values[f.key] || "(none)",
|
|
74
|
+
color: i === index ? "green" : "dim",
|
|
75
|
+
bold: i === index,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
text: f.envVar,
|
|
79
|
+
color: "dim",
|
|
80
|
+
},
|
|
81
|
+
]) }) }), _jsx(Box, { marginTop: 1, children: _jsx(StatusBar, { items: [
|
|
82
|
+
{ key: "Enter", label: "Save" },
|
|
83
|
+
{ key: "↑↓", label: "Switch role" },
|
|
84
|
+
{ key: "←→", label: "Switch model" },
|
|
85
|
+
{ key: "Esc", label: "Cancel" },
|
|
86
|
+
] }) })] }));
|
|
87
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Provider {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
apiKey: string;
|
|
6
|
+
models: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface ScenarioModels {
|
|
9
|
+
opusModelId?: string;
|
|
10
|
+
sonnetModelId?: string;
|
|
11
|
+
haikuModelId?: string;
|
|
12
|
+
subagentModelId?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ConfigStore {
|
|
15
|
+
providers: Provider[];
|
|
16
|
+
activeProviderId: string | null;
|
|
17
|
+
activeModelId: string | null;
|
|
18
|
+
scenarioModels: ScenarioModels;
|
|
19
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cc-hub",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TUI for managing Claude Code model configurations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cc-hub": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "pnpm run build && pnpm start",
|
|
11
|
+
"dev:watch": "nodemon --watch src --ext ts,tsx --exec 'pnpm run build && pnpm start'",
|
|
12
|
+
"build": "node build.mjs",
|
|
13
|
+
"start": "node dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"ink": "^6.0.0",
|
|
17
|
+
"ink-select-input": "^6.0.0",
|
|
18
|
+
"ink-spinner": "^5.0.0",
|
|
19
|
+
"ink-text-input": "^6.0.0",
|
|
20
|
+
"json5": "^2.2.3",
|
|
21
|
+
"react": "^19.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/json5": "^2.2.0",
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
26
|
+
"@types/react": "^19.0.0",
|
|
27
|
+
"nodemon": "^3.1.14",
|
|
28
|
+
"tsx": "^4.19.0",
|
|
29
|
+
"typescript": "^5.7.0"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"claude",
|
|
34
|
+
"claude-code",
|
|
35
|
+
"llm",
|
|
36
|
+
"tui"
|
|
37
|
+
],
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/slashspace/cc-hub.git"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist"
|
|
44
|
+
]
|
|
45
|
+
}
|