claude-usage-dashboard 1.4.0 → 1.4.2
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 +114 -77
- package/bin/cli.cjs +20 -20
- package/bin/cli.js +16 -16
- package/bin/cli.sh +11 -11
- package/package.json +43 -43
- package/public/css/style.css +265 -265
- package/public/index.html +108 -108
- package/public/js/api.js +16 -16
- package/public/js/app.js +304 -304
- package/public/js/charts/cache-efficiency.js +29 -29
- package/public/js/charts/cost-comparison.js +39 -39
- package/public/js/charts/model-distribution.js +56 -56
- package/public/js/charts/project-distribution.js +103 -103
- package/public/js/charts/session-stats.js +117 -117
- package/public/js/charts/token-trend.js +357 -357
- package/public/js/components/date-picker.js +35 -35
- package/public/js/components/plan-selector.js +57 -57
- package/server/aggregator.js +151 -151
- package/server/credentials.js +112 -112
- package/server/index.js +45 -45
- package/server/parser.js +129 -129
- package/server/pricing.js +52 -52
- package/server/routes/api.js +141 -130
- package/server/sync.js +69 -69
package/server/credentials.js
CHANGED
|
@@ -1,112 +1,112 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import { execSync } from 'child_process';
|
|
5
|
-
|
|
6
|
-
const CREDENTIALS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
7
|
-
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
8
|
-
|
|
9
|
-
function readFromKeychain() {
|
|
10
|
-
if (process.platform !== 'darwin') return null;
|
|
11
|
-
try {
|
|
12
|
-
const raw = execSync(
|
|
13
|
-
`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`,
|
|
14
|
-
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
15
|
-
).trim();
|
|
16
|
-
const data = JSON.parse(raw);
|
|
17
|
-
return data.claudeAiOauth || null;
|
|
18
|
-
} catch {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function readFromWindowsCredentialManager() {
|
|
24
|
-
if (process.platform !== 'win32') return null;
|
|
25
|
-
try {
|
|
26
|
-
// Use PowerShell with Win32 CredRead API to read from Windows Credential Manager.
|
|
27
|
-
// Encode as UTF-16LE base64 for -EncodedCommand to avoid all shell escaping issues.
|
|
28
|
-
const psScript = [
|
|
29
|
-
'Add-Type -TypeDefinition @"',
|
|
30
|
-
'using System; using System.Runtime.InteropServices;',
|
|
31
|
-
'public class CredManager {',
|
|
32
|
-
' [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]',
|
|
33
|
-
' public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);',
|
|
34
|
-
' [DllImport("advapi32.dll")] public static extern void CredFree(IntPtr cred);',
|
|
35
|
-
' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]',
|
|
36
|
-
' public struct CREDENTIAL {',
|
|
37
|
-
' public int Flags; public int Type; public string TargetName; public string Comment;',
|
|
38
|
-
' public long LastWritten; public int CredentialBlobSize; public IntPtr CredentialBlob;',
|
|
39
|
-
' public int Persist; public int AttributeCount; public IntPtr Attributes;',
|
|
40
|
-
' public string TargetAlias; public string UserName;',
|
|
41
|
-
' }',
|
|
42
|
-
'}',
|
|
43
|
-
'"@',
|
|
44
|
-
'$ptr = [IntPtr]::Zero',
|
|
45
|
-
`if ([CredManager]::CredRead('${KEYCHAIN_SERVICE}', 1, 0, [ref]$ptr)) {`,
|
|
46
|
-
' $c = [System.Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [Type][CredManager+CREDENTIAL])',
|
|
47
|
-
' $b = New-Object byte[] $c.CredentialBlobSize',
|
|
48
|
-
' [System.Runtime.InteropServices.Marshal]::Copy($c.CredentialBlob, $b, 0, $c.CredentialBlobSize)',
|
|
49
|
-
' [CredManager]::CredFree($ptr) | Out-Null',
|
|
50
|
-
' [System.Text.Encoding]::UTF8.GetString($b)',
|
|
51
|
-
'}',
|
|
52
|
-
].join('\n');
|
|
53
|
-
const buf = Buffer.alloc(psScript.length * 2);
|
|
54
|
-
for (let i = 0; i < psScript.length; i++) buf.writeUInt16LE(psScript.charCodeAt(i), i * 2);
|
|
55
|
-
const raw = execSync(
|
|
56
|
-
`powershell.exe -NoProfile -NonInteractive -EncodedCommand ${buf.toString('base64')}`,
|
|
57
|
-
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
|
|
58
|
-
).trim();
|
|
59
|
-
if (!raw) return null;
|
|
60
|
-
const data = JSON.parse(raw);
|
|
61
|
-
return data.claudeAiOauth || null;
|
|
62
|
-
} catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function readFromFile(credentialsPath) {
|
|
68
|
-
try {
|
|
69
|
-
const raw = fs.readFileSync(credentialsPath, 'utf-8');
|
|
70
|
-
const data = JSON.parse(raw);
|
|
71
|
-
return data.claudeAiOauth || null;
|
|
72
|
-
} catch {
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function readFromSecureStore() {
|
|
78
|
-
if (process.platform === 'darwin') return readFromKeychain();
|
|
79
|
-
if (process.platform === 'win32') return readFromWindowsCredentialManager();
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function readCredentials(credentialsPath) {
|
|
84
|
-
if (credentialsPath) {
|
|
85
|
-
// Explicit path provided (e.g. tests) — skip secure store
|
|
86
|
-
return readFromFile(credentialsPath);
|
|
87
|
-
}
|
|
88
|
-
// Try platform secure store first, then fall back to default file
|
|
89
|
-
return readFromSecureStore() || readFromFile(CREDENTIALS_PATH);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function getSubscriptionInfo(credentialsPath) {
|
|
93
|
-
const creds = readCredentials(credentialsPath);
|
|
94
|
-
if (!creds) return null;
|
|
95
|
-
|
|
96
|
-
const { subscriptionType, rateLimitTier } = creds;
|
|
97
|
-
const combined = `${subscriptionType || ''} ${rateLimitTier || ''}`.toLowerCase();
|
|
98
|
-
|
|
99
|
-
let plan = null;
|
|
100
|
-
if (combined.includes('20x')) plan = 'max20x';
|
|
101
|
-
else if (combined.includes('5x')) plan = 'max5x';
|
|
102
|
-
else if (combined.includes('pro')) plan = 'pro';
|
|
103
|
-
|
|
104
|
-
return { subscriptionType: subscriptionType || null, rateLimitTier: rateLimitTier || null, plan };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function getAccessToken(credentialsPath) {
|
|
108
|
-
const creds = readCredentials(credentialsPath);
|
|
109
|
-
if (!creds || !creds.accessToken) return null;
|
|
110
|
-
if (creds.expiresAt && creds.expiresAt < Date.now()) return null;
|
|
111
|
-
return creds.accessToken;
|
|
112
|
-
}
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
const CREDENTIALS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
7
|
+
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
8
|
+
|
|
9
|
+
function readFromKeychain() {
|
|
10
|
+
if (process.platform !== 'darwin') return null;
|
|
11
|
+
try {
|
|
12
|
+
const raw = execSync(
|
|
13
|
+
`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`,
|
|
14
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
15
|
+
).trim();
|
|
16
|
+
const data = JSON.parse(raw);
|
|
17
|
+
return data.claudeAiOauth || null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readFromWindowsCredentialManager() {
|
|
24
|
+
if (process.platform !== 'win32') return null;
|
|
25
|
+
try {
|
|
26
|
+
// Use PowerShell with Win32 CredRead API to read from Windows Credential Manager.
|
|
27
|
+
// Encode as UTF-16LE base64 for -EncodedCommand to avoid all shell escaping issues.
|
|
28
|
+
const psScript = [
|
|
29
|
+
'Add-Type -TypeDefinition @"',
|
|
30
|
+
'using System; using System.Runtime.InteropServices;',
|
|
31
|
+
'public class CredManager {',
|
|
32
|
+
' [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]',
|
|
33
|
+
' public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);',
|
|
34
|
+
' [DllImport("advapi32.dll")] public static extern void CredFree(IntPtr cred);',
|
|
35
|
+
' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]',
|
|
36
|
+
' public struct CREDENTIAL {',
|
|
37
|
+
' public int Flags; public int Type; public string TargetName; public string Comment;',
|
|
38
|
+
' public long LastWritten; public int CredentialBlobSize; public IntPtr CredentialBlob;',
|
|
39
|
+
' public int Persist; public int AttributeCount; public IntPtr Attributes;',
|
|
40
|
+
' public string TargetAlias; public string UserName;',
|
|
41
|
+
' }',
|
|
42
|
+
'}',
|
|
43
|
+
'"@',
|
|
44
|
+
'$ptr = [IntPtr]::Zero',
|
|
45
|
+
`if ([CredManager]::CredRead('${KEYCHAIN_SERVICE}', 1, 0, [ref]$ptr)) {`,
|
|
46
|
+
' $c = [System.Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [Type][CredManager+CREDENTIAL])',
|
|
47
|
+
' $b = New-Object byte[] $c.CredentialBlobSize',
|
|
48
|
+
' [System.Runtime.InteropServices.Marshal]::Copy($c.CredentialBlob, $b, 0, $c.CredentialBlobSize)',
|
|
49
|
+
' [CredManager]::CredFree($ptr) | Out-Null',
|
|
50
|
+
' [System.Text.Encoding]::UTF8.GetString($b)',
|
|
51
|
+
'}',
|
|
52
|
+
].join('\n');
|
|
53
|
+
const buf = Buffer.alloc(psScript.length * 2);
|
|
54
|
+
for (let i = 0; i < psScript.length; i++) buf.writeUInt16LE(psScript.charCodeAt(i), i * 2);
|
|
55
|
+
const raw = execSync(
|
|
56
|
+
`powershell.exe -NoProfile -NonInteractive -EncodedCommand ${buf.toString('base64')}`,
|
|
57
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
|
|
58
|
+
).trim();
|
|
59
|
+
if (!raw) return null;
|
|
60
|
+
const data = JSON.parse(raw);
|
|
61
|
+
return data.claudeAiOauth || null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readFromFile(credentialsPath) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(credentialsPath, 'utf-8');
|
|
70
|
+
const data = JSON.parse(raw);
|
|
71
|
+
return data.claudeAiOauth || null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readFromSecureStore() {
|
|
78
|
+
if (process.platform === 'darwin') return readFromKeychain();
|
|
79
|
+
if (process.platform === 'win32') return readFromWindowsCredentialManager();
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readCredentials(credentialsPath) {
|
|
84
|
+
if (credentialsPath) {
|
|
85
|
+
// Explicit path provided (e.g. tests) — skip secure store
|
|
86
|
+
return readFromFile(credentialsPath);
|
|
87
|
+
}
|
|
88
|
+
// Try platform secure store first, then fall back to default file
|
|
89
|
+
return readFromSecureStore() || readFromFile(CREDENTIALS_PATH);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getSubscriptionInfo(credentialsPath) {
|
|
93
|
+
const creds = readCredentials(credentialsPath);
|
|
94
|
+
if (!creds) return null;
|
|
95
|
+
|
|
96
|
+
const { subscriptionType, rateLimitTier } = creds;
|
|
97
|
+
const combined = `${subscriptionType || ''} ${rateLimitTier || ''}`.toLowerCase();
|
|
98
|
+
|
|
99
|
+
let plan = null;
|
|
100
|
+
if (combined.includes('20x')) plan = 'max20x';
|
|
101
|
+
else if (combined.includes('5x')) plan = 'max5x';
|
|
102
|
+
else if (combined.includes('pro')) plan = 'pro';
|
|
103
|
+
|
|
104
|
+
return { subscriptionType: subscriptionType || null, rateLimitTier: rateLimitTier || null, plan };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getAccessToken(credentialsPath) {
|
|
108
|
+
const creds = readCredentials(credentialsPath);
|
|
109
|
+
if (!creds || !creds.accessToken) return null;
|
|
110
|
+
if (creds.expiresAt && creds.expiresAt < Date.now()) return null;
|
|
111
|
+
return creds.accessToken;
|
|
112
|
+
}
|
package/server/index.js
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import { createRequire } from 'module';
|
|
5
|
-
import { fileURLToPath } from 'url';
|
|
6
|
-
import { createApiRouter } from './routes/api.js';
|
|
7
|
-
import { syncLocalToShared } from './sync.js';
|
|
8
|
-
|
|
9
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
const require = createRequire(import.meta.url);
|
|
11
|
-
const PORT = process.env.PORT || 3000;
|
|
12
|
-
const LOG_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
13
|
-
const SYNC_DIR = process.env.CLAUDE_DASH_SYNC_DIR || null;
|
|
14
|
-
const MACHINE_NAME = process.env.CLAUDE_DASH_MACHINE_NAME || os.hostname();
|
|
15
|
-
|
|
16
|
-
// Startup sync
|
|
17
|
-
if (SYNC_DIR) {
|
|
18
|
-
console.log(`Syncing local data to shared folder: ${SYNC_DIR} (machine: ${MACHINE_NAME})`);
|
|
19
|
-
await syncLocalToShared(LOG_DIR, SYNC_DIR, MACHINE_NAME);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Resolve d3 via Node module resolution so it works when dependencies are hoisted (e.g. npx)
|
|
23
|
-
const d3Dir = path.join(path.dirname(require.resolve('d3')), '..', 'dist');
|
|
24
|
-
|
|
25
|
-
const app = express();
|
|
26
|
-
app.use('/lib/d3', express.static(d3Dir));
|
|
27
|
-
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
28
|
-
app.use('/api', createApiRouter(LOG_DIR, { syncDir: SYNC_DIR, machineName: MACHINE_NAME }));
|
|
29
|
-
|
|
30
|
-
const server = app.listen(PORT, () => {
|
|
31
|
-
console.log(`Claude Usage Dashboard running at http://localhost:${PORT}`);
|
|
32
|
-
if (SYNC_DIR) {
|
|
33
|
-
console.log(`Sync mode: reading from ${SYNC_DIR} (machine: ${MACHINE_NAME})`);
|
|
34
|
-
}
|
|
35
|
-
console.log('Press Ctrl+C to stop.');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
process.on('SIGINT', () => {
|
|
39
|
-
console.log('\nShutting down...');
|
|
40
|
-
server.close(() => process.exit(0));
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
process.on('SIGTERM', () => {
|
|
44
|
-
server.close(() => process.exit(0));
|
|
45
|
-
});
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { createApiRouter } from './routes/api.js';
|
|
7
|
+
import { syncLocalToShared } from './sync.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const PORT = process.env.PORT || 3000;
|
|
12
|
+
const LOG_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
13
|
+
const SYNC_DIR = process.env.CLAUDE_DASH_SYNC_DIR || null;
|
|
14
|
+
const MACHINE_NAME = process.env.CLAUDE_DASH_MACHINE_NAME || os.hostname();
|
|
15
|
+
|
|
16
|
+
// Startup sync
|
|
17
|
+
if (SYNC_DIR) {
|
|
18
|
+
console.log(`Syncing local data to shared folder: ${SYNC_DIR} (machine: ${MACHINE_NAME})`);
|
|
19
|
+
await syncLocalToShared(LOG_DIR, SYNC_DIR, MACHINE_NAME);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolve d3 via Node module resolution so it works when dependencies are hoisted (e.g. npx)
|
|
23
|
+
const d3Dir = path.join(path.dirname(require.resolve('d3')), '..', 'dist');
|
|
24
|
+
|
|
25
|
+
const app = express();
|
|
26
|
+
app.use('/lib/d3', express.static(d3Dir));
|
|
27
|
+
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
28
|
+
app.use('/api', createApiRouter(LOG_DIR, { syncDir: SYNC_DIR, machineName: MACHINE_NAME }));
|
|
29
|
+
|
|
30
|
+
const server = app.listen(PORT, () => {
|
|
31
|
+
console.log(`Claude Usage Dashboard running at http://localhost:${PORT}`);
|
|
32
|
+
if (SYNC_DIR) {
|
|
33
|
+
console.log(`Sync mode: reading from ${SYNC_DIR} (machine: ${MACHINE_NAME})`);
|
|
34
|
+
}
|
|
35
|
+
console.log('Press Ctrl+C to stop.');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
process.on('SIGINT', () => {
|
|
39
|
+
console.log('\nShutting down...');
|
|
40
|
+
server.close(() => process.exit(0));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
process.on('SIGTERM', () => {
|
|
44
|
+
server.close(() => process.exit(0));
|
|
45
|
+
});
|
package/server/parser.js
CHANGED
|
@@ -1,129 +1,129 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
|
|
4
|
-
export function deriveProjectName(dirName) {
|
|
5
|
-
// Strip drive prefix like "C--" at the start
|
|
6
|
-
const clean = dirName.replace(/^[A-Za-z]--/, '');
|
|
7
|
-
|
|
8
|
-
// Known parent directory markers (case-insensitive search)
|
|
9
|
-
// Match the last occurrence of common parent dirs to get the project folder name
|
|
10
|
-
const lower = clean.toLowerCase();
|
|
11
|
-
const markers = ['-workspace-', '-projects-', '-repos-', '-src-', '-home-', '-desktop-', '-documents-', '-downloads-'];
|
|
12
|
-
let bestIdx = -1;
|
|
13
|
-
let bestLen = 0;
|
|
14
|
-
for (const m of markers) {
|
|
15
|
-
const idx = lower.lastIndexOf(m);
|
|
16
|
-
if (idx > bestIdx) {
|
|
17
|
-
bestIdx = idx;
|
|
18
|
-
bestLen = m.length;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
if (bestIdx !== -1) {
|
|
22
|
-
const result = clean.slice(bestIdx + bestLen);
|
|
23
|
-
// Handle worktree subdirs: "project--claude-worktrees-branch-name" → "project"
|
|
24
|
-
const wtIdx = result.indexOf('--claude-worktrees');
|
|
25
|
-
return wtIdx !== -1 ? result.slice(0, wtIdx) : result;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Fallback: strip Users-username prefix, return the rest
|
|
29
|
-
const userMatch = clean.match(/^Users-[^-]+-(.+)$/);
|
|
30
|
-
if (userMatch) {
|
|
31
|
-
const rest = userMatch[1];
|
|
32
|
-
const wtIdx = rest.indexOf('--claude-worktrees');
|
|
33
|
-
return wtIdx !== -1 ? rest.slice(0, wtIdx) : rest;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return clean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function parseLogFile(filePath) {
|
|
40
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
41
|
-
const lines = content.split('\n').filter(line => line.trim());
|
|
42
|
-
const records = [];
|
|
43
|
-
|
|
44
|
-
for (const line of lines) {
|
|
45
|
-
let entry;
|
|
46
|
-
try {
|
|
47
|
-
entry = JSON.parse(line);
|
|
48
|
-
} catch {
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (entry.type !== 'assistant') continue;
|
|
53
|
-
|
|
54
|
-
const model = entry.message?.model;
|
|
55
|
-
if (!model || model === '<synthetic>') continue;
|
|
56
|
-
|
|
57
|
-
const usage = entry.message?.usage;
|
|
58
|
-
if (!usage) continue;
|
|
59
|
-
|
|
60
|
-
records.push({
|
|
61
|
-
sessionId: entry.sessionId,
|
|
62
|
-
timestamp: entry.timestamp,
|
|
63
|
-
model,
|
|
64
|
-
input_tokens: usage.input_tokens || 0,
|
|
65
|
-
output_tokens: usage.output_tokens || 0,
|
|
66
|
-
cache_creation_tokens: usage.cache_creation_input_tokens || 0,
|
|
67
|
-
cache_read_tokens: usage.cache_read_input_tokens || 0,
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return records;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function parseLogDirectory(baseDir) {
|
|
75
|
-
const allRecords = [];
|
|
76
|
-
|
|
77
|
-
let projectDirs;
|
|
78
|
-
try {
|
|
79
|
-
projectDirs = fs.readdirSync(baseDir, { withFileTypes: true })
|
|
80
|
-
.filter(d => d.isDirectory());
|
|
81
|
-
} catch {
|
|
82
|
-
return allRecords;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
for (const dir of projectDirs) {
|
|
86
|
-
const projectName = deriveProjectName(dir.name);
|
|
87
|
-
const projectPath = path.join(baseDir, dir.name);
|
|
88
|
-
|
|
89
|
-
let files;
|
|
90
|
-
try {
|
|
91
|
-
files = fs.readdirSync(projectPath)
|
|
92
|
-
.filter(f => f.endsWith('.jsonl'));
|
|
93
|
-
} catch {
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
for (const file of files) {
|
|
98
|
-
const filePath = path.join(projectPath, file);
|
|
99
|
-
const records = parseLogFile(filePath);
|
|
100
|
-
for (const record of records) {
|
|
101
|
-
record.project = projectName;
|
|
102
|
-
record.projectDirName = dir.name;
|
|
103
|
-
}
|
|
104
|
-
allRecords.push(...records);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return allRecords;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function parseMultiMachineDirectory(syncDir) {
|
|
112
|
-
const allRecords = [];
|
|
113
|
-
|
|
114
|
-
let machineDirs;
|
|
115
|
-
try {
|
|
116
|
-
machineDirs = fs.readdirSync(syncDir, { withFileTypes: true })
|
|
117
|
-
.filter(d => d.isDirectory() && !d.isSymbolicLink());
|
|
118
|
-
} catch {
|
|
119
|
-
return allRecords;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
for (const machineDir of machineDirs) {
|
|
123
|
-
const machinePath = path.join(syncDir, machineDir.name);
|
|
124
|
-
const records = parseLogDirectory(machinePath);
|
|
125
|
-
allRecords.push(...records);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return allRecords;
|
|
129
|
-
}
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export function deriveProjectName(dirName) {
|
|
5
|
+
// Strip drive prefix like "C--" at the start
|
|
6
|
+
const clean = dirName.replace(/^[A-Za-z]--/, '');
|
|
7
|
+
|
|
8
|
+
// Known parent directory markers (case-insensitive search)
|
|
9
|
+
// Match the last occurrence of common parent dirs to get the project folder name
|
|
10
|
+
const lower = clean.toLowerCase();
|
|
11
|
+
const markers = ['-workspace-', '-projects-', '-repos-', '-src-', '-home-', '-desktop-', '-documents-', '-downloads-'];
|
|
12
|
+
let bestIdx = -1;
|
|
13
|
+
let bestLen = 0;
|
|
14
|
+
for (const m of markers) {
|
|
15
|
+
const idx = lower.lastIndexOf(m);
|
|
16
|
+
if (idx > bestIdx) {
|
|
17
|
+
bestIdx = idx;
|
|
18
|
+
bestLen = m.length;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (bestIdx !== -1) {
|
|
22
|
+
const result = clean.slice(bestIdx + bestLen);
|
|
23
|
+
// Handle worktree subdirs: "project--claude-worktrees-branch-name" → "project"
|
|
24
|
+
const wtIdx = result.indexOf('--claude-worktrees');
|
|
25
|
+
return wtIdx !== -1 ? result.slice(0, wtIdx) : result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fallback: strip Users-username prefix, return the rest
|
|
29
|
+
const userMatch = clean.match(/^Users-[^-]+-(.+)$/);
|
|
30
|
+
if (userMatch) {
|
|
31
|
+
const rest = userMatch[1];
|
|
32
|
+
const wtIdx = rest.indexOf('--claude-worktrees');
|
|
33
|
+
return wtIdx !== -1 ? rest.slice(0, wtIdx) : rest;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return clean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseLogFile(filePath) {
|
|
40
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
41
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
42
|
+
const records = [];
|
|
43
|
+
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
let entry;
|
|
46
|
+
try {
|
|
47
|
+
entry = JSON.parse(line);
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (entry.type !== 'assistant') continue;
|
|
53
|
+
|
|
54
|
+
const model = entry.message?.model;
|
|
55
|
+
if (!model || model === '<synthetic>') continue;
|
|
56
|
+
|
|
57
|
+
const usage = entry.message?.usage;
|
|
58
|
+
if (!usage) continue;
|
|
59
|
+
|
|
60
|
+
records.push({
|
|
61
|
+
sessionId: entry.sessionId,
|
|
62
|
+
timestamp: entry.timestamp,
|
|
63
|
+
model,
|
|
64
|
+
input_tokens: usage.input_tokens || 0,
|
|
65
|
+
output_tokens: usage.output_tokens || 0,
|
|
66
|
+
cache_creation_tokens: usage.cache_creation_input_tokens || 0,
|
|
67
|
+
cache_read_tokens: usage.cache_read_input_tokens || 0,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return records;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseLogDirectory(baseDir) {
|
|
75
|
+
const allRecords = [];
|
|
76
|
+
|
|
77
|
+
let projectDirs;
|
|
78
|
+
try {
|
|
79
|
+
projectDirs = fs.readdirSync(baseDir, { withFileTypes: true })
|
|
80
|
+
.filter(d => d.isDirectory());
|
|
81
|
+
} catch {
|
|
82
|
+
return allRecords;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const dir of projectDirs) {
|
|
86
|
+
const projectName = deriveProjectName(dir.name);
|
|
87
|
+
const projectPath = path.join(baseDir, dir.name);
|
|
88
|
+
|
|
89
|
+
let files;
|
|
90
|
+
try {
|
|
91
|
+
files = fs.readdirSync(projectPath)
|
|
92
|
+
.filter(f => f.endsWith('.jsonl'));
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const filePath = path.join(projectPath, file);
|
|
99
|
+
const records = parseLogFile(filePath);
|
|
100
|
+
for (const record of records) {
|
|
101
|
+
record.project = projectName;
|
|
102
|
+
record.projectDirName = dir.name;
|
|
103
|
+
}
|
|
104
|
+
allRecords.push(...records);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return allRecords;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseMultiMachineDirectory(syncDir) {
|
|
112
|
+
const allRecords = [];
|
|
113
|
+
|
|
114
|
+
let machineDirs;
|
|
115
|
+
try {
|
|
116
|
+
machineDirs = fs.readdirSync(syncDir, { withFileTypes: true })
|
|
117
|
+
.filter(d => d.isDirectory() && !d.isSymbolicLink());
|
|
118
|
+
} catch {
|
|
119
|
+
return allRecords;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const machineDir of machineDirs) {
|
|
123
|
+
const machinePath = path.join(syncDir, machineDir.name);
|
|
124
|
+
const records = parseLogDirectory(machinePath);
|
|
125
|
+
allRecords.push(...records);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return allRecords;
|
|
129
|
+
}
|