agentlytics 0.2.6 → 0.2.7

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/cache.js CHANGED
@@ -778,9 +778,10 @@ async function scanAllAsync(onProgress, opts = {}) {
778
778
  let analyzed = 0;
779
779
  let skipped = 0;
780
780
 
781
+ // Build existing cache map with both timestamp and bubble count
781
782
  const existing = {};
782
- for (const row of db.prepare('SELECT id, last_updated_at FROM chats').all()) {
783
- existing[row.id] = row.last_updated_at;
783
+ for (const row of db.prepare('SELECT id, last_updated_at, bubble_count FROM chats').all()) {
784
+ existing[row.id] = { ts: row.last_updated_at, bc: row.bubble_count };
784
785
  }
785
786
 
786
787
  // Normalize folder paths
@@ -803,10 +804,12 @@ async function scanAllAsync(onProgress, opts = {}) {
803
804
 
804
805
  for (const chat of chats) {
805
806
  scanned++;
806
- const cachedTs = existing[chat.composerId];
807
807
  const chatTs = chat.lastUpdatedAt || chat.createdAt || 0;
808
+ const chatBc = chat.bubbleCount || 0;
808
809
 
809
- if (cachedTs && cachedTs >= chatTs) {
810
+ // Skip if already cached, not updated, and bubble count hasn't grown
811
+ const cached = existing[chat.composerId];
812
+ if (cached && cached.ts && cached.ts >= chatTs && cached.bc >= chatBc) {
810
813
  const hasStat = db.prepare('SELECT 1 FROM chat_stats WHERE chat_id = ?').get(chat.composerId);
811
814
  if (hasStat) {
812
815
  skipped++;
@@ -855,6 +855,8 @@ function getMessages(chat) {
855
855
  // ============================================================
856
856
 
857
857
  function getUsage() {
858
+ const { isSubscriptionAccessAllowed } = require('./base');
859
+ if (!isSubscriptionAccessAllowed()) return null;
858
860
  const resp = callRpc('GetUserStatus', {});
859
861
  if (!resp || !resp.userStatus) return null;
860
862
 
package/editors/base.js CHANGED
@@ -1,8 +1,19 @@
1
1
  const path = require('path');
2
2
  const os = require('os');
3
+ const fs = require('fs');
3
4
 
4
5
  const HOME = os.homedir();
5
6
 
7
+ // --- Permission check ---
8
+
9
+ function isSubscriptionAccessAllowed() {
10
+ try {
11
+ const configPath = path.join(HOME, '.agentlytics', 'config.json');
12
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
13
+ return config.allowSubscriptionAccess === true;
14
+ } catch { return false; }
15
+ }
16
+
6
17
  // --- Platform utilities ---
7
18
 
8
19
  /**
@@ -303,6 +314,7 @@ function queryMcpServerToolsStdio(server, timeout) {
303
314
 
304
315
  module.exports = {
305
316
  getAppDataPath,
317
+ isSubscriptionAccessAllowed,
306
318
  scanArtifacts,
307
319
  parseMcpConfigFile,
308
320
  queryMcpServerTools,
package/editors/claude.js CHANGED
@@ -204,18 +204,11 @@ function extractAssistantContent(content) {
204
204
  // Usage / quota data from Anthropic OAuth API
205
205
  // ============================================================
206
206
 
207
- function isKeychainAccessAllowed() {
208
- try {
209
- const configPath = path.join(os.homedir(), '.agentlytics', 'config.json');
210
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
211
- return config.allowKeychainAccess === true;
212
- } catch { return false; }
213
- }
214
-
215
207
  function getClaudeCredentials() {
216
208
  // macOS: Keychain; Linux: secret-tool; Windows: not yet supported
217
- // Requires explicit user permission (allowKeychainAccess in config)
218
- if (!isKeychainAccessAllowed()) return null;
209
+ // Requires explicit user permission (allowSubscriptionAccess in config)
210
+ const { isSubscriptionAccessAllowed } = require('./base');
211
+ if (!isSubscriptionAccessAllowed()) return null;
219
212
  try {
220
213
  const { execSync } = require('child_process');
221
214
  let raw;
package/editors/codex.js CHANGED
@@ -477,6 +477,8 @@ function decodeJwtPayload(token) {
477
477
  }
478
478
 
479
479
  async function getUsage() {
480
+ const { isSubscriptionAccessAllowed } = require('./base');
481
+ if (!isSubscriptionAccessAllowed()) return null;
480
482
  const auth = getCodexAuth();
481
483
  if (!auth || !auth.tokens) return null;
482
484
 
@@ -211,6 +211,8 @@ function fetchCopilotStatus(token) {
211
211
  }
212
212
 
213
213
  async function getUsage() {
214
+ const { isSubscriptionAccessAllowed } = require('./base');
215
+ if (!isSubscriptionAccessAllowed()) return null;
214
216
  const creds = getCopilotToken();
215
217
  if (!creds) return null;
216
218
 
package/editors/cursor.js CHANGED
@@ -374,6 +374,8 @@ function cursorApiFetch(endpoint, token) {
374
374
  }
375
375
 
376
376
  async function getUsage() {
377
+ const { isSubscriptionAccessAllowed } = require('./base');
378
+ if (!isSubscriptionAccessAllowed()) return null;
377
379
  const token = getCursorAccessToken();
378
380
  if (!token) return null;
379
381
 
package/editors/vscode.js CHANGED
@@ -355,6 +355,8 @@ function fetchCopilotStatus(token) {
355
355
  }
356
356
 
357
357
  async function getUsage() {
358
+ const { isSubscriptionAccessAllowed } = require('./base');
359
+ if (!isSubscriptionAccessAllowed()) return null;
358
360
  const creds = getCopilotToken();
359
361
  if (!creds) return null;
360
362
 
@@ -476,6 +476,9 @@ function getWindsurfApiKey(appName) {
476
476
  }
477
477
 
478
478
  function getUsage() {
479
+ const { isSubscriptionAccessAllowed } = require('./base');
480
+ if (!isSubscriptionAccessAllowed()) return [];
481
+
479
482
  const results = [];
480
483
 
481
484
  for (const variant of VARIANTS) {
package/index.js CHANGED
@@ -253,37 +253,48 @@ const BOT_STYLES = [
253
253
  ];
254
254
 
255
255
  (async () => {
256
- // ── Ask for keychain access permission (first run only) ──
256
+ // ── Ask for subscription access permission (first run only) ──
257
257
  const CONFIG_DIR = path.join(os.homedir(), '.agentlytics');
258
258
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
259
259
  let agentConfig = {};
260
260
  try { agentConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); } catch {}
261
261
 
262
- if (agentConfig.allowKeychainAccess === undefined) {
263
- // Only relevant on macOS / Linux where keychain/secret-tool is used
264
- if (process.platform === 'darwin' || process.platform === 'linux') {
265
- const storeName = process.platform === 'darwin' ? 'Keychain' : 'secret store';
266
- console.log(chalk.yellow(` ⚠ Some subscription details (e.g. Claude Code) require ${storeName} access.`));
267
- console.log(chalk.dim(` This reads stored credentials to show plan/usage info.`));
268
- console.log('');
269
- const readline = require('readline');
270
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
271
- const answer = await new Promise(r => {
272
- rl.question(chalk.bold(` Allow ${storeName} access for subscription details? (y/N) `), (a) => {
273
- rl.close();
274
- r(a.trim().toLowerCase());
275
- });
262
+ if (agentConfig.allowSubscriptionAccess === undefined) {
263
+ console.log(chalk.yellow(' ⚠ Subscription & usage details require access to local auth tokens.'));
264
+ console.log('');
265
+ console.log(chalk.dim(' To show your plan and usage info, Agentlytics needs to read'));
266
+ console.log(chalk.dim(' locally stored tokens from the following sources:'));
267
+ console.log('');
268
+ console.log(chalk.dim(' • Claude Code – macOS Keychain / Linux secret-tool'));
269
+ console.log(chalk.dim(' Cursor – local SQLite (state.vscdb)'));
270
+ console.log(chalk.dim(' • Copilot – ~/.config/github-copilot/apps.json'));
271
+ console.log(chalk.dim(' VS Code – ~/.config/github-copilot/apps.json'));
272
+ console.log(chalk.dim(' • Codex – local auth.json (JWT decode only)'));
273
+ console.log(chalk.dim(' • Windsurf – local SQLite (state.vscdb)'));
274
+ console.log('');
275
+ console.log(chalk.dim(' These tokens are used to query each editor\'s own API for'));
276
+ console.log(chalk.dim(' your plan name and usage limits.'));
277
+ console.log('');
278
+ console.log(chalk.bold.white(' → Tokens are kept in-memory only and never sent to any'));
279
+ console.log(chalk.bold.white(' third-party service. They are discarded after the request.'));
280
+ console.log('');
281
+ const readline = require('readline');
282
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
283
+ const answer = await new Promise(r => {
284
+ rl.question(chalk.bold(' Allow local token inspection for subscription details? (y/N) '), (a) => {
285
+ rl.close();
286
+ r(a.trim().toLowerCase());
276
287
  });
277
- agentConfig.allowKeychainAccess = answer === 'y' || answer === 'yes';
278
- if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
279
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(agentConfig, null, 2));
280
- if (agentConfig.allowKeychainAccess) {
281
- console.log(chalk.green(` ✓ ${storeName} access enabled`));
282
- } else {
283
- console.log(chalk.dim(` – ${storeName} access skipped (subscription details won't be collected)`));
284
- }
285
- console.log('');
288
+ });
289
+ agentConfig.allowSubscriptionAccess = answer === 'y' || answer === 'yes';
290
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
291
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(agentConfig, null, 2));
292
+ if (agentConfig.allowSubscriptionAccess) {
293
+ console.log(chalk.green(' ✓ Subscription access enabled'));
294
+ } else {
295
+ console.log(chalk.dim(' – Subscription access skipped (plan/usage details won\'t be collected)'));
286
296
  }
297
+ console.log('');
287
298
  }
288
299
 
289
300
  let tick = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect } from 'react'
2
- import { Settings as SettingsIcon, EyeOff, Eye, FolderOpen, Search } from 'lucide-react'
2
+ import { Settings as SettingsIcon, EyeOff, Eye, FolderOpen, Search, ShieldCheck, ShieldOff, AlertTriangle, X } from 'lucide-react'
3
3
  import { fetchConfig, updateConfig, fetchAllProjects } from '../lib/api'
4
4
  import { editorLabel, formatNumber, formatDate } from '../lib/constants'
5
5
  import EditorIcon from '../components/EditorIcon'
@@ -13,6 +13,7 @@ export default function Settings() {
13
13
  const [loading, setLoading] = useState(true)
14
14
  const [saving, setSaving] = useState(false)
15
15
  const [search, setSearch] = useState('')
16
+ const [showConfirm, setShowConfirm] = useState(false)
16
17
 
17
18
  useEffect(() => {
18
19
  Promise.all([fetchConfig(), fetchAllProjects()]).then(([cfg, projs]) => {
@@ -47,10 +48,45 @@ export default function Settings() {
47
48
 
48
49
  const sorted = [...filtered].sort((a, b) => a.name.localeCompare(b.name))
49
50
 
51
+ const subscriptionAccess = !!config.allowSubscriptionAccess
52
+
53
+ const toggleSubscriptionAccess = async () => {
54
+ setSaving(true)
55
+ const newConfig = await updateConfig({ allowSubscriptionAccess: !subscriptionAccess })
56
+ setConfig(newConfig)
57
+ setSaving(false)
58
+ setShowConfirm(false)
59
+ }
60
+
50
61
  return (
51
62
  <div className="fade-in space-y-3">
52
63
  <PageHeader icon={SettingsIcon} title="Settings" />
53
64
 
65
+ <div className="card overflow-hidden">
66
+ <div className="px-3 py-2 flex items-center justify-between" style={{ borderBottom: '1px solid var(--c-border)' }}>
67
+ <SectionTitle>
68
+ {subscriptionAccess ? <ShieldCheck size={11} className="inline mr-1" /> : <ShieldOff size={11} className="inline mr-1" />}
69
+ subscription access
70
+ </SectionTitle>
71
+ <button
72
+ onClick={() => setShowConfirm(true)}
73
+ disabled={saving}
74
+ className="text-[11px] px-2 py-0.5 rounded transition"
75
+ style={{
76
+ background: subscriptionAccess ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)',
77
+ color: subscriptionAccess ? '#22c55e' : '#ef4444',
78
+ border: `1px solid ${subscriptionAccess ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)'}`,
79
+ }}
80
+ >
81
+ {subscriptionAccess ? 'Enabled' : 'Disabled'}
82
+ </button>
83
+ </div>
84
+ <div className="text-[11px] px-3 py-2" style={{ color: 'var(--c-text3)' }}>
85
+ When enabled, Agentlytics reads locally stored auth tokens (Keychain, SQLite, config files) to show your plan and usage info for each editor.
86
+ Tokens are kept in-memory only and <span style={{ color: 'var(--c-text2)' }}>never sent to any third-party service</span>.
87
+ </div>
88
+ </div>
89
+
54
90
  <div className="card overflow-hidden">
55
91
  <div className="px-3 py-2 flex items-center justify-between" style={{ borderBottom: '1px solid var(--c-border)' }}>
56
92
  <SectionTitle>
@@ -88,6 +124,70 @@ export default function Settings() {
88
124
  <div className="text-center py-6 text-[12px]" style={{ color: 'var(--c-text3)' }}>no projects match filter</div>
89
125
  )}
90
126
  </div>
127
+ {showConfirm && (
128
+ <ConfirmModal
129
+ enabling={!subscriptionAccess}
130
+ saving={saving}
131
+ onConfirm={toggleSubscriptionAccess}
132
+ onCancel={() => setShowConfirm(false)}
133
+ />
134
+ )}
135
+ </div>
136
+ )
137
+ }
138
+
139
+ function ConfirmModal({ enabling, saving, onConfirm, onCancel }) {
140
+ return (
141
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }} onClick={onCancel}>
142
+ <div className="card w-[420px] mx-4" onClick={e => e.stopPropagation()} style={{ background: 'var(--c-bg2)', border: '1px solid var(--c-border)' }}>
143
+ <div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
144
+ <div className="flex items-center gap-2 text-[13px] font-medium" style={{ color: 'var(--c-white)' }}>
145
+ <AlertTriangle size={14} style={{ color: enabling ? '#fbbf24' : '#ef4444' }} />
146
+ {enabling ? 'Enable' : 'Disable'} Subscription Access
147
+ </div>
148
+ <button onClick={onCancel} className="p-1 rounded hover:bg-[var(--c-bg3)]" style={{ color: 'var(--c-text3)' }}>
149
+ <X size={14} />
150
+ </button>
151
+ </div>
152
+ <div className="px-4 py-3 text-[11px] space-y-2" style={{ color: 'var(--c-text2)' }}>
153
+ {enabling ? (
154
+ <>
155
+ <p>This will allow Agentlytics to read locally stored auth tokens from:</p>
156
+ <ul className="space-y-1 pl-3" style={{ color: 'var(--c-text3)' }}>
157
+ <li>Claude Code &ndash; macOS Keychain / Linux secret-tool</li>
158
+ <li>Cursor &ndash; local SQLite (state.vscdb)</li>
159
+ <li>Copilot / VS Code &ndash; ~/.config/github-copilot/apps.json</li>
160
+ <li>Codex &ndash; local auth.json (JWT decode only)</li>
161
+ <li>Windsurf &ndash; local SQLite (state.vscdb)</li>
162
+ </ul>
163
+ <p style={{ color: 'var(--c-text2)' }}>Tokens are kept <strong>in-memory only</strong> and never sent to any third-party service.</p>
164
+ </>
165
+ ) : (
166
+ <p>This will stop Agentlytics from reading any local auth tokens. Subscription and plan details will no longer be collected.</p>
167
+ )}
168
+ </div>
169
+ <div className="flex justify-end gap-2 px-4 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
170
+ <button
171
+ onClick={onCancel}
172
+ className="text-[11px] px-3 py-1 rounded transition"
173
+ style={{ color: 'var(--c-text3)', border: '1px solid var(--c-border)' }}
174
+ >
175
+ Cancel
176
+ </button>
177
+ <button
178
+ onClick={onConfirm}
179
+ disabled={saving}
180
+ className="text-[11px] px-3 py-1 rounded transition font-medium"
181
+ style={{
182
+ background: enabling ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
183
+ color: enabling ? '#22c55e' : '#ef4444',
184
+ border: `1px solid ${enabling ? 'rgba(34,197,94,0.2)' : 'rgba(239,68,68,0.2)'}`,
185
+ }}
186
+ >
187
+ {saving ? 'Saving...' : enabling ? 'Yes, enable' : 'Yes, disable'}
188
+ </button>
189
+ </div>
190
+ </div>
91
191
  </div>
92
192
  )
93
193
  }