kob-cli 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ // Shared color tokens for the TUI
2
+ // Used by code-tui.tsx, model-picker.tsx, and any future sub-components.
3
+ export const c = {
4
+ bg: '#0b0e14',
5
+ panel: '#11151c',
6
+ border: '#1f2937',
7
+ borderDim: '#374151',
8
+ borderAccent: '#38bdf8',
9
+ text: '#e5e7eb',
10
+ textDim: '#6b7280',
11
+ textMuted: '#9ca3af',
12
+ brand: '#38bdf8', // cyan
13
+ accent: '#a78bfa', // purple
14
+ green: '#34d399',
15
+ yellow: '#fbbf24',
16
+ red: '#ef4444',
17
+ pink: '#f472b6',
18
+ blue: '#60a5fa',
19
+ };
@@ -0,0 +1,187 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { c } from './colors.js';
4
+ import { readEnvFile, writeEnvFile, describeEnvPath } from '../utils/env-file.js';
5
+
6
+ type Field = 'baseUrl' | 'apiKey' | 'modelId';
7
+
8
+ const FIELDS: { key: Field; label: string; placeholder: string; help: string; secret?: boolean }[] = [
9
+ {
10
+ key: 'baseUrl',
11
+ label: 'Base URL',
12
+ placeholder: 'https://www.kob-ai.dev',
13
+ help: 'Endpoint root — /v1, /v2 suffixes will be stripped automatically.',
14
+ },
15
+ {
16
+ key: 'apiKey',
17
+ label: 'API Key',
18
+ placeholder: 'kob_xxx:your_token (or just kob_xxx)',
19
+ help: 'Format: kob_xxx:your_token, or just kob_xxx.',
20
+ secret: true,
21
+ },
22
+ {
23
+ key: 'modelId',
24
+ label: 'Default Model',
25
+ placeholder: 'deepseek/deepseek-v4-flash',
26
+ help: 'Optional. Used as the fallback model when none is specified.',
27
+ },
28
+ ];
29
+
30
+ interface Props {
31
+ onDone: (saved: boolean) => void;
32
+ }
33
+
34
+ export function ConfigForm({ onDone }: Props) {
35
+ const envPath = describeEnvPath();
36
+ const initial = readEnvFile();
37
+ const [values, setValues] = useState<Record<Field, string>>({
38
+ baseUrl: initial.KOB_API_BASE_URL || 'https://www.kob-ai.dev',
39
+ apiKey: initial.KOB_API_KEY || '',
40
+ modelId: initial.KOB_MODEL_ID || '',
41
+ });
42
+ const [idx, setIdx] = useState<number>(0);
43
+ const [draft, setDraft] = useState<string>('');
44
+ const [editing, setEditing] = useState<boolean>(true);
45
+ const [saved, setSaved] = useState<boolean>(false);
46
+ const [error, setError] = useState<string | null>(null);
47
+
48
+ useEffect(() => {
49
+ // When field changes, seed draft with the current value
50
+ const f = FIELDS[idx]!;
51
+ setDraft(values[f.key]);
52
+ }, [idx]);
53
+
54
+ useInput((input, key) => {
55
+ if (!editing) return;
56
+ if (key.escape) {
57
+ onDone(false);
58
+ return;
59
+ }
60
+ if (key.return) {
61
+ // Commit draft, advance or save
62
+ const f = FIELDS[idx]!;
63
+ const next = { ...values, [f.key]: draft };
64
+ setValues(next);
65
+ if (idx < FIELDS.length - 1) {
66
+ setIdx(idx + 1);
67
+ } else {
68
+ // Save and exit
69
+ try {
70
+ const updates: Record<string, string> = {};
71
+ const comments: Record<string, string> = {};
72
+ if (next.baseUrl && next.baseUrl !== initial.KOB_API_BASE_URL) {
73
+ updates.KOB_API_BASE_URL = next.baseUrl;
74
+ comments.KOB_API_BASE_URL = 'Base URL for KOB AI API';
75
+ }
76
+ if (next.apiKey && next.apiKey !== initial.KOB_API_KEY) {
77
+ updates.KOB_API_KEY = next.apiKey;
78
+ comments.KOB_API_KEY = 'Your API Key (format: kob_xxx:your_token or just kob_xxx)';
79
+ }
80
+ if (next.modelId && next.modelId !== initial.KOB_MODEL_ID) {
81
+ updates.KOB_MODEL_ID = next.modelId;
82
+ comments.KOB_MODEL_ID = 'Default AI Model ID (optional)';
83
+ }
84
+ if (Object.keys(updates).length > 0) {
85
+ writeEnvFile(updates, comments);
86
+ }
87
+ setSaved(true);
88
+ setEditing(false);
89
+ } catch (e: any) {
90
+ setError(e?.message || String(e));
91
+ }
92
+ }
93
+ return;
94
+ }
95
+ if (key.backspace || key.delete) {
96
+ setDraft((d) => d.slice(0, -1));
97
+ return;
98
+ }
99
+ if (key.tab) {
100
+ // Optional: skip optional empty fields
101
+ const f = FIELDS[idx]!;
102
+ const next = { ...values, [f.key]: draft };
103
+ setValues(next);
104
+ setIdx((idx + 1) % FIELDS.length);
105
+ return;
106
+ }
107
+ if (input && !key.ctrl && !key.meta) {
108
+ setDraft((d) => d + input);
109
+ }
110
+ });
111
+
112
+ if (saved) {
113
+ return (
114
+ <Box flexDirection="column" borderStyle="round" borderColor={c.green} paddingX={1} marginTop={1}>
115
+ <Box>
116
+ <Text color={c.green} bold>✓ Configuration saved</Text>
117
+ </Box>
118
+ <Box>
119
+ <Text color={c.textDim}>File: </Text>
120
+ <Text color={c.text}>{envPath}</Text>
121
+ </Box>
122
+ <Box marginTop={1}>
123
+ <Text color={c.yellow}>↻ Restart KOB CLI to apply the new settings (env vars are loaded at startup).</Text>
124
+ </Box>
125
+ <Box marginTop={1}>
126
+ <Text color={c.textDim}>Press any key to return to the chat…</Text>
127
+ </Box>
128
+ </Box>
129
+ );
130
+ }
131
+
132
+ return (
133
+ <Box
134
+ flexDirection="column"
135
+ borderStyle="round"
136
+ borderColor={c.brand}
137
+ paddingX={1}
138
+ marginTop={1}
139
+ >
140
+ <Box>
141
+ <Text color={c.brand} bold>◆ /config</Text>
142
+ <Text color={c.textDim}> </Text>
143
+ <Text color={c.text}>edit </Text>
144
+ <Text color={c.pink} bold>{FIELDS[idx]!.label}</Text>
145
+ <Text color={c.textDim}> ({idx + 1}/{FIELDS.length})</Text>
146
+ <Box flexGrow={1} />
147
+ <Text color={c.textDim}>↵ next/save · esc cancel</Text>
148
+ </Box>
149
+
150
+ <Box flexDirection="column" marginTop={1}>
151
+ {FIELDS.map((f, i) => {
152
+ const isActive = i === idx;
153
+ const showDraft = isActive ? draft : values[f.key];
154
+ const display = f.secret && showDraft ? '•'.repeat(Math.max(0, showDraft.length)) : (showDraft || f.placeholder);
155
+ const displayColor = isActive
156
+ ? (showDraft ? c.text : c.textDim)
157
+ : (values[f.key] ? c.text : c.textDim);
158
+ return (
159
+ <Box key={f.key}>
160
+ <Text color={isActive ? c.brand : c.textDim}>
161
+ {isActive ? '▶ ' : ' '}
162
+ </Text>
163
+ <Text color={isActive ? c.text : c.textMuted}>{f.label.padEnd(14)}</Text>
164
+ <Text color={isActive ? c.text : c.textMuted}>: </Text>
165
+ <Text color={displayColor} bold={isActive}>{display}</Text>
166
+ {isActive && <Text color={c.pink}>▌</Text>}
167
+ </Box>
168
+ );
169
+ })}
170
+ </Box>
171
+
172
+ <Box marginTop={1}>
173
+ <Text color={c.textDim}>↳ {FIELDS[idx]!.help}</Text>
174
+ </Box>
175
+
176
+ <Box marginTop={1}>
177
+ <Text color={c.textDim}>File: {envPath}</Text>
178
+ </Box>
179
+
180
+ {error && (
181
+ <Box marginTop={1}>
182
+ <Text color={c.red}>✗ {error}</Text>
183
+ </Box>
184
+ )}
185
+ </Box>
186
+ );
187
+ }
@@ -0,0 +1,183 @@
1
+ import React, { useState, useEffect, useMemo, useRef } from 'react';
2
+ import { Box, Text, useInput, useApp } from 'ink';
3
+ import { KobApiClient } from '../utils/api.js';
4
+ import { getConfig } from '../utils/config.js';
5
+ import type { ModelsResponse, ProviderModels, AIModel } from '../types/index.js';
6
+ import { c } from './colors.js';
7
+
8
+ interface FlatModel extends AIModel {
9
+ provider: string;
10
+ }
11
+
12
+ interface Props {
13
+ onSelect: (modelId: string, displayName: string) => void;
14
+ onClose: () => void;
15
+ currentModel?: string;
16
+ }
17
+
18
+ export function ModelPicker({ onSelect, onClose, currentModel }: Props) {
19
+ const [providers, setProviders] = useState<ProviderModels[]>([]);
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState<string | null>(null);
22
+ const [query, setQuery] = useState('');
23
+ const [selected, setSelected] = useState(0);
24
+ const listAreaHeight = 12;
25
+ const { exit } = useApp();
26
+
27
+ useEffect(() => {
28
+ let alive = true;
29
+ const client = new KobApiClient(getConfig());
30
+ client
31
+ .post<ModelsResponse>('/api/models')
32
+ .then((data) => {
33
+ if (!alive) return;
34
+ setProviders(data.providers || []);
35
+ setLoading(false);
36
+ })
37
+ .catch((e) => {
38
+ if (!alive) return;
39
+ setError(e?.message || 'Failed to load models');
40
+ setLoading(false);
41
+ });
42
+ return () => {
43
+ alive = false;
44
+ };
45
+ }, []);
46
+
47
+ const flat: FlatModel[] = useMemo(() => {
48
+ const out: FlatModel[] = [];
49
+ for (const p of providers) {
50
+ for (const m of p.models) {
51
+ out.push({ ...m, provider: p.provider });
52
+ }
53
+ }
54
+ return out;
55
+ }, [providers]);
56
+
57
+ const filtered = useMemo(() => {
58
+ const q = query.trim().toLowerCase();
59
+ if (!q) return flat;
60
+ return flat.filter(
61
+ (m) =>
62
+ m.displayName.toLowerCase().includes(q) ||
63
+ m.modelId.toLowerCase().includes(q) ||
64
+ m.provider.toLowerCase().includes(q)
65
+ );
66
+ }, [flat, query]);
67
+
68
+ // Keep selection in bounds
69
+ useEffect(() => {
70
+ if (selected >= filtered.length) setSelected(Math.max(0, filtered.length - 1));
71
+ }, [filtered.length, selected]);
72
+
73
+ useInput((input, key) => {
74
+ if (key.escape) {
75
+ onClose();
76
+ return;
77
+ }
78
+ if (loading || error) return;
79
+ if (key.return) {
80
+ const m = filtered[selected];
81
+ if (m) {
82
+ onSelect(m.modelId, m.displayName);
83
+ }
84
+ return;
85
+ }
86
+ if (key.downArrow) {
87
+ setSelected((s) => Math.min(filtered.length - 1, s + 1));
88
+ return;
89
+ }
90
+ if (key.upArrow) {
91
+ setSelected((s) => Math.max(0, s - 1));
92
+ return;
93
+ }
94
+ if (key.pageDown) {
95
+ setSelected((s) => Math.min(filtered.length - 1, s + listAreaHeight));
96
+ return;
97
+ }
98
+ if (key.pageUp) {
99
+ setSelected((s) => Math.max(0, s - listAreaHeight));
100
+ return;
101
+ }
102
+ if (key.backspace || key.delete) {
103
+ setQuery((q) => q.slice(0, -1));
104
+ setSelected(0);
105
+ return;
106
+ }
107
+ // Plain character → append to query
108
+ if (input && !key.ctrl && !key.meta && !key.tab) {
109
+ setQuery((q) => q + input);
110
+ setSelected(0);
111
+ }
112
+ });
113
+
114
+ const total = filtered.length;
115
+ const start = Math.max(0, Math.min(selected - Math.floor(listAreaHeight / 2), Math.max(0, total - listAreaHeight)));
116
+ const end = Math.min(total, start + listAreaHeight);
117
+ const visible = filtered.slice(start, end);
118
+
119
+ return (
120
+ <Box
121
+ flexDirection="column"
122
+ borderStyle="round"
123
+ borderColor={c.brand}
124
+ paddingX={1}
125
+ marginTop={1}
126
+ >
127
+ <Box>
128
+ <Text color={c.brand} bold>◆ /models</Text>
129
+ <Text color={c.textDim}> </Text>
130
+ <Text color={c.text}>search: </Text>
131
+ <Text color={c.text} bold>{query || '​'}</Text>
132
+ <Text color={c.pink}>{loading ? ' ⋯ loading' : ` ${total} match${total === 1 ? '' : 'es'}`}</Text>
133
+ <Box flexGrow={1} />
134
+ <Text color={c.textDim}>↑↓ move · type to search · ↵ pick · esc close</Text>
135
+ </Box>
136
+
137
+ <Box flexDirection="column" marginTop={1}>
138
+ {error && (
139
+ <Text color={c.red}>✗ {error}</Text>
140
+ )}
141
+ {!error && loading && (
142
+ <Text color={c.textDim}>Fetching model catalog…</Text>
143
+ )}
144
+ {!error && !loading && total === 0 && (
145
+ <Text color={c.yellow}>No models match "{query}".</Text>
146
+ )}
147
+ {!error && !loading && total > 0 && (
148
+ <>
149
+ {visible.map((m, i) => {
150
+ const absoluteIdx = start + i;
151
+ const isSel = absoluteIdx === selected;
152
+ const isCurrent = currentModel && m.modelId === currentModel;
153
+ const provider = m.provider.padEnd(10).slice(0, 10);
154
+ const price = `$${m.inputPricePer1M.toFixed(2)}/$${m.outputPricePer1M.toFixed(2)}/M`;
155
+ return (
156
+ <Box key={`${m.provider}-${m.modelId}`}>
157
+ <Text color={isSel ? c.brand : c.textDim}>{isSel ? '▶ ' : ' '}</Text>
158
+ <Text color={isSel ? c.text : c.text} bold={isSel}>
159
+ {m.displayName}
160
+ </Text>
161
+ {isCurrent && <Text color={c.green}> ●current</Text>}
162
+ <Text color={c.textDim}> </Text>
163
+ <Text color={c.textMuted}>{provider}</Text>
164
+ <Text color={c.textDim}> </Text>
165
+ <Text color={c.yellow}>{price}</Text>
166
+ <Text color={c.textDim}> </Text>
167
+ <Text color={c.textMuted}>{m.modelId}</Text>
168
+ </Box>
169
+ );
170
+ })}
171
+ {total > listAreaHeight && (
172
+ <Box marginTop={1}>
173
+ <Text color={c.textDim}>
174
+ {start + 1}–{end} of {total}
175
+ </Text>
176
+ </Box>
177
+ )}
178
+ </>
179
+ )}
180
+ </Box>
181
+ </Box>
182
+ );
183
+ }
@@ -0,0 +1,123 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const ENV_CANDIDATES = ['.env.local', '.env'];
5
+
6
+ /**
7
+ * Find the .env file path the CLI should read/write.
8
+ * Prefers .env.local (standard for secrets), falls back to .env.
9
+ * Returns the first existing one, or .env.local if neither exists yet.
10
+ */
11
+ export function getEnvPath(): string {
12
+ const cwd = process.cwd();
13
+ for (const name of ENV_CANDIDATES) {
14
+ const p = join(cwd, name);
15
+ if (existsSync(p)) return p;
16
+ }
17
+ return join(cwd, '.env.local');
18
+ }
19
+
20
+ /**
21
+ * Parse a .env file into a key→value map. Preserves comments internally
22
+ * (so we can keep them when rewriting), but only returns the values.
23
+ */
24
+ export function readEnvFile(): Record<string, string> {
25
+ const p = getEnvPath();
26
+ if (!existsSync(p)) return {};
27
+ const content = readFileSync(p, 'utf-8');
28
+ return parseEnvText(content);
29
+ }
30
+
31
+ function parseEnvText(text: string): Record<string, string> {
32
+ const out: Record<string, string> = {};
33
+ for (const rawLine of text.split(/\r?\n/)) {
34
+ const line = rawLine.trim();
35
+ if (!line || line.startsWith('#')) continue;
36
+ const eq = line.indexOf('=');
37
+ if (eq === -1) continue;
38
+ const key = line.substring(0, eq).trim();
39
+ let val = line.substring(eq + 1).trim();
40
+ // Strip surrounding quotes
41
+ if (
42
+ (val.startsWith('"') && val.endsWith('"')) ||
43
+ (val.startsWith("'") && val.endsWith("'"))
44
+ ) {
45
+ val = val.substring(1, val.length - 1);
46
+ }
47
+ out[key] = val;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Update keys in the .env file.
54
+ * - Preserves comments, blank lines, and key ordering.
55
+ * - If a key exists, its line is replaced in place.
56
+ * - If a key is missing, it is appended to the end (with a blank line separator).
57
+ * - If the .env file doesn't exist, seeds it from .env.example when present.
58
+ *
59
+ * @param updates key→value map of entries to set
60
+ * @param comments optional key→comment map for newly-appended keys
61
+ */
62
+ export function writeEnvFile(
63
+ updates: Record<string, string>,
64
+ comments?: Record<string, string>
65
+ ): void {
66
+ const p = getEnvPath();
67
+ const examplePath = join(process.cwd(), '.env.example');
68
+
69
+ let content: string;
70
+ if (existsSync(p)) {
71
+ content = readFileSync(p, 'utf-8');
72
+ } else if (existsSync(examplePath)) {
73
+ content = readFileSync(examplePath, 'utf-8');
74
+ } else {
75
+ content = '# KOB AI Configuration\n';
76
+ }
77
+
78
+ const lines = content.split(/\r?\n/);
79
+ const updatedKeys = new Set<string>();
80
+ const out: string[] = [];
81
+
82
+ for (const line of lines) {
83
+ const trimmed = line.trim();
84
+ if (!trimmed || trimmed.startsWith('#')) {
85
+ out.push(line);
86
+ continue;
87
+ }
88
+ const eq = trimmed.indexOf('=');
89
+ if (eq === -1) {
90
+ out.push(line);
91
+ continue;
92
+ }
93
+ const key = trimmed.substring(0, eq).trim();
94
+ if (key in updates) {
95
+ out.push(`${key}=${updates[key]}`);
96
+ updatedKeys.add(key);
97
+ } else {
98
+ out.push(line);
99
+ }
100
+ }
101
+
102
+ // Append any keys that weren't already present
103
+ const toAppend = Object.entries(updates).filter(([k]) => !updatedKeys.has(k));
104
+ if (toAppend.length > 0) {
105
+ if (out.length > 0 && out[out.length - 1] !== '') out.push('');
106
+ for (const [key, val] of toAppend) {
107
+ if (comments && comments[key]) {
108
+ out.push(`# ${comments[key]}`);
109
+ }
110
+ out.push(`${key}=${val}`);
111
+ }
112
+ }
113
+
114
+ writeFileSync(p, out.join('\n'));
115
+ }
116
+
117
+ /**
118
+ * Get the human-readable path the CLI is reading from.
119
+ * Useful for UI hints (e.g. "/config" form).
120
+ */
121
+ export function describeEnvPath(): string {
122
+ return getEnvPath();
123
+ }