fuzzyworld-tools / commit
fw usage: Claude Code token use per session
README.md | 1 + bin/fw | 3 +++ lib/usage.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/README.md b/README.md index 5c6dba8..7678beb 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ On the laptop, `bin/fw` runs `deploy` locally and forwards repo commands to Drea |---|---| | `fw deploy [--ref B] [--here] [--dry-run]` | build and publish this repo's app (above) | | `fw repoint [DIR] [--https] [--dry-run]` | for each repo in DIR whose `origin` is on gitlab.com: rename it `gitlab`, add `origin` → `dh:git/<repo>.git` (or read-only `https://code.fuzzyworld.net/<repo>.git` with `--https`, public repos only), fetch, and retarget branch tracking. Changes only remotes; checks that uncommitted work, HEAD and stashes are unchanged | +| `fw usage [--all\|--now]` | Claude Code token use per session from `~/.claude/projects` (runs locally): messages, current context size, cache-read, output (cache-read and output include subagents). `--now` prints one line for the newest session, to decide when to put it down | | `fw list` | every repo, its visibility and last commit | | `fw new <name> [--public] [--description TEXT]` | create `~/git/<name>.git` on DreamHost; **private unless `--public`** | | `fw public <name>` | publish the source on code.fuzzyworld.net right away | diff --git a/bin/fw b/bin/fw index 43cfa89..1cd79dd 100755 --- a/bin/fw +++ b/bin/fw @@ -3,6 +3,7 @@ # fw deploy [--ref <branch>] [--here] [--dry-run] build and publish this repo's app (see lib/deploy.mjs) # fw repoint [DIR] [--https] [--dry-run] move working copies' origin from GitLab to DreamHost # fw list | new <name> [--public] [--description TEXT] | public <name> | private <name> +# fw usage [--all|--now] Claude Code token use per session (see lib/usage.py) # fw describe <name> TEXT | publish <name> | publish-all | ci <name> [branch] | ci-list | ci-watch # (run on DreamHost over ssh) set -euo pipefail @@ -13,6 +14,8 @@ HERE=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd) case "${1:-help}" in deploy) shift; exec node "$HERE/lib/deploy.mjs" "$@" ;; + usage) + shift; exec python3 "$HERE/lib/usage.py" "$@" ;; repoint) shift; exec bash "$HERE/lib/repoint.sh" "$@" ;; list|new|public|private|describe|publish|publish-all|ci|ci-list|ci-watch|help|-h|--help) diff --git a/lib/usage.py b/lib/usage.py new file mode 100644 index 0000000..d9a7831 --- /dev/null +++ b/lib/usage.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""fw usage: token use per Claude Code session, from ~/.claude/projects/*/*.jsonl. + + fw usage sessions of the current directory's project, newest first + fw usage --all every project + fw usage --now one line about the newest session here: messages, current + context size, model share (use it to decide on "put down") +""" +import glob, json, os, re, sys, time + +root = os.path.expanduser('~/.claude/projects') + +def scan(path): + """Totals include the session's subagent transcripts (<session>/subagents/*.jsonl); + the context size and message count are the main conversation's.""" + msgs, tot, ctx, models = scan_one(path) + for sub in glob.glob(path[:-6] + '/subagents/*.jsonl'): + _, t, _, _ = scan_one(sub) + tot = [a + b for a, b in zip(tot, t)] + return msgs, tot, ctx, models + +def scan_one(path): + seen, msgs, tot, ctx, models = set(), 0, [0, 0, 0, 0], 0, {} + for line in open(path, errors='ignore'): + try: d = json.loads(line) + except ValueError: continue + m = d.get('message') + u = m.get('usage') if isinstance(m, dict) else None + if not u or d.get('type') != 'assistant': continue + k = m.get('id') or d.get('uuid') + if k in seen: continue + seen.add(k) + v = [u.get('input_tokens', 0), u.get('output_tokens', 0), + u.get('cache_read_input_tokens', 0), u.get('cache_creation_input_tokens', 0)] + tot = [a + b for a, b in zip(tot, v)] + ctx = v[0] + v[2] + v[3] + msgs += 1 + models[m.get('model', '?')] = models.get(m.get('model', '?'), 0) + 1 + return msgs, tot, ctx, models + +def fmt(n): return f'{n:,}' + +args = sys.argv[1:] +proj = os.path.join(root, re.sub(r'[^a-zA-Z0-9]', '-', os.getcwd())) +dirs = sorted(glob.glob(root + '/*')) if '--all' in args else [proj] +files = [f for d in dirs for f in glob.glob(d + '/*.jsonl')] +files.sort(key=os.path.getmtime, reverse=True) +if not files: + sys.exit('no sessions found for ' + proj) + +if '--now' in args: + f = files[0] + msgs, tot, ctx, models = scan(f) + share = ', '.join(f'{k.replace("claude-", "")} {v}' for k, v in sorted(models.items()) if k != '<synthetic>') + print(f'{os.path.basename(f)[:8]}: {msgs} msgs, context now {fmt(ctx)} tokens, ' + f'cache-read so far {fmt(tot[2])}, output {fmt(tot[1])}; models: {share}') + sys.exit() + +print(f'{"session":9}{"project":28}{"msgs":>6}{"context":>12}{"cache-read":>16}{"output":>12} last used') +for f in files: + msgs, tot, ctx, _ = scan(f) + p = os.path.basename(os.path.dirname(f)).replace('-Users-pavel-', '')[:27] + print(f'{os.path.basename(f)[:8]:9}{p:28}{msgs:>6}{fmt(ctx):>12}{fmt(tot[2]):>16}{fmt(tot[1]):>12} ' + + time.strftime('%Y-%m-%d %H:%M', time.localtime(os.path.getmtime(f))))