Skip to content
Merged
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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,16 @@ python -m claude_prospector dashboard --track-mcp-calls
python -m claude_prospector dashboard --track-mcp-call-sizes
```

The Skills tab also reports manual Claude Code built-in slash-command usage,
including invocation counts and distinct sessions. Classification comes from a
dated snapshot of the official Claude Code command reference; bundled skills
and workflows (including `/doctor`) are excluded, while unknown command names
are shown separately for auditability. Only the `<command-name>` value and its
timestamp are retained as parsed command records for aggregation. Command
arguments and surrounding prompt text are read transiently while parsing the
transcript JSON, but claude-prospector never retains them or writes them to the
dashboard.

`--track-mcp-calls` adds a per-session MCP tool-call collection pass, which
costs additional full transcript reads: measured on the maintainer's real
corpus (~1,800 transcript files, 796 MB), `dashboard --format json` took 4.62s
Expand Down Expand Up @@ -741,7 +751,7 @@ The table below lists everything `claude-prospector` writes under that base dire

| Path | Contents | Written by | Contains prompt text? |
|---|---|---|---|
| `dashboard.html` | Aggregated token/cost stats | `dashboard` subcommand, or the opt-in `dashboard-regen` Stop hook | No |
| `dashboard.html` | Aggregated token/cost, skill, and command-name stats | `dashboard` subcommand, or the opt-in `dashboard-regen` Stop hook | No — command arguments and surrounding prompt text are read transiently from transcript JSON, but are never retained or written by claude-prospector or the dashboard |
| `hook.log` | One diagnostic line, e.g. `skipped: no skills found in Agent prompt for <agent>`; truncated and overwritten on every hook run | All hooks | No — logs the target agent *name*, never prompt content |
| `config.json` | User settings (`project_exclude_patterns`, legacy `autoregen`) | `config` subcommand / manual edit | No |
| `skill-tracking/<YYYY-MM-DD>.jsonl` | Skill name, timestamp, session-id, and (for Agent dispatches) target agent name, for each `Skill`/`Agent` tool-use event | `skill-tracker` PreToolUse hook — runs automatically on every `Skill`/`Agent` tool call once setup is `VALID` | No — only the matched skill *name* is stored, never the surrounding prompt |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ where = ["src"]
include = ["claude_prospector*"]

[tool.setuptools.package-data]
claude_prospector = ["templates/*.html", "static/**/*"]
claude_prospector = ["templates/*.html", "static/**/*", "data/*.json"]

[tool.pytest.ini_options]
testpaths = ["tests"]
63 changes: 63 additions & 0 deletions src/claude_prospector/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone

from claude_prospector.builtin_commands import load_command_catalog
from claude_prospector.constants import AGENT_PATH_SEPARATOR as _AGENT_PATH_SEPARATOR
from claude_prospector.mcp_names import normalize_mcp_tool_name
from claude_prospector.models import (
Expand Down Expand Up @@ -59,6 +60,7 @@ class AggregateResult:
sessions: list[dict] = field(default_factory=list)
by_skill_adoption: dict[str, dict] = field(default_factory=dict)
by_mcp_usage: dict[str, dict] = field(default_factory=dict)
by_command_usage: dict[str, dict] = field(default_factory=dict)


def _add_tokens(bucket: dict, msg: MessageRecord) -> None:
Expand Down Expand Up @@ -94,6 +96,66 @@ def _agent_activity(msg: MessageRecord) -> dict:
}


def _compute_command_usage(
sessions: list[SessionRecord],
from_date: datetime | None,
to_date: datetime | None,
) -> dict[str, dict]:
"""Aggregate manual built-in commands for the selected dashboard window.

Args:
sessions: Parsed sessions containing command records.
from_date: Inclusive lower timestamp bound.
to_date: Exclusive upper timestamp bound.

Returns:
Classification metadata plus built-in and unclassified command counts.
Bundled skills and workflows are excluded.
"""
catalog = load_command_catalog()
command_counts: Counter[str] = Counter()
command_sessions: dict[str, set[str]] = defaultdict(set)
unclassified_counts: Counter[str] = Counter()
unclassified_sessions: dict[str, set[str]] = defaultdict(set)

for session in sessions:
for command in session.commands:
if from_date and command.timestamp < from_date:
continue
if to_date and command.timestamp >= to_date:
continue
kind = catalog.classify(command.name)
if kind == "builtin":
command_counts[command.name] += 1
command_sessions[command.name].add(session.session_id)
elif kind == "unclassified":
unclassified_counts[command.name] += 1
unclassified_sessions[command.name].add(session.session_id)

def summarize(
counts: Counter[str],
used_sessions: dict[str, set[str]],
) -> dict[str, dict[str, int]]:
"""Convert counters and session sets into the public payload shape."""
return {
name: {
"invocation_count": counts[name],
"sessions_used_in": len(used_sessions[name]),
}
for name in sorted(counts)
}

return {
"classification": {
"available": catalog.available,
"source_url": catalog.source_url,
"retrieved_at": catalog.retrieved_at,
},
"by_command": summarize(command_counts, command_sessions),
"unclassified": summarize(unclassified_counts, unclassified_sessions),
}


def aggregate(
sessions: list[SessionRecord],
from_date: datetime | None = None,
Expand Down Expand Up @@ -278,6 +340,7 @@ def aggregate(
result.by_day[day]["by_model"][model] += msg.total_tokens

result.sessions.sort(key=lambda s: s["start_time"], reverse=True)
result.by_command_usage = _compute_command_usage(sessions, from_date, to_date)

return result

Expand Down
142 changes: 142 additions & 0 deletions src/claude_prospector/builtin_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Classify Claude Code slash commands using a packaged catalog."""

from __future__ import annotations

import json
import re
from dataclasses import dataclass
from datetime import date
from functools import lru_cache
from importlib import resources
from typing import Literal


CommandKind = Literal["builtin", "bundled_skill", "workflow", "unclassified"]
_COMMAND_NAME_RE = re.compile(r"/[^\s<>]+")


@dataclass(frozen=True, slots=True)
class CommandCatalog:
"""An auditable snapshot of Claude Code command categories.

Attributes:
available: Whether the packaged catalog loaded successfully.
source_url: Official documentation URL used to build the snapshot.
retrieved_at: ISO date when the source was retrieved.
builtins: Literal names classified as built-in commands.
bundled_skills: Literal names classified as bundled skills.
workflows: Literal names classified as bundled workflows.
"""

available: bool = False
source_url: str | None = None
retrieved_at: str | None = None
builtins: frozenset[str] = frozenset()
bundled_skills: frozenset[str] = frozenset()
workflows: frozenset[str] = frozenset()

def classify(self, command_name: str) -> CommandKind:
"""Classify one literal slash-command name.

Args:
command_name: Command name including its leading slash.

Returns:
The catalog category, or ``"unclassified"`` when unknown.
"""
if command_name in self.builtins:
return "builtin"
if command_name in self.bundled_skills:
return "bundled_skill"
if command_name in self.workflows:
return "workflow"
return "unclassified"


def _validated_commands(payload: object, field_name: str) -> frozenset[str]:
"""Validate and normalize one catalog category.

Args:
payload: Decoded JSON value for the category.
field_name: Category name used in validation errors.

Returns:
Validated unique command names.

Raises:
TypeError: If the value is not a list of strings.
ValueError: If names are duplicated or malformed.
"""
if not isinstance(payload, list) or not all(
isinstance(name, str) for name in payload
):
raise TypeError(f"{field_name} must be a list of strings")
names = frozenset(payload)
if len(names) != len(payload):
raise ValueError(f"{field_name} contains duplicate names")
if any(_COMMAND_NAME_RE.fullmatch(name) is None for name in names):
raise ValueError(f"{field_name} contains an invalid command name")
return names


def _catalog_from_payload(payload: object) -> CommandCatalog:
"""Build a catalog only when its decoded JSON schema is valid.

Args:
payload: Decoded catalog JSON.

Returns:
An available, semantically validated command catalog.

Raises:
KeyError: If a required field is absent.
TypeError: If a field has the wrong type.
ValueError: If provenance or command categories are invalid.
"""
if not isinstance(payload, dict):
raise TypeError("catalog must be an object")
source_url = payload["source_url"]
retrieved_at = payload["retrieved_at"]
if not isinstance(source_url, str) or not source_url.startswith("https://"):
raise ValueError("source_url must be an HTTPS URL")
if not isinstance(retrieved_at, str):
raise TypeError("retrieved_at must be a string")
date.fromisoformat(retrieved_at)

builtins = _validated_commands(payload["builtins"], "builtins")
bundled_skills = _validated_commands(
payload["bundled_skills"],
"bundled_skills",
)
workflows = _validated_commands(payload["workflows"], "workflows")
if builtins & bundled_skills or builtins & workflows or bundled_skills & workflows:
raise ValueError("command categories must be disjoint")

return CommandCatalog(
available=True,
source_url=source_url,
retrieved_at=retrieved_at,
builtins=builtins,
bundled_skills=bundled_skills,
workflows=workflows,
)


@lru_cache(maxsize=1)
def load_command_catalog() -> CommandCatalog:
"""Load the packaged command catalog.

Returns:
The packaged catalog, or an unavailable catalog when its resource is
missing or malformed.
"""
try:
catalog_text = (
resources.files("claude_prospector")
.joinpath("data/claude-code-commands.json")
.read_text(encoding="utf-8")
)
payload = json.loads(catalog_text)
return _catalog_from_payload(payload)
except (KeyError, OSError, TypeError, ValueError):
return CommandCatalog()
1 change: 1 addition & 0 deletions src/claude_prospector/cli/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ def run(args: argparse.Namespace) -> int:
"by_model": result.by_model,
"by_agent": result.by_agent,
"by_skill": result.by_skill,
"by_command_usage": result.by_command_usage,
"by_project": result.by_project,
"by_day": result.by_day,
"sessions": result.sessions,
Expand Down
Loading
Loading