@booncli/booncli 1.0.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/dist/cli.d.ts +2 -0
- package/dist/cli.js +8 -0
- package/dist/command-actions.d.ts +16 -0
- package/dist/command-actions.js +15 -0
- package/dist/commands/clear.d.ts +2 -0
- package/dist/commands/clear.js +6 -0
- package/dist/commands/index.d.ts +6 -0
- package/dist/commands/index.js +251 -0
- package/dist/commands/log.d.ts +2 -0
- package/dist/commands/log.js +6 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +6 -0
- package/dist/commands/loginout.d.ts +2 -0
- package/dist/commands/loginout.js +6 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +90 -0
- package/dist/login-methods.d.ts +6 -0
- package/dist/login-methods.js +17 -0
- package/dist/modules/checkbox.d.ts +11 -0
- package/dist/modules/checkbox.js +47 -0
- package/dist/modules/loading.d.ts +2 -0
- package/dist/modules/loading.js +9 -0
- package/dist/modules/radio.d.ts +9 -0
- package/dist/modules/radio.js +28 -0
- package/dist/scripts/build.d.ts +1 -0
- package/dist/scripts/build.js +7 -0
- package/package.json +62 -0
- package/readme.md +70 -0
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const commandActions: readonly [{
|
|
2
|
+
readonly command: "/login";
|
|
3
|
+
readonly result: "Login";
|
|
4
|
+
}, {
|
|
5
|
+
readonly command: "/loginout";
|
|
6
|
+
readonly result: "Loginout";
|
|
7
|
+
}, {
|
|
8
|
+
readonly command: "/log";
|
|
9
|
+
readonly result: "Log";
|
|
10
|
+
}, {
|
|
11
|
+
readonly command: "/clear";
|
|
12
|
+
readonly result: "Cleared";
|
|
13
|
+
}];
|
|
14
|
+
export type SlashCommand = (typeof commandActions)[number]['command'];
|
|
15
|
+
export declare function isSlashCommand(value: string): value is SlashCommand;
|
|
16
|
+
export declare function runCommand(value: string): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const commandActions = [
|
|
2
|
+
{ command: '/login', result: 'Login' },
|
|
3
|
+
{ command: '/loginout', result: 'Loginout' },
|
|
4
|
+
{ command: '/log', result: 'Log' },
|
|
5
|
+
{ command: '/clear', result: 'Cleared' },
|
|
6
|
+
];
|
|
7
|
+
const slashCommandSet = new Set(commandActions.map(({ command }) => command));
|
|
8
|
+
export function isSlashCommand(value) {
|
|
9
|
+
return slashCommandSet.has(value);
|
|
10
|
+
}
|
|
11
|
+
export function runCommand(value) {
|
|
12
|
+
const command = value.trim().toLowerCase();
|
|
13
|
+
const action = commandActions.find(action => action.command === command);
|
|
14
|
+
return action?.result ?? value.trim();
|
|
15
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export declare const slashCommands: readonly ["/login", "/loginout", "/log", "/clear"];
|
|
3
|
+
export declare function getAvailableSlashCommands(isLoggedIn: boolean): ("/login" | "/loginout" | "/log" | "/clear")[];
|
|
4
|
+
export declare function getCommandSuggestions(input: string, isLoggedIn?: boolean): ("/login" | "/loginout" | "/log" | "/clear")[];
|
|
5
|
+
export declare function getDefaultSuggestionIndex(input: string, isLoggedIn?: boolean): number;
|
|
6
|
+
export default function Index(): React.JSX.Element;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import BigText from 'ink-big-text';
|
|
5
|
+
import { getProviderLoginUsername, isLoginMethod, isAccountPasswordLoginMethod, loginMethods, } from '../login-methods.js';
|
|
6
|
+
import Radio from '../modules/radio.js';
|
|
7
|
+
import { runCommand } from '../command-actions.js';
|
|
8
|
+
export const slashCommands = ['/login', '/loginout', '/log', '/clear'];
|
|
9
|
+
export function getAvailableSlashCommands(isLoggedIn) {
|
|
10
|
+
return slashCommands.filter(command => {
|
|
11
|
+
if (command === '/login') {
|
|
12
|
+
return !isLoggedIn;
|
|
13
|
+
}
|
|
14
|
+
if (command === '/loginout') {
|
|
15
|
+
return isLoggedIn;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export function getCommandSuggestions(input, isLoggedIn = false) {
|
|
21
|
+
const query = input.trim().toLowerCase();
|
|
22
|
+
if (!query.startsWith('/')) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return getAvailableSlashCommands(isLoggedIn).filter(command => command.startsWith(query));
|
|
26
|
+
}
|
|
27
|
+
export function getDefaultSuggestionIndex(input, isLoggedIn = false) {
|
|
28
|
+
const query = input.trim().toLowerCase();
|
|
29
|
+
const suggestions = getCommandSuggestions(input, isLoggedIn);
|
|
30
|
+
const exactMatchIndex = suggestions.indexOf(query);
|
|
31
|
+
return Math.max(0, exactMatchIndex);
|
|
32
|
+
}
|
|
33
|
+
function getInputPlaceholder(pendingFlow) {
|
|
34
|
+
if (pendingFlow?.type === 'loginMethod') {
|
|
35
|
+
return 'Select login method';
|
|
36
|
+
}
|
|
37
|
+
if (pendingFlow?.type === 'loginUsername') {
|
|
38
|
+
return 'username';
|
|
39
|
+
}
|
|
40
|
+
if (pendingFlow?.type === 'loginPassword') {
|
|
41
|
+
return 'password';
|
|
42
|
+
}
|
|
43
|
+
if (pendingFlow?.type === 'loginoutConfirm') {
|
|
44
|
+
return 'yes/no';
|
|
45
|
+
}
|
|
46
|
+
return 'Enter your query';
|
|
47
|
+
}
|
|
48
|
+
export default function Index() {
|
|
49
|
+
const [input, setInput] = useState('');
|
|
50
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
51
|
+
const [pendingFlow, setPendingFlow] = useState();
|
|
52
|
+
const [username, setUsername] = useState();
|
|
53
|
+
const [sentMessages, setSentMessages] = useState([]);
|
|
54
|
+
const [nextMessageId, setNextMessageId] = useState(1);
|
|
55
|
+
const isLoggedIn = username !== undefined;
|
|
56
|
+
const suggestions = useMemo(() => (pendingFlow ? [] : getCommandSuggestions(input, isLoggedIn)), [input, isLoggedIn, pendingFlow]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (selectedIndex >= suggestions.length) {
|
|
59
|
+
setSelectedIndex(0);
|
|
60
|
+
}
|
|
61
|
+
}, [selectedIndex, suggestions.length]);
|
|
62
|
+
const addMessage = (command, message) => {
|
|
63
|
+
setSentMessages(messages => [
|
|
64
|
+
...messages.slice(-4),
|
|
65
|
+
{ id: nextMessageId, command, message },
|
|
66
|
+
]);
|
|
67
|
+
setNextMessageId(id => id + 1);
|
|
68
|
+
};
|
|
69
|
+
const resetInput = () => {
|
|
70
|
+
setInput('');
|
|
71
|
+
setSelectedIndex(0);
|
|
72
|
+
};
|
|
73
|
+
const clearPanel = () => {
|
|
74
|
+
setSentMessages([]);
|
|
75
|
+
setPendingFlow(undefined);
|
|
76
|
+
resetInput();
|
|
77
|
+
};
|
|
78
|
+
const confirmLoginout = (value) => {
|
|
79
|
+
const answer = value.trim().toLowerCase();
|
|
80
|
+
if (answer === 'yes') {
|
|
81
|
+
setUsername(undefined);
|
|
82
|
+
addMessage('/loginout', 'Logged out');
|
|
83
|
+
setPendingFlow(undefined);
|
|
84
|
+
resetInput();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (answer === 'no') {
|
|
88
|
+
addMessage('/loginout', 'Cancelled');
|
|
89
|
+
setPendingFlow(undefined);
|
|
90
|
+
resetInput();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setInput('');
|
|
94
|
+
};
|
|
95
|
+
const submitLoginUsername = (value) => {
|
|
96
|
+
setPendingFlow({
|
|
97
|
+
type: 'loginPassword',
|
|
98
|
+
username: value.trim() || 'anonymous',
|
|
99
|
+
});
|
|
100
|
+
resetInput();
|
|
101
|
+
};
|
|
102
|
+
const submitLoginPassword = () => {
|
|
103
|
+
if (pendingFlow?.type !== 'loginPassword') {
|
|
104
|
+
resetInput();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
setUsername(pendingFlow.username);
|
|
108
|
+
addMessage('/login', `Logged in as ${pendingFlow.username}`);
|
|
109
|
+
setPendingFlow(undefined);
|
|
110
|
+
resetInput();
|
|
111
|
+
};
|
|
112
|
+
const selectLoginMethod = (method) => {
|
|
113
|
+
if (!isLoginMethod(method)) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (isAccountPasswordLoginMethod(method)) {
|
|
117
|
+
setPendingFlow({ type: 'loginUsername' });
|
|
118
|
+
resetInput();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const providerUsername = getProviderLoginUsername(method);
|
|
122
|
+
if (providerUsername) {
|
|
123
|
+
setUsername(providerUsername);
|
|
124
|
+
addMessage('/login', `Logged in as ${providerUsername}`);
|
|
125
|
+
setPendingFlow(undefined);
|
|
126
|
+
resetInput();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const sendMessage = (value) => {
|
|
130
|
+
if (pendingFlow?.type === 'loginMethod') {
|
|
131
|
+
resetInput();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (pendingFlow?.type === 'loginUsername') {
|
|
135
|
+
submitLoginUsername(value);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (pendingFlow?.type === 'loginPassword') {
|
|
139
|
+
submitLoginPassword();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (pendingFlow?.type === 'loginoutConfirm') {
|
|
143
|
+
confirmLoginout(value);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const currentSuggestions = getCommandSuggestions(value, isLoggedIn);
|
|
147
|
+
const selectedSuggestion = currentSuggestions[selectedIndex];
|
|
148
|
+
const command = selectedSuggestion ?? value.trim();
|
|
149
|
+
const normalizedCommand = command.toLowerCase();
|
|
150
|
+
if (normalizedCommand === '/clear') {
|
|
151
|
+
clearPanel();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (normalizedCommand === '/login') {
|
|
155
|
+
if (isLoggedIn) {
|
|
156
|
+
addMessage(command, `Already logged in as ${username}`);
|
|
157
|
+
resetInput();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
setPendingFlow({ type: 'loginMethod' });
|
|
161
|
+
resetInput();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (normalizedCommand === '/loginout') {
|
|
165
|
+
if (!isLoggedIn) {
|
|
166
|
+
addMessage(command, 'Not logged in');
|
|
167
|
+
resetInput();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
setPendingFlow({ type: 'loginoutConfirm' });
|
|
171
|
+
resetInput();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const message = runCommand(command);
|
|
175
|
+
if (message.length > 0) {
|
|
176
|
+
addMessage(command, message);
|
|
177
|
+
}
|
|
178
|
+
resetInput();
|
|
179
|
+
};
|
|
180
|
+
useInput((_value, key) => {
|
|
181
|
+
if (key.upArrow && suggestions.length > 0) {
|
|
182
|
+
setSelectedIndex(index => Math.max(0, index - 1));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (key.downArrow && suggestions.length > 0) {
|
|
186
|
+
setSelectedIndex(index => Math.min(suggestions.length - 1, index + 1));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (key.tab && suggestions[selectedIndex]) {
|
|
190
|
+
const suggestion = suggestions[selectedIndex];
|
|
191
|
+
setInput(suggestion);
|
|
192
|
+
setSelectedIndex(getDefaultSuggestionIndex(suggestion, isLoggedIn));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (key.escape) {
|
|
196
|
+
setInput('');
|
|
197
|
+
setSelectedIndex(0);
|
|
198
|
+
setPendingFlow(undefined);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
const inputPlaceholder = getInputPlaceholder(pendingFlow);
|
|
202
|
+
const isLoginMethodSelection = pendingFlow?.type === 'loginMethod';
|
|
203
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
204
|
+
React.createElement(BigText, { text: "BOONCLI" }),
|
|
205
|
+
sentMessages.length > 0 && (React.createElement(Box, { flexDirection: "column" }, sentMessages.map(({ id, command, message }) => (React.createElement(Text, { key: id },
|
|
206
|
+
command.startsWith('/') ? 'Run' : 'Sent',
|
|
207
|
+
":",
|
|
208
|
+
' ',
|
|
209
|
+
React.createElement(Text, { color: "cyan" }, command),
|
|
210
|
+
" -",
|
|
211
|
+
' ',
|
|
212
|
+
React.createElement(Text, { color: "green" }, message)))))),
|
|
213
|
+
isLoginMethodSelection && (React.createElement(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1 },
|
|
214
|
+
React.createElement(Text, null, "Login: select a method."),
|
|
215
|
+
React.createElement(Radio, { isActive: isLoginMethodSelection, items: [...loginMethods], onSelect: selectLoginMethod }))),
|
|
216
|
+
pendingFlow?.type === 'loginUsername' && (React.createElement(Box, { marginLeft: 2, marginTop: 1 },
|
|
217
|
+
React.createElement(Text, null,
|
|
218
|
+
"Login: enter any ",
|
|
219
|
+
React.createElement(Text, { color: "cyan" }, "username"),
|
|
220
|
+
"."))),
|
|
221
|
+
pendingFlow?.type === 'loginPassword' && (React.createElement(Box, { marginLeft: 2, marginTop: 1 },
|
|
222
|
+
React.createElement(Text, null,
|
|
223
|
+
"Login: enter any ",
|
|
224
|
+
React.createElement(Text, { color: "cyan" }, "password"),
|
|
225
|
+
"."))),
|
|
226
|
+
pendingFlow?.type === 'loginoutConfirm' && (React.createElement(Box, { marginLeft: 2, marginTop: 1 },
|
|
227
|
+
React.createElement(Text, null,
|
|
228
|
+
"Confirm ",
|
|
229
|
+
React.createElement(Text, { color: "cyan" }, "/loginout"),
|
|
230
|
+
"? Type",
|
|
231
|
+
' ',
|
|
232
|
+
React.createElement(Text, { color: "green" }, "yes"),
|
|
233
|
+
" or ",
|
|
234
|
+
React.createElement(Text, { color: "red" }, "no"),
|
|
235
|
+
"."))),
|
|
236
|
+
React.createElement(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 1, paddingY: 0, borderLeft: false, borderRight: false, marginTop: 1 },
|
|
237
|
+
React.createElement(Text, { color: "cyan" }, "\u276F "),
|
|
238
|
+
React.createElement(TextInput, { focus: !isLoginMethodSelection, placeholder: inputPlaceholder, value: input, ...(pendingFlow?.type === 'loginPassword' ? { mask: '*' } : {}), onChange: value => {
|
|
239
|
+
setInput(value);
|
|
240
|
+
if (!pendingFlow) {
|
|
241
|
+
setSelectedIndex(getDefaultSuggestionIndex(value, isLoggedIn));
|
|
242
|
+
}
|
|
243
|
+
}, onSubmit: sendMessage })),
|
|
244
|
+
suggestions.length > 0 && (React.createElement(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1 }, suggestions.map((command, index) => {
|
|
245
|
+
const isSelected = index === selectedIndex;
|
|
246
|
+
return (React.createElement(Text, { key: command, ...(isSelected ? { color: 'yellow' } : {}) },
|
|
247
|
+
isSelected ? '>' : ' ',
|
|
248
|
+
" ",
|
|
249
|
+
command));
|
|
250
|
+
})))));
|
|
251
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type ModelProviderConfig = {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly baseUrl: string;
|
|
4
|
+
readonly wireApi: string;
|
|
5
|
+
readonly envKey: string;
|
|
6
|
+
};
|
|
7
|
+
export type ResolvedModelProviderConfig = ModelProviderConfig & {
|
|
8
|
+
readonly apiKey: string | undefined;
|
|
9
|
+
};
|
|
10
|
+
export type BoonCliConfig = {
|
|
11
|
+
readonly modelProviders: {
|
|
12
|
+
readonly deepseek: ModelProviderConfig;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export declare const defaultConfig: BoonCliConfig;
|
|
16
|
+
export declare function getConfigDirectory(): string;
|
|
17
|
+
export declare function getConfigFilePath(): string;
|
|
18
|
+
export declare function ensureConfigExists(): Promise<string>;
|
|
19
|
+
export declare function readConfig(): Promise<BoonCliConfig>;
|
|
20
|
+
export declare function resolveProviderApiKey(provider: ModelProviderConfig): string | undefined;
|
|
21
|
+
export declare function readResolvedDeepseekProvider(): Promise<{
|
|
22
|
+
apiKey: string | undefined;
|
|
23
|
+
name: string;
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
wireApi: string;
|
|
26
|
+
envKey: string;
|
|
27
|
+
}>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { access, chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
const defaultConfigFileContent = `[model_providers.deepseek]
|
|
7
|
+
name = "DeepSeek"
|
|
8
|
+
base_url = "https://api.deepseek.com/v1"
|
|
9
|
+
wire_api = "responses"
|
|
10
|
+
env_key = "DEEPSEEK_API_KEY"
|
|
11
|
+
`;
|
|
12
|
+
export const defaultConfig = {
|
|
13
|
+
modelProviders: {
|
|
14
|
+
deepseek: {
|
|
15
|
+
name: 'DeepSeek',
|
|
16
|
+
baseUrl: 'https://api.deepseek.com/v1',
|
|
17
|
+
wireApi: 'responses',
|
|
18
|
+
envKey: 'DEEPSEEK_API_KEY',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
export function getConfigDirectory() {
|
|
23
|
+
return (process.env['BOONCLI_CONFIG_DIR'] ?? path.join(os.homedir(), '.booncli'));
|
|
24
|
+
}
|
|
25
|
+
export function getConfigFilePath() {
|
|
26
|
+
return path.join(getConfigDirectory(), 'config.toml');
|
|
27
|
+
}
|
|
28
|
+
async function pathExists(filePath) {
|
|
29
|
+
try {
|
|
30
|
+
await access(filePath, constants.F_OK);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function readTomlValue(rawConfig, key) {
|
|
38
|
+
const match = new RegExp(`^${key}\\s*=\\s*"(.*)"$`, 'm').exec(rawConfig);
|
|
39
|
+
return match?.[1];
|
|
40
|
+
}
|
|
41
|
+
function parseConfig(rawConfig) {
|
|
42
|
+
return {
|
|
43
|
+
modelProviders: {
|
|
44
|
+
deepseek: {
|
|
45
|
+
name: readTomlValue(rawConfig, 'name') ??
|
|
46
|
+
defaultConfig.modelProviders.deepseek.name,
|
|
47
|
+
baseUrl: readTomlValue(rawConfig, 'base_url') ??
|
|
48
|
+
defaultConfig.modelProviders.deepseek.baseUrl,
|
|
49
|
+
wireApi: readTomlValue(rawConfig, 'wire_api') ??
|
|
50
|
+
defaultConfig.modelProviders.deepseek.wireApi,
|
|
51
|
+
envKey: readTomlValue(rawConfig, 'env_key') ??
|
|
52
|
+
defaultConfig.modelProviders.deepseek.envKey,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function ensureConfigExists() {
|
|
58
|
+
const configDirectory = getConfigDirectory();
|
|
59
|
+
const configFilePath = getConfigFilePath();
|
|
60
|
+
if (await pathExists(configFilePath)) {
|
|
61
|
+
return configFilePath;
|
|
62
|
+
}
|
|
63
|
+
await mkdir(configDirectory, { recursive: true });
|
|
64
|
+
await writeFile(configFilePath, defaultConfigFileContent, { mode: 0o600 });
|
|
65
|
+
await chmod(configFilePath, 0o600);
|
|
66
|
+
return configFilePath;
|
|
67
|
+
}
|
|
68
|
+
export async function readConfig() {
|
|
69
|
+
const configFilePath = await ensureConfigExists();
|
|
70
|
+
try {
|
|
71
|
+
const rawConfig = await readFile(configFilePath, 'utf8');
|
|
72
|
+
return parseConfig(rawConfig);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw new Error(`Failed to read config file: ${configFilePath}`, {
|
|
76
|
+
cause: error,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function resolveProviderApiKey(provider) {
|
|
81
|
+
return process.env[provider.envKey];
|
|
82
|
+
}
|
|
83
|
+
export async function readResolvedDeepseekProvider() {
|
|
84
|
+
const config = await readConfig();
|
|
85
|
+
const provider = config.modelProviders.deepseek;
|
|
86
|
+
return {
|
|
87
|
+
...provider,
|
|
88
|
+
apiKey: resolveProviderApiKey(provider),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const accountPasswordLoginMethod = "\u8D26\u53F7\u5BC6\u7801\u767B\u5F55";
|
|
2
|
+
export declare const loginMethods: readonly ["google", "github", "apple", "账号密码登录"];
|
|
3
|
+
export type LoginMethod = (typeof loginMethods)[number];
|
|
4
|
+
export declare function isLoginMethod(method: string): method is LoginMethod;
|
|
5
|
+
export declare function isAccountPasswordLoginMethod(method: string): method is typeof accountPasswordLoginMethod;
|
|
6
|
+
export declare function getProviderLoginUsername(method: LoginMethod): "google" | "github" | "apple" | undefined;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const accountPasswordLoginMethod = '账号密码登录';
|
|
2
|
+
export const loginMethods = [
|
|
3
|
+
'google',
|
|
4
|
+
'github',
|
|
5
|
+
'apple',
|
|
6
|
+
accountPasswordLoginMethod,
|
|
7
|
+
];
|
|
8
|
+
const loginMethodSet = new Set(loginMethods);
|
|
9
|
+
export function isLoginMethod(method) {
|
|
10
|
+
return loginMethodSet.has(method);
|
|
11
|
+
}
|
|
12
|
+
export function isAccountPasswordLoginMethod(method) {
|
|
13
|
+
return method === accountPasswordLoginMethod;
|
|
14
|
+
}
|
|
15
|
+
export function getProviderLoginUsername(method) {
|
|
16
|
+
return isAccountPasswordLoginMethod(method) ? undefined : method;
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
type CheckboxProps = {
|
|
3
|
+
readonly items: readonly string[];
|
|
4
|
+
readonly defaultChecked?: readonly number[];
|
|
5
|
+
readonly isActive?: boolean;
|
|
6
|
+
readonly onToggle?: (item: string, index: number, checked: boolean) => void;
|
|
7
|
+
readonly onSubmit?: (checkedItems: string[]) => void;
|
|
8
|
+
readonly onBack?: () => void;
|
|
9
|
+
};
|
|
10
|
+
export default function Checkbox({ items, defaultChecked, isActive, onToggle, onSubmit, onBack, }: CheckboxProps): React.JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
export default function Checkbox({ items, defaultChecked = [], isActive = true, onToggle, onSubmit, onBack, }) {
|
|
4
|
+
const [cursor, setCursor] = useState(0);
|
|
5
|
+
const [checkedSet, setCheckedSet] = useState(new Set(defaultChecked));
|
|
6
|
+
useInput((_input, key) => {
|
|
7
|
+
if (key.upArrow) {
|
|
8
|
+
setCursor(index => Math.max(0, index - 1));
|
|
9
|
+
}
|
|
10
|
+
if (key.downArrow) {
|
|
11
|
+
setCursor(index => Math.min(items.length - 1, index + 1));
|
|
12
|
+
}
|
|
13
|
+
// 空格切换选中
|
|
14
|
+
if (!key.return && _input === ' ') {
|
|
15
|
+
const checked = !checkedSet.has(cursor);
|
|
16
|
+
setCheckedSet(previous => {
|
|
17
|
+
const next = new Set(previous);
|
|
18
|
+
if (checked)
|
|
19
|
+
next.add(cursor);
|
|
20
|
+
else
|
|
21
|
+
next.delete(cursor);
|
|
22
|
+
return next;
|
|
23
|
+
});
|
|
24
|
+
onToggle?.(items[cursor], cursor, checked);
|
|
25
|
+
}
|
|
26
|
+
// Esc 返回
|
|
27
|
+
if (key.escape) {
|
|
28
|
+
onBack?.();
|
|
29
|
+
}
|
|
30
|
+
// 回车提交
|
|
31
|
+
if (key.return) {
|
|
32
|
+
const checkedItems = items.filter((_, index) => checkedSet.has(index));
|
|
33
|
+
onSubmit?.(checkedItems);
|
|
34
|
+
}
|
|
35
|
+
}, { isActive });
|
|
36
|
+
return (React.createElement(Box, { flexDirection: "column" }, items.map((item, i) => {
|
|
37
|
+
const isCursor = i === cursor;
|
|
38
|
+
const isChecked = checkedSet.has(i);
|
|
39
|
+
// 选中状态用 ■(实心方块),未选中用 □(空心方块),样式与 Radio 完全对齐
|
|
40
|
+
return (React.createElement(Text, { key: item, color: isCursor ? 'yellow' : 'white' },
|
|
41
|
+
isCursor ? '❯' : ' ',
|
|
42
|
+
" ",
|
|
43
|
+
isChecked ? '◉' : '○',
|
|
44
|
+
" ",
|
|
45
|
+
item));
|
|
46
|
+
})));
|
|
47
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
export default function Loading() {
|
|
5
|
+
return (React.createElement(Box, null,
|
|
6
|
+
React.createElement(Text, null,
|
|
7
|
+
React.createElement(Spinner, null),
|
|
8
|
+
" Thinking...")));
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
type RadioProps = {
|
|
3
|
+
readonly items: readonly string[];
|
|
4
|
+
readonly defaultIndex?: number;
|
|
5
|
+
readonly isActive?: boolean;
|
|
6
|
+
readonly onSelect?: (item: string, index: number) => void;
|
|
7
|
+
};
|
|
8
|
+
export default function Radio({ items, defaultIndex, isActive, onSelect, }: RadioProps): React.JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
export default function Radio({ items, defaultIndex = 0, isActive = true, onSelect, }) {
|
|
4
|
+
const [index, setIndex] = useState(defaultIndex);
|
|
5
|
+
useInput((_input, key) => {
|
|
6
|
+
if (key.upArrow) {
|
|
7
|
+
setIndex(index => Math.max(0, index - 1));
|
|
8
|
+
}
|
|
9
|
+
if (key.downArrow) {
|
|
10
|
+
setIndex(index => Math.min(items.length - 1, index + 1));
|
|
11
|
+
}
|
|
12
|
+
if (key.return) {
|
|
13
|
+
const selectedItem = items[index];
|
|
14
|
+
if (selectedItem) {
|
|
15
|
+
onSelect?.(selectedItem, index);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}, { isActive });
|
|
19
|
+
return (React.createElement(Box, { flexDirection: "column" }, items.map((item, i) => {
|
|
20
|
+
const isSelected = i === index;
|
|
21
|
+
return (React.createElement(Text, { key: item, ...(isSelected ? { color: 'yellow' } : {}) },
|
|
22
|
+
isSelected ? '❯' : ' ',
|
|
23
|
+
" ",
|
|
24
|
+
isSelected ? '◉' : '○',
|
|
25
|
+
" ",
|
|
26
|
+
item));
|
|
27
|
+
})));
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { chmod, rm } from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
await rm('dist', { force: true, recursive: true });
|
|
6
|
+
await execFileAsync('tsc');
|
|
7
|
+
await chmod('dist/cli.js', 0o755);
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@booncli/booncli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"bin": {
|
|
6
|
+
"booncli": "dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=16"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "TS_NODE_TRANSPILE_ONLY=true node --no-warnings --loader=ts-node/esm source/scripts/build.ts",
|
|
14
|
+
"dev": "TS_NODE_TRANSPILE_ONLY=true node --no-warnings --loader=ts-node/esm source/cli.tsx",
|
|
15
|
+
"watch": "tsc --watch",
|
|
16
|
+
"test": "prettier --check . && xo && TS_NODE_TRANSPILE_ONLY=true ava"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"ink": "^4.1.0",
|
|
23
|
+
"ink-big-text": "^2.0.0",
|
|
24
|
+
"ink-spinner": "^5.0.0",
|
|
25
|
+
"ink-text-input": "^5.0.1",
|
|
26
|
+
"pastel": "^2.0.0",
|
|
27
|
+
"react": "^18.2.0",
|
|
28
|
+
"zod": "^3.21.4"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@sindresorhus/tsconfig": "^3.0.1",
|
|
32
|
+
"@types/react": "^18.0.32",
|
|
33
|
+
"@vdemedes/prettier-config": "^2.0.1",
|
|
34
|
+
"ava": "^5.2.0",
|
|
35
|
+
"chalk": "^5.2.0",
|
|
36
|
+
"eslint-config-xo-react": "^0.27.0",
|
|
37
|
+
"eslint-plugin-react": "^7.32.2",
|
|
38
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
39
|
+
"ink-testing-library": "^3.0.0",
|
|
40
|
+
"prettier": "^2.8.7",
|
|
41
|
+
"ts-node": "^10.9.1",
|
|
42
|
+
"typescript": "^5.0.3",
|
|
43
|
+
"xo": "^0.54.2"
|
|
44
|
+
},
|
|
45
|
+
"ava": {
|
|
46
|
+
"extensions": {
|
|
47
|
+
"ts": "module",
|
|
48
|
+
"tsx": "module"
|
|
49
|
+
},
|
|
50
|
+
"nodeArguments": [
|
|
51
|
+
"--loader=ts-node/esm"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"xo": {
|
|
55
|
+
"extends": "xo-react",
|
|
56
|
+
"prettier": true,
|
|
57
|
+
"rules": {
|
|
58
|
+
"react/prop-types": "off"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"prettier": "@vdemedes/prettier-config"
|
|
62
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# booncli
|
|
2
|
+
|
|
3
|
+
> This readme is automatically generated by [create-pastel-app](https://github.com/vadimdemedes/create-pastel-app)
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
$ npm install --global booncli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## CLI
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
$ booncli --help
|
|
15
|
+
|
|
16
|
+
Usage
|
|
17
|
+
$ booncli
|
|
18
|
+
|
|
19
|
+
Options
|
|
20
|
+
--name Your name
|
|
21
|
+
|
|
22
|
+
Commands
|
|
23
|
+
clear
|
|
24
|
+
log
|
|
25
|
+
login
|
|
26
|
+
loginout
|
|
27
|
+
|
|
28
|
+
Examples
|
|
29
|
+
$ booncli --name=Jane
|
|
30
|
+
Hello, Jane
|
|
31
|
+
|
|
32
|
+
$ booncli login
|
|
33
|
+
Login
|
|
34
|
+
|
|
35
|
+
$ booncli loginout
|
|
36
|
+
Loginout
|
|
37
|
+
|
|
38
|
+
$ booncli log
|
|
39
|
+
Log
|
|
40
|
+
|
|
41
|
+
$ booncli clear
|
|
42
|
+
Cleared
|
|
43
|
+
|
|
44
|
+
$ booncli
|
|
45
|
+
Opens an interactive command dialog with /login, /loginout, /log and /clear suggestions.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
单选框组件: /Users/zhuxwz/webSpace/space/CLI/booncli/source/modules/radio.tsx
|
|
49
|
+
复选框组件:/Users/zhuxwz/webSpace/space/CLI/booncli/source/modules/checkbox.tsx
|
|
50
|
+
Loading 组件:/Users/zhuxwz/webSpace/space/CLI/booncli/source/modules/loading.tsx
|
|
51
|
+
|
|
52
|
+
## Local Config
|
|
53
|
+
|
|
54
|
+
首次运行 CLI 时会自动创建本地配置文件:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
~/.booncli/config.toml
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
默认内容:
|
|
61
|
+
|
|
62
|
+
```toml
|
|
63
|
+
[model_providers.deepseek]
|
|
64
|
+
name = "DeepSeek"
|
|
65
|
+
base_url = "https://api.deepseek.com/v1"
|
|
66
|
+
wire_api = "responses"
|
|
67
|
+
env_key = "DEEPSEEK_API_KEY"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
配置文件权限会设置为仅当前用户可读写。配置文件只保存环境变量名称,真实 API key 从 `DEEPSEEK_API_KEY` 环境变量读取。
|