Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Inspired by [phuryn/claude-usage](https://github.com/phuryn/claude-usage) but di

## Status

Working codebase. 68 Python unit tests (`python3 -m unittest discover tests`). Seven UI tabs wired up (Overview, Prompts, Sessions, Projects, Skills, Tips, Settings). Runs on macOS, Windows, and Linux.
Working codebase. 75 Python unit tests (`python3 -m unittest discover tests`). Seven UI tabs wired up (Overview, Prompts, Sessions, Projects, Skills, Tips, Settings). Runs on macOS, Windows, and Linux.

## Architecture

Expand All @@ -36,7 +36,7 @@ Env vars: `PORT` (default 8080), `HOST` (default 127.0.0.1), `CLAUDE_PROJECTS_DI

## Known limitations

See `docs/KNOWN_LIMITATIONS.md`. Current summary: Skills `tokens_per_call` is populated only for skills installed under the three scanned roots (`~/.claude/skills/`, `~/.claude/scheduled-tasks/`, `~/.claude/plugins/`); project-local skills and subagent-dispatched skills show invocation counts but blank token counts.
See `docs/KNOWN_LIMITATIONS.md`. Current summary: Skills `tokens_per_call` covers the three global roots (`~/.claude/skills/`, `~/.claude/scheduled-tasks/`, `~/.claude/plugins/`) plus project-local `.claude/skills/` directories discovered from cwds in the messages table. Only `Task`-dispatched subagent skills still show blank token counts.

## Verifying changes

Expand Down
1 change: 0 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ Component layout: `cli.py` (entry points) → `token_dashboard/scanner.py` (JSON

## Ideas that would genuinely help

- Broadening the Skills catalog scan to cover project-local `.claude/skills/` directories (closes the known limitation).
- A CSV or JSON export of any route.
- A session-filter UI (currently everything is all-time or implicit-"recent").
- A GitHub Actions workflow that runs the tests on push.
Expand Down
24 changes: 23 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pathlib import Path

from token_dashboard.db import init_db, default_db_path, overview_totals
from token_dashboard.scanner import scan_dir
from token_dashboard.scanner import rescan_agent_targets, rescan_slash_commands, scan_dir
from token_dashboard.tips import all_tips


Expand Down Expand Up @@ -38,6 +38,26 @@ def cmd_scan(args):
print(f"Token Dashboard: scanned {n['files']} files, {n['messages']} messages, {n['tools']} tool calls")


def cmd_rescan_agent_targets(args):
db = _db_path(args)
init_db(db)
n = rescan_agent_targets(db, _projects(args))
print(
f"Token Dashboard: reset {n['files_reset']} files, "
f"re-parsed {n['messages']} messages, {n['tools']} tool calls"
)


def cmd_rescan_slash_commands(args):
db = _db_path(args)
init_db(db)
n = rescan_slash_commands(db)
print(
f"Token Dashboard: synthesized {n['slash_commands_synthesized']} "
f"Skill rows from historical slash-command messages"
)


def cmd_today(args):
db = _db_path(args)
init_db(db)
Expand Down Expand Up @@ -94,6 +114,8 @@ def main():
p = argparse.ArgumentParser(prog="token-dashboard", description="Local Claude Code usage dashboard", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("scan", parents=[common]).set_defaults(func=cmd_scan)
sub.add_parser("rescan-agent-targets", parents=[common]).set_defaults(func=cmd_rescan_agent_targets)
sub.add_parser("rescan-slash-commands", parents=[common]).set_defaults(func=cmd_rescan_slash_commands)
sub.add_parser("today", parents=[common]).set_defaults(func=cmd_today)
sub.add_parser("stats", parents=[common]).set_defaults(func=cmd_stats)
sub.add_parser("tips", parents=[common]).set_defaults(func=cmd_tips)
Expand Down
6 changes: 3 additions & 3 deletions docs/KNOWN_LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

None of these are blockers — the dashboard still gives you useful information. They're the rough edges you'll notice if you look hard.

## Skills token counts are partial
## Skills tokens-per-call is blank when a skill runs only through Task/Agent

The Skills route shows every skill Claude Code invoked, how many times, across how many sessions, and when. The **tokens-per-call** column is populated only for skills whose `SKILL.md` lives under `~/.claude/skills/`, `~/.claude/scheduled-tasks/`, or `~/.claude/plugins/`. Skills registered elsewhere (project-local `.claude/skills/`, or invocations that go through the `Task` tool with a skill-shaped `subagent_type`) show invocation counts but leave the token column blank.
The Skills route shows every skill Claude Code invoked, how many times, across how many sessions, and when. The **tokens-per-call** column is populated for every skill whose `SKILL.md` lives under `~/.claude/skills/`, `~/.claude/scheduled-tasks/`, `~/.claude/plugins/`, or a project-local `.claude/skills/` directory discovered from the cwds in your session history. A skill that runs only through the `Task`/`Agent` tool with a skill-shaped `subagent_type` (never as a direct `Skill` invocation) arrives without a resolvable slug on disk and its tokens-per-call stays blank.

It's still a useful viewyou can see which skills dominate your session time — just don't expect a complete per-skill token cost. PRs to broaden the catalog scan welcome.
Cost attribution for orchestrator skillsany skill that dispatches subagents via `Task`/`Agent` — follows the `parent_uuid` chain from every dispatch back to the skill call that emitted it. The `total inc. subagents` column on the Skills tab reflects that. If you upgraded from an older build and the column looks low, run `python3 cli.py rescan-agent-targets` once to re-parse main-session JSONLs whose Agent rows lost their `subagent_type` target.

## Cost for Pro / Max / Max-20x users is shown as API-equivalent, not subscription value

Expand Down
95 changes: 95 additions & 0 deletions tests/test_scanner_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ def test_extracts_tool_uses(self):
self.assertEqual(parsed[0]["name"], "Read")
self.assertEqual(parsed[1]["target"], "npm run lint")

def test_agent_and_task_both_populate_target(self):
"""Claude Code renamed Task → Agent; both must resolve subagent_type as target."""
rec = {
"type": "assistant", "uuid": "u", "sessionId": "s", "timestamp": "t",
"message": {
"model": "claude-opus-4-7",
"usage": {"input_tokens": 1, "output_tokens": 1},
"content": [
{"type": "tool_use", "id": "t1", "name": "Agent",
"input": {"subagent_type": "software-architect", "description": "x"}},
{"type": "tool_use", "id": "t2", "name": "Task",
"input": {"subagent_type": "researcher", "description": "y"}},
],
},
}
_, tools = parse_record(rec, project_slug="p")
self.assertEqual(len(tools), 2)
by_name = {t["tool_name"]: t for t in tools}
self.assertEqual(by_name["Agent"]["target"], "software-architect")
self.assertEqual(by_name["Task"]["target"], "researcher")


class SidechainTests(unittest.TestCase):
def test_is_sidechain_flag_propagates(self):
Expand Down Expand Up @@ -69,5 +90,79 @@ def test_tool_result_estimates_tokens(self):
self.assertAlmostEqual(tools[0]["result_tokens"], 1000, delta=10)


class SlashCommandExtractionTests(unittest.TestCase):
"""User-typed slash commands (`/foo`) must synthesize a Skill tool_call.

Claude Code logs them as a user-role record whose content is a string
containing `<command-name>/<slug></command-name>`. Two observed orderings
— `<command-name>` first or `<command-message>` first — must both match.
"""

def _user_record(self, content):
return {
"type": "user",
"uuid": "u-cmd",
"sessionId": "s1",
"timestamp": "2026-04-24T07:12:56Z",
"isSidechain": False,
"message": {"role": "user", "content": content},
}

def test_slash_command_name_first(self):
rec = self._user_record(
"<command-name>/demo-cmd</command-name>\n"
"<command-message>demo-cmd</command-message>\n"
"<command-args></command-args>"
)
_, tools = parse_record(rec, project_slug="p")
self.assertEqual(len(tools), 1)
self.assertEqual(tools[0]["tool_name"], "Skill")
self.assertEqual(tools[0]["target"], "demo-cmd")
self.assertEqual(tools[0]["timestamp"], "2026-04-24T07:12:56Z")

def test_slash_command_message_first(self):
rec = self._user_record(
"<command-message>demo-cmd</command-message>\n"
"<command-name>/demo-cmd</command-name>"
)
_, tools = parse_record(rec, project_slug="p")
self.assertEqual(len(tools), 1)
self.assertEqual(tools[0]["target"], "demo-cmd")

def test_plugin_namespaced_slug_preserves_colon(self):
rec = self._user_record("<command-name>/codex:review</command-name>")
_, tools = parse_record(rec, project_slug="p")
self.assertEqual(tools[0]["target"], "codex:review")

def test_list_content_with_text_blocks(self):
rec = self._user_record([
{"type": "text", "text": "<command-name>/demo-skill</command-name>"},
])
_, tools = parse_record(rec, project_slug="p")
self.assertEqual(tools[0]["target"], "demo-skill")

def test_non_user_record_ignored(self):
rec = {
"type": "assistant", "uuid": "a1", "sessionId": "s1",
"timestamp": "t", "isSidechain": False,
"message": {"content": [{"type": "text",
"text": "<command-name>/foo</command-name>"}],
"usage": {"input_tokens": 1, "output_tokens": 1}},
}
_, tools = parse_record(rec, project_slug="p")
# Assistant text doesn't count as a slash invocation.
self.assertEqual([t["tool_name"] for t in tools], [])

def test_ordinary_user_message_yields_no_skill_row(self):
rec = self._user_record("just a normal question about the code")
_, tools = parse_record(rec, project_slug="p")
self.assertEqual(tools, [])

def test_malformed_slug_rejected(self):
rec = self._user_record("<command-name>/not a slug</command-name>")
_, tools = parse_record(rec, project_slug="p")
self.assertEqual(tools, [])


if __name__ == "__main__":
unittest.main()
177 changes: 177 additions & 0 deletions tests/test_scanner_slash_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""Integration tests for slash-command Skill synthesis.

Covers both paths: (a) ingest-time synthesis via ``scan_dir`` emitting a
Skill row when a user record carries ``<command-name>/<slug></command-name>``,
and (b) one-shot ``rescan_slash_commands`` backfilling existing DBs whose
user messages were ingested before the extractor existed.
"""
import json
import os
import sqlite3
import tempfile
import time
import unittest

from token_dashboard.db import connect, init_db
from token_dashboard.scanner import rescan_slash_commands, scan_dir


def _write_jsonl(path, records):
with open(path, "w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r) + "\n")


def _slash_user(uuid, ts, slug, ordering="name-first"):
if ordering == "name-first":
content = (
f"<command-name>/{slug}</command-name>\n"
f"<command-message>{slug}</command-message>\n"
f"<command-args></command-args>"
)
else:
content = (
f"<command-message>{slug}</command-message>\n"
f"<command-name>/{slug}</command-name>"
)
return {
"type": "user",
"uuid": uuid,
"sessionId": "s1",
"timestamp": ts,
"isSidechain": False,
"message": {"role": "user", "content": content},
}


class SlashCommandIngestTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.db = os.path.join(self.tmp, "t.db")
self.proj_root = os.path.join(self.tmp, "projects")
self.proj_dir = os.path.join(self.proj_root, "C--work-sample")
os.makedirs(self.proj_dir)
init_db(self.db)

def _path(self):
return os.path.join(self.proj_dir, "s1.jsonl")

def _count_target(self, target):
with sqlite3.connect(self.db) as c:
return c.execute(
"SELECT COUNT(*) FROM tool_calls WHERE tool_name='Skill' AND target=?",
(target,),
).fetchone()[0]

def test_scan_emits_skill_row_for_slash_command(self):
_write_jsonl(self._path(), [
_slash_user("u1", "2026-04-24T07:12:56Z", "demo-cmd"),
])
scan_dir(self.proj_root, self.db)
self.assertEqual(self._count_target("demo-cmd"), 1)

def test_rescan_without_content_change_does_not_duplicate(self):
"""Forced rescan (mtime bumped, content identical) must not double-count
the synthetic Skill row — relies on scan_file's per-uuid DELETE."""
_write_jsonl(self._path(), [
_slash_user("u1", "2026-04-24T07:12:56Z", "demo-cmd"),
])
scan_dir(self.proj_root, self.db)
self.assertEqual(self._count_target("demo-cmd"), 1)

future = time.time() + 10
os.utime(self._path(), (future, future))
scan_dir(self.proj_root, self.db)
self.assertEqual(self._count_target("demo-cmd"), 1)

def test_plugin_namespaced_slug_round_trips_through_db(self):
_write_jsonl(self._path(), [
_slash_user("u1", "2026-04-24T07:00:00Z", "codex:review"),
])
scan_dir(self.proj_root, self.db)
self.assertEqual(self._count_target("codex:review"), 1)


class SlashCommandBackfillTests(unittest.TestCase):
"""Verify rescan_slash_commands synthesizes rows from existing messages."""

def setUp(self):
self.tmp = tempfile.mkdtemp()
self.db = os.path.join(self.tmp, "t.db")
init_db(self.db)

def _seed_user_message(self, c, *, uuid, session, ts, content):
c.execute(
"INSERT INTO messages (uuid, session_id, project_slug, type, timestamp, "
"prompt_text, prompt_chars) "
"VALUES (?, ?, 'p', 'user', ?, ?, ?)",
(uuid, session, ts, content, len(content)),
)

def test_backfill_synthesizes_row_from_existing_message(self):
slash = "<command-name>/demo-cmd</command-name>"
with connect(self.db) as c:
self._seed_user_message(
c, uuid="u1", session="s1",
ts="2026-04-24T07:12:56Z", content=slash,
)
c.commit()

result = rescan_slash_commands(self.db)
self.assertEqual(result["slash_commands_synthesized"], 1)

with sqlite3.connect(self.db) as c:
c.row_factory = sqlite3.Row
row = c.execute(
"SELECT tool_name, target, session_id, timestamp "
"FROM tool_calls WHERE message_uuid='u1'"
).fetchone()
self.assertEqual(row["tool_name"], "Skill")
self.assertEqual(row["target"], "demo-cmd")
self.assertEqual(row["session_id"], "s1")
self.assertEqual(row["timestamp"], "2026-04-24T07:12:56Z")

def test_backfill_is_idempotent(self):
slash = (
"<command-message>demo-cmd</command-message>\n"
"<command-name>/demo-cmd</command-name>"
)
with connect(self.db) as c:
self._seed_user_message(
c, uuid="u1", session="s1",
ts="2026-04-24T07:12:56Z", content=slash,
)
c.commit()
rescan_slash_commands(self.db)
rescan_slash_commands(self.db)
with sqlite3.connect(self.db) as c:
cnt = c.execute(
"SELECT COUNT(*) FROM tool_calls WHERE message_uuid='u1'"
).fetchone()[0]
self.assertEqual(cnt, 1, "two backfill calls must leave one row, not two")

def test_backfill_skips_non_slash_user_messages(self):
with connect(self.db) as c:
self._seed_user_message(
c, uuid="u1", session="s1",
ts="2026-04-24T07:00:00Z", content="normal user prompt",
)
self._seed_user_message(
c, uuid="u2", session="s1",
ts="2026-04-24T07:01:00Z",
content="<command-name>/demo-skill</command-name>",
)
c.commit()
result = rescan_slash_commands(self.db)
self.assertEqual(result["slash_commands_synthesized"], 1)
with sqlite3.connect(self.db) as c:
targets = [
r[0] for r in c.execute(
"SELECT target FROM tool_calls WHERE tool_name='Skill'"
)
]
self.assertEqual(targets, ["demo-skill"])


if __name__ == "__main__":
unittest.main()
Loading