teamspend-cli 0.2.1__py3-none-any.whl
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.
- teamspend/__init__.py +82 -0
- teamspend/adapters/__init__.py +9 -0
- teamspend/adapters/claude_code.py +77 -0
- teamspend/adapters/claude_code_personal.py +258 -0
- teamspend/adapters/codex.py +265 -0
- teamspend/adapters/copilot.py +189 -0
- teamspend/adapters/csv_import.py +127 -0
- teamspend/adapters/cursor.py +114 -0
- teamspend/adapters/opencode.py +250 -0
- teamspend/cli.py +318 -0
- teamspend/compare.py +77 -0
- teamspend/errors.py +96 -0
- teamspend/http_client.py +204 -0
- teamspend/output.py +251 -0
- teamspend/py.typed +0 -0
- teamspend/types.py +111 -0
- teamspend_cli-0.2.1.dist-info/METADATA +386 -0
- teamspend_cli-0.2.1.dist-info/RECORD +21 -0
- teamspend_cli-0.2.1.dist-info/WHEEL +4 -0
- teamspend_cli-0.2.1.dist-info/entry_points.txt +2 -0
- teamspend_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
teamspend/output.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal summary rendering, JSON report writing, and .gitignore scaffolding.
|
|
3
|
+
|
|
4
|
+
Ported from src/output.ts. The JSON report re-serializes the snake_case
|
|
5
|
+
Python dataclasses into the same camelCase key shape the npm CLI's report
|
|
6
|
+
file uses, so a report from either distribution has the same on-disk shape.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from .adapters.csv_import import strip_control_chars
|
|
18
|
+
from .compare import ComparisonReport, PeriodOutcome
|
|
19
|
+
from .types import BreakdownMode, top_sessions
|
|
20
|
+
|
|
21
|
+
GITIGNORE_ENTRY = "teamspend-snapshot-*.json"
|
|
22
|
+
# How many sessions the terminal breakdown table shows per period.
|
|
23
|
+
SESSION_BREAKDOWN_LIMIT = 10
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def scaffold_gitignore(cwd: str) -> bool:
|
|
27
|
+
"""
|
|
28
|
+
Scaffolds a .gitignore entry for the report file in `cwd` if one doesn't
|
|
29
|
+
already exist, and returns whether the first-run spend-sensitivity
|
|
30
|
+
warning should be printed. The report contains per-user email + spend,
|
|
31
|
+
which is quasi-sensitive data that shouldn't land in a repo by accident
|
|
32
|
+
during a fast-moving migration.
|
|
33
|
+
"""
|
|
34
|
+
gitignore_path = Path(cwd) / ".gitignore"
|
|
35
|
+
already_present = False
|
|
36
|
+
|
|
37
|
+
if gitignore_path.exists():
|
|
38
|
+
contents = gitignore_path.read_text(encoding="utf-8")
|
|
39
|
+
already_present = GITIGNORE_ENTRY in contents
|
|
40
|
+
|
|
41
|
+
if not already_present:
|
|
42
|
+
if gitignore_path.exists():
|
|
43
|
+
with gitignore_path.open("a", encoding="utf-8") as handle:
|
|
44
|
+
handle.write(f"\n{GITIGNORE_ENTRY}\n")
|
|
45
|
+
else:
|
|
46
|
+
gitignore_path.write_text(f"{GITIGNORE_ENTRY}\n", encoding="utf-8")
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _format_usd(amount: float) -> str:
|
|
53
|
+
return f"${amount:.2f}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _push_session_breakdown(lines: List[str], outcome: PeriodOutcome) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Appends a per-session cost breakdown for one period's outcome, or (when
|
|
59
|
+
--breakdown session was requested but this outcome's tool/adapter
|
|
60
|
+
doesn't produce session-level data) a clear explanation of why not.
|
|
61
|
+
Never silently shows nothing and never fabricates a breakdown for a
|
|
62
|
+
tool whose real data has no session concept.
|
|
63
|
+
"""
|
|
64
|
+
if not outcome.result:
|
|
65
|
+
return # DATA UNAVAILABLE already covers this case.
|
|
66
|
+
|
|
67
|
+
all_sessions = [
|
|
68
|
+
session
|
|
69
|
+
for user in outcome.result.users
|
|
70
|
+
for session in (user.sessions or [])
|
|
71
|
+
]
|
|
72
|
+
any_user_supports_sessions = any(
|
|
73
|
+
user.sessions is not None for user in outcome.result.users
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if all_sessions:
|
|
77
|
+
shown = min(SESSION_BREAKDOWN_LIMIT, len(all_sessions))
|
|
78
|
+
lines.append(f" SESSION BREAKDOWN (top {shown} by cost):")
|
|
79
|
+
for i, session in enumerate(top_sessions(all_sessions, SESSION_BREAKDOWN_LIMIT)):
|
|
80
|
+
estimate_tag = " (estimated)" if session.is_estimated else ""
|
|
81
|
+
reqs = session.requests or 0
|
|
82
|
+
plural = "" if reqs == 1 else "s"
|
|
83
|
+
# session_id is local-log-sourced, same class of value as
|
|
84
|
+
# user_email below -- strip control chars so a crafted log
|
|
85
|
+
# entry can't inject terminal escape sequences via this print
|
|
86
|
+
# path either.
|
|
87
|
+
safe_session_id = strip_control_chars(session.session_id)
|
|
88
|
+
lines.append(
|
|
89
|
+
f" {i + 1}. {safe_session_id} "
|
|
90
|
+
f"{_format_usd(session.cost_usd)} {reqs} req{plural}{estimate_tag}"
|
|
91
|
+
)
|
|
92
|
+
elif any_user_supports_sessions:
|
|
93
|
+
lines.append(" SESSION BREAKDOWN: no session activity in this window.")
|
|
94
|
+
else:
|
|
95
|
+
lines.append(
|
|
96
|
+
f" SESSION BREAKDOWN: not available for {outcome.tool} -- this tool's data "
|
|
97
|
+
"source reports aggregate totals only, with no per-session/conversation "
|
|
98
|
+
"breakdown in its response shape. Session-level cost breakdown is only "
|
|
99
|
+
"available for claude-code-personal and opencode, which read local session "
|
|
100
|
+
"logs directly."
|
|
101
|
+
)
|
|
102
|
+
lines.append("")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def render_terminal_summary(
|
|
106
|
+
report: ComparisonReport, breakdown: Optional[BreakdownMode] = None
|
|
107
|
+
) -> str:
|
|
108
|
+
lines = []
|
|
109
|
+
lines.append("teamspend snapshot -- migration cost comparison")
|
|
110
|
+
lines.append(f"Tools: {report.before.tool} -> {report.after.tool}")
|
|
111
|
+
lines.append("")
|
|
112
|
+
|
|
113
|
+
for outcome in (report.before, report.after):
|
|
114
|
+
label = outcome.label.upper()
|
|
115
|
+
lines.append(f"{label} ({outcome.tool})")
|
|
116
|
+
if outcome.result:
|
|
117
|
+
estimate_note = (
|
|
118
|
+
"estimated" if outcome.result.is_estimated else "exact, usage-based"
|
|
119
|
+
)
|
|
120
|
+
lines.append(
|
|
121
|
+
f" Total spend: {_format_usd(outcome.result.total_cost_usd)}"
|
|
122
|
+
f" ({estimate_note})"
|
|
123
|
+
)
|
|
124
|
+
lines.append(f" Active users: {len(outcome.result.users)}")
|
|
125
|
+
else:
|
|
126
|
+
message = str(outcome.error) if outcome.error else "unknown error"
|
|
127
|
+
lines.append(f" DATA UNAVAILABLE: {message}")
|
|
128
|
+
lines.append("")
|
|
129
|
+
|
|
130
|
+
if breakdown == "session":
|
|
131
|
+
_push_session_breakdown(lines, outcome)
|
|
132
|
+
|
|
133
|
+
if report.delta_usd is not None and report.delta_percent is not None:
|
|
134
|
+
sign = "+" if report.delta_usd >= 0 else "-"
|
|
135
|
+
lines.append(
|
|
136
|
+
f"DELTA: {sign}{_format_usd(abs(report.delta_usd))} "
|
|
137
|
+
f"({sign}{abs(report.delta_percent):.1f}%)"
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
lines.append(
|
|
141
|
+
"DELTA: unavailable -- one or both periods failed to fetch, see above"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if report.top_spenders_across_both:
|
|
145
|
+
lines.append("")
|
|
146
|
+
lines.append("TOP SPENDERS (across both periods)")
|
|
147
|
+
for i, spender in enumerate(report.top_spenders_across_both):
|
|
148
|
+
# CSV-sourced emails are already stripped in csv_import.py; API-sourced
|
|
149
|
+
# emails weren't, leaving an inconsistent path to this same unsanitized
|
|
150
|
+
# terminal print -- strip here too so a compromised vendor response
|
|
151
|
+
# can't inject terminal escape sequences via user_email either.
|
|
152
|
+
email = strip_control_chars(spender.user_email) if spender.user_email else "(unknown)"
|
|
153
|
+
lines.append(
|
|
154
|
+
f" {i + 1}. {email} {spender.period} {_format_usd(spender.cost_usd)}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return "\n".join(lines)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _user_to_dict(u: Any) -> Dict[str, Any]:
|
|
161
|
+
payload: Dict[str, Any] = {
|
|
162
|
+
"userId": u.user_id,
|
|
163
|
+
"userEmail": u.user_email,
|
|
164
|
+
"inputTokens": u.input_tokens,
|
|
165
|
+
"outputTokens": u.output_tokens,
|
|
166
|
+
"cacheReadTokens": u.cache_read_tokens,
|
|
167
|
+
"cacheWriteTokens": u.cache_write_tokens,
|
|
168
|
+
"requests": u.requests,
|
|
169
|
+
"costUsd": u.cost_usd,
|
|
170
|
+
"isEstimated": u.is_estimated,
|
|
171
|
+
}
|
|
172
|
+
# Only present in the JSON report when the adapter actually produced
|
|
173
|
+
# session data AND the caller requested it -- matches the TypeScript
|
|
174
|
+
# port's stripSessionsUnlessRequested behavior in cli.py, which sets
|
|
175
|
+
# u.sessions back to None on every user when --breakdown session
|
|
176
|
+
# wasn't passed, so the default report shape is unchanged.
|
|
177
|
+
if u.sessions is not None:
|
|
178
|
+
payload["sessions"] = [
|
|
179
|
+
{
|
|
180
|
+
"sessionId": s.session_id,
|
|
181
|
+
"costUsd": s.cost_usd,
|
|
182
|
+
"inputTokens": s.input_tokens,
|
|
183
|
+
"outputTokens": s.output_tokens,
|
|
184
|
+
"requests": s.requests,
|
|
185
|
+
"isEstimated": s.is_estimated,
|
|
186
|
+
}
|
|
187
|
+
for s in u.sessions
|
|
188
|
+
]
|
|
189
|
+
return payload
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _outcome_to_dict(outcome: PeriodOutcome) -> Dict[str, Any]:
|
|
193
|
+
return {
|
|
194
|
+
"label": outcome.label,
|
|
195
|
+
"tool": outcome.tool,
|
|
196
|
+
"result": (
|
|
197
|
+
{
|
|
198
|
+
"source": outcome.result.source,
|
|
199
|
+
"window": asdict(outcome.result.window),
|
|
200
|
+
"totalCostUsd": outcome.result.total_cost_usd,
|
|
201
|
+
"isEstimated": outcome.result.is_estimated,
|
|
202
|
+
"users": [_user_to_dict(u) for u in outcome.result.users],
|
|
203
|
+
}
|
|
204
|
+
if outcome.result
|
|
205
|
+
else None
|
|
206
|
+
),
|
|
207
|
+
"error": {"message": str(outcome.error)} if outcome.error else None,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def report_to_json_dict(report: ComparisonReport) -> Dict[str, Any]:
|
|
212
|
+
return {
|
|
213
|
+
"before": _outcome_to_dict(report.before),
|
|
214
|
+
"after": _outcome_to_dict(report.after),
|
|
215
|
+
"deltaUsd": report.delta_usd,
|
|
216
|
+
"deltaPercent": report.delta_percent,
|
|
217
|
+
"topSpendersAcrossBoth": [
|
|
218
|
+
{
|
|
219
|
+
"period": entry.period,
|
|
220
|
+
"userEmail": entry.user_email,
|
|
221
|
+
"costUsd": entry.cost_usd,
|
|
222
|
+
}
|
|
223
|
+
for entry in report.top_spenders_across_both
|
|
224
|
+
],
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def write_json_report(report: ComparisonReport, cwd: str) -> str:
|
|
229
|
+
"""
|
|
230
|
+
Always writes the JSON report file, regardless of --json. --json only
|
|
231
|
+
changes what prints to the terminal.
|
|
232
|
+
"""
|
|
233
|
+
timestamp = (
|
|
234
|
+
datetime.now(timezone.utc)
|
|
235
|
+
.isoformat(timespec="milliseconds")
|
|
236
|
+
.replace("+00:00", "")
|
|
237
|
+
)
|
|
238
|
+
safe_timestamp = timestamp.replace(":", "").replace(".", "")[:15]
|
|
239
|
+
path = str(Path(cwd) / f"teamspend-snapshot-{safe_timestamp}.json")
|
|
240
|
+
|
|
241
|
+
payload = json.dumps(report_to_json_dict(report), indent=2)
|
|
242
|
+
|
|
243
|
+
# mode 0o600 restricts the file to owner read/write only. Without it,
|
|
244
|
+
# the default (masked by the process umask, typically 0o644) leaves
|
|
245
|
+
# per-user email + spend readable by any other local user on a shared
|
|
246
|
+
# host. os.open with O_CREAT + explicit mode avoids the umask-then-chmod
|
|
247
|
+
# race a separate os.chmod() call after write would have.
|
|
248
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
249
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
250
|
+
handle.write(payload)
|
|
251
|
+
return path
|
teamspend/py.typed
ADDED
|
File without changes
|
teamspend/types.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared data types for teamspend's fetch/compare pipeline.
|
|
3
|
+
|
|
4
|
+
Ported from src/schema.ts. Field names follow Python (snake_case)
|
|
5
|
+
convention; the JSON report written by teamspend.output re-serializes to
|
|
6
|
+
the same camelCase keys the npm package's report file uses, so a report
|
|
7
|
+
produced by either distribution has the same on-disk shape even though the
|
|
8
|
+
in-process Python objects use snake_case attributes (same convention this
|
|
9
|
+
account's other Python ports, e.g. skillguard-cli, already use).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import List, Literal, Optional
|
|
15
|
+
|
|
16
|
+
ToolId = Literal[
|
|
17
|
+
"cursor",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"copilot",
|
|
20
|
+
"opencode",
|
|
21
|
+
"claude-code-personal",
|
|
22
|
+
"codex",
|
|
23
|
+
]
|
|
24
|
+
"""
|
|
25
|
+
Tools teamspend can pull spend data from. `claude-code-personal` is not a
|
|
26
|
+
separate vendor -- it's a credential-free local-file read mode for Claude
|
|
27
|
+
Code's own JSONL session logs, for someone who wants their personal usage
|
|
28
|
+
without org-admin API access (see
|
|
29
|
+
teamspend.adapters.claude_code_personal). `codex` (OpenAI's Codex CLI) is
|
|
30
|
+
the same shape as `opencode` -- no admin/team API, only local per-machine
|
|
31
|
+
session logs (see teamspend.adapters.codex).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class DateWindow:
|
|
37
|
+
start: str
|
|
38
|
+
"""YYYY-MM-DD"""
|
|
39
|
+
end: str
|
|
40
|
+
"""YYYY-MM-DD"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class SessionUsage:
|
|
45
|
+
"""
|
|
46
|
+
Cost attributed to a single session/conversation -- a bounded unit of
|
|
47
|
+
one interaction, and the most honest proxy teamspend can offer for
|
|
48
|
+
"cost per task." This is NOT a measure of task success, quality, or
|
|
49
|
+
ROI: no vendor exposes whether a session's output was actually good,
|
|
50
|
+
so teamspend never claims to know that. It only ever reports what a
|
|
51
|
+
session cost.
|
|
52
|
+
|
|
53
|
+
Ported from src/schema.ts's SessionUsage interface.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
session_id: str
|
|
57
|
+
cost_usd: float
|
|
58
|
+
input_tokens: Optional[int]
|
|
59
|
+
output_tokens: Optional[int]
|
|
60
|
+
requests: Optional[int]
|
|
61
|
+
is_estimated: bool
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class UserUsage:
|
|
66
|
+
user_id: str
|
|
67
|
+
user_email: Optional[str]
|
|
68
|
+
input_tokens: Optional[int]
|
|
69
|
+
output_tokens: Optional[int]
|
|
70
|
+
cache_read_tokens: Optional[int]
|
|
71
|
+
cache_write_tokens: Optional[int]
|
|
72
|
+
requests: Optional[int]
|
|
73
|
+
cost_usd: float
|
|
74
|
+
is_estimated: bool
|
|
75
|
+
# Per-session (per-conversation) cost breakdown, populated only by
|
|
76
|
+
# adapters whose underlying data source actually exposes a session
|
|
77
|
+
# identifier -- local-log-based adapters (claude_code_personal,
|
|
78
|
+
# opencode), which parse a real sessionId/sessionID out of each log
|
|
79
|
+
# entry. Admin-API-based adapters (cursor, claude_code, copilot) report
|
|
80
|
+
# aggregate per-user totals only, with no session concept anywhere in
|
|
81
|
+
# their response shape, so they leave this field None rather than
|
|
82
|
+
# fabricating session boundaries that don't exist. None by default so
|
|
83
|
+
# every adapter that predates this field keeps working unchanged.
|
|
84
|
+
sessions: Optional[List[SessionUsage]] = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class AdapterResult:
|
|
89
|
+
"""Normalized shape every adapter (and the CSV-import fallback) maps into."""
|
|
90
|
+
|
|
91
|
+
source: ToolId
|
|
92
|
+
window: DateWindow
|
|
93
|
+
total_cost_usd: float
|
|
94
|
+
is_estimated: bool
|
|
95
|
+
users: List[UserUsage] = field(default_factory=list)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
BreakdownMode = Literal["session"]
|
|
99
|
+
"""Supported values for the CLI's `--breakdown` flag."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def sum_cost(users: List[UserUsage]) -> float:
|
|
103
|
+
return sum(user.cost_usd for user in users)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def top_spenders(users: List[UserUsage], limit: int) -> List[UserUsage]:
|
|
107
|
+
return sorted(users, key=lambda u: u.cost_usd, reverse=True)[:limit]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def top_sessions(sessions: List[SessionUsage], limit: int) -> List[SessionUsage]:
|
|
111
|
+
return sorted(sessions, key=lambda s: s.cost_usd, reverse=True)[:limit]
|