gemini-cli-devtools 0.1.4 → 0.2.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/src/index.js CHANGED
@@ -4,25 +4,19 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
  import http from 'node:http';
7
- import { EventEmitter } from 'node:events';
8
- import { execFile } from 'node:child_process';
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
- import os from 'node:os';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { readFileSync } from 'node:fs';
12
9
  import { fileURLToPath } from 'node:url';
10
+ import { dirname, join } from 'node:path';
11
+ import { EventEmitter } from 'node:events';
13
12
  import { WebSocketServer } from 'ws';
14
- const VITE_DEV_PORT = 5174;
15
- // Check if running in dev mode by looking for src directory (not in npm package)
16
- const isDev = (() => {
17
- const srcDir = path.dirname(fileURLToPath(import.meta.url));
18
- // In dev: src/index.ts -> srcDir is "src"
19
- // In prod: dist/src/index.js -> srcDir is "dist/src"
20
- return srcDir.endsWith('/src') && !srcDir.includes('/dist/');
21
- })();
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const indexHtml = readFileSync(join(__dirname, '../../client/index.html'), 'utf-8');
15
+ const mainJs = readFileSync(join(__dirname, '../client/main.js'), 'utf-8');
22
16
  /**
23
17
  * DevTools Viewer
24
18
  *
25
- * Deeply discovers and tails session logs across all projects.
19
+ * Receives logs via WebSocket from CLI sessions.
26
20
  */
