kiro-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.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: kiro-usage
3
+ Version: 0.1.0
4
+ Summary: Track token usage and costs across Kiro CLI sessions
5
+ License-Expression: MIT
6
+ Classifier: Environment :: Console
7
+ Classifier: Topic :: Utilities
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # kiro-usage
15
+
16
+ Track token usage and costs across [Kiro CLI](https://kiro.dev) sessions.
17
+
18
+ Real-time terminal dashboard with session archiving that persists across `/clear` and restarts.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ # install uv if you don't have it
24
+ curl -LsSf https://astral.sh/uv/install.sh | sh
25
+
26
+ uv tool install kiro-usage && kiro-usage install
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```sh
32
+ kiro-usage # today's usage, live refresh
33
+ kiro-usage week # last 7 days
34
+ kiro-usage month # last 30 days
35
+ kiro-usage all # all time
36
+ kiro-usage --json # JSON output
37
+ kiro-usage --no-live # print once and exit
38
+ ```
39
+
40
+ ## Service Management
41
+
42
+ ```sh
43
+ kiro-usage install # register background archiver (launchd / systemd)
44
+ kiro-usage uninstall # remove background archiver
45
+ kiro-usage status # check archiver status
46
+ ```
47
+
48
+ The background archiver watches the Kiro CLI database and snapshots sessions to `~/.kiro_sessions/` every 10 seconds. This ensures session data is preserved even if you `/clear` or quit Kiro CLI.
49
+
50
+ ## How It Works
51
+
52
+ ```
53
+ kiro-cli SQLite DB
54
+
55
+ │ mtime change (every 10s)
56
+
57
+ kiro-usage-archiver ──► ~/.kiro_sessions/*.json
58
+ (launchd / systemd)
59
+
60
+ kiro-usage viewer ◄── ~/.kiro_sessions/*.json
61
+ (on-demand)
62
+ ```
63
+
64
+ **Metrics:**
65
+ - **CacheWrite** — new tokens per turn (estimated, chars/4)
66
+ - **CacheRead** — prior context resent to API (estimated)
67
+ - **Output** — tokens streamed back (from chunks, accurate)
68
+ - **Cost** — cache-aware pricing estimate (5-min cache write rate)
69
+
70
+ ## Uninstall
71
+
72
+ ```sh
73
+ kiro-usage uninstall
74
+ uv tool uninstall kiro-usage
75
+ ```
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,66 @@
1
+ # kiro-usage
2
+
3
+ Track token usage and costs across [Kiro CLI](https://kiro.dev) sessions.
4
+
5
+ Real-time terminal dashboard with session archiving that persists across `/clear` and restarts.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ # install uv if you don't have it
11
+ curl -LsSf https://astral.sh/uv/install.sh | sh
12
+
13
+ uv tool install kiro-usage && kiro-usage install
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```sh
19
+ kiro-usage # today's usage, live refresh
20
+ kiro-usage week # last 7 days
21
+ kiro-usage month # last 30 days
22
+ kiro-usage all # all time
23
+ kiro-usage --json # JSON output
24
+ kiro-usage --no-live # print once and exit
25
+ ```
26
+
27
+ ## Service Management
28
+
29
+ ```sh
30
+ kiro-usage install # register background archiver (launchd / systemd)
31
+ kiro-usage uninstall # remove background archiver
32
+ kiro-usage status # check archiver status
33
+ ```
34
+
35
+ The background archiver watches the Kiro CLI database and snapshots sessions to `~/.kiro_sessions/` every 10 seconds. This ensures session data is preserved even if you `/clear` or quit Kiro CLI.
36
+
37
+ ## How It Works
38
+
39
+ ```
40
+ kiro-cli SQLite DB
41
+
42
+ │ mtime change (every 10s)
43
+
44
+ kiro-usage-archiver ──► ~/.kiro_sessions/*.json
45
+ (launchd / systemd)
46
+
47
+ kiro-usage viewer ◄── ~/.kiro_sessions/*.json
48
+ (on-demand)
49
+ ```
50
+
51
+ **Metrics:**
52
+ - **CacheWrite** — new tokens per turn (estimated, chars/4)
53
+ - **CacheRead** — prior context resent to API (estimated)
54
+ - **Output** — tokens streamed back (from chunks, accurate)
55
+ - **Cost** — cache-aware pricing estimate (5-min cache write rate)
56
+
57
+ ## Uninstall
58
+
59
+ ```sh
60
+ kiro-usage uninstall
61
+ uv tool uninstall kiro-usage
62
+ ```
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,128 @@
1
+ """Kiro Usage Tracker — shared constants and utilities."""
2
+
3
+ import os, re, sqlite3, json, unicodedata, platform
4
+ from pathlib import Path
5
+
6
+ # ── Platform paths ────────────────────────────────────────────────────────────
7
+ if platform.system() == "Darwin":
8
+ CLI_DB = Path.home() / "Library/Application Support/kiro-cli/data.sqlite3"
9
+ else:
10
+ CLI_DB = Path.home() / ".local/share/kiro-cli/data.sqlite3"
11
+
12
+ SESSIONS_DIR = Path.home() / ".kiro_sessions"
13
+ CHARS_PER_TOKEN = 4
14
+
15
+ # ── Pricing ($/MTok) — Anthropic 5-min cache write rate ──────────────────────
16
+ PRICING = {
17
+ "claude-opus-4.6": (6.25, 0.50, 25),
18
+ "claude-opus-4.5": (6.25, 0.50, 25),
19
+ "claude-opus-4.1": (18.75, 1.50, 75),
20
+ "claude-opus-4": (18.75, 1.50, 75),
21
+ "claude-sonnet-4.6": (3.75, 0.30, 15),
22
+ "claude-sonnet-4.5": (3.75, 0.30, 15),
23
+ "claude-sonnet-4": (3.75, 0.30, 15),
24
+ }
25
+ DEFAULT_PRICING = (6.25, 0.50, 25)
26
+
27
+ def price_for(model_id):
28
+ if not model_id:
29
+ return DEFAULT_PRICING
30
+ m = model_id.lower()
31
+ for prefix, p in PRICING.items():
32
+ if prefix in m:
33
+ return p
34
+ return DEFAULT_PRICING
35
+
36
+ def calc_cost(cw, cr, out, model_id=None):
37
+ pw, pr, po = price_for(model_id)
38
+ return (cw * pw + cr * pr + out * po) / 1_000_000
39
+
40
+ # ── ANSI ──────────────────────────────────────────────────────────────────────
41
+ C = {
42
+ "reset": "\033[0m", "bold": "\033[1m", "dim": "\033[2m",
43
+ "green": "\033[32m", "yellow": "\033[33m", "blue": "\033[34m",
44
+ "cyan": "\033[36m", "red": "\033[31m", "magenta": "\033[35m",
45
+ "white": "\033[97m", "gray": "\033[90m",
46
+ "bg_cyan": "\033[46m", "bg_blue": "\033[44m", "bg_magenta": "\033[45m",
47
+ "bg_green": "\033[42m", "bg_yellow": "\033[43m", "bg_red": "\033[41m",
48
+ "underline": "\033[4m",
49
+ }
50
+ NO_COLOR = os.environ.get("NO_COLOR") is not None
51
+
52
+ def c(text, *styles):
53
+ if NO_COLOR:
54
+ return str(text)
55
+ return "".join(C[s] for s in styles) + str(text) + C["reset"]
56
+
57
+ def fmt(n):
58
+ if n >= 1_000_000: return "{:.1f}M".format(n / 1_000_000)
59
+ if n >= 1_000: return "{:.1f}K".format(n / 1_000)
60
+ return str(n)
61
+
62
+ def fmt_cost(v):
63
+ if v >= 1: return "${:.2f}".format(v)
64
+ if v >= 0.01: return "${:.3f}".format(v)
65
+ return "${:.4f}".format(v)
66
+
67
+ def bar(pct, width=20):
68
+ filled = int(pct / 100 * width)
69
+ if pct < 40:
70
+ return c("━" * filled, "green") + c("╌" * (width - filled), "dim")
71
+ elif pct < 70:
72
+ return c("━" * filled, "yellow") + c("╌" * (width - filled), "dim")
73
+ else:
74
+ return c("━" * filled, "red") + c("╌" * (width - filled), "dim")
75
+
76
+ def tw():
77
+ try: return os.get_terminal_size().columns
78
+ except: return 80
79
+
80
+ # ── Box drawing helpers ───────────────────────────────────────────────────────
81
+ _ansi_re = re.compile(r'\033\[[0-9;]*m')
82
+
83
+ def vlen(s):
84
+ plain = _ansi_re.sub('', s)
85
+ w = 0
86
+ for ch in plain:
87
+ eaw = unicodedata.east_asian_width(ch)
88
+ w += 2 if eaw in ('W', 'F') else 1
89
+ return w
90
+
91
+ def vrpad(s, width):
92
+ return s + " " * max(width - vlen(s), 0)
93
+
94
+ def vlpad(s, width):
95
+ return " " * max(width - vlen(s), 0) + s
96
+
97
+ def vpad(s, width):
98
+ return vrpad(s, width)
99
+
100
+ def box_top(title, width):
101
+ inner = width - 2
102
+ label = " {} ".format(title)
103
+ lw = vlen(label)
104
+ left = (inner - lw) // 2
105
+ right = inner - lw - left
106
+ return (c("╭", "dim") + c("─" * left, "dim") +
107
+ c(label, "bold", "cyan") + c("─" * right, "dim") + c("╮", "dim"))
108
+
109
+ def box_bot(width):
110
+ return c("╰" + "─" * (width - 2) + "╯", "dim")
111
+
112
+ def box_sep(width):
113
+ return c("├" + "─" * (width - 2) + "┤", "dim")
114
+
115
+ def box_line(content, width):
116
+ inner = width - 2
117
+ return c("│", "dim") + vpad(" " + content, inner) + c("│", "dim")
118
+
119
+ # ── DB ────────────────────────────────────────────────────────────────────────
120
+ def query(db_path, sql, params=()):
121
+ if not db_path.exists():
122
+ return []
123
+ conn = sqlite3.connect("file:{}?mode=ro".format(db_path), uri=True)
124
+ conn.row_factory = sqlite3.Row
125
+ try:
126
+ return conn.execute(sql, params).fetchall()
127
+ finally:
128
+ conn.close()
@@ -0,0 +1,90 @@
1
+ """Background archiver — watches Kiro CLI DB and snapshots sessions to disk.
2
+
3
+ Runs as a persistent background process (launchd on macOS, systemd on Linux).
4
+ Polls the DB file's mtime every 10s and archives any new/updated conversations
5
+ to ~/.kiro_sessions/<conversation_id>.json.
6
+ """
7
+
8
+ import json, sys, os, time, signal
9
+ from . import CLI_DB, SESSIONS_DIR, query
10
+
11
+ def ensure_sessions_dir():
12
+ SESSIONS_DIR.mkdir(exist_ok=True)
13
+
14
+ def archive_sessions():
15
+ """Snapshot all live conversations from the CLI DB into ~/.kiro_sessions/."""
16
+ ensure_sessions_dir()
17
+ rows = query(CLI_DB, """
18
+ SELECT conversation_id, key as cwd, created_at, updated_at, value
19
+ FROM conversations_v2
20
+ """)
21
+ archived = 0
22
+ for row in rows:
23
+ path = SESSIONS_DIR / "{}.json".format(row["conversation_id"])
24
+ if path.exists():
25
+ try:
26
+ existing = json.loads(path.read_text())
27
+ if existing.get("updated_at") >= row["updated_at"]:
28
+ continue
29
+ except (json.JSONDecodeError, KeyError):
30
+ pass
31
+ snapshot = {
32
+ "conversation_id": row["conversation_id"],
33
+ "cwd": row["cwd"],
34
+ "created_at": row["created_at"],
35
+ "updated_at": row["updated_at"],
36
+ "value": json.loads(row["value"]),
37
+ }
38
+ path.write_text(json.dumps(snapshot, separators=(",", ":")))
39
+ archived += 1
40
+ return archived
41
+
42
+ def load_archived_sessions():
43
+ """Load all snapshots from ~/.kiro_sessions/."""
44
+ ensure_sessions_dir()
45
+ sessions = []
46
+ for path in SESSIONS_DIR.glob("*.json"):
47
+ try:
48
+ sessions.append(json.loads(path.read_text()))
49
+ except (json.JSONDecodeError, OSError):
50
+ continue
51
+ return sessions
52
+
53
+ def _log(msg):
54
+ sys.stderr.write("[kiro-archiver] {}\n".format(msg))
55
+ sys.stderr.flush()
56
+
57
+ def main():
58
+ """Poll CLI_DB mtime every 10s, archive on change."""
59
+ running = True
60
+ def _stop(*_):
61
+ nonlocal running
62
+ running = False
63
+ signal.signal(signal.SIGTERM, _stop)
64
+ signal.signal(signal.SIGINT, _stop)
65
+
66
+ interval = int(os.environ.get("KIRO_ARCHIVE_INTERVAL", "10"))
67
+ _log("started — watching {} every {}s".format(CLI_DB, interval))
68
+
69
+ last_mtime = 0.0
70
+ while running:
71
+ try:
72
+ if CLI_DB.exists():
73
+ mtime = CLI_DB.stat().st_mtime
74
+ if mtime != last_mtime:
75
+ last_mtime = mtime
76
+ n = archive_sessions()
77
+ if n:
78
+ _log("archived {} session(s)".format(n))
79
+ except Exception as e:
80
+ _log("error: {}".format(e))
81
+ # Sleep in small increments so SIGTERM is responsive
82
+ for _ in range(interval):
83
+ if not running:
84
+ break
85
+ time.sleep(1)
86
+
87
+ _log("stopped")
88
+
89
+ if __name__ == "__main__":
90
+ main()
@@ -0,0 +1,164 @@
1
+ """Service installer — register/unregister the archiver as a background service.
2
+
3
+ macOS: ~/Library/LaunchAgents/dev.kiro.usage-archiver.plist (launchctl)
4
+ Linux: ~/.config/systemd/user/kiro-usage-archiver.service (systemd --user)
5
+ """
6
+
7
+ import os, platform, shutil, subprocess, textwrap
8
+ from pathlib import Path
9
+
10
+ LABEL = "dev.kiro.usage-archiver"
11
+
12
+ def _find_archiver_bin():
13
+ """Find the kiro-usage-archiver binary on PATH."""
14
+ p = shutil.which("kiro-usage-archiver")
15
+ if p:
16
+ return p
17
+ # Fallback: same directory as the current Python interpreter
18
+ d = Path(os.sys.executable).parent / "kiro-usage-archiver"
19
+ if d.exists():
20
+ return str(d)
21
+ return None
22
+
23
+ # ── macOS (launchd) ───────────────────────────────────────────────────────────
24
+ def _launchd_plist_path():
25
+ return Path.home() / "Library/LaunchAgents/{}.plist".format(LABEL)
26
+
27
+ def _launchd_install():
28
+ bin_path = _find_archiver_bin()
29
+ if not bin_path:
30
+ print("Error: kiro-usage-archiver not found on PATH")
31
+ return False
32
+ plist = textwrap.dedent("""\
33
+ <?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
35
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
36
+ <plist version="1.0">
37
+ <dict>
38
+ <key>Label</key>
39
+ <string>{label}</string>
40
+ <key>ProgramArguments</key>
41
+ <array>
42
+ <string>{bin}</string>
43
+ </array>
44
+ <key>RunAtLoad</key>
45
+ <true/>
46
+ <key>KeepAlive</key>
47
+ <true/>
48
+ <key>StandardErrorPath</key>
49
+ <string>{log}</string>
50
+ <key>StandardOutPath</key>
51
+ <string>{log}</string>
52
+ </dict>
53
+ </plist>
54
+ """).format(
55
+ label=LABEL,
56
+ bin=bin_path,
57
+ log=str(Path.home() / ".kiro_sessions/archiver.log"),
58
+ )
59
+ path = _launchd_plist_path()
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ # Unload first if already loaded
62
+ if path.exists():
63
+ subprocess.run(["launchctl", "bootout", "gui/{}".format(os.getuid()), str(path)],
64
+ capture_output=True)
65
+ path.write_text(plist)
66
+ r = subprocess.run(["launchctl", "bootstrap", "gui/{}".format(os.getuid()), str(path)],
67
+ capture_output=True, text=True)
68
+ if r.returncode != 0:
69
+ print("Warning: launchctl bootstrap: {}".format(r.stderr.strip()))
70
+ return True
71
+
72
+ def _launchd_uninstall():
73
+ path = _launchd_plist_path()
74
+ if not path.exists():
75
+ print("Not installed.")
76
+ return False
77
+ subprocess.run(["launchctl", "bootout", "gui/{}".format(os.getuid()), str(path)],
78
+ capture_output=True)
79
+ path.unlink()
80
+ return True
81
+
82
+ # ── Linux (systemd --user) ────────────────────────────────────────────────────
83
+ def _systemd_unit_path():
84
+ return Path.home() / ".config/systemd/user/kiro-usage-archiver.service"
85
+
86
+ def _systemd_install():
87
+ bin_path = _find_archiver_bin()
88
+ if not bin_path:
89
+ print("Error: kiro-usage-archiver not found on PATH")
90
+ return False
91
+ unit = textwrap.dedent("""\
92
+ [Unit]
93
+ Description=Kiro Usage Session Archiver
94
+
95
+ [Service]
96
+ ExecStart={bin}
97
+ Restart=on-failure
98
+ RestartSec=5
99
+
100
+ [Install]
101
+ WantedBy=default.target
102
+ """).format(bin=bin_path)
103
+ path = _systemd_unit_path()
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ path.write_text(unit)
106
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
107
+ subprocess.run(["systemctl", "--user", "enable", "--now", "kiro-usage-archiver"],
108
+ capture_output=True)
109
+ return True
110
+
111
+ def _systemd_uninstall():
112
+ path = _systemd_unit_path()
113
+ if not path.exists():
114
+ print("Not installed.")
115
+ return False
116
+ subprocess.run(["systemctl", "--user", "disable", "--now", "kiro-usage-archiver"],
117
+ capture_output=True)
118
+ path.unlink()
119
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
120
+ return True
121
+
122
+ # ── Public API ────────────────────────────────────────────────────────────────
123
+ def install():
124
+ if platform.system() == "Darwin":
125
+ ok = _launchd_install()
126
+ else:
127
+ ok = _systemd_install()
128
+ if ok:
129
+ print("✅ Archiver installed and running.")
130
+ print(" Sessions will be archived to ~/.kiro_sessions/")
131
+ return ok
132
+
133
+ def uninstall():
134
+ if platform.system() == "Darwin":
135
+ ok = _launchd_uninstall()
136
+ else:
137
+ ok = _systemd_uninstall()
138
+ if ok:
139
+ print("✅ Archiver uninstalled.")
140
+ return ok
141
+
142
+ def status():
143
+ if platform.system() == "Darwin":
144
+ path = _launchd_plist_path()
145
+ if not path.exists():
146
+ print("Archiver: not installed")
147
+ return
148
+ r = subprocess.run(
149
+ ["launchctl", "print", "gui/{}/{}".format(os.getuid(), LABEL)],
150
+ capture_output=True, text=True)
151
+ if r.returncode == 0:
152
+ # Extract state line
153
+ for line in r.stdout.splitlines():
154
+ if "state" in line.lower():
155
+ print("Archiver: {}".format(line.strip()))
156
+ return
157
+ print("Archiver: loaded")
158
+ else:
159
+ print("Archiver: installed but not running")
160
+ else:
161
+ r = subprocess.run(
162
+ ["systemctl", "--user", "is-active", "kiro-usage-archiver"],
163
+ capture_output=True, text=True)
164
+ print("Archiver: {}".format(r.stdout.strip() or "not installed"))
@@ -0,0 +1,336 @@
1
+ """Kiro Usage Tracker — terminal viewer.
2
+
3
+ Reads archived sessions from ~/.kiro_sessions/ and live DB,
4
+ renders a TUI dashboard with token usage, costs, and session details.
5
+ """
6
+
7
+ import json, sys, os, time, signal
8
+ from datetime import datetime, timedelta
9
+ from pathlib import Path
10
+
11
+ from . import (CLI_DB, SESSIONS_DIR, CHARS_PER_TOKEN, calc_cost, query,
12
+ c, fmt, fmt_cost, bar, tw,
13
+ vpad, vlpad, box_top, box_bot, box_sep, box_line)
14
+ from .archiver import archive_sessions, load_archived_sessions
15
+
16
+ # ── Parse a single conversation snapshot into display-ready stats ─────────────
17
+ def parse_conversation(conv_id, cwd, created_at_ms, updated_at_ms, data):
18
+ turns = data.get("history", [])
19
+ totals = {"cw": 0, "cr": 0, "out": 0, "cost": 0.0}
20
+ cumulative, prev_asst = 0, 0
21
+ models, tools = set(), []
22
+ daily = {}
23
+
24
+ for i, turn in enumerate(turns):
25
+ meta = turn.get("request_metadata") or {}
26
+ user_tok = len(json.dumps(turn.get("user", {}))) // CHARS_PER_TOKEN
27
+ asst_tok = len(json.dumps(turn.get("assistant", {}))) // CHARS_PER_TOKEN
28
+ out_tok = len(meta.get("time_between_chunks", []))
29
+ model = meta.get("model_id")
30
+
31
+ cr = cumulative if i > 0 else 0
32
+ cw = user_tok + (prev_asst if i > 0 else 0)
33
+ tc = calc_cost(cw, cr, out_tok, model)
34
+
35
+ totals["cw"] += cw; totals["cr"] += cr
36
+ totals["out"] += out_tok; totals["cost"] += tc
37
+ cumulative += user_tok + asst_tok
38
+ prev_asst = asst_tok
39
+
40
+ if model:
41
+ models.add(model)
42
+ for t in meta.get("tool_use_ids_and_names", []):
43
+ if len(t) > 1:
44
+ tools.append(t[1])
45
+
46
+ ts_ms = meta.get("request_start_timestamp_ms")
47
+ if ts_ms:
48
+ day = datetime.fromtimestamp(ts_ms / 1000).strftime("%Y-%m-%d")
49
+ if day not in daily:
50
+ daily[day] = {"cw": 0, "cr": 0, "out": 0, "cost": 0.0, "reqs": 0}
51
+ daily[day]["cw"] += cw; daily[day]["cr"] += cr
52
+ daily[day]["out"] += out_tok; daily[day]["cost"] += tc
53
+ daily[day]["reqs"] += 1
54
+
55
+ return {
56
+ "id": conv_id[:8], "full_id": conv_id, "cwd": cwd,
57
+ "created": datetime.fromtimestamp(created_at_ms / 1000),
58
+ "updated": datetime.fromtimestamp(updated_at_ms / 1000),
59
+ "turns": len(turns), **totals,
60
+ "models": models, "tools": tools, "daily": daily,
61
+ }
62
+
63
+ # ── Load all sessions (live DB + archive), deduplicated ───────────────────────
64
+ def load_all_sessions(days=None):
65
+ cutoff_ms = None
66
+ if days and days < 9000:
67
+ cutoff_ms = int((datetime.now() - timedelta(days=days)).timestamp() * 1000)
68
+
69
+ archive_sessions()
70
+ seen = {}
71
+
72
+ for snap in load_archived_sessions():
73
+ if cutoff_ms and snap["updated_at"] < cutoff_ms:
74
+ continue
75
+ try:
76
+ seen[snap["conversation_id"]] = parse_conversation(
77
+ snap["conversation_id"], snap["cwd"],
78
+ snap["created_at"], snap["updated_at"], snap["value"])
79
+ except Exception:
80
+ continue
81
+
82
+ for row in query(CLI_DB, "SELECT conversation_id, key as cwd, created_at, updated_at, value FROM conversations_v2 ORDER BY updated_at DESC"):
83
+ if cutoff_ms and row["updated_at"] < cutoff_ms:
84
+ continue
85
+ try:
86
+ data = json.loads(row["value"])
87
+ except Exception:
88
+ continue
89
+ seen[row["conversation_id"]] = parse_conversation(
90
+ row["conversation_id"], row["cwd"],
91
+ row["created_at"], row["updated_at"], data)
92
+
93
+ return sorted(seen.values(), key=lambda x: x["updated"], reverse=True)
94
+
95
+ # ── Render ────────────────────────────────────────────────────────────────────
96
+ def render(days):
97
+ cli_convos = load_all_sessions(days)
98
+ w = min(tw(), 120)
99
+ L = []
100
+ now = datetime.now().strftime("%H:%M:%S")
101
+ label = "all time" if days > 9000 else "last {} day{}".format(days, "s" if days != 1 else "")
102
+
103
+ L.append("")
104
+ L.append(" " + c("Kiro Usage Tracker", "bold", "cyan") +
105
+ " " + c("{} {}".format(label, now), "dim"))
106
+ L.append("")
107
+
108
+ L.append(box_top("Terminal", w))
109
+ if cli_convos:
110
+ t_cw = sum(cv["cw"] for cv in cli_convos)
111
+ t_cr = sum(cv["cr"] for cv in cli_convos)
112
+ t_out = sum(cv["out"] for cv in cli_convos)
113
+ t_cost = sum(cv["cost"] for cv in cli_convos)
114
+ t_reqs = sum(cv["turns"] for cv in cli_convos)
115
+ all_models = set()
116
+ for cv in cli_convos:
117
+ all_models.update(cv["models"])
118
+
119
+ L.append(box_line(
120
+ "{} reqs CWrite {} CRead {} Output {} Cost {}".format(
121
+ c(t_reqs, "bold", "white"), c(fmt(t_cw), "green"),
122
+ c(fmt(t_cr), "yellow"), c(fmt(t_out), "blue"),
123
+ c(fmt_cost(t_cost), "red", "bold")), w))
124
+ if all_models:
125
+ L.append(box_line(
126
+ "Models: " + " ".join(c(m, "magenta") for m in sorted(all_models)), w))
127
+
128
+ # Daily breakdown
129
+ cli_daily = {}
130
+ for cv in cli_convos:
131
+ for d, v in cv["daily"].items():
132
+ if d not in cli_daily:
133
+ cli_daily[d] = {"cw": 0, "cr": 0, "out": 0, "cost": 0.0, "reqs": 0}
134
+ for k in ("cw", "cr", "out", "cost", "reqs"):
135
+ cli_daily[d][k] += v[k]
136
+
137
+ if cli_daily:
138
+ L.append(box_sep(w))
139
+ max_r = max(v["reqs"] for v in cli_daily.values())
140
+ hdr = "{} {:>5} {:>8} {:>8} {:>7} {:>8} {}".format(
141
+ vpad("Date", 12), "Reqs", "CWrite", "CRead", "Output", "Cost", "Activity")
142
+ L.append(box_line(c(hdr, "dim"), w))
143
+
144
+ for d in sorted(cli_daily.keys(), reverse=True):
145
+ v = cli_daily[d]
146
+ pct = v["reqs"] / max_r * 100 if max_r else 0
147
+ is_today = d == datetime.now().strftime("%Y-%m-%d")
148
+ day_c = c(d, "bold", "white") if is_today else c(d, "dim")
149
+ marker = c("> ", "cyan") if is_today else " "
150
+ reqs_c = c(str(v["reqs"]), "bold") if is_today else str(v["reqs"])
151
+ cost_c = c(fmt_cost(v["cost"]), "red") if v["cost"] >= 0.1 else fmt_cost(v["cost"])
152
+ line = "{}{} {} {} {} {} {} {}".format(
153
+ marker, vpad(day_c, 10), vlpad(reqs_c, 5),
154
+ vlpad(fmt(v["cw"]), 8), vlpad(fmt(v["cr"]), 8),
155
+ vlpad(fmt(v["out"]), 7), vlpad(cost_c, 8), bar(pct))
156
+ L.append(box_line(line, w))
157
+
158
+ # Tool usage
159
+ all_tools = {}
160
+ for cv in cli_convos:
161
+ for t in cv["tools"]:
162
+ all_tools[t] = all_tools.get(t, 0) + 1
163
+ if all_tools:
164
+ L.append(box_sep(w))
165
+ top = sorted(all_tools.items(), key=lambda x: -x[1])[:6]
166
+ inner = w - 4
167
+ parts, cur = [], 7
168
+ for n, cnt in top:
169
+ part = "{}{}{}".format(c(n, "cyan"), c("x", "dim"), c(cnt, "bold"))
170
+ plen = len(n) + 1 + len(str(cnt))
171
+ if cur + plen + 2 > inner:
172
+ break
173
+ parts.append(part)
174
+ cur += plen + 2
175
+ L.append(box_line("Tools: " + " ".join(parts), w))
176
+
177
+ # Sessions
178
+ L.append(box_sep(w))
179
+ shdr = "{} {} {:>5} {:>7} {:>7} {:>6} {:>6} {}".format(
180
+ vpad("ID", 8), vpad("Directory", 18),
181
+ "Turns", "CWrite", "CRead", "Out", "Cost", "Updated")
182
+ L.append(box_line(c(shdr, "dim"), w))
183
+
184
+ for cv in cli_convos:
185
+ cwd = cv["cwd"].replace(str(Path.home()), "~")
186
+ if len(cwd) > 17:
187
+ cwd = ".." + cwd[-15:]
188
+ age = datetime.now() - cv["updated"]
189
+ if age < timedelta(hours=1):
190
+ dot, ts = c("*", "green"), cv["updated"].strftime("%H:%M")
191
+ elif age < timedelta(hours=6):
192
+ dot, ts = c("*", "yellow"), cv["updated"].strftime("%H:%M")
193
+ else:
194
+ dot, ts = c(".", "dim"), cv["updated"].strftime("%m-%d %H:%M")
195
+ updated = vpad("{} {}".format(dot, ts), 13)
196
+ line = "{} {} {:>5} {:>7} {:>7} {:>6} {:>6} {}".format(
197
+ vpad(c(cv["id"], "cyan"), 8), vpad(cwd, 18), cv["turns"],
198
+ fmt(cv["cw"]), fmt(cv["cr"]), fmt(cv["out"]),
199
+ fmt_cost(cv["cost"]), updated)
200
+ L.append(box_line(line, w))
201
+ else:
202
+ L.append(box_line(c("No CLI data found.", "dim"), w))
203
+
204
+ L.append(box_bot(w))
205
+ L.append(" " + c(
206
+ "📁 Sessions archived to ~/.kiro_sessions/ — history persists across /clear and restarts",
207
+ "dim", "green"))
208
+ L.append("")
209
+ return "\n".join(L)
210
+
211
+ # ── Modes ─────────────────────────────────────────────────────────────────────
212
+ def _clear():
213
+ sys.stdout.write("\033[2J\033[H"); sys.stdout.flush()
214
+
215
+ def live(days, interval=5):
216
+ signal.signal(signal.SIGINT, lambda *_: (sys.stdout.write("\033[?25h\n"), sys.exit(0)))
217
+ sys.stdout.write("\033[?25l")
218
+ try:
219
+ while True:
220
+ _clear()
221
+ print(render(days))
222
+ print(c(" ⏸ Ctrl+C to exit │ 🔄 refreshing every {}s".format(interval), "dim"))
223
+ time.sleep(interval)
224
+ finally:
225
+ sys.stdout.write("\033[?25h")
226
+
227
+ def view_json(days):
228
+ cli_convos = load_all_sessions(days)
229
+ out = {"cli": {"daily": {}, "sessions": []}}
230
+ for cv in cli_convos:
231
+ for d, v in cv["daily"].items():
232
+ if d not in out["cli"]["daily"]:
233
+ out["cli"]["daily"][d] = {
234
+ "requests": 0, "cache_write_est": 0,
235
+ "cache_read_est": 0, "output_tokens": 0, "cost_est_usd": 0.0,
236
+ }
237
+ for k, jk in [("reqs", "requests"), ("cw", "cache_write_est"),
238
+ ("cr", "cache_read_est"), ("out", "output_tokens"),
239
+ ("cost", "cost_est_usd")]:
240
+ out["cli"]["daily"][d][jk] += v[k]
241
+ out["cli"]["sessions"].append({
242
+ "id": cv["id"], "cwd": cv["cwd"], "turns": cv["turns"],
243
+ "cache_write_est": cv["cw"], "cache_read_est": cv["cr"],
244
+ "output_tokens": cv["out"], "cost_est_usd": round(cv["cost"], 4),
245
+ "models": list(cv["models"]),
246
+ "created": cv["created"].isoformat(),
247
+ "updated": cv["updated"].isoformat(),
248
+ })
249
+ for d in out["cli"]["daily"]:
250
+ out["cli"]["daily"][d]["cost_est_usd"] = round(
251
+ out["cli"]["daily"][d]["cost_est_usd"], 4)
252
+ print(json.dumps(out, indent=2))
253
+
254
+ # ── CLI entry point ───────────────────────────────────────────────────────────
255
+ PERIODS = {"today": 1, "week": 7, "month": 30, "all": 9999}
256
+
257
+ def main():
258
+ from . import service
259
+
260
+ args = sys.argv[1:]
261
+ as_json = "--json" in args
262
+ if as_json:
263
+ args.remove("--json")
264
+ no_live = "--no-live" in args
265
+ if no_live:
266
+ args.remove("--no-live")
267
+ cmd = args[0] if args else "today"
268
+
269
+ # Service management subcommands
270
+ if cmd == "install":
271
+ service.install()
272
+ return
273
+ if cmd == "uninstall":
274
+ service.uninstall()
275
+ return
276
+ if cmd == "status":
277
+ service.status()
278
+ return
279
+
280
+ if cmd in ("help", "-h", "--help"):
281
+ print("""
282
+ {} {}
283
+
284
+ {} kiro-usage [command] [flags]
285
+
286
+ {}
287
+ today Last 24 hours (default, live refresh)
288
+ week Last 7 days
289
+ month Last 30 days
290
+ all All time
291
+ install Register background archiver as a system service
292
+ uninstall Remove background archiver service
293
+ status Show archiver service status
294
+
295
+ {}
296
+ --json JSON output (no live mode)
297
+ --no-live Print once and exit
298
+
299
+ {} (CLI only)
300
+ ✏️ CacheWrite = new tokens per turn (estimated, chars/4)
301
+ 📖 CacheRead = prior context resent to API (estimated)
302
+ 📤 Output = tokens streamed back (from chunks, accurate)
303
+ 💰 Cost = cache-aware pricing estimate
304
+
305
+ {} (5-min cache write rate)
306
+ Model CWrite CRead Output
307
+ claude-opus-4.6 $6.25 $0.50 $25/MTok
308
+ claude-opus-4.5 $6.25 $0.50 $25/MTok
309
+ claude-opus-4.1 $18.75 $1.50 $75/MTok
310
+ claude-sonnet-4.x $3.75 $0.30 $15/MTok
311
+
312
+ {} 📂
313
+ CLI DB: {}
314
+ Archive: {}
315
+ """.format(
316
+ c("⚡", "yellow"), c("Kiro Usage Tracker", "bold", "cyan"),
317
+ c("Usage:", "bold"), c("📋 Commands:", "bold"),
318
+ c("🚩 Flags:", "bold"), c("📊 Metrics:", "bold"),
319
+ c("💲 Pricing $/MTok:", "bold"),
320
+ c("Data sources:", "dim"), CLI_DB, SESSIONS_DIR))
321
+ return
322
+
323
+ period = PERIODS.get(cmd)
324
+ if period is None:
325
+ print("Unknown command: " + cmd)
326
+ sys.exit(1)
327
+
328
+ if as_json:
329
+ view_json(period)
330
+ elif no_live or not sys.stdout.isatty():
331
+ print(render(period))
332
+ else:
333
+ live(period)
334
+
335
+ if __name__ == "__main__":
336
+ main()
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: kiro-usage
3
+ Version: 0.1.0
4
+ Summary: Track token usage and costs across Kiro CLI sessions
5
+ License-Expression: MIT
6
+ Classifier: Environment :: Console
7
+ Classifier: Topic :: Utilities
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # kiro-usage
15
+
16
+ Track token usage and costs across [Kiro CLI](https://kiro.dev) sessions.
17
+
18
+ Real-time terminal dashboard with session archiving that persists across `/clear` and restarts.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ # install uv if you don't have it
24
+ curl -LsSf https://astral.sh/uv/install.sh | sh
25
+
26
+ uv tool install kiro-usage && kiro-usage install
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```sh
32
+ kiro-usage # today's usage, live refresh
33
+ kiro-usage week # last 7 days
34
+ kiro-usage month # last 30 days
35
+ kiro-usage all # all time
36
+ kiro-usage --json # JSON output
37
+ kiro-usage --no-live # print once and exit
38
+ ```
39
+
40
+ ## Service Management
41
+
42
+ ```sh
43
+ kiro-usage install # register background archiver (launchd / systemd)
44
+ kiro-usage uninstall # remove background archiver
45
+ kiro-usage status # check archiver status
46
+ ```
47
+
48
+ The background archiver watches the Kiro CLI database and snapshots sessions to `~/.kiro_sessions/` every 10 seconds. This ensures session data is preserved even if you `/clear` or quit Kiro CLI.
49
+
50
+ ## How It Works
51
+
52
+ ```
53
+ kiro-cli SQLite DB
54
+
55
+ │ mtime change (every 10s)
56
+
57
+ kiro-usage-archiver ──► ~/.kiro_sessions/*.json
58
+ (launchd / systemd)
59
+
60
+ kiro-usage viewer ◄── ~/.kiro_sessions/*.json
61
+ (on-demand)
62
+ ```
63
+
64
+ **Metrics:**
65
+ - **CacheWrite** — new tokens per turn (estimated, chars/4)
66
+ - **CacheRead** — prior context resent to API (estimated)
67
+ - **Output** — tokens streamed back (from chunks, accurate)
68
+ - **Cost** — cache-aware pricing estimate (5-min cache write rate)
69
+
70
+ ## Uninstall
71
+
72
+ ```sh
73
+ kiro-usage uninstall
74
+ uv tool uninstall kiro-usage
75
+ ```
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ kiro_usage/__init__.py
4
+ kiro_usage/archiver.py
5
+ kiro_usage/service.py
6
+ kiro_usage/viewer.py
7
+ kiro_usage.egg-info/PKG-INFO
8
+ kiro_usage.egg-info/SOURCES.txt
9
+ kiro_usage.egg-info/dependency_links.txt
10
+ kiro_usage.egg-info/entry_points.txt
11
+ kiro_usage.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ kiro-usage = kiro_usage.viewer:main
3
+ kiro-usage-archiver = kiro_usage.archiver:main
@@ -0,0 +1 @@
1
+ kiro_usage
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kiro-usage"
7
+ version = "0.1.0"
8
+ description = "Track token usage and costs across Kiro CLI sessions"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ classifiers = [
13
+ "Environment :: Console",
14
+ "Topic :: Utilities",
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: MacOS",
17
+ "Operating System :: POSIX :: Linux",
18
+ ]
19
+
20
+ [project.scripts]
21
+ kiro-usage = "kiro_usage.viewer:main"
22
+ kiro-usage-archiver = "kiro_usage.archiver:main"
23
+
24
+ [tool.setuptools.packages.find]
25
+ include = ["kiro_usage*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+