claude-cli-analytics 0.0.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/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/cli.js +44 -0
- package/dist/client/assets/index-BkOIudNK.css +1 -0
- package/dist/client/assets/index-CXwfzzf8.js +48 -0
- package/dist/client/index.html +14 -0
- package/dist/client/vite.svg +1 -0
- package/dist/server/analyzer.js +711 -0
- package/dist/server/config.js +120 -0
- package/dist/server/index.js +151 -0
- package/dist/server/parser.js +48 -0
- package/dist/server/types.js +2 -0
- package/package.json +77 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// ── Configuration management for Claude Analytics ──
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.claude-analytics');
|
|
6
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
7
|
+
const DEFAULT_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
8
|
+
/**
|
|
9
|
+
* Load the application config from disk, or return defaults.
|
|
10
|
+
*/
|
|
11
|
+
export function loadConfig() {
|
|
12
|
+
try {
|
|
13
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
14
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
console.error('Error loading config:', err);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
claude_projects_dir: '',
|
|
23
|
+
initialized: false
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Save the application config to disk.
|
|
28
|
+
*/
|
|
29
|
+
export function saveConfig(config) {
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
32
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
console.error('Error saving config:', err);
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the Claude projects directory.
|
|
43
|
+
* Priority: env var > config file > auto-detect > default path.
|
|
44
|
+
* Auto-detection makes `init` unnecessary for most users.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveProjectsDir() {
|
|
47
|
+
// 1. Environment variable (highest priority)
|
|
48
|
+
if (process.env.CLAUDE_PROJECTS_DIR) {
|
|
49
|
+
return process.env.CLAUDE_PROJECTS_DIR;
|
|
50
|
+
}
|
|
51
|
+
// 2. Saved config (user explicitly set via UI/API)
|
|
52
|
+
const config = loadConfig();
|
|
53
|
+
if (config.claude_projects_dir && config.initialized) {
|
|
54
|
+
return config.claude_projects_dir;
|
|
55
|
+
}
|
|
56
|
+
// 3. Auto-detect from common Claude Code installation paths
|
|
57
|
+
const detected = autoDetectProjectsDir();
|
|
58
|
+
if (detected.found) {
|
|
59
|
+
return detected.path;
|
|
60
|
+
}
|
|
61
|
+
// 4. Fallback to default
|
|
62
|
+
return DEFAULT_PROJECTS_DIR;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check if a given path looks like a valid Claude projects directory.
|
|
66
|
+
* Claude projects dir contains subdirectories starting with '-' (hashed project paths).
|
|
67
|
+
*/
|
|
68
|
+
export function validateProjectsDir(dirPath) {
|
|
69
|
+
try {
|
|
70
|
+
if (!fs.existsSync(dirPath)) {
|
|
71
|
+
return { valid: false, projectCount: 0 };
|
|
72
|
+
}
|
|
73
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
74
|
+
const projects = entries.filter(e => e.isDirectory() && e.name.startsWith('-'));
|
|
75
|
+
return { valid: projects.length > 0, projectCount: projects.length };
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return { valid: false, projectCount: 0 };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Auto-detect Claude projects directory from all known locations.
|
|
83
|
+
*
|
|
84
|
+
* Claude Code stores session data at ~/.claude/projects regardless of
|
|
85
|
+
* installation method (homebrew, npm, direct install).
|
|
86
|
+
*
|
|
87
|
+
* We also check XDG-based and custom env var paths for edge cases.
|
|
88
|
+
*/
|
|
89
|
+
export function autoDetectProjectsDir() {
|
|
90
|
+
const home = os.homedir();
|
|
91
|
+
const candidates = [];
|
|
92
|
+
// Standard path — works for all installation methods
|
|
93
|
+
// (homebrew: brew install --cask claude-code)
|
|
94
|
+
// (npm: npm install -g @anthropic-ai/claude-code)
|
|
95
|
+
// (direct: downloaded binary)
|
|
96
|
+
candidates.push(path.join(home, '.claude', 'projects'));
|
|
97
|
+
// XDG config path (Linux users who set XDG_CONFIG_HOME)
|
|
98
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
99
|
+
if (xdgConfigHome) {
|
|
100
|
+
candidates.push(path.join(xdgConfigHome, 'claude', 'projects'));
|
|
101
|
+
}
|
|
102
|
+
// Custom Claude config dir (if user has set CLAUDE_CONFIG_DIR)
|
|
103
|
+
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
104
|
+
if (claudeConfigDir) {
|
|
105
|
+
candidates.push(path.join(claudeConfigDir, 'projects'));
|
|
106
|
+
}
|
|
107
|
+
// Linux snap installation path
|
|
108
|
+
if (process.platform === 'linux') {
|
|
109
|
+
candidates.push(path.join(home, 'snap', 'claude', 'common', '.claude', 'projects'));
|
|
110
|
+
}
|
|
111
|
+
// Deduplicate
|
|
112
|
+
const uniqueCandidates = [...new Set(candidates)];
|
|
113
|
+
for (const candidate of uniqueCandidates) {
|
|
114
|
+
const result = validateProjectsDir(candidate);
|
|
115
|
+
if (result.valid) {
|
|
116
|
+
return { path: candidate, found: true };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { path: DEFAULT_PROJECTS_DIR, found: false };
|
|
120
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// ── Claude Analytics Server — Entry Point ──
|
|
2
|
+
// Routes + server startup only. All logic is in separate modules.
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import cors from 'cors';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { exec } from 'child_process';
|
|
9
|
+
import { resolveProjectsDir, loadConfig, saveConfig, autoDetectProjectsDir, validateProjectsDir } from './config.js';
|
|
10
|
+
import { getProjectsList } from './parser.js';
|
|
11
|
+
import { getSessionsList, getSessionDetail, getAnalytics } from './analyzer.js';
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
const app = express();
|
|
15
|
+
const PORT = parseInt(process.env.PORT || '3001', 10);
|
|
16
|
+
app.use(cors());
|
|
17
|
+
app.use(express.json());
|
|
18
|
+
// ── Helper: resolve projects dir per-request ──
|
|
19
|
+
function getProjectsDir() {
|
|
20
|
+
return resolveProjectsDir();
|
|
21
|
+
}
|
|
22
|
+
// ════════════════════════════════════════════════════════════
|
|
23
|
+
// Config / Setup API (항목 1: Init 페이지)
|
|
24
|
+
// ════════════════════════════════════════════════════════════
|
|
25
|
+
app.get('/api/config', (_req, res) => {
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
const detected = autoDetectProjectsDir();
|
|
28
|
+
const resolvedPath = resolveProjectsDir();
|
|
29
|
+
const validation = validateProjectsDir(resolvedPath);
|
|
30
|
+
res.json({
|
|
31
|
+
...config,
|
|
32
|
+
detected_path: detected.path,
|
|
33
|
+
detected: detected.found,
|
|
34
|
+
resolved_path: resolvedPath,
|
|
35
|
+
auto_detected: detected.found && !config.initialized,
|
|
36
|
+
ready: validation.valid,
|
|
37
|
+
projectCount: validation.projectCount
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
app.post('/api/config', (req, res) => {
|
|
41
|
+
try {
|
|
42
|
+
const { claude_projects_dir } = req.body;
|
|
43
|
+
if (!claude_projects_dir) {
|
|
44
|
+
return res.status(400).json({ error: 'claude_projects_dir is required' });
|
|
45
|
+
}
|
|
46
|
+
const validation = validateProjectsDir(claude_projects_dir);
|
|
47
|
+
if (!validation.valid) {
|
|
48
|
+
return res.status(400).json({
|
|
49
|
+
error: 'Invalid projects directory — no Claude project folders found',
|
|
50
|
+
path: claude_projects_dir
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
saveConfig({ claude_projects_dir, initialized: true });
|
|
54
|
+
res.json({ success: true, projectCount: validation.projectCount });
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
res.status(500).json({ error: err.message });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
// ════════════════════════════════════════════════════════════
|
|
61
|
+
// Data API
|
|
62
|
+
// ════════════════════════════════════════════════════════════
|
|
63
|
+
app.get('/api/projects', (_req, res) => {
|
|
64
|
+
res.json(getProjectsList(getProjectsDir()));
|
|
65
|
+
});
|
|
66
|
+
app.get('/api/sessions', (req, res) => {
|
|
67
|
+
const projectPath = req.query.project;
|
|
68
|
+
const startDate = req.query.from;
|
|
69
|
+
const endDate = req.query.to;
|
|
70
|
+
res.json(getSessionsList(getProjectsDir(), projectPath, startDate, endDate));
|
|
71
|
+
});
|
|
72
|
+
app.get('/api/sessions/:id', (req, res) => {
|
|
73
|
+
const projectPath = req.query.project;
|
|
74
|
+
const detail = getSessionDetail(getProjectsDir(), req.params.id, projectPath);
|
|
75
|
+
if (!detail)
|
|
76
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
77
|
+
res.json(detail);
|
|
78
|
+
});
|
|
79
|
+
app.get('/api/analytics', (req, res) => {
|
|
80
|
+
const projectPath = req.query.project;
|
|
81
|
+
const startDate = req.query.from;
|
|
82
|
+
const endDate = req.query.to;
|
|
83
|
+
res.json(getAnalytics(getProjectsDir(), projectPath, startDate, endDate));
|
|
84
|
+
});
|
|
85
|
+
app.get('/api/health', (_req, res) => {
|
|
86
|
+
res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() });
|
|
87
|
+
});
|
|
88
|
+
app.post('/api/refresh', (req, res) => {
|
|
89
|
+
const projectPath = req.body.project;
|
|
90
|
+
const analytics = getAnalytics(getProjectsDir(), projectPath);
|
|
91
|
+
res.json({ success: true, analytics });
|
|
92
|
+
});
|
|
93
|
+
// ════════════════════════════════════════════════════════════
|
|
94
|
+
// Static file serving (SPA)
|
|
95
|
+
// ════════════════════════════════════════════════════════════
|
|
96
|
+
const distPath = path.resolve(__dirname, '../client');
|
|
97
|
+
const indexHtmlPath = path.join(distPath, 'index.html');
|
|
98
|
+
if (fs.existsSync(distPath)) {
|
|
99
|
+
app.use(express.static(distPath));
|
|
100
|
+
app.use((req, res, next) => {
|
|
101
|
+
if (req.path.startsWith('/api'))
|
|
102
|
+
return next();
|
|
103
|
+
res.sendFile(indexHtmlPath);
|
|
104
|
+
});
|
|
105
|
+
console.log(`📦 Serving frontend from: ${distPath}`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.log(`⚠️ Frontend not found at: ${distPath}`);
|
|
109
|
+
console.log(` Run in dev mode: npm run dev`);
|
|
110
|
+
}
|
|
111
|
+
// ════════════════════════════════════════════════════════════
|
|
112
|
+
// Server startup
|
|
113
|
+
// ════════════════════════════════════════════════════════════
|
|
114
|
+
const server = app.listen(PORT, () => {
|
|
115
|
+
const url = `http://localhost:${PORT}`;
|
|
116
|
+
console.log(`\n 🔍 Claude Analytics Dashboard`);
|
|
117
|
+
console.log(` ${url}\n`);
|
|
118
|
+
const projectsDir = getProjectsDir();
|
|
119
|
+
const detected = autoDetectProjectsDir();
|
|
120
|
+
if (detected.found) {
|
|
121
|
+
console.log(` ✅ Auto-detected: ${projectsDir}`);
|
|
122
|
+
}
|
|
123
|
+
else if (process.env.CLAUDE_PROJECTS_DIR) {
|
|
124
|
+
console.log(` 📁 Using env var: ${projectsDir}`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
console.log(` ⚠️ Projects dir not found: ${projectsDir}`);
|
|
128
|
+
console.log(` Set CLAUDE_PROJECTS_DIR or install Claude Code first.`);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const projects = getProjectsList(projectsDir);
|
|
132
|
+
console.log(` 📊 Found ${projects.length} projects\n`);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
console.log(` 📊 No projects found yet\n`);
|
|
136
|
+
}
|
|
137
|
+
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
138
|
+
exec(`${openCmd} ${url}`, (err) => {
|
|
139
|
+
if (err)
|
|
140
|
+
console.log(` Open your browser at: ${url}`);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
process.on('uncaughtException', (err) => {
|
|
144
|
+
console.error('Uncaught exception:', err);
|
|
145
|
+
});
|
|
146
|
+
process.on('unhandledRejection', (err) => {
|
|
147
|
+
console.error('Unhandled rejection:', err);
|
|
148
|
+
});
|
|
149
|
+
server.on('close', () => {
|
|
150
|
+
console.log('Server closed');
|
|
151
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ── JSONL file parsing and project discovery ──
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
/**
|
|
5
|
+
* Parse a single JSONL file into an array of Message records.
|
|
6
|
+
*/
|
|
7
|
+
export function parseJsonlFile(filepath) {
|
|
8
|
+
const records = [];
|
|
9
|
+
try {
|
|
10
|
+
const content = fs.readFileSync(filepath, 'utf-8');
|
|
11
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
12
|
+
for (const line of lines) {
|
|
13
|
+
try {
|
|
14
|
+
records.push(JSON.parse(line));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
console.error(`Error reading ${filepath}:`, err);
|
|
23
|
+
}
|
|
24
|
+
return records;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Get sorted list of all projects from the Claude projects directory.
|
|
28
|
+
*/
|
|
29
|
+
export function getProjectsList(projectsDir) {
|
|
30
|
+
const projects = [];
|
|
31
|
+
if (!fs.existsSync(projectsDir)) {
|
|
32
|
+
console.error(`Projects directory not found: ${projectsDir}`);
|
|
33
|
+
return projects;
|
|
34
|
+
}
|
|
35
|
+
const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.isDirectory() && entry.name.startsWith('-')) {
|
|
38
|
+
const projectPath = path.join(projectsDir, entry.name);
|
|
39
|
+
const jsonlFiles = fs.readdirSync(projectPath).filter(f => f.endsWith('.jsonl'));
|
|
40
|
+
projects.push({
|
|
41
|
+
name: entry.name.replace(/-/g, '/').slice(1),
|
|
42
|
+
path: entry.name,
|
|
43
|
+
session_count: jsonlFiles.length
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return projects.sort((a, b) => b.session_count - a.session_count);
|
|
48
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-cli-analytics",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Claude CLI Analytics Dashboard - Efficiency insights for Claude Pro users",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude",
|
|
7
|
+
"analytics",
|
|
8
|
+
"dashboard",
|
|
9
|
+
"cli",
|
|
10
|
+
"token-usage",
|
|
11
|
+
"claude-cli",
|
|
12
|
+
"tool-use",
|
|
13
|
+
"visualization"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/igeunpyo/claude-analytics#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/igeunpyo/claude-analytics/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/igeunpyo/claude-analytics.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "GeunPyo Lee <igeunpyo@gmail.com>",
|
|
25
|
+
"main": "dist/server/index.js",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"claude-cli-analytics": "./bin/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"bin",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
|
38
|
+
"dev:client": "vite",
|
|
39
|
+
"dev:server": "npx tsx watch server/index.ts",
|
|
40
|
+
"build": "tsc -b && vite build && tsc -p tsconfig.server.json",
|
|
41
|
+
"preview": "vite preview",
|
|
42
|
+
"start": "node dist/server/index.js",
|
|
43
|
+
"lint": "eslint .",
|
|
44
|
+
"prepublishOnly": "npm run build"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"cors": "^2.8.6",
|
|
48
|
+
"express": "^5.2.1",
|
|
49
|
+
"react": "^19.2.0",
|
|
50
|
+
"react-dom": "^19.2.0",
|
|
51
|
+
"react-router-dom": "^7.13.0",
|
|
52
|
+
"recharts": "^3.7.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@eslint/js": "^9.39.1",
|
|
56
|
+
"@tailwindcss/vite": "^4.1.18",
|
|
57
|
+
"@types/cors": "^2.8.19",
|
|
58
|
+
"@types/express": "^5.0.6",
|
|
59
|
+
"@types/node": "^24.10.1",
|
|
60
|
+
"@types/react": "^19.2.7",
|
|
61
|
+
"@types/react-dom": "^19.2.3",
|
|
62
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
63
|
+
"concurrently": "^9.2.1",
|
|
64
|
+
"eslint": "^9.39.1",
|
|
65
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
66
|
+
"eslint-plugin-react-refresh": "^0.4.24",
|
|
67
|
+
"globals": "^16.5.0",
|
|
68
|
+
"tailwindcss": "^4.1.18",
|
|
69
|
+
"tsx": "^4.21.0",
|
|
70
|
+
"typescript": "~5.9.3",
|
|
71
|
+
"typescript-eslint": "^8.48.0",
|
|
72
|
+
"vite": "^7.3.1"
|
|
73
|
+
},
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": ">=20.0.0"
|
|
76
|
+
}
|
|
77
|
+
}
|