krasavacode 0.3.3 → 0.3.5

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.
@@ -8,7 +8,7 @@ import { runDoctor } from '../src/doctor.js';
8
8
  import { runSetupGemini } from '../src/setup-gemini.js';
9
9
 
10
10
  // Hardcoded so it works inside Bun --compile (no FS access to package.json)
11
- const VERSION = '0.3.3';
11
+ const VERSION = '0.3.5';
12
12
 
13
13
  const cmd = process.argv[2];
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "krasavacode",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "KRASAVACODE — однокнопочный бесплатный вайбкодинг для учеников. Claude Code на бесплатных провайдерах через локальный gateway. Сам ставит Node при необходимости.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/hub.js CHANGED
@@ -2,6 +2,7 @@ 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
4
  import { loadGeminiKey } from './setup-gemini.js';
5
+ import { startMetricsProxy } from './metrics-proxy.js';
5
6
 
6
7
  const HOST = '127.0.0.1';
7
8
  const PORT = CCR_PORT;
@@ -88,11 +89,24 @@ export async function startHub(paths) {
88
89
  }
89
90
  }
90
91
 
91
- return { process: child, port: PORT, baseUrl, ownedByUs: true };
92
+ // Front the ccr endpoint with our metrics-counting proxy. Claude Code
93
+ // talks to the proxy; the proxy forwards to ccr; we count requests and
94
+ // translate 429 errors into friendly Russian messages.
95
+ const metrics = await startMetricsProxy(baseUrl);
96
+
97
+ return {
98
+ process: child,
99
+ port: PORT,
100
+ ccrBaseUrl: baseUrl,
101
+ baseUrl: metrics.baseUrl, // <-- Claude Code будет ходить сюда
102
+ metrics,
103
+ ownedByUs: true,
104
+ };
92
105
  }
93
106
 
