token-finops-cli 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: token-finops-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Read-only CLI to monitor GitHub Copilot CLI token usage and estimate a monthly budget runway
5
5
  Project-URL: Homepage, https://github.com/oh-my-agent-code/token-finops-cli
6
6
  Project-URL: Issues, https://github.com/oh-my-agent-code/token-finops-cli/issues
@@ -67,7 +67,7 @@ pip install token-finops-cli
67
67
  ## Usage
68
68
 
69
69
  ```bash
70
- token-finops # full report, last 7 days
70
+ token-finops # full report, last 7 days (same as "report")
71
71
  token-finops --since 30d # last 30 days
72
72
  token-finops --since all # all-time
73
73
  token-finops --session <id> # filter to one session
@@ -78,6 +78,26 @@ token-finops --compact # 2-line minimal output
78
78
  token-finops --watch 5 # live-refreshing view every 5s
79
79
  ```
80
80
 
81
+ ## Session history & break/gap reports
82
+
83
+ The `sessions` subcommand gives per-session archival/historical reporting
84
+ — useful for looking back at past sessions rather than just current
85
+ budget status:
86
+
87
+ ```bash
88
+ token-finops sessions # list all past sessions
89
+ token-finops sessions --since 30d --limit 10
90
+ token-finops sessions --session <id> # detailed break/gap report
91
+ token-finops sessions --session <id> --gap-minutes 60
92
+ token-finops sessions --totals # ONE combined report across ALL sessions
93
+ ```
94
+
95
+ The detailed per-session view detects "breaks" — gaps between requests
96
+ longer than `--gap-minutes` (default 30) — and reports active time (time
97
+ actually spent working) vs. idle/paused time (e.g. you closed the
98
+ terminal and came back the next day), alongside total elapsed wall-clock
99
+ time and a list of each pause with its start/end/duration.
100
+
81
101
  ## Try it without your own data (synthetic demo)
82
102
 
