kob-cli 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.
@@ -0,0 +1,95 @@
1
+ // User & Authentication Types
2
+ export interface UserToken {
3
+ success: boolean;
4
+ message: string;
5
+ user_email: string;
6
+ user_name: string;
7
+ key_name: string;
8
+ credit_balance: number;
9
+ package_name: string;
10
+ package_started_at: string;
11
+ package_expires_at: string;
12
+ ip: string;
13
+ timestamp: string;
14
+ }
15
+
16
+ // AI Model Types
17
+ export interface AIModel {
18
+ modelId: string;
19
+ displayName: string;
20
+ inputPricePer1M: number;
21
+ outputPricePer1M: number;
22
+ }
23
+
24
+ export interface ProviderModels {
25
+ provider: string;
26
+ models: AIModel[];
27
+ }
28
+
29
+ export interface ModelsResponse {
30
+ success: boolean;
31
+ message: string;
32
+ provider_count: number;
33
+ model_count: number;
34
+ providers: ProviderModels[];
35
+ }
36
+
37
+ // Chat Types
38
+ export type MessageRole = 'user' | 'assistant' | 'system';
39
+
40
+ export interface ChatMessage {
41
+ role: MessageRole;
42
+ content: string;
43
+ }
44
+
45
+ export interface ChatUsage {
46
+ input_tokens: number;
47
+ output_tokens: number;
48
+ total_tokens: number;
49
+ cost_usd: number;
50
+ credits_used: number;
51
+ model: string;
52
+ input_price_per_1m: number;
53
+ output_price_per_1m: number;
54
+ }
55
+
56
+ export interface ChatResponse {
57
+ success: boolean;
58
+ message: string;
59
+ content: string;
60
+ usage: ChatUsage;
61
+ credit_balance: number;
62
+ timestamp: string;
63
+ }
64
+
65
+ // Stream Types
66
+ export type StreamEventType = 'chunk' | 'done' | 'error';
67
+
68
+ export interface StreamEvent {
69
+ type: StreamEventType;
70
+ content?: string;
71
+ message?: string;
72
+ credits_charged?: number;
73
+ credits_remaining?: number;
74
+ usage?: {
75
+ input_tokens: number;
76
+ output_tokens: number;
77
+ total_tokens: number;
78
+ cost_usd: number;
79
+ credits_used: number;
80
+ charged_input_tokens: number;
81
+ charged_output_tokens: number;
82
+ charged_cost_usd: number;
83
+ charged_credits: number;
84
+ charge_rate: number;
85
+ };
86
+ }
87
+
88
+ // CLI Configuration Types
89
+ export interface CliConfig {
90
+ baseUrl: string;
91
+ apiKey: string;
92
+ apiToken?: string;
93
+ bearerToken?: string;
94
+ modelId?: string;
95
+ }
@@ -0,0 +1,440 @@
1
+ import React, { useState, useCallback, useRef } from 'react';
2
+ import { render, Box, Text, useInput, useApp, useAnimation } from 'ink';
3
+ import { KobApiClient } from '../utils/api.js';
4
+ import { getConfig } from '../utils/config.js';
5
+ import { handleApiError } from '../utils/errors.js';
6
+ import { writeFileSync, mkdirSync, existsSync } from 'fs';
7
+ import { dirname, resolve } from 'path';
8
+
9
+ const brand = '#38bdf8';
10
+ const accent = '#a78bfa';
11
+ const green = '#34d399';
12
+ const pink = '#f472b6';
13
+ const yellow = '#fbbf24';
14
+ const red = '#ef4444';
15
+ const gray = '#6b7280';
16
+
17
+ function formatV2Model(provider: string, model?: string): string {
18
+ const m = model || 'deepseek-chat';
19
+ return m.includes('/') ? m : `${provider.toLowerCase()}/${m}`;
20
+ }
21
+
22
+ function countTokens(text: string): number {
23
+ return Math.ceil(text.length / 4);
24
+ }
25
+
26
+ export interface FileChange {
27
+ filename: string;
28
+ content: string;
29
+ addLines: number;
30
+ delLines: number;
31
+ }
32
+
33
+ export function writeFiles(files: FileChange[]): string[] {
34
+ const created: string[] = [];
35
+ for (const f of files) {
36
+ if (!f.content) continue;
37
+ const filePath = resolve(process.cwd(), f.filename);
38
+ const dir = dirname(filePath);
39
+ if (!existsSync(dir)) {
40
+ mkdirSync(dir, { recursive: true });
41
+ }
42
+ const existed = existsSync(filePath);
43
+ writeFileSync(filePath, f.content, 'utf-8');
44
+ created.push(f.filename);
45
+ }
46
+ return created;
47
+ }
48
+
49
+ export function parseFileChanges(content: string): FileChange[] {
50
+ const files: FileChange[] = [];
51
+ const lines = content.split('\n');
52
+ let currentFilename = '';
53
+ let inCode = false;
54
+ let collected: string[] = [];
55
+ let lineCount = 0;
56
+
57
+ for (let i = 0; i < lines.length; i++) {
58
+ const line = lines[i] || '';
59
+ const trimmed = line.trimStart();
60
+
61
+ if (trimmed.startsWith('```')) {
62
+ if (inCode) {
63
+ if (currentFilename) {
64
+ files.push({ filename: currentFilename, content: collected.join('\n'), addLines: lineCount, delLines: 0 });
65
+ }
66
+ currentFilename = '';
67
+ collected = [];
68
+ lineCount = 0;
69
+ inCode = false;
70
+ } else {
71
+ inCode = true;
72
+ lineCount = 0;
73
+ collected = [];
74
+ const lang = trimmed.slice(3).trim();
75
+ // Look for filename in lines before code block
76
+ for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
77
+ const prev = (lines[j] || '').trim();
78
+ const fnMatch = prev.match(/(?:file|filepath|filename|create|edit|update|write):\s*([\w./\\@-]+)/i);
79
+ if (fnMatch && fnMatch[1]) { currentFilename = fnMatch[1]; break; }
80
+ const fnMatch2 = prev.match(/^([\w./\\@-]+\.[a-z]+)\s*$/i);
81
+ if (fnMatch2 && fnMatch2[1] && !prev.includes('```') && !prev.startsWith('#')) {
82
+ currentFilename = fnMatch2[1]; break;
83
+ }
84
+ const fnMatch3 = prev.match(/^[#/]+\s*([\w./\\@-]+\.[a-z]+)/i);
85
+ if (fnMatch3 && fnMatch3[1]) { currentFilename = fnMatch3[1]; break; }
86
+ }
87
+ if (!currentFilename) currentFilename = lang || 'file';
88
+ }
89
+ continue;
90
+ }
91
+
92
+ if (inCode) {
93
+ // Detect filename from first code lines (e.g. # filename: app.py)
94
+ if (lineCount < 3) {
95
+ const codeFn = line.match(/(?:file|filepath|filename):\s*([\w./\\@-]+\.[a-z0-9]+)/i);
96
+ if (codeFn && codeFn[1]) {
97
+ currentFilename = codeFn[1];
98
+ }
99
+ }
100
+ collected.push(line);
101
+ lineCount++;
102
+ }
103
+ }
104
+ if (inCode && currentFilename && lineCount > 0) {
105
+ files.push({ filename: currentFilename, content: collected.join('\n'), addLines: lineCount, delLines: 0 });
106
+ }
107
+
108
+ return files;
109
+ }
110
+
111
+ const statusMessages = [
112
+ 'Analyzing request...',
113
+ 'Designing architecture...',
114
+ 'Writing code...',
115
+ 'Optimizing implementation...',
116
+ 'Reviewing output...',
117
+ 'Finalizing...',
118
+ ];
119
+
120
+ interface InputAreaProps {
121
+ onSubmit: (text: string) => void;
122
+ placeholder?: string;
123
+ }
124
+
125
+ function InputArea({ onSubmit, placeholder = "What's next?" }: InputAreaProps) {
126
+ const [value, setValue] = useState('');
127
+
128
+ useInput((char, key) => {
129
+ if (key.ctrl && char === 'c') {
130
+ process.stdout.write('\x1B[?25h');
131
+ process.exit(0);
132
+ }
133
+ if (key.return) {
134
+ const trimmed = value.trim();
135
+ if (trimmed) {
136
+ if (trimmed === '/exit' || trimmed === '/quit') {
137
+ process.stdout.write('\x1B[?25h');
138
+ process.exit(0);
139
+ }
140
+ onSubmit(trimmed);
141
+ setValue('');
142
+ }
143
+ return;
144
+ }
145
+ if (key.backspace || key.delete) {
146
+ setValue(prev => prev.slice(0, -1));
147
+ return;
148
+ }
149
+ if (char && char.length === 1) {
150
+ setValue(prev => prev + char);
151
+ }
152
+ });
153
+
154
+ return (
155
+ <Box flexDirection="column" marginTop={1}>
156
+ <Box>
157
+ <Text color={accent}>{' โ”ƒ '}</Text>
158
+ <Text color={gray}>{placeholder}</Text>
159
+ </Box>
160
+ <Box marginLeft={5}>
161
+ <Text color="white">{value}</Text>
162
+ <Text color={brand}>{'โ–ˆ'}</Text>
163
+ </Box>
164
+ </Box>
165
+ );
166
+ }
167
+
168
+ const spinnerChars = ['โ ‹', 'โ ™', 'โ น', 'โ ธ', 'โ ผ', 'โ ด', 'โ ฆ', 'โ ง', 'โ ‡', 'โ '];
169
+ const shimmerColors = ['#38bdf8', '#34d399', '#a78bfa', '#f472b6', '#fbbf24', '#a78bfa', '#34d399', '#38bdf8'];
170
+ const stepInterval = 2500;
171
+
172
+ function GeneratingAnimation({ messages, elapsed }: { messages: string[]; elapsed: number }) {
173
+ const { frame } = useAnimation({ interval: 80 });
174
+
175
+ const spinner = spinnerChars[frame % spinnerChars.length]!;
176
+ const colorIdx = frame % shimmerColors.length;
177
+ const secs = Math.floor(elapsed / 1000);
178
+ const timeStr = secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
179
+
180
+ const currentStep = Math.min(Math.floor(elapsed / stepInterval), messages.length - 1);
181
+
182
+ return (
183
+ <Box flexDirection="column" marginTop={1}>
184
+ <Box>
185
+ <Text color={gray}>{' โ•ญ'}{'โ”€'.repeat(44)}{'โ•ฎ'}</Text>
186
+ </Box>
187
+ {messages.map((msg, i) => {
188
+ const isDone = i < currentStep;
189
+ const isActive = i === currentStep;
190
+ const isPending = i > currentStep;
191
+
192
+ let icon = 'โ—‹';
193
+ let textColor = gray;
194
+ let iconColor = gray;
195
+
196
+ if (isDone) {
197
+ icon = 'โœ…';
198
+ textColor = green;
199
+ iconColor = green;
200
+ } else if (isActive) {
201
+ icon = spinner!;
202
+ textColor = shimmerColors[(colorIdx + i * 3) % shimmerColors.length]!;
203
+ iconColor = shimmerColors[colorIdx]!;
204
+ }
205
+
206
+ return (
207
+ <Box key={i}>
208
+ <Text color={gray}>{' โ”‚'}</Text>
209
+ <Text>{' '}</Text>
210
+ <Text color={iconColor}>{icon}</Text>
211
+ <Text>{' '}</Text>
212
+ <Text color={textColor}>{msg}</Text>
213
+ <Text color={gray}>{' โ”‚'}</Text>
214
+ </Box>
215
+ );
216
+ })}
217
+ <Box>
218
+ <Text color={gray}>{' โ”‚'}</Text>
219
+ <Text>{' '}</Text>
220
+ <Text color={gray}>{'โฑ '}{timeStr}</Text>
221
+ <Text color={gray}>{' โ”‚'}</Text>
222
+ </Box>
223
+ <Box>
224
+ <Text color={gray}>{' โ•ฐ'}{'โ”€'.repeat(44)}{'โ•ฎ'}</Text>
225
+ </Box>
226
+ </Box>
227
+ );
228
+ }
229
+
230
+ interface FileChangesProps {
231
+ files: FileChange[];
232
+ created: string[];
233
+ }
234
+
235
+ function FileChanges({ files, created }: FileChangesProps) {
236
+ if (files.length === 0) {
237
+ return (
238
+ <Box marginLeft={3} marginTop={1}>
239
+ <Text color={gray}>No file changes detected</Text>
240
+ </Box>
241
+ );
242
+ }
243
+
244
+ return (
245
+ <Box flexDirection="column" marginTop={1}>
246
+ {files.map((f, i) => {
247
+ const isCreated = created.includes(f.filename);
248
+ return (
249
+ <Box key={i} marginLeft={3}>
250
+ {isCreated ? (
251
+ <Text color={green}>{'โœ… '}</Text>
252
+ ) : (
253
+ <Text color={yellow}>{'๐Ÿ“„ '}</Text>
254
+ )}
255
+ <Text bold color={isCreated ? green : 'white'}>{f.filename}</Text>
256
+ <Text>{' '}</Text>
257
+ <Text color={green}>{'+' + f.addLines}</Text>
258
+ {f.delLines > 0 && (
259
+ <>
260
+ <Text>{' '}</Text>
261
+ <Text color={red}>{'-' + f.delLines}</Text>
262
+ </>
263
+ )}
264
+ </Box>
265
+ );
266
+ })}
267
+ </Box>
268
+ );
269
+ }
270
+
271
+ interface UsageBarProps {
272
+ model: string;
273
+ inTokens: number;
274
+ outTokens: number;
275
+ }
276
+
277
+ function UsageBar({ model, inTokens, outTokens }: UsageBarProps) {
278
+ const total = inTokens + outTokens;
279
+ return (
280
+ <Box flexDirection="column" marginTop={1}>
281
+ <Box>
282
+ <Text color={gray}>{' โ•ญ'}{'โ”€'.repeat(44)}{'โ•ฎ'}</Text>
283
+ </Box>
284
+ <Box>
285
+ <Text color={gray}>{' โ”‚'}</Text>
286
+ <Text>{' '}</Text>
287
+ <Text color={brand}>{'๐Ÿค– '}{model}</Text>
288
+ <Text color={gray}>{' โ”‚'}</Text>
289
+ </Box>
290
+ <Box>
291
+ <Text color={gray}>{' โ”‚'}</Text>
292
+ <Text>{' '}</Text>
293
+ <Text color={gray}>{'๐Ÿ“ฅ '}{inTokens}{' tok'}</Text>
294
+ <Text>{' ยท '}</Text>
295
+ <Text color={gray}>{'๐Ÿ“ค '}{outTokens}{' tok'}</Text>
296
+ <Text>{' ยท '}</Text>
297
+ <Text color={gray}>{'๐Ÿ“Š '}{total}{' tok'}</Text>
298
+ <Text color={gray}>{' โ”‚'}</Text>
299
+ </Box>
300
+ <Box>
301
+ <Text color={gray}>{' โ•ฐ'}{'โ”€'.repeat(44)}{'โ•ฏ'}</Text>
302
+ </Box>
303
+ </Box>
304
+ );
305
+ }
306
+
307
+ interface Exchange {
308
+ input: string;
309
+ output: string;
310
+ model: string;
311
+ inTokens: number;
312
+ outTokens: number;
313
+ files: FileChange[];
314
+ created: string[];
315
+ }
316
+
317
+ function ConversationView({ exchanges }: { exchanges: Exchange[] }) {
318
+ return (
319
+ <Box flexDirection="column">
320
+ {exchanges.map((exc, i) => (
321
+ <Box key={i} flexDirection="column" marginTop={1}>
322
+ <Box marginLeft={2} marginBottom={1}>
323
+ <Text color={accent}>{'โœฆ Round '}{i + 1}</Text>
324
+ </Box>
325
+ <FileChanges files={exc.files} created={exc.created} />
326
+ <UsageBar model={exc.model} inTokens={exc.inTokens} outTokens={exc.outTokens} />
327
+ {i < exchanges.length - 1 && (
328
+ <Box marginTop={1} marginLeft={2}>
329
+ <Text color={gray}>{'โ”€'.repeat(44)}</Text>
330
+ </Box>
331
+ )}
332
+ </Box>
333
+ ))}
334
+ </Box>
335
+ );
336
+ }
337
+
338
+ export function runCodeTui(): Promise<void> {
339
+ return new Promise((resolve) => {
340
+ const { waitUntilExit } = render(<CodeEngine />, { exitOnCtrlC: false });
341
+ waitUntilExit().then(() => resolve());
342
+ });
343
+ }
344
+
345
+ function CodeEngine() {
346
+ const [exchanges, setExchanges] = useState<Exchange[]>([]);
347
+ const [phase, setPhase] = useState<'input' | 'generating'>('input');
348
+ const [startMs, setStartMs] = useState(0);
349
+ const model = formatV2Model('DeepSeek', getConfig().modelId);
350
+ const messagesRef = useRef<{ role: string; content: string }[]>([]);
351
+
352
+ const handleSubmit = useCallback(async (input: string) => {
353
+ setPhase('generating');
354
+ setStartMs(Date.now());
355
+
356
+ messagesRef.current.push({ role: 'user', content: input });
357
+ const inTokens = countTokens(input);
358
+
359
+ try {
360
+ const config = getConfig();
361
+ const client = new KobApiClient(config);
362
+
363
+ let outTokens = 0;
364
+ let fullContent = '';
365
+ let usedModel = model;
366
+
367
+ const sysPrompt = 'You are an expert programmer. Generate clean, production-ready code. Always indicate the filename at the top of each code block with a comment like: // filename: path/to/file.ext or # filename: path/to/file.ext. Respond with code blocks only, keep explanations brief.';
368
+
369
+ for await (const chunk of client.chatStream(
370
+ model,
371
+ messagesRef.current,
372
+ { temperature: 0.3, max_tokens: 8192, system_prompt: sysPrompt }
373
+ )) {
374
+ const delta = chunk.choices?.[0]?.delta?.content;
375
+ if (delta) {
376
+ fullContent += delta;
377
+ outTokens += countTokens(delta);
378
+ }
379
+ if (chunk.model) usedModel = chunk.model;
380
+ }
381
+
382
+ messagesRef.current.push({ role: 'assistant', content: fullContent });
383
+ const files = parseFileChanges(fullContent);
384
+ const created = writeFiles(files);
385
+
386
+ setExchanges(prev => [...prev, {
387
+ input,
388
+ output: fullContent,
389
+ model: usedModel,
390
+ inTokens,
391
+ outTokens,
392
+ files,
393
+ created,
394
+ }]);
395
+ setPhase('input');
396
+ } catch (error) {
397
+ handleApiError(error);
398
+ process.exit(1);
399
+ }
400
+ }, [model]);
401
+
402
+ return (
403
+ <Box flexDirection="column" padding={1}>
404
+ <Box flexDirection="column" marginBottom={1}>
405
+ <Box>
406
+ <Text color={brand}>{' โ•ญ'}{'โ”€'.repeat(44)}{'โ•ฎ'}</Text>
407
+ </Box>
408
+ <Box>
409
+ <Text color={brand}>{' โ”‚'}</Text>
410
+ <Text>{' '}</Text>
411
+ <Text color={brand} bold>KOB</Text>
412
+ <Text color={gray}>{' Code Engineer'}</Text>
413
+ <Text color={brand}>{' โ”‚'}</Text>
414
+ </Box>
415
+ <Box>
416
+ <Text color={brand}>{' โ”‚'}</Text>
417
+ <Text>{' '}</Text>
418
+ <Text color={gray}>{'multi-turn code generation'}</Text>
419
+ <Text color={brand}>{' โ”‚'}</Text>
420
+ </Box>
421
+ <Box>
422
+ <Text color={brand}>{' โ•ฐ'}{'โ”€'.repeat(44)}{'โ•ฏ'}</Text>
423
+ </Box>
424
+ </Box>
425
+
426
+ <ConversationView exchanges={exchanges} />
427
+
428
+ {phase === 'generating' && (
429
+ <GeneratingAnimation messages={statusMessages} elapsed={Date.now() - startMs} />
430
+ )}
431
+
432
+ {phase === 'input' && (
433
+ <InputArea
434
+ onSubmit={handleSubmit}
435
+ placeholder={exchanges.length === 0 ? "What do you want to build?" : "What's next? (or /exit)"}
436
+ />
437
+ )}
438
+ </Box>
439
+ );
440
+ }