claude-multi-usage 0.1.0__tar.gz
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.
- claude_multi_usage-0.1.0/LICENSE +21 -0
- claude_multi_usage-0.1.0/PKG-INFO +25 -0
- claude_multi_usage-0.1.0/README.md +2 -0
- claude_multi_usage-0.1.0/claude_multi_usage/__init__.py +0 -0
- claude_multi_usage-0.1.0/claude_multi_usage/cli.py +85 -0
- claude_multi_usage-0.1.0/claude_multi_usage/dashboard.py +188 -0
- claude_multi_usage-0.1.0/claude_multi_usage/parser.py +203 -0
- claude_multi_usage-0.1.0/claude_multi_usage.egg-info/PKG-INFO +25 -0
- claude_multi_usage-0.1.0/claude_multi_usage.egg-info/SOURCES.txt +13 -0
- claude_multi_usage-0.1.0/claude_multi_usage.egg-info/dependency_links.txt +1 -0
- claude_multi_usage-0.1.0/claude_multi_usage.egg-info/entry_points.txt +2 -0
- claude_multi_usage-0.1.0/claude_multi_usage.egg-info/requires.txt +2 -0
- claude_multi_usage-0.1.0/claude_multi_usage.egg-info/top_level.txt +2 -0
- claude_multi_usage-0.1.0/pyproject.toml +41 -0
- claude_multi_usage-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nuhgnod
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-multi-usage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Claude Code usage dashboard across multiple devices
|
|
5
|
+
Author: nuhgnod
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hunknownn/claude-multi-usage
|
|
8
|
+
Project-URL: Repository, https://github.com/hunknownn/claude-multi-usage
|
|
9
|
+
Project-URL: Issues, https://github.com/hunknownn/claude-multi-usage/issues
|
|
10
|
+
Keywords: claude,cli,dashboard,usage,anthropic
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Requires-Dist: click>=8.0
|
|
25
|
+
Dynamic: license-file
|
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""CLI entry point for claude-multi-usage."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from .parser import load_usage_data
|
|
6
|
+
from .dashboard import render_dashboard, make_projects_table, make_models_table, Console
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group(invoke_without_command=True)
|
|
10
|
+
@click.pass_context
|
|
11
|
+
def main(ctx):
|
|
12
|
+
"""Claude Code usage dashboard across multiple devices."""
|
|
13
|
+
if ctx.invoked_subcommand is None:
|
|
14
|
+
ctx.invoke(dashboard)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@main.command()
|
|
18
|
+
@click.option("--days", "-d", default=14, help="Number of days to show in chart")
|
|
19
|
+
def dashboard(days: int):
|
|
20
|
+
"""Show the full usage dashboard."""
|
|
21
|
+
data = load_usage_data()
|
|
22
|
+
render_dashboard(data, days=days)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@main.command()
|
|
26
|
+
def today():
|
|
27
|
+
"""Show today's usage summary."""
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from rich.table import Table
|
|
30
|
+
from rich.panel import Panel
|
|
31
|
+
from .dashboard import format_tokens
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
data = load_usage_data()
|
|
35
|
+
today_str = datetime.now().strftime("%Y-%m-%d")
|
|
36
|
+
|
|
37
|
+
activity = next((a for a in data.daily_activity if a.date == today_str), None)
|
|
38
|
+
tokens_data = next((t for t in data.daily_model_tokens if t.date == today_str), None)
|
|
39
|
+
|
|
40
|
+
if not activity:
|
|
41
|
+
console.print(f"[dim]No usage data for today ({today_str})[/dim]")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
45
|
+
table.add_column("label", style="dim")
|
|
46
|
+
table.add_column("value", style="bold cyan")
|
|
47
|
+
|
|
48
|
+
table.add_row("Date", today_str)
|
|
49
|
+
table.add_row("Sessions", str(activity.session_count))
|
|
50
|
+
table.add_row("Messages", str(activity.message_count))
|
|
51
|
+
table.add_row("Tool Calls", str(activity.tool_call_count))
|
|
52
|
+
|
|
53
|
+
if tokens_data:
|
|
54
|
+
for model, tokens in tokens_data.tokens_by_model.items():
|
|
55
|
+
short = model.split("-202")[0] if "-202" in model else model
|
|
56
|
+
table.add_row(f"Tokens ({short})", format_tokens(tokens))
|
|
57
|
+
table.add_row("Total Tokens", format_tokens(tokens_data.total_tokens))
|
|
58
|
+
|
|
59
|
+
console.print()
|
|
60
|
+
console.print(Panel(table, title=f"Today - {data.hostname}", border_style="blue"))
|
|
61
|
+
console.print()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@main.command()
|
|
65
|
+
def projects():
|
|
66
|
+
"""Show project usage breakdown."""
|
|
67
|
+
console = Console()
|
|
68
|
+
data = load_usage_data()
|
|
69
|
+
console.print()
|
|
70
|
+
console.print(make_projects_table(data, limit=20))
|
|
71
|
+
console.print()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@main.command()
|
|
75
|
+
def models():
|
|
76
|
+
"""Show model usage breakdown."""
|
|
77
|
+
console = Console()
|
|
78
|
+
data = load_usage_data()
|
|
79
|
+
console.print()
|
|
80
|
+
console.print(make_models_table(data))
|
|
81
|
+
console.print()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Rich-based terminal dashboard for Claude Code usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.columns import Columns
|
|
11
|
+
|
|
12
|
+
from .parser import UsageData
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_tokens(n: int) -> str:
|
|
16
|
+
if n >= 1_000_000:
|
|
17
|
+
return f"{n / 1_000_000:.1f}M"
|
|
18
|
+
if n >= 1_000:
|
|
19
|
+
return f"{n / 1_000:.1f}K"
|
|
20
|
+
return str(n)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_summary_panel(data: UsageData) -> Panel:
|
|
24
|
+
"""Overall summary stats."""
|
|
25
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
26
|
+
table.add_column("label", style="dim")
|
|
27
|
+
table.add_column("value", style="bold cyan")
|
|
28
|
+
|
|
29
|
+
table.add_row("Hostname", data.hostname)
|
|
30
|
+
table.add_row("Total Sessions", str(data.total_sessions))
|
|
31
|
+
table.add_row("Total Messages", f"{data.total_messages:,}")
|
|
32
|
+
|
|
33
|
+
if data.first_session_date:
|
|
34
|
+
first = data.first_session_date[:10]
|
|
35
|
+
table.add_row("First Session", first)
|
|
36
|
+
|
|
37
|
+
total_output = sum(m.output_tokens for m in data.model_usage)
|
|
38
|
+
table.add_row("Total Output Tokens", format_tokens(total_output))
|
|
39
|
+
|
|
40
|
+
return Panel(table, title="Summary", border_style="blue")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def make_daily_chart(data: UsageData, days: int = 14) -> Panel:
|
|
44
|
+
"""Daily usage bar chart for recent days."""
|
|
45
|
+
activities = data.daily_activity[-days:]
|
|
46
|
+
if not activities:
|
|
47
|
+
return Panel("No data", title="Daily Usage")
|
|
48
|
+
|
|
49
|
+
max_tokens = 1
|
|
50
|
+
token_map: dict[str, int] = {}
|
|
51
|
+
for dt in data.daily_model_tokens:
|
|
52
|
+
token_map[dt.date] = dt.total_tokens
|
|
53
|
+
|
|
54
|
+
# 날짜 범위를 채워서 빈 날도 표시
|
|
55
|
+
all_dates: list[tuple[str, int, int, int]] = []
|
|
56
|
+
if activities:
|
|
57
|
+
start = datetime.strptime(activities[0].date, "%Y-%m-%d")
|
|
58
|
+
end = datetime.strptime(activities[-1].date, "%Y-%m-%d")
|
|
59
|
+
act_map = {a.date: a for a in activities}
|
|
60
|
+
|
|
61
|
+
current = start
|
|
62
|
+
while current <= end:
|
|
63
|
+
d = current.strftime("%Y-%m-%d")
|
|
64
|
+
a = act_map.get(d)
|
|
65
|
+
tokens = token_map.get(d, 0)
|
|
66
|
+
msgs = a.message_count if a else 0
|
|
67
|
+
sessions = a.session_count if a else 0
|
|
68
|
+
all_dates.append((d, tokens, msgs, sessions))
|
|
69
|
+
if tokens > max_tokens:
|
|
70
|
+
max_tokens = tokens
|
|
71
|
+
current += timedelta(days=1)
|
|
72
|
+
|
|
73
|
+
# 최근 N일만
|
|
74
|
+
all_dates = all_dates[-days:]
|
|
75
|
+
|
|
76
|
+
bar_width = 30
|
|
77
|
+
lines = []
|
|
78
|
+
for date_str, tokens, msgs, sessions in all_dates:
|
|
79
|
+
short_date = date_str[5:] # MM-DD
|
|
80
|
+
filled = int((tokens / max_tokens) * bar_width) if max_tokens > 0 else 0
|
|
81
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
82
|
+
|
|
83
|
+
if tokens > 0:
|
|
84
|
+
line = f" {short_date} {bar} {format_tokens(tokens):>6} ({msgs} msgs, {sessions} sess)"
|
|
85
|
+
else:
|
|
86
|
+
line = f" {short_date} {'░' * bar_width} {'':>6}"
|
|
87
|
+
lines.append(line)
|
|
88
|
+
|
|
89
|
+
return Panel("\n".join(lines), title=f"Daily Tokens (last {days} days)", border_style="green")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def make_hourly_heatmap(data: UsageData) -> Panel:
|
|
93
|
+
"""24-hour session heatmap."""
|
|
94
|
+
if not data.hour_counts:
|
|
95
|
+
return Panel("No data", title="Hourly Activity")
|
|
96
|
+
|
|
97
|
+
max_count = max(data.hour_counts.values()) if data.hour_counts else 1
|
|
98
|
+
blocks = " ░░▒▒▓▓██"
|
|
99
|
+
styles = ["dim", "green", "yellow", "red", "bold red"]
|
|
100
|
+
|
|
101
|
+
table = Table(box=None, padding=(0, 0), show_header=True, header_style="dim")
|
|
102
|
+
for h in range(24):
|
|
103
|
+
table.add_column(f"{h:02d}", justify="center", width=4)
|
|
104
|
+
|
|
105
|
+
cells = []
|
|
106
|
+
for h in range(24):
|
|
107
|
+
count = data.hour_counts.get(h, 0)
|
|
108
|
+
if count == 0:
|
|
109
|
+
idx = 0
|
|
110
|
+
else:
|
|
111
|
+
idx = min(int((count / max_count) * 4) + 1, 4)
|
|
112
|
+
block = blocks[idx * 2:idx * 2 + 2]
|
|
113
|
+
style = styles[idx]
|
|
114
|
+
cells.append(f"[{style}]{block}[/{style}]")
|
|
115
|
+
|
|
116
|
+
table.add_row(*cells)
|
|
117
|
+
|
|
118
|
+
# 수치 행
|
|
119
|
+
count_cells = []
|
|
120
|
+
for h in range(24):
|
|
121
|
+
count = data.hour_counts.get(h, 0)
|
|
122
|
+
count_cells.append(f"[dim]{count}[/dim]" if count > 0 else "[dim]·[/dim]")
|
|
123
|
+
table.add_row(*count_cells)
|
|
124
|
+
|
|
125
|
+
return Panel(table, title="Hourly Sessions (all time)", border_style="yellow")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def make_projects_table(data: UsageData, limit: int = 10) -> Panel:
|
|
129
|
+
"""Top projects by session count."""
|
|
130
|
+
table = Table(box=None, padding=(0, 1))
|
|
131
|
+
table.add_column("#", style="dim", width=3)
|
|
132
|
+
table.add_column("Project", style="bold")
|
|
133
|
+
table.add_column("Sessions", justify="right", style="cyan")
|
|
134
|
+
table.add_column("Last Used", style="dim")
|
|
135
|
+
|
|
136
|
+
for i, project in enumerate(data.projects[:limit], 1):
|
|
137
|
+
last = project.last_seen.strftime("%Y-%m-%d") if project.last_seen else "-"
|
|
138
|
+
table.add_row(str(i), project.name, str(project.session_count), last)
|
|
139
|
+
|
|
140
|
+
return Panel(table, title=f"Top Projects (total {len(data.projects)})", border_style="magenta")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def make_models_table(data: UsageData) -> Panel:
|
|
144
|
+
"""Model usage breakdown."""
|
|
145
|
+
table = Table(box=None, padding=(0, 1))
|
|
146
|
+
table.add_column("Model", style="bold")
|
|
147
|
+
table.add_column("Output", justify="right", style="cyan")
|
|
148
|
+
table.add_column("Cache Read", justify="right", style="green")
|
|
149
|
+
table.add_column("Cache Create", justify="right", style="yellow")
|
|
150
|
+
|
|
151
|
+
for m in sorted(data.model_usage, key=lambda x: x.output_tokens, reverse=True):
|
|
152
|
+
# 모델명 짧게
|
|
153
|
+
short_name = m.model.split("-202")[0] if "-202" in m.model else m.model
|
|
154
|
+
table.add_row(
|
|
155
|
+
short_name,
|
|
156
|
+
format_tokens(m.output_tokens),
|
|
157
|
+
format_tokens(m.cache_read_tokens),
|
|
158
|
+
format_tokens(m.cache_creation_tokens),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return Panel(table, title="Model Usage", border_style="red")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def render_dashboard(data: UsageData, days: int = 14) -> None:
|
|
165
|
+
"""Render the full dashboard."""
|
|
166
|
+
console = Console()
|
|
167
|
+
|
|
168
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
169
|
+
title = f"Claude Usage Dashboard ── {data.hostname} ── {today}"
|
|
170
|
+
console.print()
|
|
171
|
+
console.rule(f"[bold blue]{title}[/bold blue]")
|
|
172
|
+
console.print()
|
|
173
|
+
|
|
174
|
+
# Summary + Models side by side
|
|
175
|
+
console.print(Columns([make_summary_panel(data), make_models_table(data)], equal=True))
|
|
176
|
+
console.print()
|
|
177
|
+
|
|
178
|
+
# Daily chart
|
|
179
|
+
console.print(make_daily_chart(data, days=days))
|
|
180
|
+
console.print()
|
|
181
|
+
|
|
182
|
+
# Hourly heatmap
|
|
183
|
+
console.print(make_hourly_heatmap(data))
|
|
184
|
+
console.print()
|
|
185
|
+
|
|
186
|
+
# Projects
|
|
187
|
+
console.print(make_projects_table(data))
|
|
188
|
+
console.print()
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Parse Claude Code local data from ~/.claude"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CLAUDE_DIR = Path.home() / ".claude"
|
|
14
|
+
STATS_FILE = CLAUDE_DIR / "stats-cache.json"
|
|
15
|
+
HISTORY_FILE = CLAUDE_DIR / "history.jsonl"
|
|
16
|
+
PROJECTS_DIR = CLAUDE_DIR / "projects"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DailyActivity:
|
|
21
|
+
date: str
|
|
22
|
+
message_count: int
|
|
23
|
+
session_count: int
|
|
24
|
+
tool_call_count: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DailyModelTokens:
|
|
29
|
+
date: str
|
|
30
|
+
tokens_by_model: dict[str, int]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def total_tokens(self) -> int:
|
|
34
|
+
return sum(self.tokens_by_model.values())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ModelUsage:
|
|
39
|
+
model: str
|
|
40
|
+
input_tokens: int
|
|
41
|
+
output_tokens: int
|
|
42
|
+
cache_read_tokens: int
|
|
43
|
+
cache_creation_tokens: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ProjectSummary:
|
|
48
|
+
name: str
|
|
49
|
+
session_count: int
|
|
50
|
+
first_seen: datetime | None = None
|
|
51
|
+
last_seen: datetime | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class UsageData:
|
|
56
|
+
hostname: str
|
|
57
|
+
daily_activity: list[DailyActivity] = field(default_factory=list)
|
|
58
|
+
daily_model_tokens: list[DailyModelTokens] = field(default_factory=list)
|
|
59
|
+
model_usage: list[ModelUsage] = field(default_factory=list)
|
|
60
|
+
projects: list[ProjectSummary] = field(default_factory=list)
|
|
61
|
+
hour_counts: dict[int, int] = field(default_factory=dict)
|
|
62
|
+
total_sessions: int = 0
|
|
63
|
+
total_messages: int = 0
|
|
64
|
+
first_session_date: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_hostname() -> str:
|
|
68
|
+
return socket.gethostname()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_stats_cache() -> dict | None:
|
|
72
|
+
if not STATS_FILE.exists():
|
|
73
|
+
return None
|
|
74
|
+
with open(STATS_FILE) as f:
|
|
75
|
+
return json.load(f)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _deduplicate_project_name(name: str) -> str:
|
|
79
|
+
"""Remove duplicate path segments from project names.
|
|
80
|
+
|
|
81
|
+
e.g., 'apr-backend-assignment-apr-backend-assignment' -> 'apr-backend-assignment'
|
|
82
|
+
'url-jarvis-url-jarvis-docs' -> 'url-jarvis/docs'
|
|
83
|
+
"""
|
|
84
|
+
# 이름을 반으로 나눠서 앞뒤가 같으면 중복
|
|
85
|
+
length = len(name)
|
|
86
|
+
for split_pos in range(1, length):
|
|
87
|
+
prefix = name[:split_pos]
|
|
88
|
+
rest = name[split_pos:]
|
|
89
|
+
if rest.startswith("-" + prefix):
|
|
90
|
+
suffix = rest[len(prefix) + 1:]
|
|
91
|
+
if suffix:
|
|
92
|
+
return prefix + "/" + suffix
|
|
93
|
+
return prefix
|
|
94
|
+
return name
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_projects() -> list[ProjectSummary]:
|
|
98
|
+
"""Parse project directories to get project summaries."""
|
|
99
|
+
if not PROJECTS_DIR.exists():
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
projects = []
|
|
103
|
+
for project_dir in sorted(PROJECTS_DIR.iterdir()):
|
|
104
|
+
if not project_dir.is_dir():
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
raw_name = project_dir.name
|
|
108
|
+
# 경로 형식: -Users-gimdonghun-workspace-project-subdir
|
|
109
|
+
# "workspace" 이후 부분을 추출하고 중복 제거
|
|
110
|
+
parts = raw_name.split("-")
|
|
111
|
+
try:
|
|
112
|
+
ws_idx = parts.index("workspace")
|
|
113
|
+
path_parts = "-".join(parts[ws_idx + 1:]) if ws_idx + 1 < len(parts) else raw_name
|
|
114
|
+
# 경로 구분자로 분리 후 중복 제거 (e.g., "apr-backend-assignment-apr-backend-assignment")
|
|
115
|
+
# 실제 디렉토리 구조 기반으로 의미 있는 이름 추출
|
|
116
|
+
segments = path_parts.split("-")
|
|
117
|
+
# 연속된 동일 패턴 제거
|
|
118
|
+
project_name = _deduplicate_project_name(path_parts)
|
|
119
|
+
except ValueError:
|
|
120
|
+
project_name = raw_name
|
|
121
|
+
|
|
122
|
+
if not project_name or project_name.startswith("-Users"):
|
|
123
|
+
project_name = raw_name.rsplit("-", 1)[-1] or "home"
|
|
124
|
+
|
|
125
|
+
# "url-jarvis/-docs" → "url-jarvis-docs"
|
|
126
|
+
project_name = project_name.replace("/-", "-")
|
|
127
|
+
|
|
128
|
+
if not project_name:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# 세션 파일(.jsonl) 개수 = 세션 수
|
|
132
|
+
session_files = list(project_dir.glob("*.jsonl"))
|
|
133
|
+
session_count = len(session_files)
|
|
134
|
+
|
|
135
|
+
# 첫/마지막 세션 시간 (파일 수정시간 기준)
|
|
136
|
+
first_seen = None
|
|
137
|
+
last_seen = None
|
|
138
|
+
if session_files:
|
|
139
|
+
mtimes = [f.stat().st_mtime for f in session_files]
|
|
140
|
+
first_seen = datetime.fromtimestamp(min(mtimes))
|
|
141
|
+
last_seen = datetime.fromtimestamp(max(mtimes))
|
|
142
|
+
|
|
143
|
+
projects.append(ProjectSummary(
|
|
144
|
+
name=project_name,
|
|
145
|
+
session_count=session_count,
|
|
146
|
+
first_seen=first_seen,
|
|
147
|
+
last_seen=last_seen,
|
|
148
|
+
))
|
|
149
|
+
|
|
150
|
+
return sorted(projects, key=lambda p: p.session_count, reverse=True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_usage_data() -> UsageData:
|
|
154
|
+
"""Load all usage data from local Claude Code files."""
|
|
155
|
+
hostname = get_hostname()
|
|
156
|
+
stats = parse_stats_cache()
|
|
157
|
+
|
|
158
|
+
if stats is None:
|
|
159
|
+
return UsageData(hostname=hostname)
|
|
160
|
+
|
|
161
|
+
daily_activity = [
|
|
162
|
+
DailyActivity(
|
|
163
|
+
date=d["date"],
|
|
164
|
+
message_count=d["messageCount"],
|
|
165
|
+
session_count=d["sessionCount"],
|
|
166
|
+
tool_call_count=d["toolCallCount"],
|
|
167
|
+
)
|
|
168
|
+
for d in stats.get("dailyActivity", [])
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
daily_model_tokens = [
|
|
172
|
+
DailyModelTokens(
|
|
173
|
+
date=d["date"],
|
|
174
|
+
tokens_by_model=d["tokensByModel"],
|
|
175
|
+
)
|
|
176
|
+
for d in stats.get("dailyModelTokens", [])
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
model_usage = [
|
|
180
|
+
ModelUsage(
|
|
181
|
+
model=model,
|
|
182
|
+
input_tokens=usage["inputTokens"],
|
|
183
|
+
output_tokens=usage["outputTokens"],
|
|
184
|
+
cache_read_tokens=usage["cacheReadInputTokens"],
|
|
185
|
+
cache_creation_tokens=usage["cacheCreationInputTokens"],
|
|
186
|
+
)
|
|
187
|
+
for model, usage in stats.get("modelUsage", {}).items()
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
hour_counts = {int(k): v for k, v in stats.get("hourCounts", {}).items()}
|
|
191
|
+
projects = parse_projects()
|
|
192
|
+
|
|
193
|
+
return UsageData(
|
|
194
|
+
hostname=hostname,
|
|
195
|
+
daily_activity=daily_activity,
|
|
196
|
+
daily_model_tokens=daily_model_tokens,
|
|
197
|
+
model_usage=model_usage,
|
|
198
|
+
projects=projects,
|
|
199
|
+
hour_counts=hour_counts,
|
|
200
|
+
total_sessions=stats.get("totalSessions", 0),
|
|
201
|
+
total_messages=stats.get("totalMessages", 0),
|
|
202
|
+
first_session_date=stats.get("firstSessionDate"),
|
|
203
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-multi-usage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Claude Code usage dashboard across multiple devices
|
|
5
|
+
Author: nuhgnod
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hunknownn/claude-multi-usage
|
|
8
|
+
Project-URL: Repository, https://github.com/hunknownn/claude-multi-usage
|
|
9
|
+
Project-URL: Issues, https://github.com/hunknownn/claude-multi-usage/issues
|
|
10
|
+
Keywords: claude,cli,dashboard,usage,anthropic
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Requires-Dist: click>=8.0
|
|
25
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
claude_multi_usage/__init__.py
|
|
5
|
+
claude_multi_usage/cli.py
|
|
6
|
+
claude_multi_usage/dashboard.py
|
|
7
|
+
claude_multi_usage/parser.py
|
|
8
|
+
claude_multi_usage.egg-info/PKG-INFO
|
|
9
|
+
claude_multi_usage.egg-info/SOURCES.txt
|
|
10
|
+
claude_multi_usage.egg-info/dependency_links.txt
|
|
11
|
+
claude_multi_usage.egg-info/entry_points.txt
|
|
12
|
+
claude_multi_usage.egg-info/requires.txt
|
|
13
|
+
claude_multi_usage.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "claude-multi-usage"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Claude Code usage dashboard across multiple devices"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "nuhgnod" },
|
|
13
|
+
]
|
|
14
|
+
keywords = ["claude", "cli", "dashboard", "usage", "anthropic"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"rich>=13.0",
|
|
29
|
+
"click>=8.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/hunknownn/claude-multi-usage"
|
|
34
|
+
Repository = "https://github.com/hunknownn/claude-multi-usage"
|
|
35
|
+
Issues = "https://github.com/hunknownn/claude-multi-usage/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
claude-usage = "claude_multi_usage.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["."]
|