83
103
  A small script generates a throwaway SQLite DB with fake usage events (same
@@ -105,6 +125,16 @@ Or point the tool at any DB via the `TOKEN_FINOPS_DB` env var instead of
105
125
  | `--compact` / `-c` | 2-line minimal output (budget bar + runway status) |
106
126
  | `--verbose` / `-vv` | Full report (overrides `--compact`) |
107
127
 
128
+ `sessions` subcommand options:
129
+
130
+ | Flag | Description |
131
+ |---|---|
132
+ | `--since {1d,7d,30d,all}` | Time window for the session list (default `all`) |
133
+ | `--limit N` | Max sessions to list (default `20`) |
134
+ | `--session ID` | Show a detailed break/gap report for one session instead of the list |
135
+ | `--gap-minutes N` | Idle-gap threshold in minutes to count as a "break" (default `30`) |
136
+ | `--totals` | Print one combined report aggregated across ALL sessions matching `--since` (total requests/tokens/AI units, plus summed active/idle time and break count across every session) |
137
+
108
138
  ## What it measures
109
139
 
110
140
  The "AI unit" is GitHub's own cost/usage unit for a mixed
@@ -45,7 +45,7 @@ pip install token-finops-cli
45
45
  ## Usage
46
46
 
47
47
  ```bash
48
- token-finops # full report, last 7 days
48
+ token-finops # full report, last 7 days (same as "report")
49
49
  token-finops --since 30d # last 30 days
50
50
  token-finops --since all # all-time
51
51
  token-finops --session <id> # filter to one session
@@ -56,6 +56,26 @@ token-finops --compact # 2-line minimal output
56
56
  token-finops --watch 5 # live-refreshing view every 5s
57
57
  ```
58
58
 
59
+ ## Session history & break/gap reports
60
+
61
+ The `sessions` subcommand gives per-session archival/historical reporting
62
+ — useful for looking back at past sessions rather than just current
63
+ budget status:
64
+
65
+ ```bash
66
+ token-finops sessions # list all past sessions
67
+ token-finops sessions --since 30d --limit 10
68
+ token-finops sessions --session <id> # detailed break/gap report
69
+ token-finops sessions --session <id> --gap-minutes 60
70
+ token-finops sessions --totals # ONE combined report across ALL sessions
71
+ ```
72
+
73
+ The detailed per-session view detects "breaks" — gaps between requests
74
+ longer than `--gap-minutes` (default 30) — and reports active time (time
75
+ actually spent working) vs. idle/paused time (e.g. you closed the
76
+ terminal and came back the next day), alongside total elapsed wall-clock
77
+ time and a list of each pause with its start/end/duration.
78
+
59
79
  ## Try it without your own data (synthetic demo)
60
80
 
61
81
  A small script generates a throwaway SQLite DB with fake usage events (same
@@ -83,6 +103,16 @@ Or point the tool at any DB via the `TOKEN_FINOPS_DB` env var instead of
83
103
  | `--compact` / `-c` | 2-line minimal output (budget bar + runway status) |
84
104
  | `--verbose` / `-vv` | Full report (overrides `--compact`) |
85
105
 
106
+ `sessions` subcommand options:
107
+
108
+ | Flag | Description |
109
+ |---|---|
110
+ | `--since {1d,7d,30d,all}` | Time window for the session list (default `all`) |
111
+ | `--limit N` | Max sessions to list (default `20`) |
112
+ | `--session ID` | Show a detailed break/gap report for one session instead of the list |
113
+ | `--gap-minutes N` | Idle-gap threshold in minutes to count as a "break" (default `30`) |
114
+ | `--totals` | Print one combined report aggregated across ALL sessions matching `--since` (total requests/tokens/AI units, plus summed active/idle time and break count across every session) |
115
+
86
116
  ## What it measures
87
117
 
88
118
  The "AI unit" is GitHub's own cost/usage unit for a mixed
@@ -0,0 +1,24 @@
1
+ """CLI wrapper for building a synthetic Copilot session-store.db (demos/tests).
2
+
3
+ See token_finops_cli.examples_helper.build_synthetic_db for the actual
4
+ generation logic - kept in the package so tests can import it too.
5
+ """
6
+ import argparse
7
+
8
+ from token_finops_cli.examples_helper import build_synthetic_db
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(description=__doc__)
13
+ parser.add_argument("path", help="Output path for the synthetic .db file")
14
+ parser.add_argument("--days", type=int, default=14)
15
+ parser.add_argument("--events-per-day", type=int, default=8)
16
+ parser.add_argument("--seed", type=int, default=42)
17
+ args = parser.parse_args()
18
+
19
+ n = build_synthetic_db(args.path, args.days, args.events_per_day, args.seed)
20
+ print(f"Wrote {n} synthetic events to {args.path}")
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "token-finops-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Read-only CLI to monitor GitHub Copilot CLI token usage and estimate a monthly budget runway"
9
9
  readme = "README.md"
10
10
  license = "AGPL-3.0-or-later"
@@ -2,5 +2,5 @@
2
2
 
3
3
  from .cli import main
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"
6
6
  __all__ = ["main"]
@@ -0,0 +1,608 @@
1
+ #!/usr/bin/env python3
2
+ """Copilot CLI usage/efficiency summary.
3
+
4
+ Reads directly from the local session store's assistant_usage_events
5
+ table (~/.copilot/session-store.db) - this is the CLI's own local
6
+ telemetry, no external logs/network calls involved. Read-only (opened
7
+ in immutable/read-only mode to avoid any risk of corrupting the CLI's
8
+ live database while it's in use).
9
+
10
+ Also estimates a "runway" (days until a self-defined monthly AI-token
11
+ budget would run out at the current burn rate), based on the
12
+ `total_nano_aiu` column - GitHub's own cost/usage unit for a mixed
13
+ included-quota + pay-per-use Copilot plan (divide by 1e9 to get "AI
14
+ units" matching what's shown on the Copilot billing page). The budget
15
+ number itself is NOT fetched automatically (no API/scrape of the
16
+ billing page - that would need an authenticated browser session we
17
+ deliberately don't wire up here); it's a value you tell the script
18
+ yourself via --budget, defaulting to what you've told the agent
19
+ (50,000), and the cycle is assumed to reset monthly on --cycle-day
20
+ (default 1st) unless your actual billing cycle differs.
21
+
22
+ Also supports a `sessions` subcommand for per-session history: list all
23
+ past sessions with usage totals, or drill into one session_id for a
24
+ break/gap report (detects pauses between requests, e.g. across days you
25
+ paused and resumed) - handy for archival/relative reporting on past work.
26
+
27
+ Usage:
28
+ token-finops [report] [--since 1d|7d|30d|all] [--session <id>]
29
+ [--budget 50000] [--cycle-day 1]
30
+ token-finops sessions [--since ...] [--limit N]
31
+ token-finops sessions --session <id> [--gap-minutes 30]
32
+ """
33
+ import argparse
34
+ import os
35
+ import sqlite3
36
+ import sys
37
+ from datetime import datetime, timedelta, timezone
38
+
39
+ DEFAULT_DB_PATH = os.path.expanduser("~/.copilot/session-store.db")
40
+ DEFAULT_BUDGET_AIU = 50_000
41
+
42
+ SINCE_MAP = {
43
+ "1d": timedelta(days=1),
44
+ "7d": timedelta(days=7),
45
+ "30d": timedelta(days=30),
46
+ "all": None,
47
+ }
48
+
49
+
50
+ def connect_readonly(path):
51
+ # Open read-only via URI so we never risk writing to the CLI's live DB.
52
+ uri = f"file:{path}?mode=ro"
53
+ return sqlite3.connect(uri, uri=True)
54
+
55
+
56
+ def fetch_stats(con, since_delta, session_id=None):
57
+ where = []
58
+ params = []
59
+ if since_delta is not None:
60
+ cutoff = (datetime.now(timezone.utc) - since_delta).isoformat()
61
+ where.append("created_at >= ?")
62
+ params.append(cutoff)
63
+ if session_id:
64
+ where.append("session_id = ?")
65
+ params.append(session_id)
66
+ where_clause = f"WHERE {' AND '.join(where)}" if where else ""
67
+
68
+ query = f"""
69
+ SELECT
70
+ COUNT(*) AS requests,
71
+ COALESCE(SUM(input_tokens), 0) AS total_input,
72
+ COALESCE(SUM(output_tokens), 0) AS total_output,
73
+ COALESCE(SUM(reasoning_tokens), 0) AS total_reasoning,
74
+ COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
75
+ COUNT(DISTINCT session_id) AS sessions
76
+ FROM assistant_usage_events
77
+ {where_clause}
78
+ """
79
+ cur = con.cursor()
80
+ cur.execute(query, params)
81
+ row = cur.fetchone()
82
+ return {
83
+ "requests": row[0],
84
+ "total_input": row[1],
85
+ "total_output": row[2],
86
+ "total_reasoning": row[3],
87
+ "avg_duration_ms": row[4],
88
+ "sessions": row[5],
89
+ }
90
+
91
+
92
+ def format_tokens(n):
93
+ if n >= 1_000_000:
94
+ return f"{n / 1_000_000:.1f}M"
95
+ if n >= 1_000:
96
+ return f"{n / 1_000:.1f}k"
97
+ return str(n)
98
+
99
+
100
+ def current_cycle_start(cycle_day, now=None):
101
+ """First moment of the current billing cycle (assumed monthly, resetting
102
+ on `cycle_day` of each month). If today's day-of-month is before
103
+ cycle_day, the cycle started last month instead."""
104
+ now = now or datetime.now(timezone.utc)
105
+ year, month = now.year, now.month
106
+ if now.day < cycle_day:
107
+ month -= 1
108
+ if month == 0:
109
+ month, year = 12, year - 1
110
+ return datetime(year, month, cycle_day, tzinfo=timezone.utc)
111
+
112
+
113
+ def fetch_cycle_aiu(con, cycle_start):
114
+ cur = con.cursor()
115
+ cur.execute(
116
+ """
117
+ SELECT COALESCE(SUM(total_nano_aiu), 0), COUNT(*)
118
+ FROM assistant_usage_events
119
+ WHERE created_at >= ?
120
+ """,
121
+ (cycle_start.isoformat(),),
122
+ )
123
+ total_nano, requests = cur.fetchone()
124
+ return total_nano / 1e9, requests
125
+
126
+
127
+ def runway_report(con, budget, cycle_day):
128
+ now = datetime.now(timezone.utc)
129
+ cycle_start = current_cycle_start(cycle_day, now)
130
+ used_aiu, requests = fetch_cycle_aiu(con, cycle_start)
131
+
132
+ elapsed_days = max((now - cycle_start).total_seconds() / 86400, 1e-6)
133
+ daily_rate = used_aiu / elapsed_days
134
+ remaining_aiu = budget - used_aiu
135
+
136
+ # Days left until the cycle itself resets (next reset date).
137
+ next_reset_month = cycle_start.month + 1
138
+ next_reset_year = cycle_start.year
139
+ if next_reset_month == 13:
140
+ next_reset_month, next_reset_year = 1, next_reset_year + 1
141
+ next_reset = datetime(next_reset_year, next_reset_month, cycle_start.day, tzinfo=timezone.utc)
142
+ days_left_in_cycle = (next_reset - now).total_seconds() / 86400
143
+
144
+ if daily_rate > 0:
145
+ runway_days = remaining_aiu / daily_rate
146
+ else:
147
+ runway_days = float("inf")
148
+
149
+ cycle_length_days = max((next_reset - cycle_start).total_seconds() / 86400, 1e-6)
150
+ cycle_fraction = 1 - (days_left_in_cycle / cycle_length_days)
151
+
152
+ return {
153
+ "cycle_start": cycle_start,
154
+ "next_reset": next_reset,
155
+ "used_aiu": used_aiu,
156
+ "requests": requests,
157
+ "daily_rate": daily_rate,
158
+ "remaining_aiu": remaining_aiu,
159
+ "runway_days": runway_days,
160
+ "days_left_in_cycle": days_left_in_cycle,
161
+ "cycle_fraction": cycle_fraction,
162
+ }
163
+
164
+
165
+ def progress_bar(fraction, width=30):
166
+ """Render a plain-ASCII progress bar (fraction clamped to [0, 1]).
167
+
168
+ Deliberately uses only '#'/'-' rather than Unicode block characters
169
+ (U+2588/U+2591) - some terminal fonts don't have glyphs for those and
170
+ render them as blank space, making the bar invisible even though the
171
+ rest of the line displays fine.
172
+ """
173
+ fraction = max(0.0, min(1.0, fraction))
174
+ filled = round(width * fraction)
175
+ bar = "#" * filled + "-" * (width - filled)
176
+ return f"[{bar}] {fraction * 100:5.1f}%"
177
+
178
+
179
+ def render(args):
180
+ con = connect_readonly(args.db_path)
181
+ stats = fetch_stats(con, SINCE_MAP[args.since], args.session)
182
+ runway = runway_report(con, args.budget, args.cycle_day)
183
+ con.close()
184
+
185
+ total_tokens = stats["total_input"] + stats["total_output"]
186
+ tok_per_req = total_tokens / stats["requests"] if stats["requests"] else 0
187
+ out_in_ratio = (
188
+ stats["total_output"] / stats["total_input"] if stats["total_input"] else 0
189
+ )
190
+
191
+ lines = []
192
+ label = "all time" if args.since == "all" else f"last {args.since}"
193
+ lines.append(f"Copilot CLI usage ({label}):")
194
+ lines.append(f" requests: {stats['requests']}")
195
+ lines.append(f" sessions: {stats['sessions']}")
196
+ lines.append(f" input tokens: {format_tokens(stats['total_input'])}")
197
+ lines.append(f" output tokens: {format_tokens(stats['total_output'])}")
198
+ lines.append(f" reasoning tokens:{format_tokens(stats['total_reasoning'])}")
199
+ lines.append(f" tokens/request: {format_tokens(int(tok_per_req))}")
200
+ lines.append(f" output/input: {out_in_ratio:.2f}")
201
+ lines.append(f" avg duration: {stats['avg_duration_ms']/1000:.1f}s")
202
+
203
+ # Compact status-bar style one-liner
204
+ lines.append("")
205
+ lines.append(
206
+ f"\U0001F916 {stats['requests']} req "
207
+ f"\U0001FA99 {format_tokens(total_tokens)} tok "
208
+ f"\U0001F4C8 {format_tokens(int(tok_per_req))} tok/req "
209
+ f"\u23F1\uFE0F {stats['avg_duration_ms']/1000:.1f}s avg"
210
+ )
211
+
212
+ lines.append("")
213
+ lines.append(f"Budget runway (cycle since {runway['cycle_start'].date()}, "
214
+ f"resets {runway['next_reset'].date()}):")
215
+ used_fraction = runway["used_aiu"] / args.budget if args.budget else 0
216
+ lines.append(f" budget: {progress_bar(used_fraction)}")
217
+ lines.append(f" used: {runway['used_aiu']:.1f} / {args.budget:.0f} AI units"
218
+ f" ({runway['requests']} requests this cycle)")
219
+ lines.append(f" burn rate: {runway['daily_rate']:.1f} AI units/day")
220
+ if runway["runway_days"] == float("inf"):
221
+ lines.append(" runway: no usage yet this cycle, can't estimate")
222
+ else:
223
+ lines.append(f" runway: {runway['runway_days']:.1f} days at this rate")
224
+ status = "OK" if runway["runway_days"] >= runway["days_left_in_cycle"] else "WILL RUN OUT EARLY"
225
+ lines.append(f" vs. {runway['days_left_in_cycle']:.1f} days left in cycle -> {status}")
226
+ lines.append(f" cycle time: {progress_bar(runway['cycle_fraction'])}")
227
+
228
+ if runway["runway_days"] != float("inf"):
229
+ lines.append(f"\U0001F6E3\uFE0F runway: {runway['runway_days']:.0f}d")
230
+
231
+ return "\n".join(lines)
232
+
233
+
234
+ def render_compact(args):
235
+ """Minimal 2-line render: just the budget progress bar and a one-line
236
+ runway summary. Meant for tiny terminal panes (e.g. a 3-line split)
237
+ where the full report would scroll/clip."""
238
+ con = connect_readonly(args.db_path)
239
+ runway = runway_report(con, args.budget, args.cycle_day)
240
+ con.close()
241
+
242
+ used_fraction = runway["used_aiu"] / args.budget if args.budget else 0
243
+ lines = [
244
+ (
245
+ f"AI budget {progress_bar(used_fraction)} "
246
+ f"{runway['used_aiu']:.0f}/{args.budget:.0f}"
247
+ ),
248
+ ]
249
+ if runway["runway_days"] == float("inf"):
250
+ lines.append("runway: no usage yet this cycle")
251
+ else:
252
+ status = "OK" if runway["runway_days"] >= runway["days_left_in_cycle"] else "RUNNING OUT EARLY"
253
+ lines.append(
254
+ f"\U0001F6E3\uFE0F {runway['runway_days']:.0f}d runway "
255
+ f"({runway['days_left_in_cycle']:.0f}d left in cycle) - {status}"
256
+ )
257
+ return "\n".join(lines)
258
+
259
+
260
+
261
+ DEFAULT_GAP_MINUTES = 30.0
262
+
263
+
264
+ def list_sessions(con, since_delta=None, limit=20):
265
+ """Per-session summary: requests, tokens, AI units, first/last activity,
266
+ elapsed wall-clock span, and active time (sum of gaps below the
267
+ break threshold) vs idle time (gaps above it, i.e. actual pauses)."""
268
+ where = []
269
+ params = []
270
+ if since_delta is not None:
271
+ cutoff = (datetime.now(timezone.utc) - since_delta).isoformat()
272
+ where.append("created_at >= ?")
273
+ params.append(cutoff)
274
+ where_clause = f"WHERE {' AND '.join(where)}" if where else ""
275
+
276
+ cur = con.cursor()
277
+ cur.execute(
278
+ f"""
279
+ SELECT
280
+ session_id,
281
+ COUNT(*) AS requests,
282
+ COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) AS total_tokens,
283
+ COALESCE(SUM(total_nano_aiu), 0) / 1e9 AS aiu,
284
+ MIN(created_at) AS first_at,
285
+ MAX(created_at) AS last_at
286
+ FROM assistant_usage_events
287
+ {where_clause}
288
+ GROUP BY session_id
289
+ ORDER BY last_at DESC
290
+ LIMIT ?
291
+ """,
292
+ params + [limit],
293
+ )
294
+ rows = cur.fetchall()
295
+ return [
296
+ {
297
+ "session_id": r[0],
298
+ "requests": r[1],
299
+ "total_tokens": r[2],
300
+ "aiu": r[3],
301
+ "first_at": r[4],
302
+ "last_at": r[5],
303
+ }
304
+ for r in rows
305
+ ]
306
+
307
+
308
+ def session_breaks(con, session_id, gap_minutes=DEFAULT_GAP_MINUTES):
309
+ """Detect pauses/breaks within a single session: any gap between two
310
+ consecutive requests larger than `gap_minutes` counts as a break.
311
+ Returns (events_with_gaps, active_seconds, idle_seconds, elapsed_seconds).
312
+ "Active" time sums only the small in-between gaps (actual working
313
+ time); "idle" time sums the large gaps (time you were away/paused)."""
314
+ cur = con.cursor()
315
+ cur.execute(
316
+ """
317
+ SELECT created_at FROM assistant_usage_events
318
+ WHERE session_id = ?
319
+ ORDER BY created_at
320
+ """,
321
+ (session_id,),
322
+ )
323
+ timestamps = [
324
+ datetime.fromisoformat(row[0].replace("Z", "+00:00"))
325
+ for row in cur.fetchall()
326
+ ]
327
+ if not timestamps:
328
+ return [], 0.0, 0.0, 0.0
329
+
330
+ gap_threshold = timedelta(minutes=gap_minutes)
331
+ breaks = []
332
+ active_seconds = 0.0
333
+ idle_seconds = 0.0
334
+ for prev, cur_ts in zip(timestamps, timestamps[1:]):
335
+ gap = (cur_ts - prev).total_seconds()
336
+ if gap >= gap_threshold.total_seconds():
337
+ breaks.append((prev, cur_ts, gap))
338
+ idle_seconds += gap
339
+ else:
340
+ active_seconds += gap
341
+ elapsed_seconds = (timestamps[-1] - timestamps[0]).total_seconds()
342
+ return breaks, active_seconds, idle_seconds, elapsed_seconds
343
+
344
+
345
+ def format_duration(seconds):
346
+ seconds = int(seconds)
347
+ days, rem = divmod(seconds, 86400)
348
+ hours, rem = divmod(rem, 3600)
349
+ minutes, secs = divmod(rem, 60)
350
+ if days:
351
+ return f"{days}d {hours}h{minutes:02d}m"
352
+ if hours:
353
+ return f"{hours}h{minutes:02d}m{secs:02d}s"
354
+ if minutes:
355
+ return f"{minutes}m{secs:02d}s"
356
+ return f"{secs}s"
357
+
358
+
359
+ def render_sessions_list(args):
360
+ con = connect_readonly(args.db_path)
361
+ since_delta = SINCE_MAP[args.since]
362
+ sessions = list_sessions(con, since_delta, args.limit)
363
+
364
+ lines = [f"Sessions ({'all time' if args.since == 'all' else 'last ' + args.since}):"]
365
+ if not sessions:
366
+ lines.append(" (none found)")
367
+ con.close()
368
+ return "\n".join(lines)
369
+
370
+ lines.append(
371
+ f" {'session_id':<38} {'reqs':>5} {'tokens':>9} {'AI units':>9} last activity"
372
+ )
373
+ for s in sessions:
374
+ short_id = s["session_id"][:36]
375
+ last_at = s["last_at"][:19].replace("T", " ")
376
+ lines.append(
377
+ f" {short_id:<38} {s['requests']:>5} "
378
+ f"{format_tokens(s['total_tokens']):>9} {s['aiu']:>9.1f} {last_at}"
379
+ )
380
+ con.close()
381
+ return "\n".join(lines)
382
+
383
+
384
+ def render_session_detail(args):
385
+ con = connect_readonly(args.db_path)
386
+ stats = fetch_stats(con, None, args.session)
387
+ breaks, active_s, idle_s, elapsed_s = session_breaks(
388
+ con, args.session, args.gap_minutes
389
+ )
390
+ con.close()
391
+
392
+ if stats["requests"] == 0:
393
+ return f"No events found for session {args.session}"
394
+
395
+ total_tokens = stats["total_input"] + stats["total_output"]
396
+ lines = [f"Session {args.session}:"]
397
+ lines.append(f" requests: {stats['requests']}")
398
+ lines.append(f" input tokens: {format_tokens(stats['total_input'])}")
399
+ lines.append(f" output tokens: {format_tokens(stats['total_output'])}")
400
+ lines.append(f" reasoning tokens:{format_tokens(stats['total_reasoning'])}")
401
+ lines.append(f" total tokens: {format_tokens(total_tokens)}")
402
+ lines.append("")
403
+ lines.append(f" elapsed (first->last request): {format_duration(elapsed_s)}")
404
+ lines.append(f" active time (gaps < {args.gap_minutes:.0f}m): {format_duration(active_s)}")
405
+ lines.append(f" idle/paused time (gaps >= {args.gap_minutes:.0f}m): {format_duration(idle_s)}")
406
+ lines.append(f" breaks detected: {len(breaks)}")
407
+ if breaks:
408
+ lines.append("")
409
+ lines.append(" Breaks (pause start -> resume, duration):")
410
+ for start, end, gap in breaks:
411
+ lines.append(
412
+ f" {start.isoformat(timespec='seconds')} -> "
413
+ f"{end.isoformat(timespec='seconds')} ({format_duration(gap)})"
414
+ )
415
+ return "\n".join(lines)
416
+
417
+
418
+ def aggregate_all_sessions(con, since_delta=None, gap_minutes=DEFAULT_GAP_MINUTES):
419
+ """Combined break/gap totals across every session matching the filter
420
+ (not just the ones in a --limit-truncated list). For each session,
421
+ reuses session_breaks() and sums up active/idle time and break counts
422
+ - gives a true "all sessions" picture rather than one session at a time."""
423
+ where = []
424
+ params = []
425
+ if since_delta is not None:
426
+ cutoff = (datetime.now(timezone.utc) - since_delta).isoformat()
427
+ where.append("created_at >= ?")
428
+ params.append(cutoff)
429
+ where_clause = f"WHERE {' AND '.join(where)}" if where else ""
430
+
431
+ cur = con.cursor()
432
+ cur.execute(
433
+ f"SELECT DISTINCT session_id FROM assistant_usage_events {where_clause}",
434
+ params,
435
+ )
436
+ session_ids = [row[0] for row in cur.fetchall()]
437
+
438
+ total_active = 0.0
439
+ total_idle = 0.0
440
+ total_breaks = 0
441
+ for sid in session_ids:
442
+ breaks, active_s, idle_s, _elapsed_s = session_breaks(con, sid, gap_minutes)
443
+ total_active += active_s
444
+ total_idle += idle_s
445
+ total_breaks += len(breaks)
446
+
447
+ return {
448
+ "session_count": len(session_ids),
449
+ "active_seconds": total_active,
450
+ "idle_seconds": total_idle,
451
+ "break_count": total_breaks,
452
+ }
453
+
454
+
455
+ def render_sessions_totals(args):
456
+ """Aggregate report across ALL sessions matching --since (ignores
457
+ --limit, since this is meant to cover everything): total requests,
458
+ tokens, AI units, session count, plus combined active vs idle time
459
+ and break count summed across every individual session."""
460
+ con = connect_readonly(args.db_path)
461
+ since_delta = SINCE_MAP[args.since]
462
+ stats = fetch_stats(con, since_delta, session_id=None)
463
+ agg = aggregate_all_sessions(con, since_delta, args.gap_minutes)
464
+ con.close()
465
+
466
+ total_tokens = stats["total_input"] + stats["total_output"]
467
+ label = "all time" if args.since == "all" else f"last {args.since}"
468
+ lines = [f"All sessions report ({label}):"]
469
+ lines.append(f" sessions: {agg['session_count']}")
470
+ lines.append(f" requests: {stats['requests']}")
471
+ lines.append(f" input tokens: {format_tokens(stats['total_input'])}")
472
+ lines.append(f" output tokens: {format_tokens(stats['total_output'])}")
473
+ lines.append(f" reasoning tokens:{format_tokens(stats['total_reasoning'])}")
474
+ lines.append(f" total tokens: {format_tokens(total_tokens)}")
475
+ lines.append("")
476
+ lines.append(
477
+ f" active time (gaps < {args.gap_minutes:.0f}m), summed across "
478
+ f"all sessions: {format_duration(agg['active_seconds'])}"
479
+ )
480
+ lines.append(
481
+ f" idle/paused time (gaps >= {args.gap_minutes:.0f}m), summed: "
482
+ f"{format_duration(agg['idle_seconds'])}"
483
+ )
484
+ lines.append(f" total breaks detected: {agg['break_count']}")
485
+ return "\n".join(lines)
486
+
487
+
488
+ def _add_common_db_arg(p):
489
+ p.add_argument(
490
+ "--db-path",
491
+ default=os.environ.get("TOKEN_FINOPS_DB", DEFAULT_DB_PATH),
492
+ help="Path to the session-store.db to read (default: "
493
+ "$TOKEN_FINOPS_DB or ~/.copilot/session-store.db). Useful for "
494
+ "pointing at a synthetic/demo database.",
495
+ )
496
+
497
+
498
+ def build_parser():
499
+ parser = argparse.ArgumentParser(description=__doc__)
500
+ subparsers = parser.add_subparsers(dest="command")
501
+
502
+ report = subparsers.add_parser(
503
+ "report", help="Usage summary + budget/runway report (default)"
504
+ )
505
+ report.add_argument("--since", choices=SINCE_MAP.keys(), default="7d")
506
+ report.add_argument("--session", default=None, help="Filter to one session_id")
507
+ _add_common_db_arg(report)
508
+ report.add_argument(
509
+ "--budget", type=float, default=DEFAULT_BUDGET_AIU,
510
+ help=f"Monthly AI-unit budget for the runway estimate (default {DEFAULT_BUDGET_AIU})",
511
+ )
512
+ report.add_argument(
513
+ "--cycle-day", type=int, default=1,
514
+ help="Day of month your billing cycle resets on (default 1st)",
515
+ )
516
+ report.add_argument(
517
+ "--watch", type=float, default=None, metavar="SECONDS",
518
+ help="Keep running, redrawing the report every SECONDS (e.g. --watch 30). "
519
+ "Meant for a spare terminal pane/tmux split, not the Copilot session itself.",
520
+ )
521
+ report.add_argument(
522
+ "--compact", "-c", action="store_true",
523
+ help="Print only the budget progress bar + one-line runway summary "
524
+ "(2 lines total) instead of the full report. Good for tiny "
525
+ "terminal panes/splits.",
526
+ )
527
+ report.add_argument(
528
+ "--verbose", "-vv", action="store_true",
529
+ help="Explicitly request the full report (this is the default; "
530
+ "provided as the counterpart to --compact/-c).",
531
+ )
532
+
533
+ sessions = subparsers.add_parser(
534
+ "sessions", help="List past sessions, or show break/gap detail for one"
535
+ )
536
+ sessions.add_argument("--since", choices=SINCE_MAP.keys(), default="all")
537
+ sessions.add_argument(
538
+ "--session", default=None,
539
+ help="Show a detailed break/gap report for this one session_id "
540
+ "instead of the list",
541
+ )
542
+ sessions.add_argument(
543
+ "--limit", type=int, default=20,
544
+ help="Max number of sessions to list (default 20, ignored with "
545
+ "--session/--totals)",
546
+ )
547
+ sessions.add_argument(
548
+ "--gap-minutes", type=float, default=DEFAULT_GAP_MINUTES,
549
+ help=f"Idle gap threshold in minutes to count as a 'break' in the "
550
+ f"detailed --session/--totals views (default {DEFAULT_GAP_MINUTES:.0f})",
551
+ )
552
+ sessions.add_argument(
553
+ "--totals", action="store_true",
554
+ help="Print one combined report aggregated across ALL sessions "
555
+ "matching --since (requests, tokens, AI units, and combined "
556
+ "active/idle/break totals), instead of a per-session list.",
557
+ )
558
+ _add_common_db_arg(sessions)
559
+
560
+ return parser
561
+
562
+
563
+ def _normalize_argv(argv):
564
+ """Insert the "report" subcommand by default, so plain flag usage
565
+ (e.g. `token-finops --since 30d`) keeps working exactly as it did
566
+ before subcommands were introduced. Only "sessions" opts out."""
567
+ if not argv:
568
+ return ["report"]
569
+ if argv[0] in ("sessions", "report", "-h", "--help"):
570
+ return argv
571
+ return ["report", *argv]
572
+
573
+
574
+ def main(argv=None):
575
+ parser = build_parser()
576
+ argv = _normalize_argv(sys.argv[1:] if argv is None else argv)
577
+ args = parser.parse_args(argv)
578
+
579
+ if args.command == "sessions":
580
+ if args.session:
581
+ print(render_session_detail(args))
582
+ elif args.totals:
583
+ print(render_sessions_totals(args))
584
+ else:
585
+ print(render_sessions_list(args))
586
+ return
587
+
588
+ # command == "report"
589
+ renderer = render_compact if (args.compact and not args.verbose) else render
590
+
591
+ if args.watch is None:
592
+ print(renderer(args))
593
+ return
594
+
595
+ import time
596
+ try:
597
+ while True:
598
+ # Clear screen + move cursor home (ANSI), then redraw in place.
599
+ print("\033[2J\033[H", end="")
600
+ print(renderer(args))
601
+ print(f"\n(refreshing every {args.watch:.0f}s, Ctrl+C to stop)")
602
+ time.sleep(args.watch)
603
+ except KeyboardInterrupt:
604
+ pass
605
+
606
+
607
+ if __name__ == "__main__":
608
+ main()
@@ -1,12 +1,11 @@
1
- """Generate a synthetic Copilot session-store.db for demos and tests.
1
+ """Build a synthetic Copilot session-store.db, for demos and tests.
2
2
 
3
- Builds a throwaway SQLite database with only the columns this tool's
3
+ Creates a throwaway SQLite database with only the columns the CLI's
4
4
  queries actually reference (see cli.py's SQL) - it doesn't need to match
5
5
  GitHub's real internal schema exactly, just provide those columns with
6
6
  plausible fake data so the tool can be demonstrated/tested without ever
7
7
  touching a real ~/.copilot/session-store.db.
8
8
  """
9
- import argparse
10
9
  import random
11
10
  import sqlite3
12
11
  from datetime import datetime, timedelta, timezone
@@ -42,7 +41,9 @@ def build_synthetic_db(path, days=14, events_per_day=8, seed=42):
42
41
  reasoning_tokens = rng.randint(0, 800)
43
42
  duration_ms = rng.uniform(800, 15000)
44
43
  # Roughly proportional to tokens, in "nano AI units".
45
- total_nano_aiu = int((input_tokens + output_tokens) * rng.uniform(150_000_000, 400_000_000))
44
+ total_nano_aiu = int(
45
+ (input_tokens + output_tokens) * rng.uniform(150_000_000, 400_000_000)
46
+ )
46
47
  rows.append((
47
48
  session_id, ts.isoformat(), input_tokens, output_tokens,
48
49
  reasoning_tokens, duration_ms, total_nano_aiu,
@@ -60,19 +61,3 @@ def build_synthetic_db(path, days=14, events_per_day=8, seed=42):
60
61
  con.commit()
61
62
  con.close()
62
63
  return len(rows)
63
-
64
-
65
- def main():
66
- parser = argparse.ArgumentParser(description=__doc__)
67
- parser.add_argument("path", help="Output path for the synthetic .db file")
68
- parser.add_argument("--days", type=int, default=14)
69
- parser.add_argument("--events-per-day", type=int, default=8)
70
- parser.add_argument("--seed", type=int, default=42)
71
- args = parser.parse_args()
72
-
73
- n = build_synthetic_db(args.path, args.days, args.events_per_day, args.seed)
74
- print(f"Wrote {n} synthetic events to {args.path}")
75
-
76
-
77
- if __name__ == "__main__":
78
- main()
@@ -0,0 +1,121 @@
1
+ """Smoke tests for token_finops_cli — no live Copilot DB required."""
2
+ import subprocess
3
+ import sys
4
+
5
+
6
+ def test_help_runs():
7
+ result = subprocess.run(
8
+ [sys.executable, "-m", "token_finops_cli.cli", "--help"],
9
+ capture_output=True,
10
+ text=True,
11
+ check=False,
12
+ )
13
+ assert result.returncode == 0
14
+ assert "report" in result.stdout
15
+ assert "sessions" in result.stdout
16
+
17
+
18
+ def test_report_help_runs():
19
+ result = subprocess.run(
20
+ [sys.executable, "-m", "token_finops_cli.cli", "report", "--help"],
21
+ capture_output=True,
22
+ text=True,
23
+ check=False,
24
+ )
25
+ assert result.returncode == 0
26
+ assert "--budget" in result.stdout
27
+ assert "--watch" in result.stdout
28
+
29
+
30
+ def test_default_command_is_report():
31
+ """No subcommand should behave the same as `report` (backward compat
32
+ with the pre-subcommand CLI)."""
33
+ result = subprocess.run(
34
+ [sys.executable, "-m", "token_finops_cli.cli", "--help"],
35
+ capture_output=True,
36
+ text=True,
37
+ check=False,
38
+ )
39
+ assert result.returncode == 0
40
+
41
+
42
+ def test_sessions_help_runs():
43
+ result = subprocess.run(
44
+ [sys.executable, "-m", "token_finops_cli.cli", "sessions", "--help"],
45
+ capture_output=True,
46
+ text=True,
47
+ check=False,
48
+ )
49
+ assert result.returncode == 0
50
+ assert "--gap-minutes" in result.stdout
51
+ assert "--limit" in result.stdout
52
+
53
+
54
+ def test_sessions_list_against_synthetic_db(tmp_path):
55
+ from token_finops_cli.examples_helper import build_synthetic_db
56
+
57
+ db_path = tmp_path / "demo.db"
58
+ build_synthetic_db(str(db_path), days=10, events_per_day=5, seed=1)
59
+
60
+ result = subprocess.run(
61
+ [sys.executable, "-m", "token_finops_cli.cli", "sessions",
62
+ "--db-path", str(db_path)],
63
+ capture_output=True,
64
+ text=True,
65
+ check=False,
66
+ )
67
+ assert result.returncode == 0
68
+ assert "demo-session-0" in result.stdout
69
+
70
+
71
+ def test_sessions_totals_report(tmp_path):
72
+ from token_finops_cli.examples_helper import build_synthetic_db
73
+
74
+ db_path = tmp_path / "demo.db"
75
+ build_synthetic_db(str(db_path), days=10, events_per_day=5, seed=1)
76
+
77
+ result = subprocess.run(
78
+ [sys.executable, "-m", "token_finops_cli.cli", "sessions",
79
+ "--totals", "--db-path", str(db_path)],
80
+ capture_output=True,
81
+ text=True,
82
+ check=False,
83
+ )
84
+ assert result.returncode == 0
85
+ assert "All sessions report" in result.stdout
86
+ assert "sessions:" in result.stdout
87
+ assert "total breaks detected" in result.stdout
88
+
89
+
90
+ def test_session_breaks_detection(tmp_path):
91
+ from token_finops_cli.cli import connect_readonly, session_breaks
92
+ from token_finops_cli.examples_helper import build_synthetic_db
93
+
94
+ db_path = tmp_path / "demo.db"
95
+ build_synthetic_db(str(db_path), days=10, events_per_day=5, seed=1)
96
+
97
+ con = connect_readonly(str(db_path))
98
+ breaks, active_s, idle_s, elapsed_s = session_breaks(
99
+ con, "demo-session-0", gap_minutes=60
100
+ )
101
+ con.close()
102
+
103
+ assert elapsed_s > 0
104
+ assert active_s + idle_s <= elapsed_s + 1 # allow float rounding
105
+ assert isinstance(breaks, list)
106
+
107
+
108
+ def test_progress_bar_bounds():
109
+ from token_finops_cli.cli import progress_bar
110
+
111
+ assert progress_bar(0.0).startswith("[")
112
+ assert "100.0%" in progress_bar(1.5) # clamps above 1
113
+ assert "0.0%" in progress_bar(-0.5) # clamps below 0
114
+
115
+
116
+ def test_format_tokens():
117
+ from token_finops_cli.cli import format_tokens
118
+
119
+ assert format_tokens(500) == "500"
120
+ assert format_tokens(1_500) == "1.5k"
121
+ assert format_tokens(2_000_000) == "2.0M"
@@ -1,308 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Copilot CLI usage/efficiency summary.
3
-
4
- Reads directly from the local session store's assistant_usage_events
5
- table (~/.copilot/session-store.db) - this is the CLI's own local
6
- telemetry, no external logs/network calls involved. Read-only (opened
7
- in immutable/read-only mode to avoid any risk of corrupting the CLI's
8
- live database while it's in use).
9
-
10
- Also estimates a "runway" (days until a self-defined monthly AI-token
11
- budget would run out at the current burn rate), based on the
12
- `total_nano_aiu` column - GitHub's own cost/usage unit for a mixed
13
- included-quota + pay-per-use Copilot plan (divide by 1e9 to get "AI
14
- units" matching what's shown on the Copilot billing page). The budget
15
- number itself is NOT fetched automatically (no API/scrape of the
16
- billing page - that would need an authenticated browser session we
17
- deliberately don't wire up here); it's a value you tell the script
18
- yourself via --budget, defaulting to what you've told the agent
19
- (50,000), and the cycle is assumed to reset monthly on --cycle-day
20
- (default 1st) unless your actual billing cycle differs.
21
-
22
- Usage:
23
- python3 copilot_usage.py [--since 1d|7d|30d|all] [--session <id>]
24
- [--budget 50000] [--cycle-day 1]
25
- """
26
- import argparse
27
- import os
28
- import sqlite3
29
- from datetime import datetime, timedelta, timezone
30
-
31
- DEFAULT_DB_PATH = os.path.expanduser("~/.copilot/session-store.db")
32
- DEFAULT_BUDGET_AIU = 50_000
33
-
34
- SINCE_MAP = {
35
- "1d": timedelta(days=1),
36
- "7d": timedelta(days=7),
37
- "30d": timedelta(days=30),
38
- "all": None,
39
- }
40
-
41
-
42
- def connect_readonly(path):
43
- # Open read-only via URI so we never risk writing to the CLI's live DB.
44
- uri = f"file:{path}?mode=ro"
45
- return sqlite3.connect(uri, uri=True)
46
-
47
-
48
- def fetch_stats(con, since_delta, session_id=None):
49
- where = []
50
- params = []
51
- if since_delta is not None:
52
- cutoff = (datetime.now(timezone.utc) - since_delta).isoformat()
53
- where.append("created_at >= ?")
54
- params.append(cutoff)
55
- if session_id:
56
- where.append("session_id = ?")
57
- params.append(session_id)
58
- where_clause = f"WHERE {' AND '.join(where)}" if where else ""
59
-
60
- query = f"""
61
- SELECT
62
- COUNT(*) AS requests,
63
- COALESCE(SUM(input_tokens), 0) AS total_input,
64
- COALESCE(SUM(output_tokens), 0) AS total_output,
65
- COALESCE(SUM(reasoning_tokens), 0) AS total_reasoning,
66
- COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
67
- COUNT(DISTINCT session_id) AS sessions
68
- FROM assistant_usage_events
69
- {where_clause}
70
- """
71
- cur = con.cursor()
72
- cur.execute(query, params)
73
- row = cur.fetchone()
74
- return {
75
- "requests": row[0],
76
- "total_input": row[1],
77
- "total_output": row[2],
78
- "total_reasoning": row[3],
79
- "avg_duration_ms": row[4],
80
- "sessions": row[5],
81
- }
82
-
83
-
84
- def format_tokens(n):
85
- if n >= 1_000_000:
86
- return f"{n / 1_000_000:.1f}M"
87
- if n >= 1_000:
88
- return f"{n / 1_000:.1f}k"
89
- return str(n)
90
-
91
-
92
- def current_cycle_start(cycle_day, now=None):
93
- """First moment of the current billing cycle (assumed monthly, resetting
94
- on `cycle_day` of each month). If today's day-of-month is before
95
- cycle_day, the cycle started last month instead."""
96
- now = now or datetime.now(timezone.utc)
97
- year, month = now.year, now.month
98
- if now.day < cycle_day:
99
- month -= 1
100
- if month == 0:
101
- month, year = 12, year - 1
102
- return datetime(year, month, cycle_day, tzinfo=timezone.utc)
103
-
104
-
105
- def fetch_cycle_aiu(con, cycle_start):
106
- cur = con.cursor()
107
- cur.execute(
108
- """
109
- SELECT COALESCE(SUM(total_nano_aiu), 0), COUNT(*)
110
- FROM assistant_usage_events
111
- WHERE created_at >= ?
112
- """,
113
- (cycle_start.isoformat(),),
114
- )
115
- total_nano, requests = cur.fetchone()
116
- return total_nano / 1e9, requests
117
-
118
-
119
- def runway_report(con, budget, cycle_day):
120
- now = datetime.now(timezone.utc)
121
- cycle_start = current_cycle_start(cycle_day, now)
122
- used_aiu, requests = fetch_cycle_aiu(con, cycle_start)
123
-
124
- elapsed_days = max((now - cycle_start).total_seconds() / 86400, 1e-6)
125
- daily_rate = used_aiu / elapsed_days
126
- remaining_aiu = budget - used_aiu
127
-
128
- # Days left until the cycle itself resets (next reset date).
129
- next_reset_month = cycle_start.month + 1
130
- next_reset_year = cycle_start.year
131
- if next_reset_month == 13:
132
- next_reset_month, next_reset_year = 1, next_reset_year + 1
133
- next_reset = datetime(next_reset_year, next_reset_month, cycle_start.day, tzinfo=timezone.utc)
134
- days_left_in_cycle = (next_reset - now).total_seconds() / 86400
135
-
136
- if daily_rate > 0:
137
- runway_days = remaining_aiu / daily_rate
138
- else:
139
- runway_days = float("inf")
140
-
141
- cycle_length_days = max((next_reset - cycle_start).total_seconds() / 86400, 1e-6)
142
- cycle_fraction = 1 - (days_left_in_cycle / cycle_length_days)
143
-
144
- return {
145
- "cycle_start": cycle_start,
146
- "next_reset": next_reset,
147
- "used_aiu": used_aiu,
148
- "requests": requests,
149
- "daily_rate": daily_rate,
150
- "remaining_aiu": remaining_aiu,
151
- "runway_days": runway_days,
152
- "days_left_in_cycle": days_left_in_cycle,
153
- "cycle_fraction": cycle_fraction,
154
- }
155
-
156
-
157
- def progress_bar(fraction, width=30):
158
- """Render a plain-ASCII progress bar (fraction clamped to [0, 1]).
159
-
160
- Deliberately uses only '#'/'-' rather than Unicode block characters
161
- (U+2588/U+2591) - some terminal fonts don't have glyphs for those and
162
- render them as blank space, making the bar invisible even though the
163
- rest of the line displays fine.
164
- """
165
- fraction = max(0.0, min(1.0, fraction))
166
- filled = round(width * fraction)
167
- bar = "#" * filled + "-" * (width - filled)
168
- return f"[{bar}] {fraction * 100:5.1f}%"
169
-
170
-
171
- def render(args):
172
- con = connect_readonly(args.db_path)
173
- stats = fetch_stats(con, SINCE_MAP[args.since], args.session)
174
- runway = runway_report(con, args.budget, args.cycle_day)
175
- con.close()
176
-
177
- total_tokens = stats["total_input"] + stats["total_output"]
178
- tok_per_req = total_tokens / stats["requests"] if stats["requests"] else 0
179
- out_in_ratio = (
180
- stats["total_output"] / stats["total_input"] if stats["total_input"] else 0
181
- )
182
-
183
- lines = []
184
- label = "all time" if args.since == "all" else f"last {args.since}"
185
- lines.append(f"Copilot CLI usage ({label}):")
186
- lines.append(f" requests: {stats['requests']}")
187
- lines.append(f" sessions: {stats['sessions']}")
188
- lines.append(f" input tokens: {format_tokens(stats['total_input'])}")
189
- lines.append(f" output tokens: {format_tokens(stats['total_output'])}")
190
- lines.append(f" reasoning tokens:{format_tokens(stats['total_reasoning'])}")
191
- lines.append(f" tokens/request: {format_tokens(int(tok_per_req))}")
192
- lines.append(f" output/input: {out_in_ratio:.2f}")
193
- lines.append(f" avg duration: {stats['avg_duration_ms']/1000:.1f}s")
194
-
195
- # Compact status-bar style one-liner
196
- lines.append("")
197
- lines.append(
198
- f"\U0001F916 {stats['requests']} req "
199
- f"\U0001FA99 {format_tokens(total_tokens)} tok "
200
- f"\U0001F4C8 {format_tokens(int(tok_per_req))} tok/req "
201
- f"\u23F1\uFE0F {stats['avg_duration_ms']/1000:.1f}s avg"
202
- )
203
-
204
- lines.append("")
205
- lines.append(f"Budget runway (cycle since {runway['cycle_start'].date()}, "
206
- f"resets {runway['next_reset'].date()}):")
207
- used_fraction = runway["used_aiu"] / args.budget if args.budget else 0
208
- lines.append(f" budget: {progress_bar(used_fraction)}")
209
- lines.append(f" used: {runway['used_aiu']:.1f} / {args.budget:.0f} AI units"
210
- f" ({runway['requests']} requests this cycle)")
211
- lines.append(f" burn rate: {runway['daily_rate']:.1f} AI units/day")
212
- if runway["runway_days"] == float("inf"):
213
- lines.append(" runway: no usage yet this cycle, can't estimate")
214
- else:
215
- lines.append(f" runway: {runway['runway_days']:.1f} days at this rate")
216
- status = "OK" if runway["runway_days"] >= runway["days_left_in_cycle"] else "WILL RUN OUT EARLY"
217
- lines.append(f" vs. {runway['days_left_in_cycle']:.1f} days left in cycle -> {status}")
218
- lines.append(f" cycle time: {progress_bar(runway['cycle_fraction'])}")
219
-
220
- if runway["runway_days"] != float("inf"):
221
- lines.append(f"\U0001F6E3\uFE0F runway: {runway['runway_days']:.0f}d")
222
-
223
- return "\n".join(lines)
224
-
225
-
226
- def render_compact(args):
227
- """Minimal 2-line render: just the budget progress bar and a one-line
228
- runway summary. Meant for tiny terminal panes (e.g. a 3-line split)
229
- where the full report would scroll/clip."""
230
- con = connect_readonly(args.db_path)
231
- runway = runway_report(con, args.budget, args.cycle_day)
232
- con.close()
233
-
234
- used_fraction = runway["used_aiu"] / args.budget if args.budget else 0
235
- lines = [
236
- (
237
- f"AI budget {progress_bar(used_fraction)} "
238
- f"{runway['used_aiu']:.0f}/{args.budget:.0f}"
239
- ),
240
- ]
241
- if runway["runway_days"] == float("inf"):
242
- lines.append("runway: no usage yet this cycle")
243
- else:
244
- status = "OK" if runway["runway_days"] >= runway["days_left_in_cycle"] else "RUNNING OUT EARLY"
245
- lines.append(
246
- f"\U0001F6E3\uFE0F {runway['runway_days']:.0f}d runway "
247
- f"({runway['days_left_in_cycle']:.0f}d left in cycle) - {status}"
248
- )
249
- return "\n".join(lines)
250
-
251
-
252
- def main():
253
- parser = argparse.ArgumentParser(description=__doc__)
254
- parser.add_argument("--since", choices=SINCE_MAP.keys(), default="7d")
255
- parser.add_argument("--session", default=None, help="Filter to one session_id")
256
- parser.add_argument(
257
- "--db-path",
258
- default=os.environ.get("TOKEN_FINOPS_DB", DEFAULT_DB_PATH),
259
- help="Path to the session-store.db to read (default: "
260
- "$TOKEN_FINOPS_DB or ~/.copilot/session-store.db). Useful for "
261
- "pointing at a synthetic/demo database.",
262
- )
263
- parser.add_argument(
264
- "--budget", type=float, default=DEFAULT_BUDGET_AIU,
265
- help=f"Monthly AI-unit budget for the runway estimate (default {DEFAULT_BUDGET_AIU})",
266
- )
267
- parser.add_argument(
268
- "--cycle-day", type=int, default=1,
269
- help="Day of month your billing cycle resets on (default 1st)",
270
- )
271
- parser.add_argument(
272
- "--watch", type=float, default=None, metavar="SECONDS",
273
- help="Keep running, redrawing the report every SECONDS (e.g. --watch 30). "
274
- "Meant for a spare terminal pane/tmux split, not the Copilot session itself.",
275
- )
276
- parser.add_argument(
277
- "--compact", "-c", action="store_true",
278
- help="Print only the budget progress bar + one-line runway summary "
279
- "(2 lines total) instead of the full report. Good for tiny "
280
- "terminal panes/splits.",
281
- )
282
- parser.add_argument(
283
- "--verbose", "-vv", action="store_true",
284
- help="Explicitly request the full report (this is the default; "
285
- "provided as the counterpart to --compact/-c).",
286
- )
287
- args = parser.parse_args()
288
-
289
- renderer = render_compact if (args.compact and not args.verbose) else render
290
-
291
- if args.watch is None:
292
- print(renderer(args))
293
- return
294
-
295
- import time
296
- try:
297
- while True:
298
- # Clear screen + move cursor home (ANSI), then redraw in place.
299
- print("\033[2J\033[H", end="")
300
- print(renderer(args))
301
- print(f"\n(refreshing every {args.watch:.0f}s, Ctrl+C to stop)")
302
- time.sleep(args.watch)
303
- except KeyboardInterrupt:
304
- pass
305
-
306
-
307
- if __name__ == "__main__":
308
- main()
@@ -1,31 +0,0 @@
1
- """Smoke tests for token_finops_cli — no live Copilot DB required."""
2
- import subprocess
3
- import sys
4
-
5
-
6
- def test_help_runs():
7
- result = subprocess.run(
8
- [sys.executable, "-m", "token_finops_cli.cli", "--help"],
9
- capture_output=True,
10
- text=True,
11
- check=False,
12
- )
13
- assert result.returncode == 0
14
- assert "--budget" in result.stdout
15
- assert "--watch" in result.stdout
16
-
17
-
18
- def test_progress_bar_bounds():
19
- from token_finops_cli.cli import progress_bar
20
-
21
- assert progress_bar(0.0).startswith("[")
22
- assert "100.0%" in progress_bar(1.5) # clamps above 1
23
- assert "0.0%" in progress_bar(-0.5) # clamps below 0
24
-
25
-
26
- def test_format_tokens():
27
- from token_finops_cli.cli import format_tokens
28
-
29
- assert format_tokens(500) == "500"
30
- assert format_tokens(1_500) == "1.5k"
31
- assert format_tokens(2_000_000) == "2.0M"