94
107
  export async function stopHub(hub) {
95
108
  if (!hub) return;
109
+ if (hub.metrics) await hub.metrics.stop().catch(() => {});
96
110
  if (!hub.ownedByUs) return; // we didn't start it; leave it running for the user
97
111
  if (!hub.process || hub.process.killed) return;
98
112
 
package/src/launch.js CHANGED
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { isGeminiConfigured } from './setup-gemini.js';
6
+ import { getQuotaInfo } from './metrics-proxy.js';
6
7
 
7
8
  const PLACEHOLDER_TOKEN = 'sk-krasavacode-local';
8
9
  const CLAUDE_CONFIG_DIR = join(homedir(), '.krasavacode', 'claude-config');
@@ -15,18 +16,21 @@ export async function launchClaude(paths, hub /*, detection */) {
15
16
  // Organization · API Usage Billing" header on the welcome screen.
16
17
  await mkdir(CLAUDE_CONFIG_DIR, { recursive: true });
17
18
 
18
- // Drop any pre-existing ANTHROPIC_API_KEY (from the user's shell or a real
19
- // Anthropic login) so it doesn't conflict with our auth-token, and so that
20
- // Claude Code's welcome screen doesn't show the user's real Anthropic org.
19
+ // Drop any pre-existing Anthropic creds from the shell/Keychain so the
20
+ // welcome screen doesn't greet the student with the real Anthropic owner's
21
+ // name and "API Usage Billing".
21
22
  const cleanEnv = { ...process.env };
22
23
  delete cleanEnv.ANTHROPIC_API_KEY;
24
+ delete cleanEnv.ANTHROPIC_AUTH_TOKEN;
23
25
  delete cleanEnv.ANTHROPIC_VERTEX_PROJECT_ID;
24
26
  delete cleanEnv.ANTHROPIC_BEDROCK_BASE_URL;
25
27
 
26
28
  const env = {
27
29
  ...cleanEnv,
28
30
  ANTHROPIC_BASE_URL: hub.baseUrl,
29
- ANTHROPIC_AUTH_TOKEN: PLACEHOLDER_TOKEN,
31
+ // --bare mode requires ANTHROPIC_API_KEY (OAuth token is ignored).
32
+ // Our proxy doesn't actually validate this — any non-empty value works.
33
+ ANTHROPIC_API_KEY: PLACEHOLDER_TOKEN,
30
34
  // Isolate config/credentials: own dir, separate from ~/.claude/
31
35
  ANTHROPIC_CONFIG_DIR: CLAUDE_CONFIG_DIR,
32
36
  DISABLE_AUTOUPDATER: '1',
@@ -37,13 +41,21 @@ export async function launchClaude(paths, hub /*, detection */) {
37
41
  ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-5',
38
42
  };
39
43
 
40
- const passthroughArgs = process.argv.slice(2).filter(a => !['doctor', 'upgrade'].includes(a));
44
+ // --bare mode: skips Keychain reads, plugin sync, auto-memory, attribution,
45
+ // CLAUDE.md auto-discovery — i.e. everything that would leak the user's
46
+ // real Anthropic identity into the welcome screen.
47
+ // Set KRASAVACODE_BARE=0 to disable for debugging.
48
+ const useBare = process.env.KRASAVACODE_BARE !== '0';
49
+ const passthroughArgs = process.argv.slice(2)
50
+ .filter(a => !['doctor', 'upgrade', 'setup-gemini', 'gemini'].includes(a));
51
+ if (useBare && !passthroughArgs.includes('--bare')) passthroughArgs.unshift('--bare');
41
52
 
42
53
  const W = 64;
43
54
  const line = (txt) => {
44
55
  const pad = Math.max(0, W - 2 - [...txt].length);
45
56
  return '┃ ' + txt + ' '.repeat(pad) + '┃';
46
57
  };
58
+ const quota = await getQuotaInfo();
47
59
  console.log('');
48
60
  console.log('┏' + '━'.repeat(W - 1) + '┓');
49
61
  console.log(line(' K R A S A V A C O D E'));
@@ -51,10 +63,19 @@ export async function launchClaude(paths, hub /*, detection */) {
51
63
  console.log('┣' + '━'.repeat(W - 1) + '┫');
52
64
  if (geminiOn) {
53
65
  console.log(line(' ✓ Модель: Google Gemini 2.5 Flash'));
54
- console.log(line(' (1500 запросов в день, бесплатно)'));
66
+ const left = quota.perDay - quota.used;
67
+ if (left > 100) {
68
+ console.log(line(` Сегодня осталось: ${left} из ${quota.perDay} запросов`));
69
+ } else if (left > 0) {
70
+ console.log(line(` ⚠️ Осталось ${left} из ${quota.perDay} — обнулится завтра`));
71
+ } else {
72
+ console.log(line(` ❌ Лимит на сегодня исчерпан (${quota.used} из ${quota.perDay})`));
73
+ console.log(line(` Обнулится в ~21:00 МСК`));
74
+ }
55
75
  } else {
56
76
  console.log(line(' · Модель: gpt-oss-20b через Pollinations'));
57
77
  console.log(line(' (бесплатно, без логина)'));
78
+ console.log(line(` Сегодня сделал ${quota.used} запросов`));
58
79
  console.log(line(' 💡 Лучше модель: krasavacode setup-gemini'));
59
80
  }
60
81
  console.log('┗' + '━'.repeat(W - 1) + '┛');
@@ -0,0 +1,147 @@
1
+ import http from 'node:http';
2
+ import net from 'node:net';
3
+ import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { isGeminiConfigured } from './setup-gemini.js';
7
+
8
+ const ROOT = join(homedir(), '.krasavacode');
9
+ const USAGE_FILE = join(ROOT, 'usage.json');
10
+
11
+ const FREE_QUOTA = {
12
+ gemini: { perDay: 1500, label: 'Google Gemini 2.5 Flash (free tier)' },
13
+ pollinations: { perDay: null, label: 'Pollinations (free)' },
14
+ };
15
+
16
+ function todayKey() {
17
+ return new Date().toISOString().slice(0, 10);
18
+ }
19
+
20
+ async function readUsage() {
21
+ try { return JSON.parse(await readFile(USAGE_FILE, 'utf8')); }
22
+ catch { return {}; }
23
+ }
24
+
25
+ async function writeUsage(u) {
26
+ await mkdir(ROOT, { recursive: true });
27
+ await writeFile(USAGE_FILE, JSON.stringify(u, null, 2));
28
+ }
29
+
30
+ async function bump() {
31
+ const u = await readUsage();
32
+ const day = todayKey();
33
+ u[day] = (u[day] || 0) + 1;
34
+ u.lastRequestAt = new Date().toISOString();
35
+ // keep only last 30 days
36
+ for (const k of Object.keys(u)) {
37
+ if (/^\d{4}-\d{2}-\d{2}$/.test(k)) {
38
+ const age = (Date.now() - new Date(k).getTime()) / 86400000;
39
+ if (age > 30) delete u[k];
40
+ }
41
+ }
42
+ await writeUsage(u);
43
+ }
44
+
45
+ export async function getTodayUsage() {
46
+ const u = await readUsage();
47
+ return u[todayKey()] || 0;
48
+ }
49
+
50
+ export async function getQuotaInfo() {
51
+ const provider = (await isGeminiConfigured()) ? 'gemini' : 'pollinations';
52
+ const used = await getTodayUsage();
53
+ const { perDay, label } = FREE_QUOTA[provider];
54
+ return { provider, used, perDay, label, remaining: perDay ? Math.max(0, perDay - used) : null };
55
+ }
56
+
57
+ function getFreePort() {
58
+ return new Promise((resolve, reject) => {
59
+ const srv = net.createServer();
60
+ srv.unref();
61
+ srv.on('error', reject);
62
+ srv.listen(0, '127.0.0.1', () => {
63
+ const { port } = srv.address();
64
+ srv.close(() => resolve(port));
65
+ });
66
+ });
67
+ }
68
+
69
+ const FRIENDLY_429 = (provider, used) => ({
70
+ type: 'error',
71
+ error: {
72
+ type: 'rate_limit_error',
73
+ message:
74
+ provider === 'gemini'
75
+ ? `Закончились бесплатные запросы Google Gemini на сегодня (использовано ${used} из 1500).\n\n` +
76
+ `Квота обнулится в ~10:00 PT (~21:00 МСК).\n\n` +
77
+ `Что делать сейчас:\n` +
78
+ ` • Подожди до завтра, и продолжи\n` +
79
+ ` • Или подключи второй Google-аккаунт через krasavacode setup-gemini\n` +
80
+ ` • Или временно вернись на Pollinations: удали ~/.krasavacode/gemini.env`
81
+ : `Pollinations на минуту перегружен. Подожди ~30 секунд и нажми Enter ещё раз.\n` +
82
+ `Или подключи Gemini для стабильности: krasavacode setup-gemini`,
83
+ },
84
+ });
85
+
86
+ /**
87
+ * Proxy: Claude Code → metrics-proxy (this) → ccr → upstream.
88
+ *
89
+ * - Counts every successful POST /v1/messages as one request, written to ~/.krasavacode/usage.json
90
+ * - Replaces 429 responses with a friendly Russian message
91
+ * - Streams everything else through unmodified (so SSE works)
92
+ */
93
+ export async function startMetricsProxy(upstreamBaseUrl) {
94
+ const upstream = new URL(upstreamBaseUrl);
95
+ const port = await getFreePort();
96
+
97
+ const debug = process.env.KRASAVACODE_DEBUG === '1';
98
+ const server = http.createServer((req, res) => {
99
+ const path = (req.url || '').split('?')[0];
100
+ const isMessages = req.method === 'POST' && path === '/v1/messages';
101
+ if (debug) console.error(`[metrics] ${req.method} ${req.url}`);
102
+
103
+ const proxyReq = http.request({
104
+ hostname: upstream.hostname,
105
+ port: upstream.port,
106
+ path: req.url,
107
+ method: req.method,
108
+ headers: req.headers,
109
+ }, async (upRes) => {
110
+ if (debug) console.error(`[metrics] ← ${upRes.statusCode} ${req.url}`);
111
+ // Treat any 2xx on /v1/messages as one billable request — count immediately.
112
+ if (isMessages && upRes.statusCode >= 200 && upRes.statusCode < 300) {
113
+ bump().catch(e => debug && console.error('[metrics] bump fail', e));
114
+ }
115
+
116
+ // 429: try to replace body with a friendly message (non-streaming).
117
+ // If body is streaming/SSE we still let it through — Claude Code shows it.
118
+ if (upRes.statusCode === 429 && !/text\/event-stream/.test(upRes.headers['content-type'] || '')) {
119
+ const used = await getTodayUsage();
120
+ const provider = (await isGeminiConfigured()) ? 'gemini' : 'pollinations';
121
+ const body = JSON.stringify(FRIENDLY_429(provider, used));
122
+ const headers = { ...upRes.headers, 'content-type': 'application/json' };
123
+ delete headers['content-length'];
124
+ delete headers['content-encoding'];
125
+ res.writeHead(429, headers);
126
+ upRes.resume(); // drain the original
127
+ res.end(body);
128
+ return;
129
+ }
130
+
131
+ res.writeHead(upRes.statusCode, upRes.headers);
132
+ upRes.pipe(res);
133
+ });
134
+
135
+ proxyReq.on('error', (err) => {
136
+ res.writeHead(502, { 'Content-Type': 'application/json' });
137
+ res.end(JSON.stringify({ error: { type: 'upstream_error', message: err.message } }));
138
+ });
139
+
140
+ req.pipe(proxyReq);
141
+ });
142
+
143
+ await new Promise(r => server.listen(port, '127.0.0.1', r));
144
+
145
+ const baseUrl = `http://127.0.0.1:${port}`;
146
+ return { server, port, baseUrl, stop: () => new Promise(r => server.close(r)) };
147
+ }