27
21
  export class DevTools extends EventEmitter {
28
22
  static instance;
@@ -31,8 +25,8 @@ export class DevTools extends EventEmitter {
31
25
  server = null;
32
26
  wss = null;
33
27
  sessions = new Map();
28
+ heartbeatTimer = null;
34
29
  port = 25417;
35
- watchedFiles = new Map(); // filePath -> lastSize
36
30
  constructor() {
37
31
  super();
38
32
  }
@@ -42,126 +36,17 @@ export class DevTools extends EventEmitter {
42
36
  }
43
37
  return DevTools.instance;
44
38
  }
45
- getNetworkLogs() {
46
- return this.logs;
47
- }
48
- getConsoleLogs() {
49
- return this.consoleLogs;
50
- }
51
- /**
52
- * Main entry for log discovery.
53
- * It scans both user home and system tmp for .gemini/tmp folders.
54
- */
55
- setLogFile() {
56
- const potentialRoots = [
57
- path.join(os.homedir(), '.gemini', 'tmp'),
58
- path.join(os.tmpdir(), '.gemini', 'tmp'),
59
- ];
60
- for (const baseDir of potentialRoots) {
61
- if (fs.existsSync(baseDir)) {
62
- console.log(`🔍 Scanning for logs in: ${baseDir}`);
63
- this.deepDiscover(baseDir);
64
- this.watchRoot(baseDir);
65
- }
66
- }
67
- }
68
- watchRoot(root) {
69
- try {
70
- fs.watch(root, { recursive: true }, (_event, filename) => {
71
- if (filename &&
72
- filename.includes('session-') &&
73
- filename.endsWith('.jsonl')) {
74
- this.deepDiscover(root);
75
- }
76
- });
77
- }
78
- catch {
79
- setInterval(() => this.deepDiscover(root), 2000);
80
- }
81
- }
82
- deepDiscover(dir) {
83
- try {
84
- const items = fs.readdirSync(dir);
85
- for (const item of items) {
86
- const fullPath = path.join(dir, item);
87
- let stats;
88
- try {
89
- stats = fs.statSync(fullPath);
90
- }
91
- catch {
92
- continue;
93
- }
94
- if (stats.isDirectory()) {
95
- if (item === 'logs' || item.length > 20) {
96
- this.deepDiscover(fullPath);
97
- }
98
- }
99
- else if (item.startsWith('session-') && item.endsWith('.jsonl')) {
100
- if (!this.watchedFiles.has(fullPath)) {
101
- this.watchedFiles.set(fullPath, 0);
102
- this.readNewLogs(fullPath, 0);
103
- }
104
- else if (stats.size > this.watchedFiles.get(fullPath)) {
105
- this.readNewLogs(fullPath, this.watchedFiles.get(fullPath));
106
- this.watchedFiles.set(fullPath, stats.size);
107
- }
108
- }
109
- }
110
- }
111
- catch {
112
- /* ignore */
113
- }
114
- }
115
- readNewLogs(filePath, startByte) {
116
- try {
117
- const filename = path.basename(filePath);
118
- const sessionMatch = filename.match(/session-(.*)\.jsonl/);
119
- const fallbackSessionId = sessionMatch ? sessionMatch[1] : undefined;
120
- const stream = fs.createReadStream(filePath, { start: startByte });
121
- let buffer = '';
122
- stream.on('data', (chunk) => {
123
- buffer += chunk.toString();
124
- const lines = buffer.split('\n');
125
- buffer = lines.pop() || '';
126
- for (const line of lines) {
127
- if (!line.trim())
128
- continue;
129
- try {
130
- const entry = JSON.parse(line);
131
- const sid = entry.sessionId || fallbackSessionId;
132
- if (entry.type === 'console') {
133
- this.addInternalConsoleLog(entry.payload, sid, entry.timestamp);
134
- }
135
- else if (entry.type === 'network') {
136
- this.addInternalNetworkLog(entry.payload, sid, entry.timestamp);
137
- }
138
- }
139
- catch {
140
- /* ignore */
141
- }
142
- }
143
- try {
144
- this.watchedFiles.set(filePath, fs.statSync(filePath).size);
145
- }
146
- catch {
147
- /* ignore */
148
- }
149
- });
150
- }
151
- catch {
152
- /* ignore */
153
- }
154
- }
155
39
  addInternalConsoleLog(payload, sessionId, timestamp) {
156
- this.consoleLogs.push({
40
+ const entry = {
157
41
  ...payload,
158
- id: Math.random().toString(36).substring(7),
42
+ id: randomUUID(),
159
43
  sessionId,
160
44
  timestamp: timestamp || Date.now(),
161
- });
45
+ };
46
+ this.consoleLogs.push(entry);
162
47
  if (this.consoleLogs.length > 5000)
163
48
  this.consoleLogs.shift();
164
- this.emit('console-update');
49
+ this.emit('console-update', entry);
165
50
  }
166
51
  addInternalNetworkLog(payload, sessionId, timestamp) {
167
52
  if (!payload.id)
@@ -173,7 +58,6 @@ export class DevTools extends EventEmitter {
173
58
  if (payload.chunk) {
174
59
  const chunks = existing.chunks || [];
175
60
  chunks.push(payload.chunk);
176
- console.log(`[DevTools] Received chunk ${payload.chunk.index} for request ${payload.id}, total chunks: ${chunks.length}`);
177
61
  this.logs[existingIndex] = {
178
62
  ...existing,
179
63
  chunks,
@@ -191,31 +75,47 @@ export class DevTools extends EventEmitter {
191
75
  : existing.response,
192
76
  };
193
77
  }
78
+ this.emit('update', this.logs[existingIndex]);
194
79
  }
195
80
  else if (payload.url) {
196
- this.logs.push({
81
+ const entry = {
197
82
  ...payload,
198
83
  sessionId,
199
84
  timestamp: timestamp || Date.now(),
200
85
  chunks: payload.chunk ? [payload.chunk] : undefined,
201
- });
86
+ };
87
+ this.logs.push(entry);
202
88
  if (this.logs.length > 2000)
203
89
  this.logs.shift();
90
+ this.emit('update', entry);
204
91
  }
205
- this.emit('update');
206
92
  }
207
93
  getUrl() {
208
94
  return `http://127.0.0.1:${this.port}`;
209
95
  }
210
- getClientPath() {
211
- const srcDir = path.dirname(fileURLToPath(import.meta.url));
212
- // When running with tsx from src/, look for dist/client
213
- // When running compiled from dist/src/, look for ../client
214
- const distClient = path.join(srcDir, '../dist/client');
215
- if (fs.existsSync(distClient)) {
216
- return distClient;
217
- }
218
- return path.join(srcDir, '../client');
96
+ getPort() {
97
+ return this.port;
98
+ }
99
+ stop() {
100
+ return new Promise((resolve) => {
101
+ if (this.heartbeatTimer) {
102
+ clearInterval(this.heartbeatTimer);
103
+ this.heartbeatTimer = null;
104
+ }
105
+ if (this.wss) {
106
+ this.wss.close();
107
+ this.wss = null;
108
+ }
109
+ if (this.server) {
110
+ this.server.close(() => resolve());
111
+ this.server = null;
112
+ }
113
+ else {
114
+ resolve();
115
+ }
116
+ // Reset singleton so a fresh start() is possible
117
+ DevTools.instance = undefined;
118
+ });
219
119
  }
220
120
  start() {
221
121
  return new Promise((resolve) => {
@@ -223,100 +123,53 @@ export class DevTools extends EventEmitter {
223
123
  resolve(this.getUrl());
224
124
  return;
225
125
  }
226
- const clientPath = this.getClientPath();
227
126
  this.server = http.createServer((req, res) => {
228
127
  res.setHeader('Access-Control-Allow-Origin', '*');
229
128
  // API routes
230
- if (req.url === '/logs') {
231
- res.writeHead(200, { 'Content-Type': 'application/json' });
232
- res.end(JSON.stringify(this.logs));
233
- }
234
- else if (req.url === '/console-logs') {
235
- res.writeHead(200, { 'Content-Type': 'application/json' });
236
- res.end(JSON.stringify(this.consoleLogs));
237
- }
238
- else if (req.url === '/sessions') {
239
- res.writeHead(200, { 'Content-Type': 'application/json' });
240
- const sessionIds = Array.from(this.sessions.keys());
241
- res.end(JSON.stringify(sessionIds));
242
- }
243
- else if (req.url === '/events') {
129
+ if (req.url === '/events') {
244
130
  res.writeHead(200, {
245
131
  'Content-Type': 'text/event-stream',
246
132
  'Cache-Control': 'no-cache',
247
133
  Connection: 'keep-alive',
248
134
  });
249
- const l1 = () => res.write(`event: update\ndata: \n\n`);
250
- const l2 = () => res.write(`event: console-update\ndata: \n\n`);
251
- const l3 = () => res.write(`event: session-update\ndata: \n\n`);
252
- this.on('update', l1);
253
- this.on('console-update', l2);
254
- this.on('session-update', l3);
135
+ // Send full snapshot on connect
136
+ const snapshot = JSON.stringify({
137
+ networkLogs: this.logs,
138
+ consoleLogs: this.consoleLogs,
139
+ sessions: Array.from(this.sessions.keys()),
140
+ });
141
+ res.write(`event: snapshot\ndata: ${snapshot}\n\n`);
142
+ // Incremental updates
143
+ const onNetwork = (log) => {
144
+ res.write(`event: network\ndata: ${JSON.stringify(log)}\n\n`);
145
+ };
146
+ const onConsole = (log) => {
147
+ res.write(`event: console\ndata: ${JSON.stringify(log)}\n\n`);
148
+ };
149
+ const onSession = () => {
150
+ const sessions = Array.from(this.sessions.keys());
151
+ res.write(`event: session\ndata: ${JSON.stringify(sessions)}\n\n`);
152
+ };
153
+ this.on('update', onNetwork);
154
+ this.on('console-update', onConsole);
155
+ this.on('session-update', onSession);
255
156
  req.on('close', () => {
256
- this.off('update', l1);
257
- this.off('console-update', l2);
258
- this.off('session-update', l3);
157
+ this.off('update', onNetwork);
158
+ this.off('console-update', onConsole);
159
+ this.off('session-update', onSession);
259
160
  });
260
161
  }
261
- else if (req.method === 'POST' && req.url === '/ingest') {
262
- let body = '';
263
- req.on('data', (chunk) => (body += chunk));
264
- req.on('end', () => {
265
- try {
266
- const entry = JSON.parse(body);
267
- if (entry.type === 'console') {
268
- this.addInternalConsoleLog(entry.payload, entry.sessionId, entry.timestamp);
269
- }
270
- else if (entry.type === 'network') {
271
- this.addInternalNetworkLog(entry.payload, entry.sessionId, entry.timestamp);
272
- }
273
- res.writeHead(200);
274
- res.end('OK');
275
- }
276
- catch {
277
- res.writeHead(400);
278
- res.end('Invalid JSON');
279
- }
280
- });
162
+ else if (req.url === '/' || req.url === '/index.html') {
163
+ res.writeHead(200, { 'Content-Type': 'text/html' });
164
+ res.end(indexHtml);
281
165
  }
282
- else if (req.method === 'POST' && req.url === '/api/to-textproto') {
283
- let body = '';
284
- req.on('data', (chunk) => (body += chunk));
285
- req.on('end', () => {
286
- const tmpIn = path.join(os.tmpdir(), `req-${Date.now()}.json`);
287
- fs.writeFileSync(tmpIn, body);
288
- const script = `
289
- import sys
290
- sys.path.insert(0, '/tmp/proto_gen')
291
- from google.cloud.aiplatform.v1 import prediction_service_pb2
292
- from google.protobuf.json_format import Parse
293
- from google.protobuf import text_format
294
- import json
295
-
296
- with open(sys.argv[1]) as f:
297
- msg = Parse(f.read(), prediction_service_pb2.GenerateContentRequest(), ignore_unknown_fields=True)
298
- print(text_format.MessageToString(msg, as_utf8=True), end='')
299
- `;
300
- execFile('/tmp/proto_venv/bin/python3', ['-c', script, tmpIn], { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
301
- fs.unlinkSync(tmpIn);
302
- if (err) {
303
- res.writeHead(500, { 'Content-Type': 'text/plain' });
304
- res.end(stderr || err.message);
305
- }
306
- else {
307
- res.writeHead(200, { 'Content-Type': 'text/plain' });
308
- res.end(stdout);
309
- }
310
- });
311
- });
312
- }
313
- else if (isDev) {
314
- // Dev mode: proxy to Vite
315
- this.proxyToVite(req, res);
166
+ else if (req.url === '/assets/main.js') {
167
+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
168
+ res.end(mainJs);
316
169
  }
317
170
  else {
318
- // Production: serve static files
319
- this.serveStatic(req, res, clientPath);
171
+ res.writeHead(404);
172
+ res.end('Not Found');
320
173
  }
321
174
  });
322
175
  this.server.on('error', (e) => {
@@ -351,7 +204,6 @@ print(text_format.MessageToString(msg, as_utf8=True), end='')
351
204
  ws,
352
205
  lastPing: Date.now(),
353
206
  });
354
- console.log(`📡 WebSocket registered: ${sessionId}`);
355
207
  // Notify session update
356
208
  this.emit('session-update');
357
209
  // Send registration acknowledgement
@@ -365,28 +217,25 @@ print(text_format.MessageToString(msg, as_utf8=True), end='')
365
217
  this.handleWebSocketMessage(sessionId, message);
366
218
  }
367
219
  }
368
- catch (err) {
369
- console.error('Invalid WebSocket message:', err);
220
+ catch {
221
+ // Invalid WebSocket message
370
222
  }
371
223
  });
372
224
  ws.on('close', () => {
373
225
  if (sessionId) {
374
- console.log(`📡 WebSocket disconnected: ${sessionId}`);
375
226
  this.sessions.delete(sessionId);
376
- this.emit('session-disconnected', sessionId);
377
227
  this.emit('session-update');
378
228
  }
379
229
  });
380
- ws.on('error', (err) => {
381
- console.error(`WebSocket error:`, err);
230
+ ws.on('error', () => {
231
+ // WebSocket error — no action needed
382
232
  });
383
233
  });
384
234
  // Heartbeat mechanism
385
- setInterval(() => {
235
+ this.heartbeatTimer = setInterval(() => {
386
236
  const now = Date.now();
387
237
  this.sessions.forEach((session, sessionId) => {
388
238
  if (now - session.lastPing > 30000) {
389
- console.log(`⚠️ Session ${sessionId} timeout, closing...`);
390
239
  session.ws.close();
391
240
  this.sessions.delete(sessionId);
392
241
  }
@@ -412,47 +261,7 @@ print(text_format.MessageToString(msg, as_utf8=True), end='')
412
261
  this.addInternalNetworkLog(message.payload, sessionId, message.timestamp);
413
262
  break;
414
263
  default:
415
- console.warn(`Unknown message type: ${message.type}`);
264
+ break;
416
265
  }
417
266
  }
418
- proxyToVite(req, res) {
419
- const proxyReq = http.request({
420
- hostname: '127.0.0.1',
421
- port: VITE_DEV_PORT,
422
- path: req.url,
423
- method: req.method,
424
- headers: req.headers,
425
- }, (proxyRes) => {
426
- res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
427
- proxyRes.pipe(res);
428
- });
429
- proxyReq.on('error', () => {
430
- res.writeHead(502);
431
- res.end('Vite dev server not ready');
432
- });
433
- req.pipe(proxyReq);
434
- }
435
- serveStatic(req, res, clientPath) {
436
- const url = req.url === '/' ? '/index.html' : req.url || '/index.html';
437
- const filePath = path.join(clientPath, url);
438
- fs.readFile(filePath, (err, data) => {
439
- if (err) {
440
- res.writeHead(404);
441
- res.end('Not Found');
442
- return;
443
- }
444
- const ext = path.extname(filePath);
445
- const contentTypes = {
446
- '.html': 'text/html',
447
- '.js': 'application/javascript',
448
- '.css': 'text/css',
449
- '.svg': 'image/svg+xml',
450
- '.json': 'application/json',
451
- };
452
- res.writeHead(200, {
453
- 'Content-Type': contentTypes[ext] || 'text/plain',
454
- });
455
- res.end(data);
456
- });
457
- }
458
267
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ export interface NetworkLog {
7
+ id: string;
8
+ sessionId?: string;
9
+ timestamp: number;
10
+ method: string;
11
+ url: string;
12
+ headers: Record<string, string | string[] | undefined>;
13
+ body?: string;
14
+ pending?: boolean;
15
+ chunks?: Array<{
16
+ index: number;
17
+ data: string;
18
+ timestamp: number;
19
+ }>;
20
+ response?: {
21
+ status: number;
22
+ headers: Record<string, string | string[] | undefined>;
23
+ body?: string;
24
+ durationMs: number;
25
+ };
26
+ error?: string;
27
+ }
28
+ export interface ConsoleLogPayload {
29
+ type: 'log' | 'warn' | 'error' | 'debug' | 'info';
30
+ content: string;
31
+ }
32
+ export interface InspectorConsoleLog extends ConsoleLogPayload {
33
+ id: string;
34
+ sessionId?: string;
35
+ timestamp: number;
36
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ export {};
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "gemini-cli-devtools",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",
7
- "bin": {
8
- "gemini-cli-devtools": "dist/src/bin.js"
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/src/index.d.ts",
10
+ "default": "./dist/src/index.js"
11
+ }
9
12
  },
10
13
  "scripts": {
11
- "dev": "concurrently -k \"vite --port 5174\" \"tsx watch src/bin.ts\"",
12
- "build": "npm run build:client && tsc --outDir dist/src --module ESNext --target ESNext --moduleResolution bundler src/index.ts src/bin.ts",
13
- "build:client": "vite build"
14
+ "build": "npm run build:client && tsc --outDir dist/src --declaration --module ESNext --target ESNext --moduleResolution bundler src/index.ts",
15
+ "build:client": "node esbuild.client.js"
14
16
  },
15
17
  "files": [
16
18
  "dist"
@@ -19,19 +21,16 @@
19
21
  "node": ">=20"
20
22
  },
21
23
  "dependencies": {
22
- "react": "^18.2.0",
23
- "react-dom": "^18.2.0",
24
24
  "ws": "^8.16.0"
25
25
  },
26
26
  "devDependencies": {
27
+ "@types/node": "^20.11.24",
27
28
  "@types/react": "^18.2.0",
28
29
  "@types/react-dom": "^18.2.0",
29
30
  "@types/ws": "^8.5.10",
30
- "@vitejs/plugin-react": "^4.2.0",
31
- "typescript": "^5.3.3",
32
- "vite": "^5.0.0",
33
- "@types/node": "^20.11.24",
34
- "concurrently": "^8.2.2",
35
- "tsx": "^4.7.0"
31
+ "esbuild": "^0.24.0",
32
+ "react": "^18.2.0",
33
+ "react-dom": "^18.2.0",
34
+ "typescript": "^5.3.3"
36
35
  }
37
36
  }