krasavacode 0.2.2 → 0.3.1

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 CHANGED
@@ -67,6 +67,34 @@ chmod +x ~/krasavacode
67
67
 
68
68
  ---
69
69
 
70
+ ## Хочешь модель помощнее? (опционально, 60 секунд)
71
+
72
+ По дефолту работает простая модель — её хватит на первые проекты. Если хочешь качество как у настоящего Claude — подключи бесплатный **Google Gemini 2.5 Flash** (1500 запросов в день, без карты).
73
+
74
+ **Как:**
75
+
76
+ 1. Запусти **значок «ВАЙБКОДИНГ»** на Рабочем столе. В программе напиши команду:
77
+
78
+ ```
79
+ /setup-gemini
80
+ ```
81
+
82
+ *(или из Терминала: `npx krasavacode setup-gemini`)*
83
+
84
+ 2. У тебя в браузере **сама откроется страница** с тремя шагами и красивой кнопкой.
85
+
86
+ 3. На этой странице:
87
+ - Кликни **«Открыть Google AI Studio»** → войдёшь через свой Gmail
88
+ - Нажми **«Create API key»** — появится строка вида `AIzaSy…`
89
+ - Скопируй её, вернись в нашу страницу и **вставь** в поле
90
+ - Нажми **«Подключить и проверить»**
91
+
92
+ 4. Если зелёная галочка — **готово**. Окно само закроется.
93
+
94
+ С этого момента значок **«ВАЙБКОДИНГ»** работает уже на Gemini. Качество — ощутимо выше.
95
+
96
+ ---
97
+
70
98
  ## Что-то пошло не так?
71
99
 
72
100
  | Проблема | Что делать |
@@ -5,15 +5,21 @@ import { ensurePreset } from '../src/preset.js';
5
5
  import { launchClaude } from '../src/launch.js';
6
6
  import { runUpgrade } from '../src/upgrade.js';
7
7
  import { runDoctor } from '../src/doctor.js';
8
+ import { runSetupGemini } from '../src/setup-gemini.js';
8
9
 
9
10
  // Hardcoded so it works inside Bun --compile (no FS access to package.json)
10
- const VERSION = '0.2.2';
11
+ const VERSION = '0.3.1';
11
12
 
12
13
  const cmd = process.argv[2];
13
14
 
