{"slug": "claude-code-usage", "title": "Claude Code Usage", "summary": "A developer created a Python script called claude-usage.py that captures the output of the /usage command from the Claude CLI. The script uses a pseudo-terminal to launch Claude, send the command, and render the screen output, then parses usage reset times into ISO 8601 timestamps.", "body_md": "|\n#!/usr/bin/env -S uv run |\n|\n# /// script |\n|\n# dependencies = [\"pyte\", \"tzdata\"] |\n|\n# /// |\n|\n|\n|\n\"\"\" |\n|\nclaude-usage.py - Captures the output of the `/usage` command from the Claude CLI. |\n|\n|\n|\nDrives the interactive `claude` TUI through a pseudo-terminal (PTY): launches it, |\n|\nsends `/usage`, snapshots the rendered screen, then shuts the process down cleanly. |\n|\n|\n|\nUsage: |\n|\nuv run claude-usage.py |\n|\n|\n|\nSteps performed: |\n|\n1. Launch `claude` attached to a PTY so it behaves as an interactive session. |\n|\n2. Wait for it to reach the input prompt. |\n|\n3. Type `/usage` and press ENTER. |\n|\n4. Wait, then capture the rendered screen. |\n|\n5. Shut down: ESC -> wait -> `/exit` ENTER -> wait -> kill if still alive. |\n|\n6. Print the captured screen from step 4. |\n|\n\"\"\" |\n|\n|\n|\nfrom __future__ import annotations |\n|\n|\n|\nimport argparse |\n|\nimport fcntl |\n|\nimport json |\n|\nimport os |\n|\nimport re |\n|\nimport select |\n|\nimport shutil |\n|\nimport signal |\n|\nimport struct |\n|\nimport sys |\n|\nimport termios |\n|\nimport threading |\n|\nimport time |\n|\nfrom datetime import datetime, timedelta |\n|\nfrom zoneinfo import ZoneInfo |\n|\n|\n|\nimport pyte |\n|\n|\n|\nCLAUDE_COMMAND = \"claude\" |\n|\n|\n|\nTERM_ROWS = 150 |\n|\nTERM_COLS = 160 |\n|\n|\n|\nWAIT_AFTER_START = 3.0 |\n|\nWAIT_AFTER_USAGE = 3.0 |\n|\nWAIT_AFTER_ESC = 2.0 |\n|\nWAIT_AFTER_EXIT = 3.0 |\n|\nTYPE_DELAY = 0.4 |\n|\n|\n|\nENTER = b\"\\r\" |\n|\nESC = b\"\\x1b\" |\n|\n|\n|\n|\n|\nVERBOSE = True |\n|\n|\n|\n|\n|\ndef log(message: str) -> None: |\n|\nif VERBOSE: |\n|\nprint(f\"[claude-usage] {message}\", file=sys.stderr, flush=True) |\n|\n|\n|\n|\n|\ndef require_command(name: str) -> None: |\n|\nif shutil.which(name) is None: |\n|\nprint(f\"{name} is not installed or not on PATH\", file=sys.stderr) |\n|\nsys.exit(1) |\n|\n|\n|\n|\n|\ndef set_window_size(fd: int, rows: int, cols: int) -> None: |\n|\nwinsize = struct.pack(\"HHHH\", rows, cols, 0, 0) |\n|\nfcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) |\n|\n|\n|\n|\n|\ndef render_screen(screen: pyte.Screen) -> str: |\n|\nlines = [line.rstrip() for line in screen.display] |\n|\nwhile lines and not lines[-1]: |\n|\nlines.pop() |\n|\nreturn \"\\n\".join(lines) |\n|\n|\n|\n|\n|\nMONTHS = { |\n|\n\"jan\": 1, \"feb\": 2, \"mar\": 3, \"apr\": 4, \"may\": 5, \"jun\": 6, |\n|\n\"jul\": 7, \"aug\": 8, \"sep\": 9, \"oct\": 10, \"nov\": 11, \"dec\": 12, |\n|\n} |\n|\n|\n|\n|\n|\ndef parse_clock(text: str) -> tuple[int, int]: |\n|\n\"\"\"Parses a clock string like '7:30pm', '4am' or '12pm' into (hour, minute).\"\"\" |\n|\nmatch = re.match(r\"^(\\d{1,2})(?::(\\d{2}))?\\s*([ap]m)$\", text.strip().lower()) |\n|\nif not match: |\n|\nraise ValueError(f\"Unrecognized time format: {text!r}\") |\n|\nhour = int(match.group(1)) |\n|\nminute = int(match.group(2)) if match.group(2) else 0 |\n|\nmeridiem = match.group(3) |\n|\nif meridiem == \"pm\" and hour != 12: |\n|\nhour += 12 |\n|\nelif meridiem == \"am\" and hour == 12: |\n|\nhour = 0 |\n|\nreturn hour, minute |\n|\n|\n|\n|\n|\ndef parse_reset(text: str) -> str | None: |\n|\n\"\"\"Resolves a reset string into an ISO 8601 timestamp. |\n|\n|\n|\nAccepts 'time (Zone)' (next occurrence of that time) or |\n|\n'Month Day at time (Zone)' (next occurrence of that date). |\n|\n\"\"\" |\n|\nif not text: |\n|\nreturn None |\n|\n|\n|\ntz_match = re.search(r\"\\(([^)]+)\\)\\s*$\", text) |\n|\ntry: |\n|\ntz = ZoneInfo(tz_match.group(1)) if tz_match else ZoneInfo(\"UTC\") |\n|\nexcept Exception: |\n|\ntz = ZoneInfo(\"UTC\") |\n|\n|\n|\nbody = re.sub(r\"\\s*\\([^)]+\\)\\s*$\", \"\", text).strip() |\n|\nnow = datetime.now(tz) |\n|\n|\n|\ndated = re.match(r\"^([A-Za-z]+)\\s+(\\d+)\\s+at\\s+(.+)$\", body) |\n|\nif dated: |\n|\nmonth = MONTHS.get(dated.group(1)[:3].lower()) |\n|\nday = int(dated.group(2)) |\n|\nhour, minute = parse_clock(dated.group(3)) |\n|\nif month is None: |\n|\nreturn None |\n|\ndt = datetime(now.year, month, day, hour, minute, tzinfo=tz) |\n|\nif dt < now: |\n|\ndt = dt.replace(year=now.year + 1) |\n|\nreturn dt.isoformat() |\n|\n|\n|\ntry: |\n|\nhour, minute = parse_clock(body) |\n|\nexcept ValueError: |\n|\nreturn None |\n|\ndt = now.replace(hour=hour, minute=minute, second=0, microsecond=0) |\n|\nif dt <= now: |\n|\ndt += timedelta(days=1) |\n|\nreturn dt.isoformat() |\n|\n|\n|\n|\n|\ndef parse_usage_block(lines: list[str], label: str) -> dict | None: |\n|\n\"\"\"Finds a usage label and extracts its '<n>% used' value and reset time.\"\"\" |\n|\nfor i, line in enumerate(lines): |\n|\nif line.strip() == label: |\n|\nusage = None |\n|\nresets = None |\n|\nfor row in lines[i + 1:i + 5]: |\n|\nif usage is None: |\n|\nused = re.search(r\"(\\d+)%\\s+used\", row) |\n|\nif used: |\n|\nusage = int(used.group(1)) |\n|\nif resets is None: |\n|\nreset_line = re.search(r\"Resets\\s+(.+)\", row) |\n|\nif reset_line: |\n|\nresets = reset_line.group(1).strip() |\n|\nreturn {\"usage\": usage, \"resets\": parse_reset(resets)} |\n|\nreturn None |\n|\n|\n|\n|\n|\ndef parse_percentage_table(lines: list[str], header: str) -> dict: |\n|\n\"\"\"Parses a '<Header> % of usage' table into {name: percentage}.\"\"\" |\n|\nresult: dict[str, int] = {} |\n|\nfor i, line in enumerate(lines): |\n|\nif re.match(rf\"\\s*{re.escape(header)}\\s+% of usage\", line): |\n|\nfor row in lines[i + 1:]: |\n|\nif not row.strip(): |\n|\nbreak |\n|\ncell = re.match(r\"^(.*?)\\s+(\\d+)%\\s*$\", row.strip()) |\n|\nif not cell: |\n|\nbreak |\n|\nresult[cell.group(1).strip()] = int(cell.group(2)) |\n|\nbreak |\n|\nreturn result |\n|\n|\n|\n|\n|\ndef build_usage_json(captured: str) -> dict: |\n|\nlines = captured.splitlines() |\n|\nreturn { |\n|\n\"current_usage\": { |\n|\n\"current_session\": parse_usage_block(lines, \"Current session\"), |\n|\n\"current_week\": parse_usage_block(lines, \"Current week (all models)\"), |\n|\n\"current_week_sonnet\": parse_usage_block(lines, \"Current week (Sonnet only)\"), |\n|\n}, |\n|\n\"skills\": parse_percentage_table(lines, \"Skills\"), |\n|\n\"subagents\": parse_percentage_table(lines, \"Subagents\"), |\n|\n\"plugins\": parse_percentage_table(lines, \"Plugins\"), |\n|\n} |\n|\n|\n|\n|\n|\nHELP_DESCRIPTION = \"\"\"\\ |\n|\nCaptures the `/usage` output of the Claude CLI and emits it as JSON. |\n|\n|\n|\nDrives the interactive `claude` TUI through a pseudo-terminal (PTY): launches it, |\n|\ntypes `/usage`, snapshots the rendered screen, shuts the process down cleanly, |\n|\nparses the screen and prints structured JSON to stdout. |\n|\n|\n|\nOutput streams: |\n|\nstdout Structured JSON result (single compact line by default). |\n|\nstderr Progress logs and the raw captured screen (suppressed with --quiet). |\n|\n|\n|\nJSON shape: |\n|\n{ |\n|\n\"current_usage\": { |\n|\n\"current_session\": { \"usage\": <int %>, \"resets\": <ISO 8601> }, |\n|\n\"current_week\": { \"usage\": <int %>, \"resets\": <ISO 8601> }, |\n|\n\"current_week_sonnet\": { \"usage\": <int %>, \"resets\": <ISO 8601> } |\n|\n}, |\n|\n\"skills\": { \"<name>\": <int %>, ... }, |\n|\n\"subagents\": { \"<name>\": <int %>, ... }, |\n|\n\"plugins\": { \"<name>\": <int %>, ... } |\n|\n} |\n|\n\"\"\" |\n|\n|\n|\nHELP_EPILOG = \"\"\"\\ |\n|\nExamples: |\n|\nuv run claude-usage.py Compact JSON on stdout, logs on stderr. |\n|\nuv run claude-usage.py -f Pretty-printed JSON, logs on stderr. |\n|\nuv run claude-usage.py -V Compact JSON only; no stderr output. |\n|\nuv run claude-usage.py -V -f Pretty-printed JSON only; no stderr output. |\n|\n\"\"\" |\n|\n|\n|\n|\n|\ndef parse_args() -> argparse.Namespace: |\n|\nparser = argparse.ArgumentParser( |\n|\nprog=\"claude-usage.py\", |\n|\ndescription=HELP_DESCRIPTION, |\n|\nepilog=HELP_EPILOG, |\n|\nformatter_class=argparse.RawDescriptionHelpFormatter, |\n|\n) |\n|\nverbosity = parser.add_mutually_exclusive_group() |\n|\nverbosity.add_argument( |\n|\n\"-v\", \"--verbose\", action=\"store_true\", |\n|\nhelp=\"Print progress logs and the captured screen to stderr (default).\", |\n|\n) |\n|\nverbosity.add_argument( |\n|\n\"-V\", \"--quiet\", action=\"store_true\", |\n|\nhelp=\"Suppress all stderr log output.\", |\n|\n) |\n|\nparser.add_argument( |\n|\n\"-f\", \"--format\", action=\"store_true\", |\n|\nhelp=\"Pretty-print the JSON (indented). Default is a single compact line.\", |\n|\n) |\n|\nreturn parser.parse_args() |\n|\n|\n|\n|\n|\ndef main() -> None: |\n|\nargs = parse_args() |\n|\nglobal VERBOSE |\n|\nVERBOSE = not args.quiet |\n|\n|\n|\nrequire_command(CLAUDE_COMMAND) |\n|\n|\n|\nmaster_fd, slave_fd = os.openpty() |\n|\nset_window_size(slave_fd, TERM_ROWS, TERM_COLS) |\n|\n|\n|\nscreen = pyte.Screen(TERM_COLS, TERM_ROWS) |\n|\nstream = pyte.ByteStream(screen) |\n|\nlock = threading.Lock() |\n|\nstop = threading.Event() |\n|\n|\n|\ndef reader() -> None: |\n|\nwhile not stop.is_set(): |\n|\ntry: |\n|\nready, _, _ = select.select([master_fd], [], [], 0.2) |\n|\nexcept (OSError, ValueError): |\n|\nbreak |\n|\nif not ready: |\n|\ncontinue |\n|\ntry: |\n|\ndata = os.read(master_fd, 65536) |\n|\nexcept OSError: |\n|\nbreak |\n|\nif not data: |\n|\nbreak |\n|\nwith lock: |\n|\nstream.feed(data) |\n|\n|\n|\ndef preexec() -> None: |\n|\nos.setsid() |\n|\nfcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) |\n|\n|\n|\nreader_thread = threading.Thread(target=reader, daemon=True) |\n|\nreader_thread.start() |\n|\n|\n|\nenv = dict(os.environ) |\n|\nenv[\"TERM\"] = env.get(\"TERM\", \"xterm-256color\") |\n|\n|\n|\nimport subprocess |\n|\n|\n|\nlog(f\"Launching `{CLAUDE_COMMAND}` on a {TERM_COLS}x{TERM_ROWS} PTY...\") |\n|\nproc = subprocess.Popen( |\n|\n[CLAUDE_COMMAND], |\n|\nstdin=slave_fd, |\n|\nstdout=slave_fd, |\n|\nstderr=slave_fd, |\n|\npreexec_fn=preexec, |\n|\nenv=env, |\n|\nclose_fds=True, |\n|\n) |\n|\nos.close(slave_fd) |\n|\nlog(f\"Process started (pid {proc.pid}).\") |\n|\n|\n|\ndef send(data: bytes) -> None: |\n|\ntry: |\n|\nos.write(master_fd, data) |\n|\nexcept OSError: |\n|\npass |\n|\n|\n|\ncaptured = \"\" |\n|\ntry: |\n|\n# Step 2: wait for the input prompt. |\n|\nlog(f\"Waiting {WAIT_AFTER_START:g}s for the input prompt...\") |\n|\ntime.sleep(WAIT_AFTER_START) |\n|\n|\n|\n# Step 3: type `/usage` then ENTER. |\n|\nlog(\"Sending `/usage` + ENTER.\") |\n|\nsend(b\"/usage\") |\n|\ntime.sleep(TYPE_DELAY) |\n|\nsend(ENTER) |\n|\n|\n|\n# Step 4: wait, then snapshot the rendered screen. |\n|\nlog(f\"Waiting {WAIT_AFTER_USAGE:g}s for `/usage` output...\") |\n|\ntime.sleep(WAIT_AFTER_USAGE) |\n|\nlog(\"Capturing rendered screen.\") |\n|\nwith lock: |\n|\ncaptured = render_screen(screen) |\n|\n|\n|\n# Step 5: shut down cleanly. |\n|\nlog(\"Shutting down: ESC.\") |\n|\nsend(ESC) |\n|\ntime.sleep(WAIT_AFTER_ESC) |\n|\nlog(\"Sending `/exit` + ENTER.\") |\n|\nsend(b\"/exit\") |\n|\ntime.sleep(TYPE_DELAY) |\n|\nsend(ENTER) |\n|\n|\n|\nlog(f\"Waiting up to {WAIT_AFTER_EXIT:g}s for clean exit...\") |\n|\ntry: |\n|\nproc.wait(timeout=WAIT_AFTER_EXIT) |\n|\nlog(\"Process exited on its own.\") |\n|\nexcept subprocess.TimeoutExpired: |\n|\nlog(\"Still alive; terminating.\") |\n|\nproc.terminate() |\n|\ntry: |\n|\nproc.wait(timeout=2.0) |\n|\nexcept subprocess.TimeoutExpired: |\n|\nlog(\"Did not terminate; killing.\") |\n|\nproc.kill() |\n|\nfinally: |\n|\nstop.set() |\n|\nif proc.poll() is None: |\n|\nproc.send_signal(signal.SIGKILL) |\n|\ntry: |\n|\nos.close(master_fd) |\n|\nexcept OSError: |\n|\npass |\n|\nreader_thread.join(timeout=2.0) |\n|\n|\n|\n# Step 6: log the raw capture (stderr), emit structured JSON (stdout). |\n|\nif VERBOSE: |\n|\nlog(\"Captured screen:\") |\n|\nprint(captured, file=sys.stderr) |\n|\nlog(\"Done. Emitting JSON to stdout.\") |\n|\n|\n|\ndata = build_usage_json(captured) |\n|\nindent = 2 if args.format else None |\n|\nprint(json.dumps(data, indent=indent, ensure_ascii=False)) |\n|\n|\n|\n|\n|\nif __name__ == \"__main__\": |\n|\ntry: |\n|\nmain() |\n|\nexcept KeyboardInterrupt: |\n|\nsys.exit(130) |", "url": "https://wpnews.pro/news/claude-code-usage", "canonical_source": "https://gist.github.com/reduardo7/ad6e95dbf17c87fb0b982f6fdd9ff16e", "published_at": "2026-05-27 20:12:39+00:00", "updated_at": "2026-06-13 22:50:56.626594+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "large-language-models"], "entities": ["Claude", "pyte", "Python"], "alternates": {"html": "https://wpnews.pro/news/claude-code-usage", "markdown": "https://wpnews.pro/news/claude-code-usage.md", "text": "https://wpnews.pro/news/claude-code-usage.txt", "jsonld": "https://wpnews.pro/news/claude-code-usage.jsonld"}}