14
15
  async function main() {
15
16
  if (cmd === 'doctor') return runDoctor();
16
17
  if (cmd === 'upgrade') return runUpgrade();
18
+ if (cmd === 'setup-gemini' || cmd === 'gemini') {
19
+ const result = await runSetupGemini();
20
+ if (!result?.launchAfter) return;
21
+ // fall through to normal launch flow below
22
+ }
17
23
  if (cmd === '--version' || cmd === '-v') {
18
24
  console.log(`KRASAVACODE v${VERSION}`);
19
25
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "krasavacode",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "KRASAVACODE — однокнопочный бесплатный вайбкодинг для учеников. Claude Code на бесплатных провайдерах через локальный gateway. Сам ставит Node при необходимости.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/hub.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { setTimeout as sleep } from 'node:timers/promises';
3
3
  import { CCR_PORT } from './preset.js';
4
+ import { loadGeminiKey } from './setup-gemini.js';
4
5
 
5
6
  const HOST = '127.0.0.1';
6
7
  const PORT = CCR_PORT;
@@ -38,10 +39,16 @@ export async function startHub(paths) {
38
39
 
39
40
  process.stdout.write(`🚀 Поднимаю локальный gateway на порту ${PORT}… `);
40
41
 
42
+ // Inject GEMINI_API_KEY into ccr env if user has configured Gemini.
43
+ // ccr's config.json references it as $GEMINI_API_KEY (env-interpolation).
44
+ const ccrEnv = { ...paths.env };
45
+ const geminiKey = await loadGeminiKey();
46
+ if (geminiKey) ccrEnv.GEMINI_API_KEY = geminiKey;
47
+
41
48
  const child = spawn(paths.ccrBin, ['start'], {
42
49
  stdio: process.env.KRASAVACODE_DEBUG ? 'inherit' : 'pipe',
43
50
  detached: false,
44
- env: paths.env,
51
+ env: ccrEnv,
45
52
  });
46
53
 
47
54
  let stderrTail = '';
package/src/launch.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { isGeminiConfigured } from './setup-gemini.js';
2
3
 
3
4
  const PLACEHOLDER_TOKEN = 'sk-krasavacode-local';
4
5
 
5
6
  export async function launchClaude(paths, hub /*, detection */) {
7
+ const geminiOn = await isGeminiConfigured();
8
+
6
9
  const env = {
7
10
  ...process.env,
8
11
  ANTHROPIC_BASE_URL: hub.baseUrl,
@@ -21,7 +24,12 @@ export async function launchClaude(paths, hub /*, detection */) {
21
24
  console.log('');
22
25
  console.log('━'.repeat(58));
23
26
  console.log(' KRASAVACODE — вайбкодинг через локальный hub');
24
- console.log(' Hub: ' + hub.baseUrl + ' (claude-code-router → Pollinations)');
27
+ if (geminiOn) {
28
+ console.log(' Модель: Google Gemini 2.5 Flash (1500 запросов в день)');
29
+ } else {
30
+ console.log(' Модель: gpt-oss-20b через Pollinations (бесплатно, без логина)');
31
+ console.log(' 💡 Хочешь модель посильнее бесплатно? → krasavacode setup-gemini');
32
+ }
25
33
  console.log(' Пиши задачу обычным языком, ИИ сделает.');
26
34
  console.log('━'.repeat(58));
27
35
  console.log('');
package/src/preset.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile, copyFile, access } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
- import { join, dirname } from 'node:path';
3
+ import { join } from 'node:path';
4
+ import { isGeminiConfigured } from './setup-gemini.js';
4
5
 
5
6
  const CCR_DIR = join(homedir(), '.claude-code-router');
6
7
  const CCR_CONFIG = join(CCR_DIR, 'config.json');
@@ -8,52 +9,76 @@ const STATE_FILE = join(homedir(), '.krasavacode', 'state.json');
8
9
 
9
10
  const KRASAVACODE_MARKER = 'krasavacode/managed';
10
11
 
11
- const FREE_CONFIG = {
12
- HOST: '127.0.0.1',
13
- PORT: 3456,
14
- LOG: false,
15
- API_TIMEOUT_MS: 600000,
16
- Providers: [
17
- {
18
- name: 'pollinations',
19
- api_base_url: 'https://text.pollinations.ai/openai/chat/completions',
20
- api_key: 'public',
21
- models: ['openai', 'openai-fast', 'gpt-oss-20b'],
22
- },
23
- ],
24
- Router: {
25
- default: 'pollinations,openai',
26
- background: 'pollinations,openai-fast',
27
- think: 'pollinations,openai',
28
- longContext: 'pollinations,openai',
29
- longContextThreshold: 60000,
30
- },
31
- // Marker so future runs know this config is ours and we may overwrite it.
32
- // If a user has manually edited config (no marker), we leave it alone.
33
- _krasavacode: KRASAVACODE_MARKER,
34
- };
12
+ function pollinationsProvider() {
13
+ return {
14
+ name: 'pollinations',
15
+ api_base_url: 'https://text.pollinations.ai/openai/chat/completions',
16
+ api_key: 'public',
17
+ models: ['openai', 'openai-fast', 'gpt-oss-20b'],
18
+ };
19
+ }
35
20
 
36
- async function readState() {
37
- try { return JSON.parse(await readFile(STATE_FILE, 'utf8')); }
38
- catch { return {}; }
21
+ function geminiProvider() {
22
+ return {
23
+ name: 'gemini',
24
+ api_base_url: 'https://generativelanguage.googleapis.com/v1beta/models/',
25
+ api_key: '$GEMINI_API_KEY',
26
+ models: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-flash-latest'],
27
+ transformer: { use: ['gemini'] },
28
+ };
39
29
  }
40
- async function writeState(s) {
41
- await writeFile(STATE_FILE, JSON.stringify(s, null, 2));
30
+
31
+ function buildConfig({ withGemini }) {
32
+ const Providers = withGemini
33
+ ? [geminiProvider(), pollinationsProvider()]
34
+ : [pollinationsProvider()];
35
+
36
+ const Router = withGemini
37
+ ? {
38
+ default: 'gemini,gemini-2.5-flash',
39
+ background: 'gemini,gemini-2.5-flash',
40
+ think: 'gemini,gemini-2.5-pro',
41
+ longContext: 'gemini,gemini-2.5-pro',
42
+ longContextThreshold: 60000,
43
+ }
44
+ : {
45
+ default: 'pollinations,openai',
46
+ background: 'pollinations,openai-fast',
47
+ think: 'pollinations,openai',
48
+ longContext: 'pollinations,openai',
49
+ longContextThreshold: 60000,
50
+ };
51
+
52
+ return {
53
+ HOST: '127.0.0.1',
54
+ PORT: 3456,
55
+ LOG: false,
56
+ API_TIMEOUT_MS: 600000,
57
+ Providers,
58
+ Router,
59
+ _krasavacode: KRASAVACODE_MARKER,
60
+ };
42
61
  }
43
62
 
44
- async function exists(p) {
45
- return access(p).then(() => true).catch(() => false);
63
+ async function readState() {
64
+ try { return JSON.parse(await readFile(STATE_FILE, 'utf8')); }
65
+ catch { return {}; }
46
66
  }
67
+ async function writeState(s) { await writeFile(STATE_FILE, JSON.stringify(s, null, 2)); }
68
+ async function exists(p) { return access(p).then(() => true).catch(() => false); }
47
69
 
48
70
  /**
49
- * Writes a default claude-code-router config pointing at Pollinations
50
- * (free, no-API-key). Backs up any existing user config before overwriting.
71
+ * Generates ~/.claude-code-router/config.json:
72
+ * - If user has run setup-gemini Gemini first, Pollinations as fallback Provider
73
+ * - Else → Pollinations only
51
74
  *
52
- * Returns { mode: 'anthropic-direct' } so launch.js stays generic.
75
+ * Returns { withGemini: boolean }.
53
76
  */
54
- export async function ensurePreset(/* hub unused in CCR mode */) {
77
+ export async function ensurePreset() {
55
78
  await mkdir(CCR_DIR, { recursive: true });
56
79
  const state = await readState();
80
+ const withGemini = await isGeminiConfigured();
81
+ const config = buildConfig({ withGemini });
57
82
 
58
83
  if (await exists(CCR_CONFIG)) {
59
84
  let existing;
@@ -69,16 +94,10 @@ export async function ensurePreset(/* hub unused in CCR mode */) {
69
94
  await writeState(state);
70
95
  console.log(`💾 Найден свой config.json у claude-code-router — сохранил резервную копию: ${backupPath}`);
71
96
  }
72
-
73
- if (isOurs) {
74
- // Already managed by us; rewrite each time so updates roll out.
75
- await writeFile(CCR_CONFIG, JSON.stringify(FREE_CONFIG, null, 2));
76
- return { mode: 'anthropic-direct' };
77
- }
78
97
  }
79
98
 
80
- await writeFile(CCR_CONFIG, JSON.stringify(FREE_CONFIG, null, 2));
81
- return { mode: 'anthropic-direct' };
99
+ await writeFile(CCR_CONFIG, JSON.stringify(config, null, 2));
100
+ return { withGemini };
82
101
  }
83
102
 
84
- export const CCR_PORT = FREE_CONFIG.PORT;
103
+ export const CCR_PORT = 3456;
@@ -0,0 +1,427 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, writeFile, chmod, readFile, access } from 'node:fs/promises';
3
+ import { homedir, platform } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import http from 'node:http';
7
+ import net from 'node:net';
8
+
9
+ const ROOT = join(homedir(), '.krasavacode');
10
+ const ENV_FILE = join(ROOT, 'gemini.env');
11
+ const STATE_FILE = join(ROOT, 'state.json');
12
+
13
+ const CONSOLE_URL = 'https://aistudio.google.com/apikey';
14
+
15
+ function openBrowser(url) {
16
+ const cmd = platform() === 'darwin' ? 'open'
17
+ : platform() === 'win32' ? 'start'
18
+ : 'xdg-open';
19
+ const args = platform() === 'win32' ? ['', url] : [url];
20
+ try {
21
+ spawn(cmd, args, { detached: true, stdio: 'ignore', shell: platform() === 'win32' }).unref();
22
+ return true;
23
+ } catch { return false; }
24
+ }
25
+
26
+ function readState() { return readFile(STATE_FILE, 'utf8').then(JSON.parse).catch(() => ({})); }
27
+ async function writeState(s) { await writeFile(STATE_FILE, JSON.stringify(s, null, 2)); }
28
+
29
+ function isValidKeyFormat(key) {
30
+ return /^AIza[A-Za-z0-9_-]{35}$/.test(key);
31
+ }
32
+
33
+ async function verifyKey(key) {
34
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${encodeURIComponent(key)}`;
35
+ const t0 = Date.now();
36
+ let res;
37
+ try {
38
+ res = await fetch(url, {
39
+ method: 'POST',
40
+ headers: { 'content-type': 'application/json' },
41
+ body: JSON.stringify({
42
+ contents: [{ parts: [{ text: 'Say only: ok' }] }],
43
+ generationConfig: { maxOutputTokens: 20 },
44
+ }),
45
+ signal: AbortSignal.timeout(15000),
46
+ });
47
+ } catch (e) {
48
+ return { ok: false, error: 'Сеть не отвечает: ' + e.message, ms: Date.now() - t0 };
49
+ }
50
+ const ms = Date.now() - t0;
51
+ if (!res.ok) {
52
+ let msg = `HTTP ${res.status}`;
53
+ try { const j = await res.json(); msg = j.error?.message || msg; } catch {}
54
+ return { ok: false, error: msg, ms };
55
+ }
56
+ const data = await res.json();
57
+ const text = (data.candidates?.[0]?.content?.parts?.[0]?.text || '').trim();
58
+ return { ok: true, text, ms };
59
+ }
60
+
61
+ function getFreePort() {
62
+ return new Promise((resolve, reject) => {
63
+ const srv = net.createServer();
64
+ srv.unref();
65
+ srv.on('error', reject);
66
+ srv.listen(0, '127.0.0.1', () => {
67
+ const { port } = srv.address();
68
+ srv.close(() => resolve(port));
69
+ });
70
+ });
71
+ }
72
+
73
+ async function persistKey(key) {
74
+ await mkdir(ROOT, { recursive: true });
75
+ await writeFile(ENV_FILE, `GEMINI_API_KEY=${key}\n`);
76
+ try { await chmod(ENV_FILE, 0o600); } catch {}
77
+ const state = await readState();
78
+ state.geminiConfigured = true;
79
+ state.geminiConfiguredAt = new Date().toISOString();
80
+ await writeState(state);
81
+ }
82
+
83
+ const HTML = (port) => `<!doctype html>
84
+ <html lang="ru">
85
+ <head>
86
+ <meta charset="utf-8">
87
+ <title>KRASAVACODE — подключение Google Gemini</title>
88
+ <meta name="viewport" content="width=device-width, initial-scale=1">
89
+ <style>
90
+ * { box-sizing: border-box; }
91
+ body {
92
+ margin: 0; padding: 32px 16px;
93
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
94
+ background: linear-gradient(180deg, #f7f7fb 0%, #ecedf3 100%);
95
+ color: #1d1d1f; min-height: 100vh;
96
+ display: flex; align-items: flex-start; justify-content: center;
97
+ }
98
+ .card {
99
+ width: 100%; max-width: 640px;
100
+ background: #fff; border-radius: 20px;
101
+ box-shadow: 0 20px 60px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.04);
102
+ padding: 36px;
103
+ }
104
+ h1 { font-size: 28px; margin: 0 0 6px; letter-spacing: -.5px; }
105
+ .sub { color: #65656d; margin: 0 0 28px; }
106
+ .step { display: flex; gap: 14px; margin: 18px 0; align-items: flex-start; }
107
+ .num {
108
+ flex: 0 0 32px; height: 32px; border-radius: 50%;
109
+ background: #1d1d1f; color: #fff; font-weight: 600;
110
+ display: flex; align-items: center; justify-content: center; font-size: 15px;
111
+ }
112
+ .num.done { background: #2ecc71; }
113
+ .body { flex: 1; padding-top: 4px; }
114
+ .body strong { display: block; font-weight: 600; margin-bottom: 4px; }
115
+ .body p { margin: 0; color: #515158; font-size: 14.5px; line-height: 1.5; }
116
+ .open-btn {
117
+ display: inline-block; margin-top: 8px; padding: 10px 18px;
118
+ background: #1a73e8; color: #fff; text-decoration: none;
119
+ border-radius: 10px; font-weight: 500; font-size: 14px;
120
+ }
121
+ .open-btn:hover { background: #1666d3; }
122
+ .field { margin-top: 26px; }
123
+ label { display: block; font-weight: 600; margin-bottom: 8px; font-size: 15px; }
124
+ input[type="text"] {
125
+ width: 100%; padding: 14px 16px;
126
+ border: 2px solid #e3e3eb; border-radius: 12px;
127
+ font-size: 15px; font-family: 'SF Mono', Menlo, Consolas, monospace;
128
+ transition: border-color .15s; outline: none;
129
+ }
130
+ input[type="text"]:focus { border-color: #1a73e8; }
131
+ .submit {
132
+ margin-top: 12px; width: 100%; padding: 14px;
133
+ background: #1d1d1f; color: #fff;
134
+ border: none; border-radius: 12px;
135
+ font-size: 16px; font-weight: 600; cursor: pointer;
136
+ transition: background .15s;
137
+ }
138
+ .submit:hover:not(:disabled) { background: #000; }
139
+ .submit:disabled { background: #aaa; cursor: not-allowed; }
140
+ .msg { margin-top: 14px; padding: 14px 16px; border-radius: 10px; font-size: 14px; }
141
+ .msg.error { background: #fff1f0; color: #b00020; border: 1px solid #ffd1cc; }
142
+ .msg.ok { background: #effaf3; color: #1a7f4d; border: 1px solid #b8e6c9; font-size: 15px; }
143
+ .msg.ok strong { display: block; font-size: 17px; margin-bottom: 4px; }
144
+ .footer { margin-top: 24px; font-size: 13px; color: #8b8b94; text-align: center; }
145
+ .countdown { font-weight: 600; color: #1a7f4d; }
146
+ @media (prefers-color-scheme: dark) {
147
+ body { background: linear-gradient(180deg, #1a1a1f 0%, #0f0f12 100%); color: #f0f0f5; }
148
+ .card { background: #232328; box-shadow: 0 20px 60px rgba(0,0,0,.4); }
149
+ .num { background: #f0f0f5; color: #1d1d1f; }
150
+ .body p { color: #b8b8c0; }
151
+ input[type="text"] { background: #2c2c33; border-color: #3a3a42; color: #f0f0f5; }
152
+ input[type="text"]:focus { border-color: #4a90e2; }
153
+ .submit { background: #f0f0f5; color: #1d1d1f; }
154
+ .submit:hover:not(:disabled) { background: #fff; }
155
+ .footer { color: #6a6a72; }
156
+ .msg.error { background: #3a1f1f; color: #ff8a80; border-color: #5a2929; }
157
+ .msg.ok { background: #1f3a2a; color: #8eddb0; border-color: #295a3c; }
158
+ }
159
+ </style>
160
+ </head>
161
+ <body>
162
+ <div class="card">
163
+ <h1>Подключение Google Gemini</h1>
164
+ <p class="sub">Бесплатно. 1500 запросов в день. Без банковской карты.</p>
165
+
166
+ <div class="step">
167
+ <div class="num">1</div>
168
+ <div class="body">
169
+ <strong>Открой Google AI Studio и войди через свой Gmail</strong>
170
+ <p>Нажми кнопку ниже — откроется новая вкладка. Войди под тем же гуглом, что и обычно.</p>
171
+ <a class="open-btn" href="${CONSOLE_URL}" target="_blank" rel="noopener">Открыть Google AI Studio →</a>
172
+ </div>
173
+ </div>
174
+
175
+ <div class="step">
176
+ <div class="num">2</div>
177
+ <div class="body">
178
+ <strong>Нажми «Create API key»</strong>
179
+ <p>Большая синяя кнопка наверху страницы. Если попросят выбрать проект — оставь предложенный.</p>
180
+ </div>
181
+ </div>
182
+
183
+ <div class="step">
184
+ <div class="num">3</div>
185
+ <div class="body">
186
+ <strong>Скопируй полученный ключ</strong>
187
+ <p>Длинная строка, которая начинается с «AIza». Нажми «Copy» рядом с ней.</p>
188
+ </div>
189
+ </div>
190
+
191
+ <div class="field">
192
+ <label for="key">Вставь ключ сюда (Cmd/Ctrl+V):</label>
193
+ <input id="key" type="text" autocomplete="off" spellcheck="false" placeholder="AIzaSy…">
194
+ <button id="submit" class="submit">Подключить и проверить</button>
195
+ <div id="msg"></div>
196
+ </div>
197
+
198
+ <div class="footer">Это окно открыл сам KRASAVACODE на твоём компьютере. Никуда ничего не отправляется, кроме одного тестового запроса в Google.</div>
199
+ </div>
200
+
201
+ <script>
202
+ const input = document.getElementById('key');
203
+ const btn = document.getElementById('submit');
204
+ const msg = document.getElementById('msg');
205
+ input.focus();
206
+
207
+ btn.addEventListener('click', submit);
208
+ input.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); });
209
+
210
+ async function submit() {
211
+ const key = input.value.trim();
212
+ msg.className = '';
213
+ msg.textContent = '';
214
+ if (!key) { showError('Поле пустое — вставь ключ из шага 3.'); return; }
215
+ if (!/^AIza[A-Za-z0-9_-]{35}$/.test(key)) {
216
+ showError('Это не похоже на ключ Gemini. Должно быть AIza + 35 символов (всего 39). Скопируй ещё раз.');
217
+ return;
218
+ }
219
+ btn.disabled = true; btn.textContent = 'Проверяю…';
220
+ try {
221
+ const r = await fetch('/api/verify', {
222
+ method: 'POST',
223
+ headers: {'content-type': 'application/json'},
224
+ body: JSON.stringify({ key }),
225
+ });
226
+ const data = await r.json();
227
+ if (!r.ok || !data.ok) {
228
+ showError(data.error || 'Не получилось проверить. Попробуй ещё раз.');
229
+ btn.disabled = false; btn.textContent = 'Подключить и проверить';
230
+ return;
231
+ }
232
+ showSuccess(data);
233
+ } catch (e) {
234
+ showError('Сеть не отвечает: ' + e.message);
235
+ btn.disabled = false; btn.textContent = 'Подключить и проверить';
236
+ }
237
+ }
238
+
239
+ function showError(text) {
240
+ msg.className = 'msg error';
241
+ msg.textContent = text;
242
+ }
243
+
244
+ function showSuccess(data) {
245
+ msg.className = 'msg ok';
246
+ msg.innerHTML = '<strong>✅ Готово!</strong>Gemini ответил «' + escapeHtml(data.text || 'ok') + '» за ' + (data.ms/1000).toFixed(1) + ' сек. Теперь твой вайбкодинг — на Gemini 2.5 Flash. <span class="countdown">Окно закроется через <span id="cd">5</span>…</span>';
247
+ btn.style.display = 'none';
248
+ let n = 5;
249
+ const cd = document.getElementById('cd');
250
+ const timer = setInterval(() => {
251
+ n--;
252
+ if (cd) cd.textContent = n;
253
+ if (n <= 0) {
254
+ clearInterval(timer);
255
+ fetch('/api/done', { method: 'POST' }).catch(() => {});
256
+ window.close();
257
+ }
258
+ }, 1000);
259
+ }
260
+
261
+ function escapeHtml(s) {
262
+ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
263
+ }
264
+
265
+ window.addEventListener('beforeunload', () => {
266
+ // best-effort cancel if user closes the tab without finishing
267
+ navigator.sendBeacon && navigator.sendBeacon('/api/cancel');
268
+ });
269
+ </script>
270
+ </body>
271
+ </html>
272
+ `;
273
+
274
+ function readJsonBody(req, max = 8 * 1024) {
275
+ return new Promise((resolve, reject) => {
276
+ const chunks = [];
277
+ let total = 0;
278
+ req.on('data', d => {
279
+ total += d.length;
280
+ if (total > max) { req.destroy(); reject(new Error('body too large')); }
281
+ chunks.push(d);
282
+ });
283
+ req.on('end', () => {
284
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
285
+ catch { reject(new Error('bad json')); }
286
+ });
287
+ req.on('error', reject);
288
+ });
289
+ }
290
+
291
+ async function browserOnboarding() {
292
+ const port = await getFreePort();
293
+ const url = `http://127.0.0.1:${port}`;
294
+
295
+ let resolveResult;
296
+ const done = new Promise(r => { resolveResult = r; });
297
+
298
+ const server = http.createServer(async (req, res) => {
299
+ if (req.method === 'GET' && (req.url === '/' || req.url.startsWith('/?'))) {
300
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
301
+ return res.end(HTML(port));
302
+ }
303
+ if (req.method === 'POST' && req.url === '/api/verify') {
304
+ try {
305
+ const body = await readJsonBody(req);
306
+ const key = String(body.key || '').trim();
307
+ if (!isValidKeyFormat(key)) {
308
+ res.writeHead(400, { 'Content-Type': 'application/json' });
309
+ return res.end(JSON.stringify({ error: 'Не похоже на ключ Gemini.' }));
310
+ }
311
+ const r = await verifyKey(key);
312
+ if (!r.ok) {
313
+ res.writeHead(400, { 'Content-Type': 'application/json' });
314
+ return res.end(JSON.stringify({ error: r.error }));
315
+ }
316
+ await persistKey(key);
317
+ res.writeHead(200, { 'Content-Type': 'application/json' });
318
+ res.end(JSON.stringify({ ok: true, text: r.text, ms: r.ms }));
319
+ // Schedule shutdown after 6s — gives the page time to show the success state
320
+ setTimeout(() => resolveResult({ launchAfter: true, configured: true }), 6000);
321
+ } catch (e) {
322
+ res.writeHead(400, { 'Content-Type': 'application/json' });
323
+ res.end(JSON.stringify({ error: e.message }));
324
+ }
325
+ return;
326
+ }
327
+ if (req.method === 'POST' && (req.url === '/api/done' || req.url === '/api/cancel')) {
328
+ res.writeHead(200, { 'Content-Type': 'application/json' });
329
+ res.end('{}');
330
+ // If cancel arrived before a successful verify, treat as cancellation.
331
+ // If after — resolveResult was already called by /api/verify timeout.
332
+ setTimeout(() => resolveResult({ launchAfter: false, configured: false }), 100);
333
+ return;
334
+ }
335
+ res.writeHead(404);
336
+ res.end();
337
+ });
338
+
339
+ server.listen(port, '127.0.0.1');
340
+
341
+ const opened = openBrowser(url);
342
+ console.log('');
343
+ console.log(' ╔══════════════════════════════════════════════════╗');
344
+ console.log(' ║ KRASAVACODE — подключаем Google Gemini ║');
345
+ console.log(' ╚══════════════════════════════════════════════════╝');
346
+ console.log('');
347
+ if (opened) {
348
+ console.log(` 🌐 Открыл настройку в браузере: ${url}`);
349
+ } else {
350
+ console.log(` Не получилось открыть браузер автоматически.`);
351
+ console.log(` Скопируй и открой эту ссылку вручную: ${url}`);
352
+ }
353
+ console.log(' Это окно (терминал) можно не трогать — оно закроется само.');
354
+ console.log('');
355
+ console.log(' Нажми Ctrl+C чтобы отменить и вернуться позже.');
356
+ console.log('');
357
+
358
+ let result;
359
+ try {
360
+ result = await Promise.race([
361
+ done,
362
+ new Promise((_, rej) => process.once('SIGINT', () => rej(new Error('cancelled'))) ),
363
+ ]);
364
+ } catch {
365
+ result = { launchAfter: false, configured: false };
366
+ }
367
+ server.close();
368
+ return result;
369
+ }
370
+
371
+ /* ────── CLI fallback (kept for non-GUI environments) ────── */
372
+
373
+ function prompt(q) {
374
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
375
+ return new Promise(resolve => rl.question(q, ans => { rl.close(); resolve(ans); }));
376
+ }
377
+
378
+ async function cliOnboarding() {
379
+ console.log('\n Браузерный режим недоступен — запускаю текстовый.\n');
380
+ console.log(` Открой в браузере: ${CONSOLE_URL}`);
381
+ console.log(' Войди через Google → «Create API key» → скопируй ключ.\n');
382
+
383
+ let key;
384
+ for (let i = 0; i < 3; i++) {
385
+ key = (await prompt(' Вставь ключ Gemini сюда: ')).trim();
386
+ if (isValidKeyFormat(key)) break;
387
+ console.log(' ⚠️ Не похоже на ключ Gemini. Попробуй ещё раз.\n');
388
+ }
389
+ if (!isValidKeyFormat(key)) {
390
+ console.log('\n ❌ Не удалось получить ключ. Запусти команду ещё раз позже.');
391
+ return { launchAfter: false, configured: false };
392
+ }
393
+ console.log('\n ⏳ Проверяю…');
394
+ const r = await verifyKey(key);
395
+ if (!r.ok) {
396
+ console.log(` ❌ ${r.error}\n Запусти команду ещё раз.`);
397
+ return { launchAfter: false, configured: false };
398
+ }
399
+ await persistKey(key);
400
+ console.log(` ✅ Готово! Gemini ответил за ${(r.ms/1000).toFixed(1)} сек.\n`);
401
+ const launch = (await prompt(' Запустить вайбкодинг сейчас? [Enter — да, n — позже]: ')).trim().toLowerCase();
402
+ return { launchAfter: launch === '' || launch === 'y' || launch === 'yes' || launch === 'д' || launch === 'да', configured: true };
403
+ }
404
+
405
+ export async function runSetupGemini() {
406
+ const useBrowser = process.env.KRASAVACODE_NO_BROWSER !== '1' && process.stdout.isTTY !== false;
407
+ if (useBrowser) {
408
+ try { return await browserOnboarding(); }
409
+ catch (e) {
410
+ console.error(' Браузерный мастер упал:', e.message);
411
+ return cliOnboarding();
412
+ }
413
+ }
414
+ return cliOnboarding();
415
+ }
416
+
417
+ export async function loadGeminiKey() {
418
+ try {
419
+ const content = await readFile(ENV_FILE, 'utf8');
420
+ const m = content.match(/^GEMINI_API_KEY=(.+)$/m);
421
+ return m ? m[1].trim() : null;
422
+ } catch { return null; }
423
+ }
424
+
425
+ export async function isGeminiConfigured() {
426
+ return access(ENV_FILE).then(() => true).catch(() => false);
427
+ }