# ccsinfo > Inspect Claude Code sessions, tasks, prompt history, and usage statistics from a CLI or REST API. --- Source: overview.md # Overview `ccsinfo` is a Python CLI and FastAPI service for exploring Claude Code activity from the data Claude already writes to disk. Instead of manually opening session JSONL files and task JSON files, you get a cleaner view of sessions, projects, tasks, search results, and usage statistics. It is built for developers and maintainers who want to understand what Claude Code has been doing in a project or on a machine. If you need answers to questions like "What sessions are active?", "What did Claude say in that conversation?", "Which tools were used?", "What tasks are still pending?", or "Which projects were busiest lately?", `ccsinfo` gives you that visibility without forcing you to read raw artifacts by hand. > **Note:** `ccsinfo` works with Claude Code's existing local artifacts. It does not require a separate export step before it becomes useful. ## What it helps you do - Browse Claude activity as `sessions`, `projects`, `tasks`, `search`, and `stats` instead of raw files. - Move from a summary view to the exact messages and tool calls behind it. - Use the same mental model locally or over HTTP from a remote machine. - Switch between human-friendly terminal output and JSON output for automation. ## Local or remote, same model `Local mode:` Run `ccsinfo` on the same machine as Claude Code and it reads the local Claude data directly. `Remote mode:` Run `ccsinfo serve` where the Claude data lives, then point your CLI at it with `--server-url` or `CCSINFO_SERVER_URL`. The API exposes the same core domains over FastAPI: sessions, projects, tasks, stats, search, plus basic `health` and `info` endpoints. ```mermaid flowchart LR Data["Claude Code data
`~/.claude/projects`
`~/.claude/tasks`"] --> Parsers["Parsers"] Parsers --> Services["Services
sessions, projects, tasks, search, stats"] Services --> LocalCLI["`ccsinfo` CLI
local mode"] Services --> API["FastAPI server"] RemoteCLI["`ccsinfo` CLI
with `--server-url`"] --> API ``` The server entrypoint is built into the CLI: ```27:33:src/ccsinfo/cli/main.py @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` Remote access is configured through the CLI option and environment variable: ```43:62:src/ccsinfo/cli/main.py @app.callback() def main_callback( _version: bool | None = typer.Option( None, "--version", "-v", help="Show version information.", callback=version_callback, is_eager=True, ), server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ) -> None: """Claude Code Session Info CLI.""" state.server_url = server_url ``` > **Tip:** Start with local mode when you already have access to the machine that ran Claude Code. Use remote mode when the data lives on another workstation, container, or VM and you want the same workflow without direct filesystem access. > **Warning:** This repo does not define a built-in authentication or authorization layer for the API. Treat remote mode as a trusted-network tool unless you put it behind your own access controls. ## Where the data comes from `ccsinfo` reads the same Claude Code directories you would inspect manually, then parses them into higher-level models. The main storage locations are defined directly in the path helpers: ```8:20:src/ccsinfo/utils/paths.py def get_claude_base_dir() -> Path: """Get the base Claude Code directory (~/.claude).""" return Path.home() / ".claude" def get_projects_dir() -> Path: """Get the projects directory (~/.claude/projects).""" return get_claude_base_dir() / "projects" def get_tasks_dir() -> Path: """Get the tasks directory (~/.claude/tasks).""" return get_claude_base_dir() / "tasks" ``` From there, `ccsinfo` reads: - Session transcripts from JSONL files under `~/.claude/projects/...`. - Prompt history from per-project `.history.jsonl` files. - Task JSON files from `~/.claude/tasks//`. The test fixtures in the repo show the kind of local data the parser expects: ```26:62:tests/conftest.py def sample_session_data() -> list[dict[str, Any]]: """Sample session JSONL data.""" return [ { "type": "user", "uuid": "msg-001", "message": { "role": "user", "content": [{"type": "text", "text": "Hello"}], }, "timestamp": "2024-01-15T10:00:00Z", }, { "type": "assistant", "uuid": "msg-002", "parentMessageUuid": "msg-001", "message": { "role": "assistant", "content": [{"type": "text", "text": "Hi there!"}], }, "timestamp": "2024-01-15T10:00:01Z", }, ] @pytest.fixture def sample_task_data() -> dict[str, Any]: """Sample task JSON data.""" return { "id": "1", "subject": "Test task", "description": "A test task", "status": "pending", "owner": None, "blockedBy": [], "blocks": [], } ``` > **Note:** The JSONL parser is intentionally tolerant and skips malformed or invalid lines by default, so one bad entry does not make an entire session unreadable. > **Warning:** Project IDs come from Claude Code's encoded project directory names. They are useful identifiers, but decoded filesystem paths should be treated as approximate when dots and dashes are involved. ## What you can explore - `sessions` for recent and active sessions, full message streams, and assistant tool calls. - `projects` for project-level activity, session counts, and last activity. - `tasks` for Claude task files, including status, owner, blockers, and dependencies. - `search` for session metadata, message text, and saved prompt history. - `stats` for totals, daily activity, recent trends, most active projects, most-used tools, and average session length. In practice, that means you can start broad and then zoom in. Begin with overall activity, identify an interesting project or session, inspect the exact conversation and tool calls behind it, and then jump back out to trends or cross-project search when you need more context. The search side is broader than just IDs. It looks across session metadata such as slug, working directory, git branch, and project path, and it also searches full message text and prompt history. The analytics side aggregates total sessions, projects, messages, and tool calls, then adds daily views and recent trend summaries. ## Why this matters The core value of `ccsinfo` is that it makes Claude Code activity explorable at the level humans actually care about. You can work directly from local data when you are on the right machine, or move the same workflow behind a remote service when the data lives somewhere else. Either way, `ccsinfo` turns Claude Code's raw artifacts into something you can query, skim, and understand quickly. ## Related Pages - [Installation](installation.html) - [Quickstart: Local CLI Mode](local-cli-quickstart.html) - [Quickstart: Remote Server Mode](remote-server-quickstart.html) - [Architecture and Project Structure](architecture-and-project-structure.html) - [API Overview](api-overview.html) --- Source: architecture-and-project-structure.md # Architecture and Project Structure `ccsinfo` is built around one simple idea: read Claude Code data from disk, normalize it into typed Python models, and expose it through either a command-line interface or a small HTTP API. If you only remember one thing from this page, remember this: **the real behavior lives in `core.services`**. The CLI and the server are intentionally thin layers on top of that shared logic. ## Package Map | Package | What it contains | Why it exists | |---|---|---| | `src/ccsinfo/cli` | Typer app, command groups, CLI state | User-facing commands such as `sessions`, `projects`, `tasks`, `stats`, and `search` | | `src/ccsinfo/core/models` | Pydantic models for sessions, messages, tasks, projects, and stats | Defines the typed data shape returned by services and the API | | `src/ccsinfo/core/parsers` | Low-level readers for JSON and JSONL files | Knows how Claude Code data is stored on disk | | `src/ccsinfo/core/services` | Business logic and aggregation | Turns raw parsed data into useful summaries, searches, and reports | | `src/ccsinfo/server` | FastAPI app and route modules | Exposes the same data through HTTP | | `src/ccsinfo/utils` | Path helpers and Rich output helpers | Keeps filesystem discovery and terminal formatting out of the business logic | | `tests` | Fixtures and layer-focused tests | Verifies parsers, models, services, and path handling | ## High-Level Architecture At runtime, `ccsinfo` reads three kinds of Claude Code data: - session transcripts in `~/.claude/projects//*.jsonl` - prompt history in `~/.claude/projects//.history.jsonl` - task files in `~/.claude/tasks//*.json` Those files flow through parsers, then services, and finally out through the CLI or the API. ```mermaid flowchart LR Files[Claude Code data on disk
projects/*.jsonl
projects/.history.jsonl
tasks/*.json] Parsers[core.parsers
jsonl.py
sessions.py
history.py
tasks.py] Services[core.services
session/project/task/stats/search] Models[core.models
Pydantic output models] CLI[cli
Typer commands] API[server
FastAPI routers] Files --> Parsers Parsers --> Services Services --> Models Models --> CLI Models --> API Services --> CLI Services --> API ``` A practical way to trace any feature is: 1. Find the user-facing command or route. 2. Jump to the matching service in `core.services`. 3. Follow that service into the parser that reads the underlying files. ## The Data Layout ccsinfo Expects The test fixtures are a good way to see the expected Claude Code directory shape without guessing. The repository creates a miniature `.claude` tree like this: ```84:112:tests/conftest.py def mock_claude_dir( tmp_path: Path, sample_session_data: list[dict[str, Any]], sample_task_data: dict[str, Any] ) -> Path: """Create a fully populated mock .claude directory.""" claude_dir = tmp_path / ".claude" # Create projects directory with a sample project projects_dir = claude_dir / "projects" project_dir = projects_dir / "-home-user-test-project" project_dir.mkdir(parents=True) # Create a session file in the project session_file = project_dir / "abc-123-def-456.jsonl" with session_file.open("w") as f: for entry in sample_session_data: f.write(json.dumps(entry) + "\n") # Create tasks directory with a session's tasks tasks_dir = claude_dir / "tasks" session_tasks_dir = tasks_dir / "abc-123-def-456" session_tasks_dir.mkdir(parents=True) # Create a task file task_file = session_tasks_dir / "1.json" with task_file.open("w") as f: json.dump(sample_task_data, f) return claude_dir ``` That fixture highlights an important design decision: - sessions and history are grouped by project - tasks are grouped by session This is why project-focused views mostly start from `~/.claude/projects`, while task-focused views start from `~/.claude/tasks`. ### Encoded Project Paths Claude Code stores project directories using an encoded version of the original path. `ccsinfo` centralizes that logic in `utils.paths`: ```23:44:src/ccsinfo/utils/paths.py def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' Example: '/home/user/project' -> '-home-user-project' """ return project_path.replace("/", "-").replace(".", "-") def decode_project_path(encoded_path: str) -> str: """Decode a Claude Code directory name back to the original path. Note: This is lossy - we cannot distinguish between original '-' and encoded '/' or '.'. The path returned should be treated as approximate. """ # Handle the pattern where /. becomes -- result = encoded_path.replace("--", "/.") result = result.replace("-", "/") return result ``` > **Warning:** Project IDs are encoded path strings, and `decode_project_path()` is intentionally lossy. The decoded path is useful for display and grouping, but it should not be treated as a perfect round-trip back to the original filesystem path. ## The `core` Package The `core` package is the center of the project. It is split into three layers: - `core.models`: typed outputs - `core.parsers`: file readers - `core.services`: the behavior shared by CLI and API ### `core.models` The models are all Pydantic-based and describe the stable shapes the rest of the project works with. The most important model groups are: - `sessions.py`: `SessionSummary`, `Session`, and `SessionDetail` - `messages.py`: message blocks, tool calls, and tool results - `projects.py`: project metadata - `tasks.py`: task status, blockers, owner, and metadata - `stats.py`: global, daily, and per-project statistics This separation matters because the raw Claude Code files are not especially friendly to consume directly. The models give the rest of the codebase a predictable interface. A good example is task alias handling. On disk, task JSON uses names like `blockedBy` and `activeForm`, but the codebase exposes `blocked_by` and `active_form` consistently through the model layer. ### `core.parsers` The parser layer understands Claude Code’s storage formats. Here is what each parser module does: - `jsonl.py` provides the generic JSON and JSONL reading utilities used everywhere else. - `sessions.py` parses transcript files, computes counts, exposes session metadata, and discovers whether a session is currently active. - `history.py` parses `.history.jsonl` and supports prompt search across projects. - `tasks.py` parses per-session task JSON files and groups them into `TaskCollection` objects. A few details are especially worth knowing: - `parse_jsonl()` skips malformed lines by default, so transcript parsing is intentionally resilient. - `sessions.py` ignores dot-prefixed JSONL files when scanning session files, which keeps `.history.jsonl` separate from transcript parsing. - `tasks.py` sorts tasks numerically when possible, so `1.json`, `2.json`, `10.json` come back in a sensible order. > **Note:** Session “active” status is not stored in transcript files. It is computed at runtime by inspecting live `claude` processes and cached briefly in `core.parsers.sessions`, so active-state results are best-effort rather than permanent metadata. ### `core.services` Services are where raw parsed data becomes user-facing behavior. | Service | Main job | Main dependencies | |---|---|---| | `SessionService` | Lists sessions, returns session detail, extracts messages and tool calls | `core.parsers.sessions`, `core.models.sessions`, `core.models.messages` | | `ProjectService` | Discovers projects and computes project-level stats | `core.parsers.sessions`, `utils.paths`, `core.models.projects`, `core.models.stats` | | `TaskService` | Reads and filters tasks by session or status | `core.parsers.tasks`, `core.models.tasks` | | `StatsService` | Computes totals, daily breakdowns, and usage trends | `core.parsers.sessions`, `core.models.stats` | | `SearchService` | Searches session metadata, message text, and prompt history | `core.parsers.sessions`, `core.parsers.history` | This is the layer to read first when you want to understand behavior. For example, `SessionService.list_sessions()` does not care whether the caller is the CLI or the API. It just iterates parsed sessions, filters them, sorts them, and returns summaries: ```47:76:src/ccsinfo/core/services/session_service.py for project_path, session in get_all_sessions(): # Filter by project if specified if project_id is not None: try: decoded = decode_project_path(project_id) if project_path != decoded: continue except Exception: continue # Convert to summary summary = self._session_to_summary(session, project_path) # Filter active only if active_only and not summary.is_active: continue summaries.append(summary) # Sort by updated_at descending (most recent first) summaries.sort( key=lambda s: s.updated_at or pendulum.datetime(1970, 1, 1), reverse=True, ) # Apply limit if limit is not None: summaries = summaries[:limit] return summaries ``` That same pattern repeats across the service layer: - search services read raw session/history data, then return compact search results - stats services aggregate across all sessions - project services reuse parsed sessions to compute project summaries - task services convert parsed task JSON into typed `Task` objects > **Note:** Task IDs are only unique within a session. That is why single-task lookups require both a task ID and a session ID in the API and CLI. ## The `server` Package The server is a small FastAPI wrapper around the service layer. It lives in `src/ccsinfo/server` and is split into: - `app.py` for application setup - `routers/` for route groups: - `sessions.py` - `projects.py` - `tasks.py` - `stats.py` - `search.py` - `health.py` The route modules are intentionally thin. They validate request parameters, call the matching service, and return typed models or plain dictionaries. A sessions route looks like this: ```13:20:src/ccsinfo/server/routers/sessions.py @router.get("", response_model=list[SessionSummary]) async def list_sessions( project_id: str | None = Query(None, description="Filter by project"), active_only: bool = Query(False, description="Show only active sessions"), limit: int = Query(50, ge=1, le=500, description="Maximum results"), ) -> list[SessionSummary]: """List all sessions.""" return session_service.list_sessions(project_id=project_id, active_only=active_only, limit=limit) ``` That same “thin router” pattern is used across the rest of `server/routers`. A few practical takeaways: - `/sessions`, `/projects`, `/tasks`, `/stats`, and `/search` mirror the project’s main user-facing concepts. - `health.py` adds `/health` and `/info` for health and lightweight instance metadata. - The API surface is read-only in the current codebase. > **Tip:** Every route in the current repository is a `GET` endpoint. The server is designed for inspection, reporting, and remote access to Claude Code data, not for mutating it. ## The `cli` Package The CLI is built with Typer and mirrors the same high-level concepts as the API: sessions, projects, tasks, stats, and search. The application composition is straightforward: ```13:33:src/ccsinfo/cli/main.py app = typer.Typer( name="ccsinfo", help="Claude Code Session Info CLI", no_args_is_help=True, ) # Add command groups app.add_typer(sessions.app, name="sessions", help="Session management") app.add_typer(projects.app, name="projects", help="Project management") app.add_typer(tasks.app, name="tasks", help="Task management") app.add_typer(stats.app, name="stats", help="Statistics") app.add_typer(search.app, name="search", help="Search") @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` This tells you almost everything you need to know about the CLI package: - `main.py` assembles the command tree - `commands/` holds one module per subject area - `serve` launches the same FastAPI app defined in `server/app.py` - presentation is handled with Rich tables, panels, and JSON helpers from `utils/formatters.py` The command modules follow a consistent shape: - accept Typer arguments and options - call the relevant service logic - render either rich terminal output or `--json` That consistency is useful when you are learning the project. If you understand `sessions`, the `projects`, `tasks`, `stats`, and `search` command modules feel very similar. ## The `utils` Package `utils` is deliberately small, but it does important support work. `utils.paths.py` is responsible for: - locating `~/.claude` - finding the `projects` and `tasks` directories - listing project/session/task files - encoding and decoding project directory names `utils.formatters.py` is responsible for: - formatting timestamps and relative times - creating Rich tables - printing JSON cleanly - showing CLI errors, warnings, and success messages The architectural benefit is that neither the service layer nor the parser layer needs to care about terminal presentation, and the CLI layer does not need to hard-code Claude Code path conventions. ## The `tests` Package The test suite is focused on the layers where most behavior actually lives. | Test module | What it verifies | |---|---| | `tests/conftest.py` | reusable fixtures, including a fake `.claude` directory | | `tests/test_parsers.py` | JSON/JSONL parsing behavior and malformed-line handling | | `tests/test_models.py` | Pydantic models, aliases, enums, and computed properties | | `tests/test_services.py` | service singletons, sorting, aggregation, and integration-style behavior | | `tests/test_utils_paths.py` | Claude path discovery plus project path encode/decode rules | One useful architectural signal is what is *not* here: most of the test coverage is below the CLI and router layer. That fits the rest of the codebase, because the CLI and server are intentionally thin and the core behavior is centralized in `core`. The repository’s main test runner is `tox`, and it drives `pytest` through `uv`: ```1:10:tox.ini [tox] envlist = py312 isolated_build = true [testenv] allowlist_externals = uv commands = uv sync --extra dev uv run pytest -n auto {posargs:tests} ``` Other quality tooling is configured in the root: - `pyproject.toml` defines the package entry point, strict mypy settings, Ruff settings, and pytest defaults. - `.pre-commit-config.yaml` runs Flake8, Ruff, MyPy, and secret-scanning hooks. - There are no checked-in GitHub Actions workflow files in the current repository. > **Tip:** If you change behavior in `ccsinfo`, the best place to add tests is usually the matching parser, model, service, or utility module, because that is where the shared behavior lives. ## How the Pieces Fit in Practice Here is the simplest mental model for the whole project: - `cli` and `server` are the front doors. - `core.services` is the control room. - `core.parsers` knows how Claude Code stores data. - `core.models` defines what “good, normalized output” looks like. - `utils` handles filesystem conventions and terminal presentation. - `tests` are strongest around the shared core logic. If you are trying to orient yourself quickly: - start in `cli` when you care about command names, arguments, and output formatting - start in `server` when you care about route names and HTTP response shapes - start in `core.services` when you care about actual behavior - start in `core.parsers` when a field seems to be missing or misread from disk - start in `utils.paths` when project names or paths look surprising - start in `tests` when you want the shortest path to concrete examples of expected behavior That division is what makes `ccsinfo` easy to follow: one shared core, two thin interfaces, and a file-oriented parser layer underneath both. ## Related Pages - [Overview](overview.html) - [Data Model and Storage](data-model-and-storage.html) - [Development Setup](development-setup.html) - [Testing and Quality Checks](testing-and-quality.html) - [API Overview](api-overview.html) --- Source: data-model-and-storage.md # Data Model and Storage `ccsinfo` does not maintain its own database. It reads Claude Code’s files directly from `~/.claude`, so the storage model is really the Claude Code filesystem layout. At a high level, there are two roots: | Location | What it stores | Format | Key | | --- | --- | --- | --- | | `~/.claude/projects//` | One project’s stored Claude data | Directory | Encoded project path | | `~/.claude/projects//.jsonl` | Full session transcript | JSONL | Session UUID from filename | | `~/.claude/projects//.history.jsonl` | Prompt history for the project | JSONL | `sessionId` inside each line | | `~/.claude/tasks//` | One session’s task directory | Directory | Session UUID | | `~/.claude/tasks//*.json` | Individual task records | JSON | Task `id` within that session | The most important relationship is this: project data is organized by **project path**, while task data is organized by **session UUID**. The session UUID is the join key that ties transcripts, prompt history, and tasks together. ## On-Disk Layout The test suite builds a representative `.claude` tree like this: ```89:110:tests/conftest.py claude_dir = tmp_path / ".claude" # Create projects directory with a sample project projects_dir = claude_dir / "projects" project_dir = projects_dir / "-home-user-test-project" project_dir.mkdir(parents=True) # Create a session file in the project session_file = project_dir / "abc-123-def-456.jsonl" with session_file.open("w") as f: for entry in sample_session_data: f.write(json.dumps(entry) + "\n") # Create tasks directory with a session's tasks tasks_dir = claude_dir / "tasks" session_tasks_dir = tasks_dir / "abc-123-def-456" session_tasks_dir.mkdir(parents=True) # Create a task file task_file = session_tasks_dir / "1.json" with task_file.open("w") as f: json.dump(sample_task_data, f) ``` ```mermaid flowchart TD Claude["~/.claude"] --> Projects["projects/"] Claude --> Tasks["tasks/"] Projects --> ProjectDir["/"] ProjectDir --> SessionFile[".jsonl"] ProjectDir --> HistoryFile[".history.jsonl"] Tasks --> TaskDir["/"] TaskDir --> TaskFile[".json"] SessionFile -. "filename stem = session UUID" .- TaskDir HistoryFile -. "history lines include sessionId" .- SessionFile ``` > **Tip:** If you are tracing data by hand, start with the session UUID. It is the cleanest link between the transcript file, prompt history entries, and the task directory. ## Encoded Project Path Names Directories under `~/.claude/projects` are **not** stored with the literal filesystem path. Instead, Claude Code uses a simple dash-based encoding, and `ccsinfo` mirrors that logic directly: ```23:44:src/ccsinfo/utils/paths.py def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' Example: '/home/user/project' -> '-home-user-project' """ return project_path.replace("/", "-").replace(".", "-") def decode_project_path(encoded_path: str) -> str: """Decode a Claude Code directory name back to the original path. Note: This is lossy - we cannot distinguish between original '-' and encoded '/' or '.'. The path returned should be treated as approximate. """ # Handle the pattern where /. becomes -- result = encoded_path.replace("--", "/.") result = result.replace("-", "/") return result ``` In practice, that means: - `/home/user/project` becomes `-home-user-project` - `/home/user/.config/project` becomes `-home-user--config-project` - The directory name under `~/.claude/projects` is also the `project_id` used by the API layer > **Warning:** Decoding is best-effort, not perfectly reversible. Because `/`, `.` and original `-` characters all collapse into dashes during encoding, a decoded path should be treated as approximate human context, not as a guaranteed round-trip identifier. > **Tip:** If you are calling the API, use the encoded directory name as `project_id`, not the decoded filesystem path. ## Session JSONL Files A session transcript lives at: `~/.claude/projects//.jsonl` The filename stem becomes the session ID. For example, `abc-123-def-456.jsonl` maps to session `abc-123-def-456`. When `ccsinfo` enumerates session transcripts, it deliberately ignores dot-prefixed JSONL files so that `.history.jsonl` is handled separately: ```359:362:src/ccsinfo/core/parsers/sessions.py for session_file in sorted(project_path.glob("*.jsonl")): # Skip history files if session_file.name.startswith("."): continue ``` The test fixtures use session entries like these before writing them one-per-line to JSONL: ```28:47:tests/conftest.py { "type": "user", "uuid": "msg-001", "message": { "role": "user", "content": [{"type": "text", "text": "Hello"}], }, "timestamp": "2024-01-15T10:00:00Z", }, { "type": "assistant", "uuid": "msg-002", "parentMessageUuid": "msg-001", "message": { "role": "assistant", "content": [{"type": "text", "text": "Hi there!"}], }, "timestamp": "2024-01-15T10:00:01Z", }, ``` What to expect in session files: - Each line is a standalone JSON object. - `type` distinguishes the kind of entry you are looking at. `user` and `assistant` are the main conversational records. - `message` holds the actual conversational payload. - `timestamp`, `cwd`, `version`, `gitBranch`, `permissionMode`, `slug`, and related fields can appear as metadata on entries. - Assistant entries can include tool usage inside `message.content`, not in separate files. - The parser also recognizes snapshot-style data, with fields such as `messageId`, `snapshot`, and `isSnapshotUpdate`. This means a session file is more than a plain chat log. It is the main per-session event stream. > **Note:** Session transcripts use JSONL rather than a single large JSON object. That makes them append-friendly and easy to process one line at a time. > **Note:** `ccsinfo` reads JSONL defensively. Blank lines are ignored, and malformed lines are skipped by default instead of aborting the entire file. ## Prompt History Files Prompt history lives beside session transcripts as a hidden file named `.history.jsonl` inside the project directory. `ccsinfo` models each history line with a very small schema: ```24:33:src/ccsinfo/core/parsers/history.py class HistoryEntry(BaseModel): """A single entry in a prompt history file.""" prompt: str | None = None timestamp: str | None = None session_id: str | None = Field(default=None, alias="sessionId") cwd: str | None = None version: str | None = None model_config = {"populate_by_name": True, "extra": "allow"} ``` A prompt history file is useful when you want a lightweight record of what was asked without opening the full session transcript. A few important details: - Prompt history is **project-scoped**, not global. - The file is named exactly `.history.jsonl`. - Each entry includes the `sessionId`, which lets `ccsinfo` map a prompt back to the session that produced it. - The history model stores prompt text and basic context, but not the full assistant response stream. That separation is why `ccsinfo` can offer prompt-history search independently from full session search. > **Note:** If a project has no `.history.jsonl`, `ccsinfo` treats that as “no prompt history” for that project rather than as an error. ## Task Files Under `~/.claude/tasks` Tasks are stored separately from the project directories. Instead of grouping by project path, Claude Code groups tasks by **session UUID**: `~/.claude/tasks//*.json` A task file in the tests looks like this: ```54:62:tests/conftest.py return { "id": "1", "subject": "Test task", "description": "A test task", "status": "pending", "owner": None, "blockedBy": [], "blocks": [], } ``` From the parser, the task model also supports optional fields such as `activeForm` and `metadata`, alongside the core dependency fields: - `id` - `subject` - `description` - `status` - `blockedBy` - `blocks` - `owner` - `activeForm` - `metadata` A few behaviors matter here: - Task files are regular JSON, not JSONL. - Task IDs are only unique **within a session**, not globally. - Tasks are read from `~/.claude/tasks//` and then sorted by `id`, using numeric order when possible. - Dependency information is explicit through `blockedBy` and `blocks`. The API makes the session-scoped nature of task IDs explicit: ```35:44:src/ccsinfo/server/routers/tasks.py @router.get("/{task_id}", response_model=Task) async def get_task( task_id: str, session_id: str = Query(..., description="Session ID (required since task IDs are only unique within a session)"), ) -> Task: """Get task details.""" task = task_service.get_task(task_id, session_id=session_id) if not task: raise HTTPException(status_code=404, detail="Task not found") ``` > **Tip:** If you only know a project and need to find its tasks, first identify the session UUID from the project’s session JSONL or `.history.jsonl`. Tasks do not live under the project directory. ## How the Pieces Fit Together The storage model is easiest to remember as three layers: - **Project layer:** `~/.claude/projects//` - **Session layer:** `.jsonl` inside a project directory - **Task layer:** `~/.claude/tasks//` That gives you a simple mental model: - Open the session JSONL file when you need the full event stream for a conversation. - Open `.history.jsonl` when you only need prompt history for a project. - Open `~/.claude/tasks//` when you need task state for a specific session. - Use the session UUID as the stable link across all three. If you keep that one rule in mind, the rest of the storage layout is straightforward. ## Related Pages - [Architecture and Project Structure](architecture-and-project-structure.html) - [Project IDs and Lookups](project-ids-and-lookups.html) - [Working with Sessions](sessions-guide.html) - [Working with Tasks](tasks-guide.html) - [Searching Sessions, Messages, and History](search-guide.html) --- Source: installation.md # Installation `ccsinfo` is a standard Python package that installs a command-line tool named `ccsinfo`. A normal install also gives you the built-in FastAPI server behind `ccsinfo serve`, so there is no separate server package or extra to install. ## Requirements You need Python 3.12 or newer. The package metadata explicitly requires `>=3.12`, and it advertises Python 3.12 and 3.13 support. ```31:51:pyproject.toml [project] name = "ccsinfo" version = "0.1.2" description = "Claude Code Session Info CLI and Server" readme = "README.md" license = "MIT" requires-python = ">=3.12" authors = [{ name = "Meni Yakove", email = "myakove@gmail.com" }] keywords = ["claude", "claude-code", "cli", "sessions", "api"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] ``` When you use `ccsinfo` in local mode, it looks for Claude Code data in `~/.claude`, specifically `~/.claude/projects` and `~/.claude/tasks`. ```8:20:src/ccsinfo/utils/paths.py def get_claude_base_dir() -> Path: """Get the base Claude Code directory (~/.claude).""" return Path.home() / ".claude" def get_projects_dir() -> Path: """Get the projects directory (~/.claude/projects).""" return get_claude_base_dir() / "projects" def get_tasks_dir() -> Path: """Get the tasks directory (~/.claude/tasks).""" return get_claude_base_dir() / "tasks" ``` > **Note:** `~/.claude/projects` and `~/.claude/tasks` are only needed for local mode. If those directories do not exist yet, `ccsinfo` can still install and start, but local-mode commands will not have Claude Code data to show. ## Install the package From the repository root, install `ccsinfo` into a virtual environment. The example below uses a Python 3.12 interpreter explicitly, but any Python 3.12+ interpreter is fine. ```bash python3.12 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip python -m pip install . ``` The base install already includes both the CLI and server dependencies, and it registers the `ccsinfo` console script: ```52:75:pyproject.toml dependencies = [ "typer>=0.9.0", "rich>=13.0.0", "orjson>=3.9.0", "pydantic>=2.0.0", "pendulum>=3.0.0", "fastapi>=0.109.0", "uvicorn[standard]>=0.27.0", "httpx>=0.27.0", ] [project.optional-dependencies] dev = [ "pytest>=7.4.0", "pytest-cov>=4.1.0", "pytest-asyncio>=0.21.0", "pytest-xdist>=3.5.0", "ruff>=0.1.0", "mypy>=1.5.0", "tox>=4.0.0", ] [project.scripts] ccsinfo = "ccsinfo.cli.main:main" ``` > **Tip:** If you already have an active virtual environment, the only required install step is `python -m pip install .`. ## Entry points After installation, you can start the tool in either of these ways: - `ccsinfo` - `python -m ccsinfo` The top-level CLI exposes these main command groups: - `sessions` - `projects` - `tasks` - `stats` - `search` - `serve` The entrypoint wiring is defined directly in the code: ```13:33:src/ccsinfo/cli/main.py app = typer.Typer( name="ccsinfo", help="Claude Code Session Info CLI", no_args_is_help=True, ) # Add command groups app.add_typer(sessions.app, name="sessions", help="Session management") app.add_typer(projects.app, name="projects", help="Project management") app.add_typer(tasks.app, name="tasks", help="Task management") app.add_typer(stats.app, name="stats", help="Statistics") app.add_typer(search.app, name="search", help="Search") @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` ```43:67:src/ccsinfo/cli/main.py @app.callback() def main_callback( _version: bool | None = typer.Option( None, "--version", "-v", help="Show version information.", callback=version_callback, is_eager=True, ), server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ) -> None: """Claude Code Session Info CLI.""" state.server_url = server_url def main() -> None: """Entry point for the CLI.""" app() ``` ```1:5:src/ccsinfo/__main__.py """Entry point for running ccsinfo as a module.""" from ccsinfo.cli.main import main main() ``` A quick smoke test after installation: ```bash ccsinfo --version ccsinfo --help python -m ccsinfo --version ``` > **Tip:** Running `ccsinfo` with no arguments shows help, because the CLI is configured with `no_args_is_help=True`. ## Local mode and server mode `ccsinfo` supports two practical ways of working: - Local mode: the CLI reads Claude Code data directly from `~/.claude`. - Server mode: `ccsinfo serve` starts an API server, and the CLI talks to it over HTTP when `--server-url` or `CCSINFO_SERVER_URL` is set. ```mermaid flowchart LR A[Installed package] --> B[ccsinfo] A --> C[python -m ccsinfo] B --> D[Local mode] C --> D D --> E[~/.claude/projects] D --> F[~/.claude/tasks] B --> G[ccsinfo serve] C --> G H[--server-url or CCSINFO_SERVER_URL] --> I[Remote client mode] I --> G ``` Common first commands after installation: ```bash # Read local Claude Code data ccsinfo sessions list ccsinfo projects list ccsinfo stats global # Start the built-in API server ccsinfo serve --host 127.0.0.1 --port 8080 # Use the server for a single command ccsinfo --server-url http://127.0.0.1:8080 sessions list # Make remote mode the default in your current shell export CCSINFO_SERVER_URL=http://127.0.0.1:8080 ccsinfo tasks list ``` > **Note:** If `--server-url` is not set, the CLI reads local files. If you set `CCSINFO_SERVER_URL`, remote mode becomes the default for that shell session. > **Warning:** `ccsinfo serve` binds to `127.0.0.1` by default. Use `--host 0.0.0.0` only when you intentionally want the service reachable from outside the local machine. ## Optional developer dependencies If you are contributing, running tests, or editing the package locally, install the `dev` extra in editable mode: ```bash python -m pip install -e ".[dev]" ``` Editable mode is convenient during development because the installed `ccsinfo` command points at your working tree, so local code changes are picked up immediately. The `dev` extra includes: - `pytest` - `pytest-cov` - `pytest-asyncio` - `pytest-xdist` - `ruff` - `mypy` - `tox` If you prefer the repository's `uv`-based workflow, the `tox` configuration shows the expected pattern: ```bash uv sync --extra dev uv run pytest -n auto ``` ```1:9:tox.ini [tox] envlist = py312 isolated_build = true [testenv] allowlist_externals = uv commands = uv sync --extra dev uv run pytest -n auto {posargs:tests} ``` > **Note:** The package itself supports Python 3.12+, while the repository's current `tox` configuration defines a `py312` test environment. ## Related Pages - [Overview](overview.html) - [Quickstart: Local CLI Mode](local-cli-quickstart.html) - [Quickstart: Remote Server Mode](remote-server-quickstart.html) - [Configuration](configuration.html) - [Development Setup](development-setup.html) --- Source: local-cli-quickstart.md ## How Local Mode Works The CLI switches into remote mode only when you pass `--server-url` or set `CCSINFO_SERVER_URL`. Otherwise, it reads local files. ```43:62:src/ccsinfo/cli/main.py @app.callback() def main_callback( _version: bool | None = typer.Option( None, "--version", "-v", help="Show version information.", callback=version_callback, is_eager=True, ), server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ) -> None: """Claude Code Session Info CLI.""" state.server_url = server_url ``` > **Note:** If `CCSINFO_SERVER_URL` is set in your shell, unset it before following this walkthrough. `ccsinfo` resolves the Claude data directory from your current home directory, then reads session files from `~/.claude/projects`. ```8:33:src/ccsinfo/utils/paths.py def get_claude_base_dir() -> Path: """Get the base Claude Code directory (~/.claude).""" return Path.home() / ".claude" def get_projects_dir() -> Path: """Get the projects directory (~/.claude/projects).""" return get_claude_base_dir() / "projects" def get_tasks_dir() -> Path: """Get the tasks directory (~/.claude/tasks).""" return get_claude_base_dir() / "tasks" def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' Example: '/home/user/project' -> '-home-user-project' """ ``` That means two practical things: - Session and stats commands in this page read `~/.claude/projects`. - Project IDs are encoded directory names, not raw filesystem paths. ```mermaid flowchart LR A["Run `ccsinfo ...`"] --> B{"`--server-url` or `CCSINFO_SERVER_URL` set?"} B -- No --> C["Local services"] C --> D["Read `~/.claude/projects//.jsonl`"] D --> E["Parse session entries"] E --> F["Render Rich tables or JSON"] B -- Yes --> G["Remote HTTP client"] ``` > **Tip:** Local mode always reads the current user's `~/.claude`. There is no separate `--claude-dir` flag in this workflow, so make sure you are running as the user whose Claude data you want to inspect. ## List Sessions Start by browsing what `ccsinfo` can see locally: ```bash ccsinfo sessions list ``` Useful variations: ```bash ccsinfo sessions list --limit 100 ccsinfo sessions active ccsinfo sessions list --json ``` What these do: - `sessions list` shows up to 50 sessions by default. - Sessions are sorted by most recent activity first. - `sessions active` narrows the view to sessions that appear to be attached to a running `claude` process. - `--json` returns structured data you can inspect or pipe into other tools. > **Tip:** The table view is great for browsing, but it shortens IDs. Use `ccsinfo sessions list --json` when you need the full `id` for a follow-up command. If you want to narrow the list to one project, get the full project ID first: ```bash ccsinfo projects list --json ccsinfo sessions list --project ``` > **Note:** `--project` expects the Claude project ID, which is the encoded directory name under `~/.claude/projects`, not the original filesystem path. ## Inspect One Session Once you have a full session ID, inspect its summary: ```bash ccsinfo sessions show ``` For structured output: ```bash ccsinfo sessions show --json ``` The session view includes: - The full session ID - Project name and decoded project path - Created and updated timestamps - Total message count - Active/inactive status - The underlying session file path on disk > **Warning:** Use the full session ID from `sessions list --json`. In local mode, `ccsinfo` resolves sessions by the exact session filename in `~/.claude/projects/.../.jsonl`. ## Inspect Messages To look inside a session, use the messages command: ```bash ccsinfo sessions messages ``` Common filters: ```bash ccsinfo sessions messages --role user ccsinfo sessions messages --role assistant ccsinfo sessions messages --limit 10 --json ``` The table view shows a compact preview for each message: - Message UUID - Message type - Relative timestamp - A shortened text preview Use `--json` when you want the full structured message payload instead of a preview. The test fixture below mirrors the message structure `ccsinfo` parses from session JSONL files: ```26:47:tests/conftest.py return [ { "type": "user", "uuid": "msg-001", "message": { "role": "user", "content": [{"type": "text", "text": "Hello"}], }, "timestamp": "2024-01-15T10:00:00Z", }, { "type": "assistant", "uuid": "msg-002", "parentMessageUuid": "msg-001", "message": { "role": "assistant", "content": [{"type": "text", "text": "Hi there!"}], }, "timestamp": "2024-01-15T10:00:01Z", }, ] ``` > **Note:** Message previews are intentionally compact. If a row has little or no visible text, use `--json` to inspect the full content blocks. > **Tip:** If you want to inspect tool calls for a session, `ccsinfo sessions tools ` is the companion command. ## View Statistics `ccsinfo` can also summarize your local Claude usage without a server. Start with the big-picture totals: ```bash ccsinfo stats global ``` Then drill into recent activity: ```bash ccsinfo stats daily ccsinfo stats daily --days 7 ccsinfo stats trends ccsinfo stats trends --json ``` What each stats command shows: - `stats global` shows total projects, total sessions, total messages, and total tool calls. - `stats daily` shows per-day session and message counts for the last 30 days by default. - `stats trends` shows 7-day and 30-day totals, average session length, most active projects, and most used tools. > **Note:** `stats daily` groups activity by a session's first timestamp, so a long session is counted on the day it started. > **Note:** In `stats trends`, the "Most Used Tools" view is session-oriented: a tool is counted by the sessions it appears in, not by every individual invocation. ## A Good Local Workflow A practical local workflow looks like this: ```bash ccsinfo sessions list --json ccsinfo sessions show ccsinfo sessions messages --limit 20 ccsinfo stats trends ``` If that returns no data, check the basics: - Confirm Claude Code has actually created `~/.claude/projects` for this user. - Make sure you are not accidentally in remote mode via `CCSINFO_SERVER_URL`. - Use `--json` whenever you need full session or project IDs instead of the shortened table view. That is all you need for day-to-day local inspection: list sessions, open one, inspect its messages, and use stats commands to understand recent activity across your Claude history. ## Related Pages - [Installation](installation.html) - [Configuration](configuration.html) - [Working with Sessions](sessions-guide.html) - [Working with Projects](projects-guide.html) - [Using Statistics and Trends](statistics-guide.html) --- Source: remote-server-quickstart.md # Quickstart: Remote Server Mode `ccsinfo` remote server mode is for the case where your Claude Code data lives on one machine, but you want to inspect it from somewhere else. You start the FastAPI server on the data host with `ccsinfo serve`, then point any `ccsinfo` CLI client at that base URL with `--server-url` or `CCSINFO_SERVER_URL`. > **Note:** This page assumes the `ccsinfo` command is already installed on the machine that will host the server and on any machine that will act as a client. ## Where the server reads data from The server does not read data from your current working directory. It reads Claude Code data from the server host's `~/.claude` tree, specifically `~/.claude/projects` and `~/.claude/tasks`: ```8:20:src/ccsinfo/utils/paths.py def get_claude_base_dir() -> Path: """Get the base Claude Code directory (~/.claude).""" return Path.home() / ".claude" def get_projects_dir() -> Path: """Get the projects directory (~/.claude/projects).""" return get_claude_base_dir() / "projects" def get_tasks_dir() -> Path: """Get the tasks directory (~/.claude/tasks).""" return get_claude_base_dir() / "tasks" ``` > **Note:** Start the server on the machine that actually has the Claude Code data you want to browse. Remote mode changes where the client reads from, not where the data is stored. ## Start the server The `serve` command is intentionally small: it starts Uvicorn with a host and port. The defaults are `127.0.0.1` and `8080`. ```27:33:src/ccsinfo/cli/main.py @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` If you only need access from the same machine: ```bash ccsinfo serve ``` If you want other machines to connect to it: ```bash ccsinfo serve --host 0.0.0.0 --port 8080 ``` > **Warning:** The default bind address is `127.0.0.1`, so other machines cannot reach it. For remote access, use `--host 0.0.0.0` or another routable interface. > **Warning:** The server code in this repo does not configure authentication, authorization, or TLS. Only expose it on a network you trust, or place it behind your own VPN, reverse proxy, or firewall rules. ## What the server exposes The FastAPI app mounts routers for sessions, projects, tasks, stats, search, and health: ```8:20:src/ccsinfo/server/app.py app = FastAPI( title="ccsinfo", description="Claude Code Session Info API", version=__version__, ) # Include routers app.include_router(sessions.router, prefix="/sessions", tags=["sessions"]) app.include_router(projects.router, prefix="/projects", tags=["projects"]) app.include_router(tasks.router, prefix="/tasks", tags=["tasks"]) app.include_router(stats.router, prefix="/stats", tags=["stats"]) app.include_router(search.router, prefix="/search", tags=["search"]) app.include_router(health.router, tags=["health"]) ``` In practice, that gives you a small read-only API with endpoints like: - `GET /health` and `GET /info` - `GET /sessions` and `GET /sessions/active` - `GET /projects` - `GET /tasks` and `GET /tasks/pending` - `GET /stats`, `GET /stats/daily`, and `GET /stats/trends` - `GET /search`, `GET /search/messages`, and `GET /search/history` > **Note:** In the current codebase, the server routers only expose `GET` handlers. Remote server mode is for reading, listing, and searching Claude Code data, not modifying it. ## Verify the server with health endpoints The health router defines two endpoints: ```13:27:src/ccsinfo/server/routers/health.py @router.get("/health") async def health() -> dict[str, str]: """Health check endpoint.""" return {"status": "healthy"} @router.get("/info") async def info() -> dict[str, Any]: """Server info endpoint.""" stats = stats_service.get_global_stats() return { "version": __version__, "total_sessions": stats.total_sessions, "total_projects": stats.total_projects, } ``` Use them as your first smoke test after startup: ```bash curl http://localhost:8080/health curl http://localhost:8080/info ``` If you are calling the server from another machine, replace `localhost` with the server hostname or IP address. What each endpoint tells you: - `GET /health` confirms that the FastAPI process is up and answering requests. - `GET /info` confirms that the server can also calculate basic stats, and returns the server `version`, `total_sessions`, and `total_projects`. > **Tip:** `GET /info` is usually the better quick check. If `/health` works but `/info` shows `0` sessions and `0` projects, the server is up, but it is probably looking at an empty or unexpected `~/.claude` directory on that host. ## Point CLI clients at the server The CLI has a top-level `--server-url` option, and the same value can be supplied through `CCSINFO_SERVER_URL`: ```43:62:src/ccsinfo/cli/main.py @app.callback() def main_callback( _version: bool | None = typer.Option( None, "--version", "-v", help="Show version information.", callback=version_callback, is_eager=True, ), server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ) -> None: """Claude Code Session Info CLI.""" state.server_url = server_url ``` Once that URL is set, commands switch from local file access to HTTP requests. For example, `stats global` checks `state.server_url` and uses the HTTP client in remote mode: ```29:38:src/ccsinfo/cli/commands/stats.py @app.command("global") def global_stats( json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"), ) -> None: """Show global statistics.""" client = get_client(state.server_url) if client: # Remote mode - use HTTP client stats_data = client.get_global_stats() ``` ### One-off client calls Use `--server-url` before the subcommand: ```bash ccsinfo --server-url http://localhost:8080 stats global ccsinfo --server-url http://localhost:8080 sessions active ccsinfo --server-url http://localhost:8080 tasks pending ccsinfo --server-url http://localhost:8080 search messages "timeout" --json ``` ### Persistent shell configuration If you will run several commands, exporting the URL is more convenient: ```bash export CCSINFO_SERVER_URL=http://localhost:8080 ccsinfo stats global ccsinfo sessions active ccsinfo tasks pending ccsinfo search messages "timeout" --json ``` > **Tip:** When you want machine-readable output for scripts or pipelines, add `--json`. The CLI commands support both Rich-formatted terminal output and JSON output. ## Request flow ```mermaid flowchart LR A[Client shell] --> B["`ccsinfo` CLI with `--server-url` or `CCSINFO_SERVER_URL`"] B --> C["HTTP requests to `ccsinfo serve`"] C --> D["FastAPI routers: `/health`, `/info`, `/sessions`, `/projects`, `/tasks`, `/stats`, `/search`"] D --> E["ccsinfo services"] E --> F["`~/.claude/projects` on the server host"] E --> G["`~/.claude/tasks` on the server host"] ``` ## Useful first commands | Goal | CLI or HTTP call | Backing endpoint | | --- | --- | --- | | Confirm the API process is alive | `curl http://localhost:8080/health` | `GET /health` | | Check server version and visible data counts | `curl http://localhost:8080/info` | `GET /info` | | Get high-level usage totals | `ccsinfo --server-url http://localhost:8080 stats global` | `GET /stats` | | Show currently active sessions | `ccsinfo --server-url http://localhost:8080 sessions active` | `GET /sessions/active` | | Show pending tasks | `ccsinfo --server-url http://localhost:8080 tasks pending` | `GET /tasks/pending` | | Search message content | `ccsinfo --server-url http://localhost:8080 search messages "error" --json` | `GET /search/messages?q=error` | If you keep those three ideas in mind, remote mode stays simple: - Start `ccsinfo serve` on the machine with the Claude Code data. - Verify it with `/health` and `/info`. - Point your CLI at that base URL with `--server-url` or `CCSINFO_SERVER_URL`. ## Related Pages - [Installation](installation.html) - [Configuration](configuration.html) - [Running the Server](server-operations.html) - [API Overview](api-overview.html) - [Stats and Health API](api-stats-and-health.html) --- Source: configuration.md # Configuration `ccsinfo` has two runtime modes: - Local mode: the CLI reads Claude Code data from files on the current machine. - Remote mode: the CLI sends requests to a running `ccsinfo` API server. You switch between those modes with `CCSINFO_SERVER_URL` or `--server-url`. If you run the built-in API server with `ccsinfo serve`, you can also choose the bind `--host` and `--port`. ```mermaid flowchart TD A[Run a ccsinfo command] --> B{Server URL configured?} B -- Yes --> C[Create HTTP client] C --> D[Call API routes like /sessions, /projects, /tasks, /stats, /search, /health, /info] B -- No --> E[Use local services] E --> F[Read ~/.claude/projects, ~/.claude/tasks, and project .history.jsonl files] ``` ## Quick Reference | Setting | Scope | Default | What it controls | | --- | --- | --- | --- | | `CCSINFO_SERVER_URL` | Environment variable | Unset | Makes CLI commands use a remote `ccsinfo` server | | `--server-url`, `-s` | Global CLI option | Unset | One-run way to point the CLI at a remote server | | `serve --host`, `-h` | `serve` command option | `127.0.0.1` | Which network interface the API server binds to | | `serve --port`, `-p` | `serve` command option | `8080` | Which port the API server listens on | ## Remote CLI Configuration ### `CCSINFO_SERVER_URL` `CCSINFO_SERVER_URL` is the persistent way to put the CLI into remote mode. The top-level CLI definition wires the environment variable directly into the global `--server-url` option: ```python @app.callback() def main_callback( _version: bool | None = typer.Option( None, "--version", "-v", help="Show version information.", callback=version_callback, is_eager=True, ), server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ) -> None: """Claude Code Session Info CLI.""" state.server_url = server_url ``` If this value is present, `ccsinfo` uses HTTP. If it is missing, the CLI falls back to local file access. Ordinary commands continue to work the same way. With a server URL configured, they use the API instead of local files: ```bash ccsinfo sessions list --json ccsinfo sessions active --json ccsinfo projects list --json ccsinfo stats global --json ccsinfo search sessions "" --json ``` > **Note:** Use the server root URL, such as `http://localhost:8080`, not a specific endpoint like `/sessions` or `/health`. > **Tip:** A trailing slash is fine. The client normalizes the value with `rstrip("/")`, so both `http://localhost:8080` and `http://localhost:8080/` work. ### `--server-url` `--server-url` is the command-line version of the same setting. Its short form is `-s`. Use it when you want remote mode for a single invocation instead of changing your shell environment. Because it is defined in the top-level `@app.callback()`, it applies across the whole CLI, not just one subcommand. In practical terms: - Set `CCSINFO_SERVER_URL` when you want a default server for your shell session or environment. - Use `--server-url` when you want to choose a server for just one run. - Leave both unset when you want local mode. The client code expects a base URL and then adds API paths itself, including `/sessions`, `/projects`, `/tasks`, `/stats`, `/search`, `/health`, and `/info`. ## Local Mode If no server URL is configured, `ccsinfo` reads Claude Code data directly from your home directory. The path helpers in the project point at `~/.claude/projects` and `~/.claude/tasks`: ```python def get_projects_dir() -> Path: """Get the projects directory (~/.claude/projects).""" return get_claude_base_dir() / "projects" def get_tasks_dir() -> Path: """Get the tasks directory (~/.claude/tasks).""" return get_claude_base_dir() / "tasks" ``` Search history is also read locally from `.history.jsonl` files inside project directories. This is the default behavior, so you do not need to configure anything to use `ccsinfo` against data on the same machine where Claude Code ran. > **Note:** Local mode is what the CLI uses whenever `CCSINFO_SERVER_URL` and `--server-url` are both absent. ## Built-in Server Configuration The built-in server is started with `ccsinfo serve`. In the code, that command passes its options straight to Uvicorn: ```python @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` ### `serve --host` `--host` controls which interface the API server listens on. - Default: `127.0.0.1` - Short flag: `-h` With the default `127.0.0.1`, the server is only reachable from the same machine. That is the safest default and matches the example URL shown in the CLI help text: `http://localhost:8080`. If you need clients on other machines to connect, use `0.0.0.0` or another reachable interface address. > **Warning:** Binding to `0.0.0.0` exposes the API to the network. The server code in this repository does not configure authentication, TLS, or CORS middleware, so do not expose it broadly without your own network controls in front of it. > **Tip:** On the `serve` command, `-h` means `--host`, not help. Use `--help` if you want the help screen. ### `serve --port` `--port` controls which TCP port the API server listens on. - Default: `8080` - Short flag: `-p` If you change the port, the client URL needs to change with it. For example, the default combination of `127.0.0.1` and `8080` corresponds to `http://localhost:8080`. > **Note:** The repository code does not define separate environment variables for the built-in server's host or port. For `ccsinfo serve`, you configure both at startup with `--host` and `--port`. ## Checking That the Server Is Up The server exposes lightweight health routes that are useful when you are confirming the right base URL: - `GET /health` returns `{"status": "healthy"}` - `GET /info` returns server metadata including version, total sessions, and total projects Those routes come from the built-in FastAPI app and are part of the same server URL you use for `CCSINFO_SERVER_URL` or `--server-url`. ## Recommended Setups For most users, one of these patterns is enough: - Local-only workflow: leave `CCSINFO_SERVER_URL` unset and let `ccsinfo` read local files from `~/.claude`. - Same-machine server workflow: start `ccsinfo serve` with the defaults and use `http://localhost:8080` as the server URL. - Shared-server workflow: start `ccsinfo serve` with a network-reachable host and chosen port, then point each client at that host and port with `CCSINFO_SERVER_URL` or `--server-url`. If you remember just one rule, make it this: use `CCSINFO_SERVER_URL` or `--server-url` for the CLI, and use `serve --host` and `serve --port` for the server process itself. ## Related Pages - [Quickstart: Local CLI Mode](local-cli-quickstart.html) - [Quickstart: Remote Server Mode](remote-server-quickstart.html) - [Running the Server](server-operations.html) - [JSON Output and Automation](json-output-and-automation.html) - [Troubleshooting](troubleshooting.html) --- Source: project-ids-and-lookups.md # Project IDs and Lookups `ccsinfo` does not invent its own identifier scheme. It exposes the IDs Claude Code already uses under `~/.claude`, which is why project IDs look like paths, session IDs behave like filenames, and task lookups need more context than you might expect. | ID | What it identifies | Where it comes from | What to use it for | | --- | --- | --- | --- | | `project_id` | one Claude project | the directory name under `~/.claude/projects/` | project detail and project-scoped session discovery | | `session_id` | one session transcript | the `.jsonl` filename without the extension | session detail, messages, tools, and session-scoped task lists | | `task_id` | one task inside one session | a task JSON file under `~/.claude/tasks//` | task detail, but only when paired with `session_id` | ## Project IDs Project IDs are dash-encoded filesystem paths. Claude Code replaces `/` and `.` with `-`, and `ccsinfo` uses that directory name as the project identifier: ```23:44:src/ccsinfo/utils/paths.py def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' Example: '/home/user/project' -> '-home-user-project' """ return project_path.replace("/", "-").replace(".", "-") def decode_project_path(encoded_path: str) -> str: """Decode a Claude Code directory name back to the original path. Note: This is lossy - we cannot distinguish between original '-' and encoded '/' or '.'. The path returned should be treated as approximate. """ # Handle the pattern where /. becomes -- result = encoded_path.replace("--", "/.") result = result.replace("-", "/") return result ``` In practice, that means: - `/home/user/project` becomes `-home-user-project` - `/home/user/.config/project` becomes `-home-user--config-project` - a double dash often means a dot-prefixed segment such as `/.config` > **Warning:** Treat `project_id` as an opaque value returned by `ccsinfo projects list` or `GET /projects`. The decode step is lossy, so the displayed path is best-effort context, not a safe source for rebuilding the ID by hand. ## Storage Layout The repo’s own test fixture shows the on-disk relationship between project IDs, session IDs, and task files: ```91:109:tests/conftest.py # Create projects directory with a sample project projects_dir = claude_dir / "projects" project_dir = projects_dir / "-home-user-test-project" project_dir.mkdir(parents=True) # Create a session file in the project session_file = project_dir / "abc-123-def-456.jsonl" with session_file.open("w") as f: for entry in sample_session_data: f.write(json.dumps(entry) + "\n") # Create tasks directory with a session's tasks tasks_dir = claude_dir / "tasks" session_tasks_dir = tasks_dir / "abc-123-def-456" session_tasks_dir.mkdir(parents=True) # Create a task file task_file = session_tasks_dir / "1.json" ``` ```mermaid flowchart TD P["Original project path"] -->|dash-encode| PID["project_id"] PID --> PD["~/.claude/projects//"] PD --> S[".jsonl"] S --> SID["session_id"] SID --> TD["~/.claude/tasks//"] TD --> T[".json"] ``` ## Session Lookups Use `project_id` when you are finding sessions inside a project. Use `session_id` when you already know the exact session you want. A reliable CLI flow is: ```bash ccsinfo projects list --json ccsinfo sessions list --project --json ccsinfo sessions show --json ccsinfo sessions messages --json ccsinfo sessions tools --json ``` The reason `session_id` works on its own is that `ccsinfo` searches every project directory for an exact `.jsonl` filename: ```394:402:src/ccsinfo/core/parsers/sessions.py projects_dir = get_projects_directory() if not projects_dir.exists(): return None for project_dir in projects_dir.iterdir(): if project_dir.is_dir(): session_file = project_dir / f"{session_id}.jsonl" if session_file.exists(): return parse_session_file(session_file) ``` That has two practical consequences: - session detail lookups are global across projects - once you have `session_id`, you do not need `project_id` again - the match is filename-based, so the safest input is the full session ID If you prefer the API, the common entry points are: - `GET /projects/{project_id}` - `GET /projects/{project_id}/sessions` - `GET /sessions/{session_id}` > **Note:** One CLI help string says a session ID “can be partial,” but the current lookup implementation checks for an exact `.jsonl` filename. Use the full session ID for reliable lookups. ## Why Task Lookups Need `session_id` Task detail lookup is stricter because tasks are stored under a session-specific directory, not under a globally unique task namespace: ```113:128:src/ccsinfo/core/parsers/tasks.py tasks_dir = get_tasks_directory() / session_id tasks: list[Task] = [] if not tasks_dir.exists(): logger.debug("No tasks directory found for session %s", session_id) return TaskCollection(session_id=session_id, tasks=[]) for task_file in iter_json_files(tasks_dir, "*.json"): task = parse_task_file(task_file) if task is not None: tasks.append(task) # Sort by ID (numeric sort if possible) tasks.sort(key=lambda t: (int(t.id) if t.id.isdigit() else float("inf"), t.id)) return TaskCollection(session_id=session_id, tasks=tasks) ``` Because task IDs are only unique inside that one session, the public task-detail API requires `session_id`: ```35:44:src/ccsinfo/server/routers/tasks.py @router.get("/{task_id}", response_model=Task) async def get_task( task_id: str, session_id: str = Query(..., description="Session ID (required since task IDs are only unique within a session)"), ) -> Task: """Get task details.""" task = task_service.get_task(task_id, session_id=session_id) if not task: raise HTTPException(status_code=404, detail="Task not found") return task ``` The CLI enforces the same rule: ```130:145:src/ccsinfo/cli/commands/tasks.py @app.command("show") def show_task( task_id: str = typer.Argument(..., help="Task ID"), session: str = typer.Option( ..., "--session", "-s", help="Session ID (required since task IDs are only unique within a session)" ), json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"), ) -> None: """Show task details.""" client = get_client(state.server_url) if client: # Remote mode - use HTTP client try: task_data = client.get_task(task_id, session_id=session) ``` In other words, the stable API shape is: - `GET /sessions/{session_id}/tasks` to list tasks for a known session - `GET /tasks/{task_id}?session_id=` to fetch one specific task > **Warning:** `task_id` is not a global key. `1` means “task 1 in this session,” not “the only task 1 everywhere.” > **Tip:** Prefer a session-first workflow for tasks: `ccsinfo tasks list --session --json` or `GET /sessions/{session_id}/tasks` first, then `ccsinfo tasks show --session --json`. The public task payload does not carry `session_id`, so a global task list is best treated as an overview, not as a standalone lookup table. ## Recommended Lookup Flow When you need to move from a project to a specific task, the safest path is: 1. List projects and copy the exact `project_id`. 2. List sessions for that project and pick the `session_id`. 3. Open the session by `session_id`. 4. List tasks for that session. 5. Open the task with both `task_id` and `session_id`. ```bash ccsinfo projects list --json ccsinfo sessions list --project --json ccsinfo sessions show --json ccsinfo tasks list --session --json ccsinfo tasks show --session --json ``` If you are building scripts or UI flows, keep the three IDs together as you drill down: - `project_id` gets you to the right project and session list - `session_id` gets you to the right transcript - `session_id + task_id` gets you to the right task That one rule of thumb will keep nearly every lookup in `ccsinfo` unambiguous and predictable. ## Related Pages - [Data Model and Storage](data-model-and-storage.html) - [Working with Projects](projects-guide.html) - [Working with Sessions](sessions-guide.html) - [Working with Tasks](tasks-guide.html) - [API Overview](api-overview.html) --- Source: sessions-guide.md # Working with Sessions A session in `ccsinfo` is a single Claude Code conversation. Sessions are discovered from Claude’s local data under `~/.claude/projects`, where each project gets its own encoded directory and each session is stored as a `*.jsonl` file named after the session ID. You can work with sessions in two ways: - local mode: run `ccsinfo` directly and it reads your local Claude data - server mode: run `ccsinfo serve`, then use the same CLI commands with `--server-url` or `CCSINFO_SERVER_URL` ```mermaid flowchart LR A["~/.claude/projects//.jsonl"] --> B["session parser"] B --> C["SessionService"] C --> D["CLI in local mode"] C --> E["FastAPI /sessions endpoints"] E --> F["CLI with --server-url or CCSINFO_SERVER_URL"] ``` | Task | CLI | API | | --- | --- | --- | | List sessions | `ccsinfo sessions list` | `GET /sessions` | | Show only active sessions | `ccsinfo sessions list --active` or `ccsinfo sessions active` | `GET /sessions/active` | | Filter by project | `ccsinfo sessions list --project ` | `GET /sessions?project_id=` or `GET /projects/{project_id}/sessions` | | View session details | `ccsinfo sessions show ` | `GET /sessions/{session_id}` | | Read messages | `ccsinfo sessions messages ` | `GET /sessions/{session_id}/messages` | | Inspect tool calls | `ccsinfo sessions tools ` | `GET /sessions/{session_id}/tools` | ## Local and server mode If you want to expose session data over HTTP, start the built-in server and point the CLI at it: ```bash ccsinfo serve --host 127.0.0.1 --port 8080 CCSINFO_SERVER_URL=http://127.0.0.1:8080 ccsinfo sessions list ``` The session routes exposed by the API are defined here: ```13:58:src/ccsinfo/server/routers/sessions.py @router.get("", response_model=list[SessionSummary]) async def list_sessions( project_id: str | None = Query(None, description="Filter by project"), active_only: bool = Query(False, description="Show only active sessions"), limit: int = Query(50, ge=1, le=500, description="Maximum results"), ) -> list[SessionSummary]: """List all sessions.""" return session_service.list_sessions(project_id=project_id, active_only=active_only, limit=limit) @router.get("/active", response_model=list[SessionSummary]) async def active_sessions() -> list[SessionSummary]: """List currently running sessions.""" return session_service.get_active_sessions() @router.get("/{session_id}", response_model=Session) async def get_session(session_id: str) -> Session: """Get session details.""" session = session_service.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") return session @router.get("/{session_id}/messages") async def get_messages( session_id: str, role: str | None = Query(None), limit: int = Query(100, ge=1, le=500), ) -> list[dict[str, Any]]: """Get messages from a session.""" session = session_service.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") messages = session_service.get_session_messages(session_id, role=role, limit=limit) return [msg.model_dump(mode="json") for msg in messages] @router.get("/{session_id}/tools") async def get_tools(session_id: str) -> list[dict[str, Any]]: """Get tool calls from a session.""" session = session_service.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") return session_service.get_session_tools(session_id) ``` ## List sessions Use `ccsinfo sessions list` to browse sessions across all projects. Results are sorted by most recent activity first, and the CLI defaults to `--limit 50`. ```bash ccsinfo sessions list ccsinfo sessions list --limit 20 ccsinfo sessions list --json ccsinfo sessions active ``` The table view is designed for scanning. It shows: - session ID - project name - message count - last activity time - active or inactive status On the API side, `GET /sessions` supports: - `project_id` - `active_only` - `limit` The API default is `limit=50`, and the allowed range is `1` to `500`. > **Note:** Active status is a live, best-effort check based on running `claude` processes, not just the timestamp in the session file. The implementation caches that check briefly, so a just-started or just-finished session may take a few seconds to update. ## Filter by project or active status Project filtering uses the project ID, not the human-friendly project name. The safest way to get it is: ```bash ccsinfo projects list --json ``` Then use that exact `id` value when filtering sessions: ```bash ccsinfo sessions list --project ccsinfo sessions list --project --active ``` If you are using the API, you can either pass the query parameter: - `GET /sessions?project_id=` Or go straight to the project-scoped endpoints: - `GET /projects/{project_id}/sessions` - `GET /projects/{project_id}/sessions/active` The tests show how project IDs are encoded from paths: ```26:39:tests/test_utils_paths.py path = "/home/user/project" encoded = encode_project_path(path) assert "/" not in encoded assert encoded == "-home-user-project" path = "/home/user/.config/project" encoded = encode_project_path(path) assert "/" not in encoded assert "." not in encoded assert encoded == "-home-user--config-project" ``` > **Warning:** Treat project IDs as opaque values. `ccsinfo` can decode them back into a readable path, but that decode is explicitly approximate. Copy the exact `id` returned by `projects list --json` instead of rebuilding it by hand. > **Tip:** The Rich table views shorten IDs for readability. If you need the exact `project_id` or `session_id` for a follow-up command, use `--json`. ## View session details Use `ccsinfo sessions show ` to inspect one session without loading the whole transcript. ```bash ccsinfo sessions show ccsinfo sessions show --json ``` The detail view includes: - the full session `id` - `project_name` - `project_path` - `created_at` - `updated_at` - `message_count` - `is_active` - `file_path` when available The API equivalent is `GET /sessions/{session_id}`. A practical detail matters here: the details endpoint returns session metadata only. If you want the conversation itself, use the messages endpoint next. > **Warning:** Use a full session ID for `show`, `messages`, and `tools`. The list tables display shortened IDs, so `ccsinfo sessions list --json` is the easiest way to copy an exact value. ## Read messages Use `ccsinfo sessions messages ` to read the conversation inside a session. ```bash ccsinfo sessions messages ccsinfo sessions messages --role user ccsinfo sessions messages --role assistant --limit 100 --json ``` The CLI supports: - `--role user` - `--role assistant` - `--limit` - `--json` The API equivalent is: - `GET /sessions/{session_id}/messages` - `GET /sessions/{session_id}/messages?role=user` - `GET /sessions/{session_id}/messages?role=assistant&limit=100` The API default is `limit=100` and accepts up to `500`. The CLI default is `--limit 50`. The repository’s test fixtures show the JSONL structure `ccsinfo` parses into messages: ```28:47:tests/conftest.py { "type": "user", "uuid": "msg-001", "message": { "role": "user", "content": [{"type": "text", "text": "Hello"}], }, "timestamp": "2024-01-15T10:00:00Z", }, { "type": "assistant", "uuid": "msg-002", "parentMessageUuid": "msg-001", "message": { "role": "assistant", "content": [{"type": "text", "text": "Hi there!"}], }, "timestamp": "2024-01-15T10:00:01Z", }, ``` A few practical details make the transcript view easier to use: - messages are returned in session order - the `role` filter is applied before the `limit` - the table view shows a content preview, not the full message body - if a message contains only tool calls and no text, the preview shows `` > **Tip:** For long conversations, use `--json` and raise the limit. The limit is applied from the start of the session, not the end. > **Note:** The transcript view is conversation-focused. It surfaces `user` and `assistant` entries, not every raw record that may exist in the underlying session file. ## Inspect tool calls Use `ccsinfo sessions tools ` when you want to see what the assistant actually invoked. ```bash ccsinfo sessions tools ccsinfo sessions tools --json ``` The CLI table shows: - tool call ID - tool name - a preview of the input payload The API equivalent is `GET /sessions/{session_id}/tools`. `ccsinfo` flattens tool calls out of the assistant messages and returns simple dictionaries with `id`, `name`, and `input`: ```173:195:src/ccsinfo/core/services/session_service.py def get_session_tools(self, session_id: str) -> list[dict[str, Any]]: """Get tool calls from a session. ... """ detail = self.get_session_detail(session_id) if detail is None: return [] tools: list[dict[str, Any]] = [] for message in detail.messages: for tool_call in message.tool_calls: tools.append({ "id": tool_call.id, "name": tool_call.name, "input": tool_call.input, }) return tools ``` The message model tests show the kind of calls that get extracted: ```535:550:tests/test_models.py msg = Message( uuid="msg-1", type="assistant", message=MessageContent( role="assistant", content=[ TextContent(text="Let me run this"), ToolUseContent(id="t1", name="bash", input={"command": "ls"}), ToolUseContent(id="t2", name="read_file", input={"path": "/tmp"}), ], ), ) calls = msg.tool_calls assert len(calls) == 2 assert calls[0].name == "bash" assert calls[1].name == "read_file" ``` > **Note:** `sessions tools` is a flat list of tool call metadata. It is great for answering “what tools were used?”, but it does not preserve the surrounding message grouping or show full tool output. > **Tip:** If you need the assistant text around a tool call, use `ccsinfo sessions messages --json` alongside `sessions tools`. ## Typical workflow 1. Get the exact project ID with `ccsinfo projects list --json`. 2. Narrow the session list with `ccsinfo sessions list --project --json`. 3. Inspect the session metadata with `ccsinfo sessions show `. 4. Read the transcript with `ccsinfo sessions messages `. 5. Review the assistant’s actions with `ccsinfo sessions tools `. That flow maps cleanly to the repository’s CLI commands and API routes, and it is the most reliable way to move from “which session do I want?” to “what happened inside it?” ## Related Pages - [Working with Projects](projects-guide.html) - [Working with Tasks](tasks-guide.html) - [Project IDs and Lookups](project-ids-and-lookups.html) - [Sessions API](api-sessions.html) - [Active Session Detection](active-session-detection.html) --- Source: projects-guide.md # Working with Projects `ccsinfo` discovers projects automatically from Claude Code data stored under `~/.claude/projects`. A project is not something you register manually. If Claude Code has created a project directory with session files, `ccsinfo` can list it, inspect it, and summarize it. The test fixtures show the on-disk shape `ccsinfo` expects: ```91:100:tests/conftest.py # Create projects directory with a sample project projects_dir = claude_dir / "projects" project_dir = projects_dir / "-home-user-test-project" project_dir.mkdir(parents=True) # Create a session file in the project session_file = project_dir / "abc-123-def-456.jsonl" with session_file.open("w") as f: for entry in sample_session_data: f.write(json.dumps(entry) + "\n") ``` That storage layout drives the rest of the project workflow: - browse all discovered projects - inspect one project's metadata - list that project's sessions to see activity - summarize the project's overall stats > **Note:** By default, the CLI reads local Claude Code files directly. If you point it at a running `ccsinfo` server, the same commands use the REST API instead. ## How Project IDs Work Every project is identified by the encoded directory name Claude Code uses under `~/.claude/projects`. `ccsinfo` uses that encoded name as the project ID in CLI arguments, filters, and API paths. The encoding logic is defined here: ```23:44:src/ccsinfo/utils/paths.py def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' Example: '/home/user/project' -> '-home-user-project' """ return project_path.replace("/", "-").replace(".", "-") def decode_project_path(encoded_path: str) -> str: """Decode a Claude Code directory name back to the original path. Note: This is lossy - we cannot distinguish between original '-' and encoded '/' or '.'. The path returned should be treated as approximate. """ # Handle the pattern where /. becomes -- result = encoded_path.replace("--", "/.") result = result.replace("-", "/") return result ``` In practice, a path like `/home/user/project` becomes `-home-user-project`. > **Warning:** The displayed `path` is reconstructed from the encoded directory name. If the original path contained `-` or `.` characters, the decoded path can be approximate rather than exact. > **Tip:** `projects list` shortens long IDs in the table view. When you need the exact project ID for a follow-up command, use the full encoded directory name from `~/.claude/projects`. ```mermaid flowchart LR A[~/.claude/projects/] --> B[Session .jsonl files] B --> C[Session parser] C --> D[Project service] C --> E[Session service] D --> F[projects list / projects show / projects stats] E --> G[sessions list --project] F --> H[CLI output] G --> H D --> I[/projects and /projects/{id}/stats] E --> J[/projects/{id}/sessions] ``` ## Browse Projects Start with the project commands: ```bash ccsinfo projects list ccsinfo projects show ccsinfo projects stats ``` Use them like this: - `ccsinfo projects list` gives you the full catalog of discoverable projects. - `ccsinfo projects show ` gives you the detail view for one project. - `ccsinfo projects stats ` gives you a compact summary of that project's activity. `projects list` is ordered by most recent activity, so the projects you touched most recently appear first. The detail view is better when you need the full project ID, decoded path, and exact timestamp. If you prefer HTTP, these are the project endpoints: | Endpoint | Purpose | | --- | --- | | `GET /projects` | List all projects | | `GET /projects/{project_id}` | Fetch metadata for one project | | `GET /projects/{project_id}/sessions` | List sessions for a project | | `GET /projects/{project_id}/sessions/active` | List only active sessions for a project | | `GET /projects/{project_id}/stats` | Fetch aggregate stats for a project | Example requests: ```bash curl http://127.0.0.1:8080/projects curl http://127.0.0.1:8080/projects/ curl "http://127.0.0.1:8080/projects//sessions?limit=50" curl http://127.0.0.1:8080/projects//sessions/active curl http://127.0.0.1:8080/projects//stats ``` The project-session API returns up to 50 sessions by default and accepts values up to 500. ## Inspect Project Metadata A project's metadata is intentionally small and easy to scan: | Field | Meaning | | --- | --- | | `id` | The encoded project directory name. Use this in CLI commands and API URLs. | | `name` | A human-friendly name derived from the last segment of the decoded path. | | `path` | The decoded project path string. Treat it as best-effort. | | `session_count` | The number of stored session files `ccsinfo` found for that project directory. | | `last_activity` | The newest timestamp found in that project's session data. | This is the information you use to answer quick questions such as: - Which repository or folder does this project correspond to? - How much stored conversation history does it have? - When was it last active? > **Tip:** `projects show` is the most useful command when you are matching an encoded ID back to a real project path. ## Follow Project Activity Project activity is session-based. On the CLI, that means you move from the `projects` commands to the `sessions` commands: ```bash ccsinfo sessions list --project ccsinfo sessions list --project --active ccsinfo sessions show ``` Use this flow when you want to answer questions like: - What happened most recently in this project? - Which sessions are still running? - Which specific session should I inspect next? A project-filtered session list gives you: - the session ID - the project name - the message count - the last activity time - whether the session appears active The `--project` filter expects the project ID, not the human-friendly project name. If you are using the API, the equivalent views are: - `GET /projects/{project_id}/sessions` - `GET /projects/{project_id}/sessions/active` > **Note:** `--active` is a live-process filter. It shows sessions `ccsinfo` currently detects as running, not merely sessions with recent timestamps. > **Tip:** Think of `projects list` as the catalog, `projects show` as the metadata view, and `sessions list --project ` as the activity timeline. ## Understand Project Statistics Use the stats view when you want a compact summary instead of a session-by-session timeline: ```bash ccsinfo projects stats ``` That summary includes: - `project_id` - `project_name` - `session_count` - `message_count` - `last_activity` The core aggregation logic is in `src/ccsinfo/core/services/project_service.py`: ```84:102:src/ccsinfo/core/services/project_service.py # Calculate detailed stats total_messages = 0 last_activity = None for session in get_project_sessions(project_dir): total_messages += session.message_count session_last = session.last_timestamp if session_last: session_dt = pendulum.instance(session_last) if last_activity is None or session_dt > last_activity: last_activity = session_dt return ProjectStats( project_id=project_id, project_name=project.name, session_count=project.session_count, message_count=total_messages, last_activity=last_activity, ) ``` This is the practical meaning of each stat: - `session_count` tells you how many stored session files were found for the project. - `message_count` tells you how much conversation activity those sessions contain. - `last_activity` tells you the newest timestamp found across the project's sessions. > **Note:** In `ccsinfo`, `message_count` is a count of session message entries, not a token count. It reflects conversation volume, not model billing or token usage. Use project stats when you want a fast answer to questions like: - Which project has the most conversation history? - Which project has been active most recently? - Is this a lightly used project or a busy one? When you need the story behind the numbers, go back to the filtered session list for that project. ## Use Local Or Server Mode By default, the CLI reads local Claude Code files. If you set a server URL, the same project commands switch to HTTP calls. The configuration is wired into the CLI here: ```53:59:src/ccsinfo/cli/main.py server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ``` Start the built-in API server like this: ```bash ccsinfo serve ``` By default, it binds to `127.0.0.1:8080`. Once it is running, you can point the CLI at it: ```bash CCSINFO_SERVER_URL=http://127.0.0.1:8080 ccsinfo projects list CCSINFO_SERVER_URL=http://127.0.0.1:8080 ccsinfo projects show CCSINFO_SERVER_URL=http://127.0.0.1:8080 ccsinfo sessions list --project ``` > **Note:** Local mode and server mode expose the same project concepts. The difference is only where the data is read from. ## Recommended Workflow 1. Run `ccsinfo projects list` to see every discoverable project. 2. Use `ccsinfo projects show ` to inspect the project path, session count, and last activity. 3. Use `ccsinfo sessions list --project ` to follow recent work in that project. 4. Add `--active` when you only care about sessions that are currently running. 5. Use `ccsinfo projects stats ` when you want a concise summary of the project's overall activity. ## Related Pages - [Project IDs and Lookups](project-ids-and-lookups.html) - [Working with Sessions](sessions-guide.html) - [Using Statistics and Trends](statistics-guide.html) - [Projects API](api-projects.html) - [Data Model and Storage](data-model-and-storage.html) --- Source: tasks-guide.md # Working with Tasks ccsinfo gives you a read-only view of Claude Code task data. You can use it to scan all tasks across sessions, narrow the view to one session, filter by status, inspect task details, and understand blocker relationships. > **Note:** ccsinfo does not create, update, or complete tasks. It reads task files that Claude Code has already written. ## Where tasks come from Each session's tasks live in `~/.claude/tasks//` as individual `*.json` files. ccsinfo can read them in two ways: - locally, by reading your `~/.claude` directory directly - remotely, by querying the built-in API server The CLI is wired for both modes: ```python server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ) ``` In practice, that means: ```bash # Local mode ccsinfo tasks list # Start the API server ccsinfo serve --host 127.0.0.1 --port 8080 # Remote mode CCSINFO_SERVER_URL=http://localhost:8080 ccsinfo tasks list --json ``` ```mermaid flowchart LR A["~/.claude/tasks//*.json"] --> B["Task parser"] B --> C["TaskService"] C --> D["CLI local mode"] C --> E["FastAPI task routes"] E --> F["/tasks"] E --> G["/sessions/{session_id}/tasks"] E --> H["/sessions/{session_id}/progress"] F --> I["CLI remote mode"] G --> I H --> I ``` Within a session, ccsinfo reads sorted `*.json` task files and then sorts the task collection by numeric task ID when it can. ## Task format and fields A raw task file looks like this: ```json { "id": "1", "subject": "Test task", "description": "A test task", "status": "pending", "owner": null, "blockedBy": [], "blocks": [] } ``` Optional fields such as `activeForm` and `metadata` may also be present. The supported status values come directly from the task model: ```python class TaskStatus(StrEnum): """Task status enum.""" PENDING = "pending" IN_PROGRESS = "in_progress" COMPLETED = "completed" ``` | Field | Meaning | | --- | --- | | `id` | The task's identifier inside one session | | `subject` | Short task title | | `description` | Longer task details | | `status` | One of `pending`, `in_progress`, or `completed` | | `owner` | Optional assignee or owner | | `blockedBy` / `blocked_by` | Task IDs that must finish first | | `blocks` | Task IDs that depend on this task | | `activeForm` / `active_form` | Optional active wording for the task | | `metadata` | Extra structured data attached to the task | > **Tip:** If you work with both raw Claude Code task files and ccsinfo output, watch the naming style. Raw files use camelCase names such as `blockedBy` and `activeForm`, while ccsinfo's internal models use snake_case names such as `blocked_by` and `active_form`. > **Warning:** Status filters accept only `pending`, `in_progress`, or `completed`. Invalid values return an error in both the CLI and the API. ## List and filter tasks Use `ccsinfo tasks list` for a broad overview. ```bash ccsinfo tasks list --json ccsinfo tasks list --session --json ccsinfo tasks list --status pending --json ccsinfo tasks list --session --status in_progress --json ``` If you are using the API directly, the same filters are available on `GET /tasks`: ```bash curl "http://localhost:8080/tasks" curl "http://localhost:8080/tasks?session_id=" curl "http://localhost:8080/tasks?status=pending" curl "http://localhost:8080/tasks?session_id=&status=in_progress" ``` In human-readable mode, the list view shows a compact table with: - `ID` - `Subject` - `Status` - `Owner` - `Blocked By` That makes `tasks list` good for scanning, but not for deep inspection. Descriptions, `blocks`, `active_form`, and `metadata` only appear in the detailed view. > **Tip:** Use `--json` when you need full field values or want to script against the results. > **Warning:** The top-level task list is cross-session, but the returned tasks do not include a session ID. Use it as an overview. When you need an unambiguous task reference, scope the list to one session first. ## Pending work views The quickest way to see outstanding work across all sessions is: ```bash ccsinfo tasks pending --json ``` API equivalent: ```bash curl "http://localhost:8080/tasks/pending" ``` In human-readable mode, the pending view focuses on: - `ID` - `Subject` - `Owner` - `Blocked By` The pending view is purely status-based. In the service layer, it is implemented as: ```python def get_pending_tasks(self) -> list[Task]: """Get all pending tasks across all sessions. Returns: List of pending tasks. """ return self.list_tasks(status=TaskStatus.PENDING) ``` That matters because `pending` does not automatically mean "ready to start." The parser also has a stricter internal helper for ready work: ```python def get_ready_tasks(self) -> list[Task]: """Get all pending tasks that are not blocked.""" return [t for t in self.tasks if t.status == "pending" and not t.blocked_by] ``` So the practical difference is: - `pending` means the task status is still open - `ready` would mean `pending` and no blockers > **Note:** ccsinfo exposes a built-in `pending` view, but it does not currently expose a dedicated `ready` or `unblocked` command. A task can appear in `ccsinfo tasks pending` and still be blocked by other tasks. > **Tip:** For pending work in one session, combine the normal list and status filter: `ccsinfo tasks list --session --status pending`. Because the pending view is also cross-session and omits session IDs, it works best as a backlog scan. If you plan to inspect a specific task next, switch to a session-scoped list first. ## View task details Use the detailed view when you already know the task ID and the session it belongs to: ```bash ccsinfo tasks show --session --json ``` API equivalent: ```bash curl "http://localhost:8080/tasks/?session_id=" ``` The detailed CLI view shows: - `ID` - `Subject` - `Description` - `Status` - `Owner` - `Active Form` - `Blocked By` - `Blocks` - `Metadata` > **Warning:** Task IDs are only unique within a session. That is why `ccsinfo tasks show` requires `--session`, and the API requires the `session_id` query parameter for `GET /tasks/{task_id}`. Use the full session ID here. A missing task returns a `Task not found` error in the CLI and a `404` from the API. ## Understand blocking relationships ccsinfo surfaces task dependencies directly instead of hiding them behind a derived status. The task model keeps both sides of the relationship: - `blockedBy` / `blocked_by`: this task cannot move forward until those task IDs are resolved - `blocks`: these downstream tasks depend on this task The model also exposes a simple blocked check: ```python @property def is_blocked(self) -> bool: """Check if task is blocked by other tasks.""" return len(self.blocked_by) > 0 ``` In practice, this means: - A task with `status: "pending"` and a non-empty `blockedBy` list is still pending, but not ready. - A task with an empty `blockedBy` list may be ready, as long as its status is still `pending`. - `blocks` helps you see the downstream impact of completing a task. The list and pending views already show the `Blocked By` column, so you can spot blocked work quickly. Use the detailed view when you need both sides of the relationship, especially the `blocks` list. > **Tip:** There is no dedicated `ccsinfo tasks blocked` command today. Use the `Blocked By` column in list views or the detailed view to inspect dependencies. ## Session-scoped task lookup When you want task context instead of a global overview, start from the session. There are two useful session-scoped task entry points: - `ccsinfo tasks list --session ` - `GET /sessions/{session_id}/tasks` ```bash curl "http://localhost:8080/sessions//tasks" ``` This is the safest way to inspect a session's task set, because you keep the session context all the way through. If you want a session's current activity instead of its full task list, use the progress route. The server builds that response like this: ```python active_tasks = [t for t in tasks if t.status.value == "in_progress"] return { "session_id": session_id, "is_active": session.is_active, "last_activity": session.updated_at.isoformat() if session.updated_at else None, "message_count": session.message_count, "active_tasks": [t.model_dump(mode="json") for t in active_tasks], } ``` So `GET /sessions/{session_id}/progress` is best when you want: - whether the session is active - the last activity timestamp - the session's message count - only the tasks that are currently `in_progress` ```bash curl "http://localhost:8080/sessions//progress" ``` > **Tip:** A session-scoped task list is usually the best starting point when you plan to follow up with `ccsinfo tasks show`, because top-level task lists do not include session IDs. The session ID is also the link between Claude Code's session and task storage: - session transcript: `~/.claude/projects//.jsonl` - task directory: `~/.claude/tasks//` That shared session ID is what lets ccsinfo connect task data back to the right conversation session. ## Related Pages - [Working with Sessions](sessions-guide.html) - [Project IDs and Lookups](project-ids-and-lookups.html) - [Tasks API](api-tasks.html) - [Sessions API](api-sessions.html) - [Data Model and Storage](data-model-and-storage.html) --- Source: search-guide.md # Searching Sessions, Messages, and History `ccsinfo` has three different search commands because Claude Code data is stored in two different places: session JSONL files for conversations, and a separate `.history.jsonl` file for prompt history. The right search depends on what you remember. | Search | Scans | Does not scan | Best when you remember | | --- | --- | --- | --- | | `ccsinfo search sessions ` | Session-level metadata | Message bodies and prompt-history text | a project name, branch, slug, or part of a session identifier | | `ccsinfo search messages ` | Text from `user` and `assistant` messages | Tool-call payloads and `.history.jsonl` prompts | a phrase from the conversation itself | | `ccsinfo search history ` | Prompt text stored in `.history.jsonl` | Assistant replies and session-level metadata | something you typed, but not where you typed it | > **Tip:** If you only remember the wording and do not remember whether you typed it or Claude said it, run both `ccsinfo search history ` and `ccsinfo search messages `. ```mermaid flowchart LR U[You] --> CLI[ccsinfo CLI] CLI -->|local mode| S[Search service] CLI -->|--server-url / CCSINFO_SERVER_URL| API[HTTP API] API --> S S --> META[Session metadata search] S --> MSG[Message text search] S --> HIST[Prompt history search] META --> SESSIONFILES["Project `*.jsonl` session files"] MSG --> SESSIONFILES HIST --> HISTORYFILES["Per-project `.history.jsonl` files"] ``` ## Run Search By default, the CLI reads local files under `~/.claude`. If you want the same commands to go through the server API instead, start the server with `ccsinfo serve` and point the CLI at it with `--server-url` or `CCSINFO_SERVER_URL`. ```27:62:src/ccsinfo/cli/main.py @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) # ... @app.callback() def main_callback( # ... server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ) -> None: """Claude Code Session Info CLI.""" state.server_url = server_url ``` The CLI and API line up directly: | CLI | API | | --- | --- | | `ccsinfo search sessions ` | `GET /search?q=&limit=` | | `ccsinfo search messages ` | `GET /search/messages?q=&limit=` | | `ccsinfo search history ` | `GET /search/history?q=&limit=` | All three API routes use `q` for the query. The API default `limit` is `50`, and the HTTP routes enforce `1` through `500`. The CLI also supports `--limit` and `--json`. ## Search Sessions `ccsinfo search sessions ` is the broadest search. It does a case-insensitive match against session metadata, including the session identifier, slug, branch information, and stored path information. It returns session summaries, not conversation text. Use session search when: - you remember a project or path fragment - you remember a branch name or slug - you want to find the right session before opening it Once you have the right match, follow up with `ccsinfo sessions show ` or `ccsinfo sessions messages --json`. ## Search Message Text `ccsinfo search messages ` reads the session files themselves. The test fixture shows the kind of JSONL entries it searches: ```29:46:tests/conftest.py { "type": "user", "uuid": "msg-001", "message": { "role": "user", "content": [{"type": "text", "text": "Hello"}], }, "timestamp": "2024-01-15T10:00:00Z", }, { "type": "assistant", "uuid": "msg-002", "parentMessageUuid": "msg-001", "message": { "role": "assistant", "content": [{"type": "text", "text": "Hi there!"}], }, "timestamp": "2024-01-15T10:00:01Z", }, ``` When message search runs, it only scans `user` and `assistant` entries and only extracts text content. It then builds a context snippet around the match instead of returning the full message body: ```85:112:src/ccsinfo/core/services/search_service.py for project_path, session in get_all_sessions(): for entry in session.entries: if entry.type not in ("user", "assistant"): continue text_content = "" if entry.message and entry.message.content: if isinstance(entry.message.content, str): text_content = entry.message.content elif isinstance(entry.message.content, list): texts = [] for content in entry.message.content: if content.type == "text" and content.text: texts.append(content.text) text_content = "\n".join(texts) if query_lower in text_content.lower(): idx = text_content.lower().find(query_lower) start = max(0, idx - 50) end = min(len(text_content), idx + len(query) + 50) snippet = text_content[start:end] if start > 0: snippet = "..." + snippet if end < len(text_content): snippet = snippet + "..." ``` That makes message search good for finding where a phrase appeared in a conversation, but not for dumping the whole conversation. If you need the complete exchange after a hit, use `ccsinfo sessions messages --json`. > **Warning:** `search messages` only searches extracted text blocks from `user` and `assistant` messages. Tool-call payloads, tool results, and other non-text content are not part of message search. ## Search Prompt History `ccsinfo search history ` is for finding what you asked, not what Claude answered. It searches the prompt text stored in each project's `.history.jsonl` file. ```98:129:src/ccsinfo/core/parsers/history.py def search_prompts(self, query: str, *, case_sensitive: bool = False) -> list[HistoryEntry]: """Search for prompts containing a query string.""" results: list[HistoryEntry] = [] search_query = query if case_sensitive else query.lower() for entry in self.entries: if entry.prompt: prompt_text = entry.prompt if case_sensitive else entry.prompt.lower() if search_query in prompt_text: results.append(entry) return results def get_history_file(project_dir: Path) -> Path: """Get the path to the history file for a project.""" return project_dir / ".history.jsonl" ``` This is the best search when: - you remember part of a prompt but not the session it came from - you want to reuse an older prompt across projects - you want the stored prompt text itself History search returns the project path, prompt text, timestamp, and the related session identifier. In table output the prompt preview is shortened; in JSON or API output the full stored prompt is returned. ## Practical Limits and Behaviors All three searches are simple, case-insensitive substring scans over JSONL data. There is no fuzzy matching, stemming, or semantic ranking, so broad queries can produce noisy results. If you expect lots of hits, use a more specific phrase or raise `--limit`. Search also tolerates damaged files by skipping bad lines instead of failing the whole run: ```43:77:src/ccsinfo/core/parsers/jsonl.py if not file_path.exists(): raise FileNotFoundError(f"JSONL file not found: {file_path}") with file_path.open("rb") as f: for line_num, line in enumerate(f, start=1): line = line.strip() if not line: continue try: data = orjson.loads(line) except orjson.JSONDecodeError as e: if skip_malformed: logger.warning( "Skipping malformed JSON at line %d in %s: %s", line_num, file_path, e, ) continue raise if model is not None: try: yield model.model_validate(data) except Exception as e: if skip_malformed: logger.warning( "Skipping invalid data at line %d in %s: %s", line_num, file_path, e, ) continue ``` > **Tip:** Use `--json` when you want full identifiers, want to script against results, or want the full prompt text from history search. The pretty tables shorten IDs and previews. > **Note:** Project paths are reconstructed from encoded directory names under `~/.claude/projects`, so treat them as best-effort labels rather than perfect originals. > **Warning:** Malformed JSONL lines are skipped rather than failing the whole search. That keeps the command usable, but it can also mean a damaged session or history file produces incomplete results instead of a hard error. ## Related Pages - [Working with Sessions](sessions-guide.html) - [Search API](api-search.html) - [Data Model and Storage](data-model-and-storage.html) - [JSON Output and Automation](json-output-and-automation.html) - [Troubleshooting](troubleshooting.html) --- Source: statistics-guide.md # Using Statistics and Trends `ccsinfo` gives you three built-in ways to understand Claude Code usage: - `global` for all-time totals - `daily` for day-by-day activity - `trends` for recent momentum, active projects, and tool usage By default, the CLI reads your local Claude Code data from `~/.claude`. If you start the API server, the same statistics are available over HTTP at `/stats`, `/stats/daily`, and `/stats/trends`. ## Quick Start Use the CLI directly against your local data: ```bash ccsinfo stats global ccsinfo stats daily --days 30 ccsinfo stats trends ccsinfo stats trends --json ``` Or run the API server and query the same information remotely: ```bash ccsinfo serve ccsinfo --server-url http://127.0.0.1:8080 stats trends curl "http://127.0.0.1:8080/stats" curl "http://127.0.0.1:8080/stats/daily?days=30" curl "http://127.0.0.1:8080/stats/trends" ``` > **Note:** You can set `CCSINFO_SERVER_URL` instead of passing `--server-url` every time. If it is not set, the CLI reads local files directly. ```53:59:src/ccsinfo/cli/main.py server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ``` ## How Statistics Flow `ccsinfo` builds statistics from Claude Code session files stored under `~/.claude/projects`. In local mode, the CLI parses those files directly. In server mode, the API parses them and returns the same categories of data over HTTP. ```mermaid flowchart TD A[~/.claude/projects//.jsonl] --> B[Session parser] B --> C[StatsService] C --> D[CLI local mode
ccsinfo stats ...] C --> E[FastAPI stats endpoints] E --> F[CLI remote mode
ccsinfo --server-url ...] E --> G[curl or other HTTP clients] ``` ## Global Totals Use `ccsinfo stats global` when you want the big picture. The command returns four values: - `total_projects`: unique projects found across parsed sessions - `total_sessions`: total parsed sessions - `total_messages`: total `user` and `assistant` messages - `total_tool_calls`: total assistant `tool_use` entries ```20:46:src/ccsinfo/core/services/stats_service.py def get_global_stats(self) -> GlobalStats: """Get global usage statistics across all sessions and projects.""" total_sessions = 0 total_projects = 0 total_messages = 0 total_tool_calls = 0 project_ids = set() for project_path, session in get_all_sessions(): total_sessions += 1 project_ids.add(project_path) total_messages += session.message_count total_tool_calls += session.tool_use_count total_projects = len(project_ids) return GlobalStats( total_sessions=total_sessions, total_projects=total_projects, total_messages=total_messages, total_tool_calls=total_tool_calls, ) ``` > **Tip:** Use `ccsinfo stats global --json` if you want to feed the totals into a script or dashboard. ## Daily Activity Breakdowns Use `ccsinfo stats daily --days 7`, `30`, or `90` when you want to see how activity is spread over time. Each row contains: - `date` - `session_count` - `message_count` The API accepts `days` values from `1` to `365`. ```48:90:src/ccsinfo/core/services/stats_service.py def get_daily_stats(self, days: int = 30) -> list[DailyStats]: """Get daily activity breakdown for the last N days.""" now = pendulum.now() cutoff = now.subtract(days=days) daily_data: dict[str, dict[str, int]] = defaultdict(lambda: {"session_count": 0, "message_count": 0}) for _project_path, session in get_all_sessions(): ts = session.first_timestamp if ts is None: continue session_dt = pendulum.instance(ts) if session_dt < cutoff: continue date_key = session_dt.format("YYYY-MM-DD") daily_data[date_key]["session_count"] += 1 daily_data[date_key]["message_count"] += session.message_count results: list[DailyStats] = [] for date_str, data in sorted(daily_data.items()): parsed_dt = pendulum.parse(date_str) date = parsed_dt.date() if parsed_dt else None results.append( DailyStats( date=date, session_count=data["session_count"], message_count=data["message_count"], ) ) return results ``` > **Warning:** Daily stats are based on each session's first timestamp. If a session crosses midnight, its messages are still counted on the day the session started. > **Note:** The built-in daily view tracks sessions and messages only. It does not include a per-day tool breakdown. > **Tip:** Quiet days are omitted rather than filled with zeroes. If you are charting the API output, add missing dates yourself if you want a continuous timeline. ## Trend Analysis Use `ccsinfo stats trends` when you want one compact summary of recent activity plus your biggest projects and tools. The trend output includes: - sessions in the last 7 days - sessions in the last 30 days - messages in the last 7 days - messages in the last 30 days - the top 5 most active projects - the top 10 most used tools - average session length ```105:163:src/ccsinfo/core/services/stats_service.py now = pendulum.now() cutoff_7 = now.subtract(days=7) cutoff_30 = now.subtract(days=30) sessions_7 = 0 sessions_30 = 0 messages_7 = 0 messages_30 = 0 project_activity: dict[str, int] = defaultdict(int) tool_usage: dict[str, int] = defaultdict(int) total_sessions = 0 total_messages = 0 for project_path, session in get_all_sessions(): total_sessions += 1 total_messages += session.message_count project_activity[project_path] += session.message_count for tool in session.get_unique_tools_used(): tool_usage[tool] += 1 ts = session.first_timestamp if ts is not None: session_dt = pendulum.instance(ts) if session_dt >= cutoff_30: sessions_30 += 1 messages_30 += session.message_count if session_dt >= cutoff_7: sessions_7 += 1 messages_7 += session.message_count most_active = sorted(project_activity.items(), key=lambda x: x[1], reverse=True)[:5] most_used_tools = sorted(tool_usage.items(), key=lambda x: x[1], reverse=True)[:10] avg_length = total_messages / total_sessions if total_sessions > 0 else 0 return { "sessions_last_7_days": sessions_7, "sessions_last_30_days": sessions_30, "messages_last_7_days": messages_7, "messages_last_30_days": messages_30, "most_active_projects": [{"project": p, "message_count": c} for p, c in most_active], "most_used_tools": [{"tool": t, "count": c} for t, c in most_used_tools], "average_session_length": round(avg_length, 2), } ``` > **Note:** The 7-day and 30-day counts are recent windows, but `most_active_projects`, `most_used_tools`, and `average_session_length` are calculated across all parsed sessions. > **Warning:** `average_session_length` is not time. It is `total_messages / total_sessions`, shown in messages. ## Most Active Projects The "Most Active Projects" leaderboard is based on total message volume per project, not session count. A project with fewer but longer conversations can outrank a project with many short sessions. A few things are worth knowing: - only the top 5 projects are shown - the trend table uses the decoded project path as its display value - long paths are truncated in the CLI table - if you need an exact identifier, use the project ID A practical follow-up workflow is: ```bash ccsinfo projects list --json ccsinfo projects stats curl "http://127.0.0.1:8080/projects//stats" ``` `project_id` is the encoded directory name inside `~/.claude/projects`. The human-readable path is derived from that encoded name and is only approximate. ```23:44:src/ccsinfo/utils/paths.py def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' """ return project_path.replace("/", "-").replace(".", "-") def decode_project_path(encoded_path: str) -> str: """Decode a Claude Code directory name back to the original path. Note: This is lossy - we cannot distinguish between original '-' and encoded '/' or '.'. The path returned should be treated as approximate. """ result = encoded_path.replace("--", "/.") result = result.replace("-", "/") return result ``` > **Warning:** Use the project ID for scripts and follow-up commands. The decoded path shown in trend output is meant to be readable, not perfectly reversible. > **Tip:** `ccsinfo projects list --json` is the safest way to get the full project ID because the default table shortens long values. ## Most Used Tools The "Most Used Tools" table shows which tools appear most often across your session history, but its `count` field is session-based, not call-based. That means: - a tool counts once per session if it appears at all - repeated calls to the same tool in one session do not increase its trend count - the leaderboard shows only the top 10 tools ```205:215:src/ccsinfo/core/parsers/sessions.py def get_unique_tools_used(self) -> set[str]: """Get the set of unique tool names used in the session.""" tools: set[str] = set() for entry in self.entries: if entry.type == "assistant" and entry.message: content = entry.message.content if isinstance(content, list): for c in content: if isinstance(c, MessageContent) and c.type == "tool_use" and c.name: tools.add(c.name) return tools ``` > **Note:** A tool used 20 times in one session still contributes only `1` to `most_used_tools` for that session. If you need raw tool-call volume, check `ccsinfo stats global` and use `total_tool_calls`. ## Practical Workflow A simple way to use these features day to day is: - start with `ccsinfo stats global` to understand overall scale - use `ccsinfo stats daily --days 7` and `--days 30` to spot short-term changes - use `ccsinfo stats trends` to compare recent activity with your all-time project and tool patterns - switch to `--json` whenever you want to chart, export, or automate the results - drill into standout projects with `ccsinfo projects stats ` ## Related Pages - [Stats and Health API](api-stats-and-health.html) - [Working with Projects](projects-guide.html) - [Quickstart: Local CLI Mode](local-cli-quickstart.html) - [JSON Output and Automation](json-output-and-automation.html) - [Overview](overview.html) --- Source: json-output-and-automation.md # JSON Output and Automation `ccsinfo` has two output styles: human-friendly terminal views and machine-friendly JSON. For scripts, CI jobs, dashboards, or other tooling, add `--json` (or `-j`) so you can consume structured data instead of parsing Rich tables and panels. ## JSON-First Workflow The same CLI commands work in both local and remote modes. By default, `ccsinfo` reads Claude Code data from the local machine. If you set `CCSINFO_SERVER_URL` or pass `--server-url`, it switches to HTTP and reads from a `ccsinfo` server instead. You can start that server with `ccsinfo serve`. ```27:59:src/ccsinfo/cli/main.py @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) # ... other callback options omitted ... server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ) ``` ```mermaid flowchart LR A[Your script or shell] --> B[ccsinfo command with --json] B --> C{Server URL set?} C -- No --> D[Local Claude Code files] C -- Yes --> E[ccsinfo FastAPI server] E --> D D --> F[Structured JSON] E --> F F --> G[jq, Python, CI, dashboards, other tools] ``` ```bash ccsinfo serve --host 127.0.0.1 --port 8080 export CCSINFO_SERVER_URL=http://127.0.0.1:8080 ccsinfo sessions list --json ccsinfo stats global --json ``` > **Note:** Most list and search commands default to `50` results. `stats daily` defaults to `30` days. If you call the HTTP API directly, `limit` is capped at `500` and `days` at `365`. ## Local Data Sources In local mode, `ccsinfo` reads from the Claude Code data directory under your home folder. ```8:20:src/ccsinfo/utils/paths.py def get_claude_base_dir() -> Path: """Get the base Claude Code directory (~/.claude).""" return Path.home() / ".claude" def get_projects_dir() -> Path: """Get the projects directory (~/.claude/projects).""" return get_claude_base_dir() / "projects" def get_tasks_dir() -> Path: """Get the tasks directory (~/.claude/tasks).""" return get_claude_base_dir() / "tasks" ``` That resolves to a layout like this: ```text ~/.claude/ projects/ / .jsonl .history.jsonl tasks/ / .json ``` - Session transcripts come from `~/.claude/projects//*.jsonl` - Prompt history comes from `~/.claude/projects//.history.jsonl` - Tasks come from `~/.claude/tasks//*.json` > **Tip:** Use the `id` values returned by `projects list --json` instead of trying to build project IDs yourself. Claude's project path encoding is lossy, so the returned ID is the safest thing to feed back into later commands. ## Commands That Support `--json` All of the major command families expose JSON output. | Area | JSON-enabled commands | Useful flags | Typical data | | --- | --- | --- | --- | | Sessions | `sessions list`, `sessions show`, `sessions messages`, `sessions tools`, `sessions active` | `--project`, `--active`, `--limit`, `--role` | session summaries, session details, message objects, tool-call objects | | Projects | `projects list`, `projects show`, `projects stats` | none beyond `--json` | project objects and per-project stats | | Tasks | `tasks list`, `tasks show`, `tasks pending` | `--session`, `--status` | task objects and filtered task lists | | Stats | `stats global`, `stats daily`, `stats trends` | `--days` | totals, day-by-day activity, trend summaries | | Search | `search sessions`, `search messages`, `search history` | `--limit` | matching sessions, message snippets, prompt history hits | Representative commands: ```bash ccsinfo sessions list --active --json ccsinfo sessions messages --role user --json ccsinfo projects stats --json ccsinfo tasks show --session --json ccsinfo stats daily --days 14 --json ccsinfo search messages "timeout" --limit 10 --json ``` ## What Each JSON Command Returns - `sessions list`, `sessions active`, and `search sessions` return session summary objects with `id`, `project_path`, `project_name`, `created_at`, `updated_at`, `message_count`, and `is_active`. - `sessions show` returns the same core session metadata plus `file_path`. - `sessions messages` returns message objects with `uuid`, `parent_message_uuid`, `timestamp`, `type`, and nested `message.content` blocks. - `sessions tools` returns normalized tool-call objects with just `id`, `name`, and `input`, which is usually easier to automate against than parsing message content yourself. - `projects list` and `projects show` return `id`, `name`, `path`, `session_count`, and `last_activity`. - `projects stats` returns `project_id`, `project_name`, `session_count`, `message_count`, and `last_activity`. - `tasks list`, `tasks show`, and `tasks pending` return task objects with `id`, `subject`, `description`, `status`, `owner`, `blocked_by`, `blocks`, `active_form`, `metadata`, and `created_at`. - `stats global` returns `total_projects`, `total_sessions`, `total_messages`, and `total_tool_calls`. - `stats daily` returns a list of `{date, session_count, message_count}` objects. - `stats trends` returns `sessions_last_7_days`, `sessions_last_30_days`, `messages_last_7_days`, `messages_last_30_days`, `most_active_projects`, `most_used_tools`, and `average_session_length`. - `search messages` returns `session_id`, `project_path`, `message_uuid`, `message_type`, `timestamp`, and a matched `snippet`. - `search history` returns `project_path`, `prompt`, `session_id`, and `timestamp`. > **Note:** `search sessions` is broader than its name suggests. It searches session ID, slug, working directory, git branch, and project path, so it is often the fastest way to find a session before following up with `sessions show` or `sessions messages`. > **Note:** `tasks show` requires `--session`, because task IDs are only unique within a session. ## Shell Automation For shell pipelines, `--json` works especially well with `jq`. ```bash # List active session IDs ccsinfo sessions active --json | jq -r '.[].id' # Build a compact 14-day activity report ccsinfo stats daily --days 14 --json \ | jq '[.[] | {date, sessions: .session_count, messages: .message_count}]' # Fetch stats for the first project in the list PROJECT_ID="$(ccsinfo projects list --json | jq -r '.[0].id')" ccsinfo projects stats "$PROJECT_ID" --json \ | jq '{project_name, session_count, message_count}' ``` If you are scripting in Python, you can call the CLI directly and decode the JSON result: ```python import json import subprocess result = subprocess.run( ["ccsinfo", "search", "history", "automation", "--json"], check=True, capture_output=True, text=True, ) entries = json.loads(result.stdout) for entry in entries: print(entry["session_id"], entry["timestamp"]) ``` > **Tip:** For tool usage automation, prefer `sessions tools --json` over scanning raw session JSONL files yourself. It gives you a clean list of `{id, name, input}` objects. ## Using the HTTP API Directly If you do not want to shell out to the CLI, `ccsinfo serve` exposes a FastAPI application. The CLI's remote mode is a thin HTTP client on top of that API. ```88:106:src/ccsinfo/core/client.py def get_global_stats(self) -> dict[str, Any]: return self._get_dict("/stats") def get_daily_stats(self, days: int = 30) -> list[dict[str, Any]]: return self._get_list("/stats/daily", {"days": days}) def search_sessions(self, query: str, limit: int = 50) -> list[dict[str, Any]]: return self._get_list("/search", {"q": query, "limit": limit}) def search_messages(self, query: str, limit: int = 50) -> list[dict[str, Any]]: return self._get_list("/search/messages", {"q": query, "limit": limit}) def search_history(self, query: str, limit: int = 50) -> list[dict[str, Any]]: return self._get_list("/search/history", {"q": query, "limit": limit}) ``` > **Note:** The HTTP paths are close to the CLI names, but not identical. `stats global` maps to `GET /stats`, and `search sessions` maps to `GET /search`. Common mappings: | CLI command | HTTP endpoint | | --- | --- | | `ccsinfo sessions list --json` | `GET /sessions` | | `ccsinfo sessions active --json` | `GET /sessions/active` | | `ccsinfo sessions show --json` | `GET /sessions/{id}` | | `ccsinfo sessions messages --json` | `GET /sessions/{id}/messages` | | `ccsinfo sessions tools --json` | `GET /sessions/{id}/tools` | | `ccsinfo projects list --json` | `GET /projects` | | `ccsinfo projects stats --json` | `GET /projects/{id}/stats` | | `ccsinfo tasks list --json` | `GET /tasks` | | `ccsinfo tasks show --session --json` | `GET /tasks/{id}?session_id=` | | `ccsinfo stats global --json` | `GET /stats` | | `ccsinfo stats daily --days 30 --json` | `GET /stats/daily?days=30` | | `ccsinfo stats trends --json` | `GET /stats/trends` | | `ccsinfo search sessions "foo" --json` | `GET /search?q=foo` | | `ccsinfo search messages "foo" --json` | `GET /search/messages?q=foo` | | `ccsinfo search history "foo" --json` | `GET /search/history?q=foo` | A few direct `curl` examples: ```bash curl http://127.0.0.1:8080/health curl http://127.0.0.1:8080/info curl "http://127.0.0.1:8080/sessions?active_only=true&limit=10" curl "http://127.0.0.1:8080/search/messages?q=timeout&limit=10" ``` The server also exposes a few JSON endpoints that are useful for dashboards or services even though they are not first-class CLI commands: - `GET /sessions/{session_id}/tasks` - `GET /sessions/{session_id}/progress` - `GET /sessions/{session_id}/summary` - `GET /projects/{project_id}/sessions` - `GET /projects/{project_id}/sessions/active` - `GET /health` - `GET /info` ## Caveats for Automation > **Warning:** `ccsinfo sessions messages --json` and `ccsinfo sessions tools --json` are not strict JSON when a session exists but has no matching data. In that case the CLI prints a human message instead of `[]`. If you need guaranteed JSON for empty results, call the HTTP API directly. > **Tip:** Treat `show` commands as assertions in scripts. `sessions show`, `projects show`, `projects stats`, and `tasks show` exit with a non-zero status when the requested item does not exist. > **Note:** `search messages --json` returns snippets around the match, not the full message body. If you need the complete stored message content, follow up with `sessions messages --json`. > **Note:** `stats daily --json` returns only dates that had activity. If you need a gap-free time series, fill missing dates in your own script. > **Note:** In `stats trends --json`, `most_used_tools` counts whether a tool appeared in a session, not the raw number of tool invocations. > **Note:** `is_active` is computed on the machine that reads the data. In local mode that is your machine; in server mode it is the server host. ## Related Pages - [API Overview](api-overview.html) - [Searching Sessions, Messages, and History](search-guide.html) - [Using Statistics and Trends](statistics-guide.html) - [Configuration](configuration.html) - [Quickstart: Remote Server Mode](remote-server-quickstart.html) --- Source: api-overview.md # API Overview `ccsinfo` includes a read-only REST API for browsing Claude Code sessions, projects, tasks, search results, and usage statistics over HTTP. It sits on top of the same parser and service layer that powers the local CLI, so the data exposed by the API is the same data the CLI reads directly in local mode. > **Note:** Every route in the current implementation is a `GET` endpoint. The API is designed for inspection, reporting, and integrations, not for mutating data. ## Running The Server Use the built-in `serve` command to start the FastAPI app. The CLI can also switch into remote mode with `--server-url` or the `CCSINFO_SERVER_URL` environment variable. ```27:59:src/ccsinfo/cli/main.py @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) # ... @app.callback() def main_callback( # ... server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ) -> None: """Claude Code Session Info CLI.""" state.server_url = server_url ``` By default, the server listens on `127.0.0.1:8080`, and the routes are mounted directly at the root of that address. There is no extra prefix such as `/api/v1`. > **Warning:** If you bind the server to `0.0.0.0` for network access, remember that responses include local path data such as `project_path` and, on session detail responses, `file_path`. The route groups are registered directly on the FastAPI app: ```8:20:src/ccsinfo/server/app.py app = FastAPI( title="ccsinfo", description="Claude Code Session Info API", version=__version__, ) # Include routers app.include_router(sessions.router, prefix="/sessions", tags=["sessions"]) app.include_router(projects.router, prefix="/projects", tags=["projects"]) app.include_router(tasks.router, prefix="/tasks", tags=["tasks"]) app.include_router(stats.router, prefix="/stats", tags=["stats"]) app.include_router(search.router, prefix="/search", tags=["search"]) app.include_router(health.router, tags=["health"]) ``` Because the app uses the standard `FastAPI(...)` setup, a running server also exposes FastAPI's generated schema and interactive API documentation. ## Endpoint Groups ### Sessions | Route | Returns | Notes | | --- | --- | --- | | `GET /sessions` | list of `SessionSummary` | Optional `project_id`, `active_only`, and `limit`. Sorted by most recent update. | | `GET /sessions/active` | list of `SessionSummary` | All currently active sessions. | | `GET /sessions/{session_id}` | `Session` | Full session detail, including `file_path`. | | `GET /sessions/{session_id}/messages` | list of message objects | Optional `role` and `limit`. Returns parsed `user` and `assistant` conversation entries. | | `GET /sessions/{session_id}/tools` | list of tool call objects | Each item contains `id`, `name`, and `input`. | | `GET /sessions/{session_id}/tasks` | list of task objects | Tasks associated with the session. | | `GET /sessions/{session_id}/progress` | progress object | Includes `session_id`, `is_active`, `last_activity`, `message_count`, and `active_tasks`. | | `GET /sessions/{session_id}/summary` | summary object | Lightweight session summary without the full session detail payload. | ### Projects | Route | Returns | Notes | | --- | --- | --- | | `GET /projects` | list of `Project` | All projects, sorted by last activity. | | `GET /projects/{project_id}` | `Project` | One project by encoded project ID. | | `GET /projects/{project_id}/sessions` | list of `SessionSummary` | Project-scoped session list with optional `limit`. | | `GET /projects/{project_id}/sessions/active` | list of `SessionSummary` | Active sessions for one project. | | `GET /projects/{project_id}/stats` | `ProjectStats` | Per-project totals and last activity. | ### Tasks | Route | Returns | Notes | | --- | --- | --- | | `GET /tasks` | list of `Task` | Optional `session_id` and `status` filters. | | `GET /tasks/pending` | list of `Task` | Pending tasks across all sessions. | | `GET /tasks/{task_id}` | `Task` | Requires `session_id` as a query parameter. | ### Stats | Route | Returns | Notes | | --- | --- | --- | | `GET /stats` | `GlobalStats` | Totals across all projects and sessions. | | `GET /stats/daily` | list of `DailyStats` | Daily activity window controlled by `days`. | | `GET /stats/trends` | trend object | Last 7/30 day counts, most active projects, most used tools, average session length. | ### Search | Route | Returns | Notes | | --- | --- | --- | | `GET /search` | list of `SessionSummary` | Searches session ID, slug, working directory, git branch, and project path. | | `GET /search/messages` | list of match objects | Full-text search over user and assistant message text, with snippets. | | `GET /search/history` | list of match objects | Searches saved prompt history, returning `prompt`, `session_id`, `project_path`, and `timestamp`. | ### Health | Route | Returns | Notes | | --- | --- | --- | | `GET /health` | health object | Simple health check with `status`. | | `GET /info` | info object | Server version plus total project and session counts. | > **Note:** Detail routes use standard FastAPI-style error payloads. Missing sessions, projects, and tasks return `404` with a `detail` message, and an invalid task `status` filter returns `400`. ## Shared Models Most list and detail routes reuse a small set of Pydantic models, which makes the API predictable across resource groups. | Model | Used by | Key fields | | --- | --- | --- | | `SessionSummary` | session lists, project session lists, session search | `id`, `project_path`, `project_name`, `created_at`, `updated_at`, `message_count`, `is_active` | | `Session` | session detail | all `SessionSummary` fields plus `file_path` | | `Project` | project list and project detail | `id`, `name`, `path`, `session_count`, `last_activity` | | `Task` | task endpoints and `active_tasks` in session progress | identifier, subject, description, status, owner, blockers, blocked tasks, active form, metadata | | `GlobalStats` | `GET /stats` | `total_sessions`, `total_projects`, `total_messages`, `total_tool_calls` | | `DailyStats` | `GET /stats/daily` | `date`, `session_count`, `message_count` | | `ProjectStats` | `GET /projects/{project_id}/stats` | `project_id`, `project_name`, `session_count`, `message_count`, `last_activity` | A few routes return endpoint-specific objects rather than one of the shared models above. That includes message payloads, tool-call lists, progress objects, search snippet results, trend results, and the health/info routes. Timestamps are serialized as JSON date/time strings, and task status values are plain strings: `pending`, `in_progress`, and `completed`. > **Tip:** If you need exact wire-level field names for code generation, use the generated schema from your running server. That is especially useful for endpoints that return plain JSON objects instead of a named shared model. ## Common Query Parameters ### Project IDs `project_id` is not a random database key. It is an encoded version of the project path. ```23:44:src/ccsinfo/utils/paths.py def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' Example: '/home/user/project' -> '-home-user-project' """ return project_path.replace("/", "-").replace(".", "-") def decode_project_path(encoded_path: str) -> str: """Decode a Claude Code directory name back to the original path. Note: This is lossy - we cannot distinguish between original '-' and encoded '/' or '.'. """ # Handle the pattern where /. becomes -- result = encoded_path.replace("--", "/.") result = result.replace("-", "/") return result ``` > **Warning:** Treat `project_id` as an opaque identifier in API clients. The decode step is intentionally lossy, so it is not a guaranteed round-trip filesystem path. ### Task IDs Task IDs are only unique inside a session, so the task detail endpoint needs both a path parameter and a query parameter: ```35:44:src/ccsinfo/server/routers/tasks.py @router.get("/{task_id}", response_model=Task) async def get_task( task_id: str, session_id: str = Query(..., description="Session ID (required since task IDs are only unique within a session)"), ) -> Task: """Get task details.""" task = task_service.get_task(task_id, session_id=session_id) if not task: raise HTTPException(status_code=404, detail="Task not found") return task ``` ### Parameter Reference | Parameter | Where it appears | Default and limits | What it does | | --- | --- | --- | --- | | `limit` | `GET /sessions`, `GET /projects/{project_id}/sessions`, `GET /sessions/{session_id}/messages`, all search routes | usually `50`; `100` on session messages; `1..500` where enforced | Caps the number of returned rows. | | `project_id` | `GET /sessions` | optional | Filters the session list to one project. | | `active_only` | `GET /sessions` | `false` | Returns only active sessions. | | `role` | `GET /sessions/{session_id}/messages` | optional | Filters to `user` or `assistant` messages. | | `status` | `GET /tasks` | optional | Filters to `pending`, `in_progress`, or `completed`. Invalid values return `400`. | | `session_id` | `GET /tasks/{task_id}` | required | Disambiguates task IDs. | | `days` | `GET /stats/daily` | `30`, range `1..365` | Controls the daily stats window. | | `q` | all search routes | required | The search string. | FastAPI validation also enforces required parameters and numeric bounds automatically, so missing values such as `q` on search routes or out-of-range values such as `days=0` return validation errors before the request reaches the service layer. ## CLI And HTTP Without `--server-url`, the CLI reads local data directly. With `--server-url`, it becomes a thin HTTP client over the REST API. ```27:37:src/ccsinfo/core/client.py def list_sessions( self, project_id: str | None = None, active_only: bool = False, limit: int = 50, ) -> list[dict[str, Any]]: params: dict[str, Any] = {"limit": limit, "active_only": active_only} if project_id: params["project_id"] = project_id return self._get_list("/sessions", params) ``` ```mermaid sequenceDiagram participant User participant CLI as ccsinfo CLI participant Client as CCSInfoClient participant API as FastAPI server participant Services as service layer participant Data as Claude Code local data User->>CLI: run a command with --server-url CLI->>Client: call a client method Client->>API: GET /resource API->>Services: resolve request Services->>Data: parse local data Data-->>Services: structured records Services-->>API: models and JSON-ready objects API-->>Client: JSON response Client-->>CLI: Python dicts CLI-->>User: table or JSON output ``` ### Command Mapping | CLI command | Remote HTTP route | | --- | --- | | `ccsinfo sessions list` | `GET /sessions` | | `ccsinfo sessions list --project ` | `GET /sessions` with `project_id` | | `ccsinfo sessions list --active` | `GET /sessions` with `active_only=true` | | `ccsinfo sessions active` | `GET /sessions/active` | | `ccsinfo sessions show ` | `GET /sessions/{session_id}` | | `ccsinfo sessions messages ` | `GET /sessions/{session_id}/messages` | | `ccsinfo sessions tools ` | `GET /sessions/{session_id}/tools` | | `ccsinfo projects list` | `GET /projects` | | `ccsinfo projects show ` | `GET /projects/{project_id}` | | `ccsinfo projects stats ` | `GET /projects/{project_id}/stats` | | `ccsinfo tasks list` | `GET /tasks` | | `ccsinfo tasks show --session ` | `GET /tasks/{task_id}` with `session_id` | | `ccsinfo tasks pending` | `GET /tasks/pending` | | `ccsinfo stats global` | `GET /stats` | | `ccsinfo stats daily` | `GET /stats/daily` | | `ccsinfo stats trends` | `GET /stats/trends` | | `ccsinfo search sessions ` | `GET /search` | | `ccsinfo search messages ` | `GET /search/messages` | | `ccsinfo search history ` | `GET /search/history` | The bundled CLI does not currently call every server route directly. The most notable API-only helpers are `GET /sessions/{session_id}/tasks`, `GET /sessions/{session_id}/progress`, `GET /sessions/{session_id}/summary`, `GET /projects/{project_id}/sessions`, `GET /projects/{project_id}/sessions/active`, `GET /health`, and `GET /info`. > **Tip:** If you are building an integration, call the REST API directly for helper endpoints such as session progress and project-scoped session listings. The CLI focuses on common workflows and does not wrap every available route. > **Note:** The CLI is not always a byte-for-byte passthrough of HTTP defaults. For example, the HTTP route for session messages defaults to `limit=100`, while the CLI command defaults to `--limit 50` unless you override it. ## Behavior Notes - `is_active` is computed from currently running Claude processes, so it reflects live activity instead of a field stored statically in session data. - Session and project listings are sorted by most recent activity. - `GET /search` looks at session metadata such as session ID, slug, working directory, git branch, and project path. - `GET /search/messages` searches the text content of `user` and `assistant` messages and returns short snippets around the match. - `GET /sessions/{session_id}/messages` exposes parsed conversation entries for `user` and `assistant`; it is the conversational view, while `GET /sessions/{session_id}/tools` is the cleaner endpoint for tool inventory. - `GET /stats/daily` buckets activity by each session's first timestamp, not by every day a session may have remained active. - In `GET /stats/trends`, `most_used_tools` counts how many sessions used a tool at least once, while `GET /stats` reports raw `total_tool_calls` across all sessions. ## Related Pages - [Sessions API](api-sessions.html) - [Projects API](api-projects.html) - [Tasks API](api-tasks.html) - [Search API](api-search.html) - [Stats and Health API](api-stats-and-health.html) --- Source: api-sessions.md # Sessions API The Sessions API exposes Claude Code session history under the `/sessions` prefix. All endpoints on this page are read-only `GET` routes. Use them to list sessions across projects, see which sessions are active, inspect one session, retrieve normalized messages, flatten tool calls, read session-scoped task files, and fetch compact progress or summary views. ## Starting the API `ccsinfo` mounts the sessions router at `/sessions`, and the CLI starts Uvicorn on `127.0.0.1:8080` by default. ```8:19:src/ccsinfo/server/app.py app = FastAPI( title="ccsinfo", description="Claude Code Session Info API", version=__version__, ) # Include routers app.include_router(sessions.router, prefix="/sessions", tags=["sessions"]) app.include_router(projects.router, prefix="/projects", tags=["projects"]) app.include_router(tasks.router, prefix="/tasks", tags=["tasks"]) app.include_router(stats.router, prefix="/stats", tags=["stats"]) app.include_router(search.router, prefix="/search", tags=["search"]) ``` ```27:33:src/ccsinfo/cli/main.py @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` If you use those defaults, the API base URL is `http://127.0.0.1:8080`. ## Quick Reference | Endpoint | Use it for | Returns | | --- | --- | --- | | `GET /sessions` | List sessions, optionally filtered | Array of session summary objects | | `GET /sessions/active` | List active sessions across all projects | Array of active session summary objects | | `GET /sessions/{session_id}` | Inspect one session's metadata | One session detail object | | `GET /sessions/{session_id}/messages` | Read normalized conversation messages | Array of message objects | | `GET /sessions/{session_id}/tools` | Read flattened tool calls | Array of tool-call objects | | `GET /sessions/{session_id}/tasks` | Read Claude Code tasks for the session | Array of task objects | | `GET /sessions/{session_id}/progress` | Get a combined session/task progress view | One progress object | | `GET /sessions/{session_id}/summary` | Get a compact single-session summary | One session summary object | ## How The Data Is Built The Sessions API is file-backed. Session endpoints read Claude Code JSONL files from `~/.claude/projects`, and session task data comes from JSON files under `~/.claude/tasks`. There is no separate database layer behind these routes. ```mermaid flowchart LR Client["Client or script"] --> API["FastAPI `/sessions` router"] API --> SessionSvc["Session service"] API --> TaskSvc["Task service"] SessionSvc --> SessionFiles["`~/.claude/projects//.jsonl`"] TaskSvc --> TaskFiles["`~/.claude/tasks//*.json`"] SessionSvc --> ActiveCheck["Live `claude` process scan"] ``` The test fixtures are a good example of the on-disk input that the API parses before turning it into HTTP responses: ```25:62:tests/conftest.py @pytest.fixture def sample_session_data() -> list[dict[str, Any]]: """Sample session JSONL data.""" return [ { "type": "user", "uuid": "msg-001", "message": { "role": "user", "content": [{"type": "text", "text": "Hello"}], }, "timestamp": "2024-01-15T10:00:00Z", }, { "type": "assistant", "uuid": "msg-002", "parentMessageUuid": "msg-001", "message": { "role": "assistant", "content": [{"type": "text", "text": "Hi there!"}], }, "timestamp": "2024-01-15T10:00:01Z", }, ] @pytest.fixture def sample_task_data() -> dict[str, Any]: """Sample task JSON data.""" return { "id": "1", "subject": "Test task", "description": "A test task", "status": "pending", "owner": None, "blockedBy": [], "blocks": [], } ``` > **Note:** The HTTP API normalizes parsed records into snake_case fields such as `parent_message_uuid`, `blocked_by`, and `active_form`. > **Warning:** Treat `project_id` as an opaque value from `GET /projects`. The path encoding is lossy, so constructing or reverse-engineering IDs from a filesystem path can be unreliable. ```23:44:src/ccsinfo/utils/paths.py def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' Example: '/home/user/project' -> '-home-user-project' """ return project_path.replace("/", "-").replace(".", "-") def decode_project_path(encoded_path: str) -> str: """Decode a Claude Code directory name back to the original path. Note: This is lossy - we cannot distinguish between original '-' and encoded '/' or '.'. The path returned should be treated as approximate. """ # Handle the pattern where /. becomes -- result = encoded_path.replace("--", "/.") result = result.replace("-", "/") return result ``` > **Note:** Active status is computed from live `claude` processes and `/proc` inspection, not stored in the session file. The active-session lookup is cached for 5 seconds. ## Built-In Client Example The repository already contains a small HTTP client that calls the core session routes: ```27:57:src/ccsinfo/core/client.py # Sessions def list_sessions( self, project_id: str | None = None, active_only: bool = False, limit: int = 50, ) -> list[dict[str, Any]]: params: dict[str, Any] = {"limit": limit, "active_only": active_only} if project_id: params["project_id"] = project_id return self._get_list("/sessions", params) def get_session(self, session_id: str) -> dict[str, Any]: return self._get_dict(f"/sessions/{session_id}") def get_session_messages( self, session_id: str, role: str | None = None, limit: int = 100, ) -> list[dict[str, Any]]: params: dict[str, Any] = {"limit": limit} if role: params["role"] = role return self._get_list(f"/sessions/{session_id}/messages", params) def get_session_tools(self, session_id: str) -> list[dict[str, Any]]: return self._get_list(f"/sessions/{session_id}/tools") def get_active_sessions(self) -> list[dict[str, Any]]: return self._get_list("/sessions/active") ``` > **Note:** The bundled `CCSInfoClient` covers listing, detail, messages, tools, and active sessions. If you need session `tasks`, `progress`, or `summary`, call those HTTP routes directly. ## Endpoint Reference ### `GET /sessions` Lists sessions across all projects. - Query `project_id` filters to a single project. Use the `id` field from `GET /projects`. - Query `active_only` defaults to `false`. - Query `limit` defaults to `50` and accepts values from `1` to `500`. - Results are returned most-recent-first, sorted by `updated_at`. - The route supports a limit, but not offset or cursor pagination. - Each item uses the session summary shape described below. > **Tip:** If you want active sessions for one project, use `GET /sessions?project_id=&active_only=true`. ### `GET /sessions/active` Lists only sessions that are currently active. - Returns the same summary shape as `GET /sessions`. - Results are returned most-recent-first. - This route does not expose `project_id` or `limit`. - Use `GET /sessions` instead when you want active sessions plus a project filter or a bounded result set. ### `GET /sessions/{session_id}` Returns full metadata for one session. - Includes `file_path`, which is not present in the list and summary responses. - This is metadata-only detail. Conversation content lives under `GET /sessions/{session_id}/messages`. - Returns `404` with `{"detail":"Session not found"}` when the session does not exist. ### `GET /sessions/{session_id}/messages` Returns normalized conversation messages for one session. - Query `role` is optional. The useful values are `user` and `assistant`, because those are the entry types the extractor keeps. - Query `limit` defaults to `100` and accepts values from `1` to `500`. - The route supports a limit, but not offset or cursor pagination. - Each message includes top-level metadata plus a normalized `message` object when content blocks are present. - When a message contains both text and tool-use blocks, they are preserved inside `message.content`. - Returns `404` with `{"detail":"Session not found"}` when the session does not exist. ### `GET /sessions/{session_id}/tools` Returns a flat list of tool calls found in the session. - Each item contains `id`, `name`, and `input`. - The list is flattened across the whole session rather than grouped by message. - Use this endpoint when you care about tool usage and payloads more than full conversation context. - Returns `404` with `{"detail":"Session not found"}` when the session does not exist. ### `GET /sessions/{session_id}/tasks` Returns Claude Code task records associated with the session. - Data comes from `~/.claude/tasks//*.json`. - Items use the task shape described below, including `status`, `blocked_by`, `blocks`, and `active_form`. - This route delegates straight to task parsing rather than performing the upfront session lookup used by the other session-detail routes. > **Tip:** If you need to verify the session itself exists before reading tasks, call `GET /sessions/{session_id}` first. ### `GET /sessions/{session_id}/progress` Returns a small combined view of session activity and task progress. - The response object contains `session_id`, `is_active`, `last_activity`, `message_count`, and `active_tasks`. - `active_tasks` includes only tasks whose `status` is `in_progress`. - `last_activity` is the session's `updated_at` value serialized to ISO 8601. - `active_tasks` reuses the same task object shape as `GET /sessions/{session_id}/tasks`. - Returns `404` with `{"detail":"Session not found"}` when the session does not exist. ### `GET /sessions/{session_id}/summary` Returns a compact single-session summary. - The shape matches the list items returned by `GET /sessions`. - It omits `file_path`. - Use it when you already know the session ID and only want the lightweight metadata fields. - Returns `404` with `{"detail":"Session not found"}` when the session does not exist. ## Response Shapes ### Session Summary Object Used by `GET /sessions`, `GET /sessions/active`, and `GET /sessions/{session_id}/summary`. | Field | Type | Meaning | | --- | --- | --- | | `id` | string | Session identifier. In practice this is the session file stem and is typically UUID-like. | | `project_path` | string | Approximate decoded project path. | | `project_name` | string | Last path component of `project_path`. | | `created_at` | string or `null` | First parseable timestamp in the session file. | | `updated_at` | string or `null` | Last parseable timestamp in the session file. | | `message_count` | integer | Count of `user` and `assistant` entries in the session. | | `is_active` | boolean | Live active-session flag. | ### Session Detail Object `GET /sessions/{session_id}` returns the session summary fields plus: | Field | Type | Meaning | | --- | --- | --- | | `file_path` | string or `null` | Full path to the backing JSONL session file. | ### Message Object Used by `GET /sessions/{session_id}/messages`. | Field | Type | Meaning | | --- | --- | --- | | `uuid` | string | Message identifier. | | `parent_message_uuid` | string or `null` | Parent message link when present. | | `timestamp` | string or `null` | Parsed message timestamp. | | `type` | `user` or `assistant` | Message type. | | `message` | object or `null` | Normalized message body. | When `message` is present, it contains: | Nested field | Type | Meaning | | --- | --- | --- | | `message.role` | `user` or `assistant` | Sender role. | | `message.content` | array | Typed content blocks. | Content block types used by the session message extractor: | Block type | Fields | | --- | --- | | `text` | `text` | | `tool_use` | `id`, `name`, `input` | ### Tool Call Object Used by `GET /sessions/{session_id}/tools`. | Field | Type | Meaning | | --- | --- | --- | | `id` | string | Tool call identifier. | | `name` | string | Tool name. | | `input` | object | Tool input payload. | ### Task Object Used by `GET /sessions/{session_id}/tasks` and inside `active_tasks` on the progress response. | Field | Type | Meaning | | --- | --- | --- | | `id` | string | Task identifier within the session. | | `subject` | string | Short task title. | | `description` | string | Longer task description. | | `status` | `pending`, `in_progress`, or `completed` | Current task status. | | `owner` | string or `null` | Task owner if present. | | `blocked_by` | array of strings | Task IDs blocking this task. | | `blocks` | array of strings | Task IDs this task blocks. | | `active_form` | string or `null` | Verb-style wording for the task when present. | | `metadata` | object | Arbitrary task metadata. | | `created_at` | string or `null` | Creation time if available in the parsed model. | ### Progress Object Used by `GET /sessions/{session_id}/progress`. | Field | Type | Meaning | | --- | --- | --- | | `session_id` | string | Requested session ID. | | `is_active` | boolean | Same active-session flag used elsewhere. | | `last_activity` | string or `null` | ISO 8601 version of the session's `updated_at`. | | `message_count` | integer | Same user-plus-assistant message count used on the session object. | | `active_tasks` | array of task objects | Only tasks whose status is `in_progress`. | ## Practical Behavior Notes - Session listings are file-backed and reflect what is currently on disk under `~/.claude`. - `message_count` is not a raw line count of the JSONL file. It counts only `user` and `assistant` entries. - `GET /sessions/{session_id}/tools` is intentionally flattened. It does not include surrounding assistant text or per-message grouping. - `GET /sessions/{session_id}/summary` is the lightest single-session metadata route when `file_path` is not needed. ## Related Pages - [Working with Sessions](sessions-guide.html) - [API Overview](api-overview.html) - [Projects API](api-projects.html) - [Tasks API](api-tasks.html) - [Active Session Detection](active-session-detection.html) --- Source: api-projects.md # Projects API The Projects API is the read-only part of `ccsinfo` that groups Claude Code sessions by project. It lets you discover all available projects, inspect one project, list its sessions, see which of those sessions are currently active, and fetch per-project totals without loading every session into the client yourself. All routes in this page live under `/projects`. The usual workflow is: 1. Call `GET /projects`. 2. Keep the `id` from the project you want. 3. Reuse that same `id` in the detail, sessions, active sessions, and stats endpoints. > **Tip:** If you are running the built-in API server, it binds to `127.0.0.1:8080` by default. The CLI can also target a remote server with `--server-url` or `CCSINFO_SERVER_URL`. ```27:33:src/ccsinfo/cli/main.py @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` ## At a Glance | Endpoint | Use it for | Query parameters | Returns | | --- | --- | --- | --- | | `GET /projects` | List every known project | None | `Project[]` | | `GET /projects/{project_id}` | Fetch one project | None | `Project` | | `GET /projects/{project_id}/sessions` | List sessions for one project | `limit` (`1` to `500`, default `50`) | `SessionSummary[]` | | `GET /projects/{project_id}/sessions/active` | List currently active sessions for one project | None | `SessionSummary[]` | | `GET /projects/{project_id}/stats` | Get per-project totals | None | `ProjectStats` | The server defines those routes exactly like this: ```15:51:src/ccsinfo/server/routers/projects.py @router.get("", response_model=list[Project]) async def list_projects() -> list[Project]: """List all projects.""" return project_service.list_projects() @router.get("/{project_id}", response_model=Project) async def get_project(project_id: str) -> Project: """Get project details.""" project = project_service.get_project(project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") return project @router.get("/{project_id}/sessions", response_model=list[SessionSummary]) async def get_project_sessions( project_id: str, limit: int = Query(50, ge=1, le=500), ) -> list[SessionSummary]: """Get sessions for a project.""" return session_service.list_sessions(project_id=project_id, limit=limit) @router.get("/{project_id}/sessions/active", response_model=list[SessionSummary]) async def get_project_active_sessions(project_id: str) -> list[SessionSummary]: """Get active sessions for a project.""" return session_service.list_sessions(project_id=project_id, active_only=True) @router.get("/{project_id}/stats", response_model=ProjectStats) async def get_project_stats(project_id: str) -> ProjectStats | dict[str, Any]: """Get project statistics.""" stats = project_service.get_project_stats(project_id) if not stats: raise HTTPException(status_code=404, detail="Project not found") return stats ``` ## Understanding `project_id` `project_id` is not a human slug. It is the encoded directory name Claude Code uses under `~/.claude/projects`. The safest pattern is to read it from `GET /projects` and reuse it as-is. > **Warning:** The decode step is explicitly lossy. Use `path` and `project_path` for display, not as authoritative identifiers. ```23:44:src/ccsinfo/utils/paths.py def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' Example: '/home/user/project' -> '-home-user-project' """ return project_path.replace("/", "-").replace(".", "-") def decode_project_path(encoded_path: str) -> str: """Decode a Claude Code directory name back to the original path. Note: This is lossy - we cannot distinguish between original '-' and encoded '/' or '.'. The path returned should be treated as approximate. """ # Handle the pattern where /. becomes -- result = encoded_path.replace("--", "/.") result = result.replace("-", "/") return result ``` In practice, `/home/user/project` becomes `-home-user-project`. A dotted path such as `/home/user/.config/project` becomes `-home-user--config-project`. ## How the Data Is Assembled The Projects API is file-backed. It reads Claude Code’s project directories and session JSONL files on demand, then builds project and session summaries from that data. Active session views add a live process check on top of the file data. ```mermaid flowchart TD A[API client] --> B[/projects endpoints] B --> C[project_service] B --> D[session_service] C --> E[~/.claude/projects//] D --> E E --> F[session JSONL files] B --> G[/projects/{project_id}/sessions/active] G --> H[active session detector] H --> I[pgrep -f claude and /proc inspection] ``` > **Note:** There is no separate database behind these endpoints. Project and session views come from Claude Code files; active-session status is calculated live. ## Response Objects All JSON keys use snake_case. Timestamp fields serialize as ISO 8601 strings or `null`. ### `Project` | Field | Type | Description | | --- | --- | --- | | `id` | string | Encoded project identifier used in project routes | | `name` | string | Display name derived from the last segment of the decoded path | | `path` | string | Decoded project path for display; treat it as approximate | | `session_count` | integer | Number of session `*.jsonl` files in the project directory | | `last_activity` | string or `null` | Most recent parsed session timestamp for the project | ### `SessionSummary` | Field | Type | Description | | --- | --- | --- | | `id` | string | Session UUID, taken from the session filename | | `project_path` | string | Decoded project path | | `project_name` | string | Display name derived from `project_path` | | `created_at` | string or `null` | First timestamp found in the session | | `updated_at` | string or `null` | Last timestamp found in the session | | `message_count` | integer | Count of parsed `user` and `assistant` messages | | `is_active` | boolean | Whether the session is currently considered active | ### `ProjectStats` | Field | Type | Description | | --- | --- | --- | | `project_id` | string | Encoded project identifier | | `project_name` | string | Display name for the project | | `session_count` | integer | Number of sessions in the project | | `message_count` | integer | Sum of message counts across the project’s sessions | | `last_activity` | string or `null` | Latest timestamp found across the project’s sessions | ## Endpoint Reference ### `GET /projects` Use this to discover valid `project_id` values. Behavior: - Returns every project as `Project[]`. - Results are sorted by `last_activity` descending, so the most recently active projects come first. - There are no query parameters. - This is the best starting point for clients that need to drill into a specific project. ### `GET /projects/{project_id}` Use this when you already know the encoded project ID and want one object back. Behavior: - Returns a single `Project`. - The response shape is the same as each item from `GET /projects`. - It does not include nested sessions or stats. - If the project does not exist, the server returns `404` with `Project not found`. ### `GET /projects/{project_id}/sessions` Use this to list a project’s sessions without loading full session detail. Behavior: - Returns `SessionSummary[]`. - Supports a `limit` query parameter with a default of `50`, a minimum of `1`, and a maximum of `500`. - Results are sorted by `updated_at` descending, so the newest sessions appear first. - The URL uses the encoded `project_id`, but the response uses decoded display fields such as `project_path` and `project_name`. - If nothing matches, the endpoint returns an empty list. > **Note:** `message_count` is based on parsed `user` and `assistant` entries in the session JSONL file, not every raw JSONL record. ### `GET /projects/{project_id}/sessions/active` Use this when you only want sessions that are currently running. Behavior: - Returns `SessionSummary[]` with the same shape as the regular project sessions endpoint. - There are no query parameters. - There is no built-in `limit` on this route. - If no active sessions match the project, the endpoint returns an empty list. > **Note:** Active status is calculated live, not stored in the session file. > **Note:** The active-session lookup is cached for 5 seconds, so sessions may take a moment to appear or disappear. > **Warning:** Active-session detection is best-effort. The implementation looks for running `claude` processes and inspects `/proc`, so results depend on the host environment and permissions. ```252:295:src/ccsinfo/core/parsers/sessions.py try: # Use pgrep to find claude processes result = subprocess.run( ["pgrep", "-f", "claude"], capture_output=True, text=True, timeout=5, ) if result.returncode != 0: _active_sessions_cache = active_ids _active_sessions_cache_time = current_time return active_ids pids = result.stdout.strip().split("\n") # UUID pattern for session IDs uuid_pattern = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE) for pid in pids: if not pid: continue try: cmdline_path = Path(f"/proc/{pid}/cmdline") if cmdline_path.exists(): cmdline = cmdline_path.read_text() # Extract all UUIDs from cmdline active_ids.update(uuid_pattern.findall(cmdline)) # Also check environment variables environ_path = Path(f"/proc/{pid}/environ") if environ_path.exists(): environ = environ_path.read_text() active_ids.update(uuid_pattern.findall(environ)) # Check file descriptors for session UUIDs fd_dir = Path(f"/proc/{pid}/fd") if fd_dir.exists(): for fd_link in fd_dir.iterdir(): try: target = fd_link.resolve() target_str = str(target) # Look for ~/.claude/tasks/{UUID} or ~/.claude/projects/*/{UUID}.jsonl if ".claude/tasks/" in target_str or ".claude/projects/" in target_str: active_ids.update(uuid_pattern.findall(target_str)) ``` ### `GET /projects/{project_id}/stats` Use this when you want per-project totals and recency without aggregating session data in the client. Behavior: - Returns a single `ProjectStats` object. - `session_count` comes from the number of session files in the project. - `message_count` is the sum of parsed session message counts across that project. - `last_activity` is the newest timestamp found across the project’s sessions. - If the project does not exist, the server returns `404` with `Project not found`. > **Tip:** This is the best project endpoint for dashboard cards, summary panels, and overview pages. ## Missing-Project Behavior These endpoints do not all behave the same way when a `project_id` does not match anything. | Endpoint | Missing `project_id` behavior | | --- | --- | | `GET /projects/{project_id}` | `404 Project not found` | | `GET /projects/{project_id}/sessions` | `200 OK` with `[]` | | `GET /projects/{project_id}/sessions/active` | `200 OK` with `[]` | | `GET /projects/{project_id}/stats` | `404 Project not found` | That difference matters if you are building a client. Use the detail or stats endpoints when you want a hard existence check, and use the sessions endpoints when an empty collection is a normal outcome. ## Related Pages - [Working with Projects](projects-guide.html) - [API Overview](api-overview.html) - [Sessions API](api-sessions.html) - [Project IDs and Lookups](project-ids-and-lookups.html) - [Stats and Health API](api-stats-and-health.html) --- Source: api-tasks.md I have enough to draft now. The remaining details are implementation quirks rather than missing endpoint behavior, so I’m switching from repo exploration to writing the page.# Tasks API The Tasks API lets you inspect Claude Code tasks across all sessions or within a single session. The key idea is simple: task IDs are **not globally unique**, so a full task lookup needs both the task `id` and the `session_id` it belongs to. By default, the bundled server starts on `127.0.0.1:8080`, so task endpoints are typically available under `http://127.0.0.1:8080/tasks`. ```27:33:src/ccsinfo/cli/main.py @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` ## How Tasks Relate To Sessions `ccsinfo` reads task JSON files from `~/.claude/tasks//`. Session transcripts live separately under `~/.claude/projects/.../.jsonl`, but both use the same session UUID. That shared `session_id` is what ties the Sessions API and the Tasks API together. ```79:85:src/ccsinfo/core/parsers/tasks.py def get_tasks_directory() -> Path: """Get the Claude Code tasks directory path. Returns: Path to ~/.claude/tasks/ """ return Path.home() / ".claude" / "tasks" ``` ```mermaid flowchart LR S["Session transcript
`~/.claude/projects/.../.jsonl`"] T["Task files
`~/.claude/tasks//*.json`"] L1["`GET /sessions/{session_id}/tasks`"] L2["`GET /tasks?session_id=...`"] P["`GET /tasks/pending`"] D["`GET /tasks/{task_id}?session_id=...`"] S -->|same `session_id`| T T --> L1 T --> L2 T --> P L1 -->|pick a task `id`| D ``` > **Tip:** If you know the session but not the task ID, start with `GET /sessions/{session_id}/tasks`, then call `GET /tasks/{task_id}?session_id=...` for the single task you want. ## Endpoint Summary | Endpoint | What it does | Parameters | | --- | --- | --- | | `GET /tasks` | List tasks across all sessions, or within one session | `session_id` optional, `status` optional | | `GET /tasks/pending` | List all tasks whose status is `pending` | None | | `GET /tasks/{task_id}` | Get one task by ID within a specific session | `session_id` required | | `GET /sessions/{session_id}/tasks` | List tasks for one session | `session_id` path parameter | ## `GET /tasks` Use `GET /tasks` to list tasks globally, or narrow the result set with query parameters. Supported query parameters: - `session_id`: limit the list to one session - `status`: limit the list to one status Accepted status values are: - `pending` - `in_progress` - `completed` The route validates `status` and returns HTTP `400` for anything else. ```11:26:src/ccsinfo/server/routers/tasks.py @router.get("", response_model=list[Task]) async def list_tasks( session_id: str | None = Query(None), status: str | None = Query(None), ) -> list[Task]: """List all tasks.""" status_enum: TaskStatus | None = None if status: try: status_enum = TaskStatus(status) except ValueError as e: raise HTTPException( status_code=400, detail=f"Invalid status: {status}. Valid values: pending, in_progress, completed", ) from e return task_service.list_tasks(session_id=session_id, status=status_enum) ``` ### What to expect - No query parameters: returns tasks from all sessions that have task files. - `session_id` only: returns tasks from that session. - `status` only: returns matching tasks across all sessions. - `session_id` plus `status`: returns matching tasks inside that session only. - If nothing matches, you get an empty array. > **Note:** The API expects the exact lowercase status strings shown above. `pending` works; `Pending` and `PENDING` do not. > **Note:** Task endpoints do not expose pagination, `limit`, or server-side sort parameters. ## `GET /tasks/pending` `GET /tasks/pending` is the convenience endpoint for “show me every pending task across all sessions.” In the service layer, it is just a status filter: ```81:87:src/ccsinfo/core/services/task_service.py def get_pending_tasks(self) -> list[Task]: """Get all pending tasks across all sessions. Returns: List of pending tasks. """ return self.list_tasks(status=TaskStatus.PENDING) ``` That means it is functionally equivalent to `GET /tasks?status=pending`. > **Warning:** “Pending” does **not** mean “ready to work.” Blocked tasks are still included here. If you need a ready queue, filter the results client-side so that `blocked_by` or `blockedBy` is empty. The parser makes this distinction explicit: ```70:76:src/ccsinfo/core/parsers/tasks.py def get_blocked_tasks(self) -> list[Task]: """Get all tasks that are blocked by other tasks.""" return [t for t in self.tasks if t.blocked_by] def get_ready_tasks(self) -> list[Task]: """Get all pending tasks that are not blocked.""" return [t for t in self.tasks if t.status == "pending" and not t.blocked_by] ``` ## `GET /sessions/{session_id}/tasks` This is the most practical session-scoped listing endpoint. Use it when you already know the session and want to see all of its tasks before drilling into one. ```61:65:src/ccsinfo/server/routers/sessions.py @router.get("/{session_id}/tasks") async def get_session_tasks(session_id: str) -> list[dict[str, Any]]: """Get tasks for a session.""" tasks = task_service.get_session_tasks(session_id) return [t.model_dump(mode="json") for t in tasks] ``` ### When to use it - You already have a session ID from the Sessions API. - You want to discover task IDs within that session. - You want the full task list for one session without using query parameters. ### Behavior details - If the session has no task directory, this endpoint returns an empty array. - It does not add additional filtering for status. - It is the safest first step before calling the single-task detail endpoint. ## `GET /tasks/{task_id}` Use this endpoint when you need one specific task. The important detail is that `session_id` is required as a query parameter: ```35:44:src/ccsinfo/server/routers/tasks.py @router.get("/{task_id}", response_model=Task) async def get_task( task_id: str, session_id: str = Query(..., description="Session ID (required since task IDs are only unique within a session)"), ) -> Task: """Get task details.""" task = task_service.get_task(task_id, session_id=session_id) if not task: raise HTTPException(status_code=404, detail="Task not found") return task ``` ### Why the extra `session_id` matters Task IDs are only guaranteed to be unique **inside a session**. A task with ID `1` can exist in more than one session, so the API requires the session context to resolve the correct task. ### Recommended lookup flow 1. Get or identify the session ID. 2. Call `GET /sessions/{session_id}/tasks`. 3. Take the task `id` you want from that list. 4. Call `GET /tasks/{task_id}?session_id={session_id}`. > **Warning:** If you omit the session context in your own tooling or UI flow, you can easily point to the wrong task or fail to resolve the task at all. ## Task Shape The task model exposes these core fields: ```14:34:src/ccsinfo/core/models/tasks.py class TaskStatus(StrEnum): """Task status enum.""" PENDING = "pending" IN_PROGRESS = "in_progress" COMPLETED = "completed" class Task(BaseORJSONModel): """A Claude Code task from ~/.claude/tasks//*.json.""" id: str subject: str description: str = "" status: TaskStatus = TaskStatus.PENDING owner: str | None = None blocked_by: list[str] = Field(default_factory=list, alias="blockedBy") blocks: list[str] = Field(default_factory=list) active_form: str | None = Field(default=None, alias="activeForm") metadata: dict[str, Any] = Field(default_factory=dict) created_at: datetime | None = None ``` ### Field reference | Field | Meaning | | --- | --- | | `id` | Task identifier string | | `subject` | Short task title | | `description` | Longer description, defaulting to an empty string | | `status` | One of `pending`, `in_progress`, or `completed` | | `owner` | Optional owner/assignee | | `blocked_by` / `blockedBy` | Task IDs that must finish first | | `blocks` | Task IDs this task is blocking | | `active_form` / `activeForm` | Optional active wording for the task | | `metadata` | Free-form metadata object | | `created_at` | Nullable timestamp field on the model | > **Note:** The repository uses snake_case fields in Python models and camelCase aliases for some raw task JSON fields. In practice, `blocked_by` maps to `blockedBy`, and `active_form` maps to `activeForm`. The test fixture below shows the raw task JSON shape the parser expects: ```52:62:tests/conftest.py return { "id": "1", "subject": "Test task", "description": "A test task", "status": "pending", "owner": None, "blockedBy": [], "blocks": [], } ``` ## Ordering And Filtering Details Within a session, tasks are read from `*.json` files and then sorted by task ID, using numeric ordering when the IDs are numeric strings. ```104:128:src/ccsinfo/core/parsers/tasks.py def parse_session_tasks(session_id: str) -> TaskCollection: """Parse all tasks for a given session.""" tasks_dir = get_tasks_directory() / session_id tasks: list[Task] = [] if not tasks_dir.exists(): logger.debug("No tasks directory found for session %s", session_id) return TaskCollection(session_id=session_id, tasks=[]) for task_file in iter_json_files(tasks_dir, "*.json"): task = parse_task_file(task_file) if task is not None: tasks.append(task) # Sort by ID (numeric sort if possible) tasks.sort(key=lambda t: (int(t.id) if t.id.isdigit() else float("inf"), t.id)) return TaskCollection(session_id=session_id, tasks=tasks) ``` What this means in practice: - Per-session ordering is stable and ID-based. - Cross-session listing is not exposed with a custom sort option. - If a session has no task directory, session-scoped listing returns `[]`. > **Note:** If an individual task file fails to parse, the parser skips that file instead of failing the entire task list response. ## Python Client Example From The Codebase If you want to see how the project itself calls these endpoints, the built-in HTTP client is a good reference: ```69:86:src/ccsinfo/core/client.py # Tasks def list_tasks( self, session_id: str | None = None, status: str | None = None, ) -> list[dict[str, Any]]: params: dict[str, Any] = {} if session_id: params["session_id"] = session_id if status: params["status"] = status return self._get_list("/tasks", params) def get_task(self, task_id: str, session_id: str) -> dict[str, Any]: return self._get_dict(f"/tasks/{task_id}", {"session_id": session_id}) def get_pending_tasks(self) -> list[dict[str, Any]]: return self._get_list("/tasks/pending") ``` This mirrors the intended usage: - `list_tasks()` for broad listing and filtering - `get_pending_tasks()` for a cross-session pending queue - `get_task(task_id, session_id)` for an exact task lookup ## Best Practices - Use `GET /sessions/{session_id}/tasks` first when you know the session but not the task ID. - Use `GET /tasks?status=in_progress` or `GET /tasks?status=completed` for simple status filtering. - Use `GET /tasks/pending` when you want a global backlog view. - Check `blocked_by` / `blockedBy` before treating a pending task as ready to work. - Do not assume task IDs are globally unique across sessions. ## Related Pages - [Working with Tasks](tasks-guide.html) - [API Overview](api-overview.html) - [Sessions API](api-sessions.html) - [Project IDs and Lookups](project-ids-and-lookups.html) - [Data Model and Storage](data-model-and-storage.html) --- Source: api-search.md # Search API The Search API gives you three read-only endpoints under `/search` for finding Claude Code sessions, conversation text, and prompt history. It scans the same local Claude Code data that `ccsinfo` reads from `~/.claude/projects`, so there is no separate search index to maintain. > **Note:** All search endpoints are `GET` endpoints. Each one requires `q` and accepts `limit`. `limit` defaults to `50` and must be between `1` and `500`. ## Starting the server If you are using the built-in API server, `ccsinfo` binds to `127.0.0.1:8080` by default: ```27:33:src/ccsinfo/cli/main.py @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` If you use the CLI against a running server, the same codebase also supports `--server-url` and `CCSINFO_SERVER_URL`. ## Endpoint overview | Endpoint | Best for | Searches these fields | Returns | | --- | --- | --- | --- | | `GET /search` | Finding a session by ID, slug, branch, directory, or project | `session_id`, `slug`, `cwd`, `git_branch`, decoded `project_path` | Session summaries | | `GET /search/messages` | Finding words or phrases in conversation text | `user` and `assistant` message text only | Message match objects with snippets | | `GET /search/history` | Finding prompts you typed | Prompt history `prompt` only | Prompt-history match objects | Mounted under `/search`, the router exposes exactly these three handlers: ```13:37:src/ccsinfo/server/routers/search.py @router.get("", response_model=list[SessionSummary]) async def search_sessions( q: str = Query(..., description="Search query"), limit: int = Query(50, ge=1, le=500, description="Maximum results"), ) -> list[SessionSummary]: """Full-text search across sessions.""" return search_service.search_sessions(q, limit=limit) @router.get("/messages") async def search_messages( q: str = Query(..., description="Search query"), limit: int = Query(50, ge=1, le=500, description="Maximum results"), ) -> list[dict[str, Any]]: """Search message content across all sessions.""" return search_service.search_messages(q, limit=limit) @router.get("/history") async def search_history( q: str = Query(..., description="Search query"), limit: int = Query(50, ge=1, le=500, description="Maximum results"), ) -> list[dict[str, Any]]: """Search prompt history.""" return search_service.search_history(q, limit=limit) ``` ## How search works ```mermaid flowchart TD A[Client] --> B[GET /search] A --> C[GET /search/messages] A --> D[GET /search/history] B --> E[SearchService.search_sessions] C --> F[SearchService.search_messages] D --> G[SearchService.search_history] E --> H["Session files
~/.claude/projects/*/*.jsonl"] F --> H G --> I["Prompt history files
~/.claude/projects/*/.history.jsonl"] ``` All three search modes use case-insensitive substring matching. They scan files on disk rather than querying a database or full-text index. > **Tip:** If search helps you find the right session, follow up with `/sessions/{session_id}` or `/sessions/{session_id}/messages` for the full record. The search endpoints are intentionally lightweight. ## `GET /search` Use session search when you know something about the session itself, not necessarily the conversation text. This is the right endpoint if you remember a session ID fragment, a branch name, a working directory, a slug, or a project path. This endpoint is metadata search, not conversation search. It does not look inside message bodies. The service checks these fields for a match: ```36:56:src/ccsinfo/core/services/search_service.py for project_path, session in get_all_sessions(): # Search in various session fields searchable_fields = [ session.session_id, session.slug or "", session.cwd or "", session.git_branch or "", project_path, ] # Check if query matches any field if any(query_lower in field.lower() for field in searchable_fields if field): summary = SessionSummary( id=session.session_id, project_path=project_path, project_name=Path(project_path).name if project_path else "Unknown", created_at=pendulum.instance(session.first_timestamp) if session.first_timestamp else None, updated_at=pendulum.instance(session.last_timestamp) if session.last_timestamp else None, message_count=session.message_count, is_active=session.is_active(), ) ``` ### Response fields | Field | Type | Meaning | | --- | --- | --- | | `id` | string | Session UUID | | `project_path` | string | Decoded Claude project path for the session | | `project_name` | string | Last path component of `project_path`, or `Unknown` if no path is available | | `created_at` | string or `null` | First timestamp seen in the session, serialized as ISO 8601 | | `updated_at` | string or `null` | Last timestamp seen in the session, serialized as ISO 8601 | | `message_count` | integer | Count of `user` and `assistant` entries in the session | | `is_active` | boolean | Whether the session currently appears to be active | Among collected matches, session ID hits are prioritized ahead of other fields, and then newer sessions are preferred. > **Note:** `project_path` comes from Claude Code’s encoded project directory name. That decode is intentionally lossy for some path patterns, so treat `project_path` as a useful label rather than a perfect round-trip filesystem path. ## `GET /search/messages` Use message search when you remember wording from the conversation itself. It searches across all sessions, but it does not search every block inside a Claude Code message. The implementation only searches `user` and `assistant` entries, and within structured content it only searches blocks whose type is `text`: ```85:121:src/ccsinfo/core/services/search_service.py for project_path, session in get_all_sessions(): for entry in session.entries: if entry.type not in ("user", "assistant"): continue # Extract text content to search text_content = "" if entry.message and entry.message.content: if isinstance(entry.message.content, str): text_content = entry.message.content elif isinstance(entry.message.content, list): texts = [] for content in entry.message.content: if content.type == "text" and content.text: texts.append(content.text) text_content = "\n".join(texts) if query_lower in text_content.lower(): # ... snippet building omitted ... results.append({ "session_id": session.session_id, "project_path": project_path, "message_uuid": entry.uuid, "message_type": entry.type, "timestamp": entry_ts.isoformat() if entry_ts else None, "snippet": snippet, }) ``` Each matching message produces one result. If the same message contains the query multiple times, the API still returns a single hit and builds the snippet around the first occurrence. ### What this endpoint does search | Covered content | Notes | | --- | --- | | Plain-string message content | Searched directly | | `text` blocks inside structured message content | Joined with newlines before matching | | `user` entries | Included | | `assistant` entries | Included | ### What this endpoint does not search | Not covered | Why it matters | | --- | --- | | `tool_use` blocks | Tool names and tool input JSON are not matched | | `tool_result` blocks | Tool output is not searched here | | Non-message session entries | Events outside `user` and `assistant` are skipped | | Prompt-history-only entries | Use `/search/history` for those | ### Response fields | Field | Type | Meaning | | --- | --- | --- | | `session_id` | string | Session UUID containing the match | | `project_path` | string | Decoded project path | | `message_uuid` | string or `null` | UUID of the matching message entry | | `message_type` | string | `user` or `assistant` | | `timestamp` | string or `null` | Message timestamp as ISO 8601 | | `snippet` | string | A short excerpt around the first match | The returned `snippet` is not the full message. It is built from roughly 50 characters before and after the match, with `...` added when the excerpt is trimmed. > **Warning:** If you need to search tool calls, tool results, or non-text message blocks, `/search/messages` is not enough. This endpoint is text-only by design. ## `GET /search/history` Use prompt-history search when you want to find prompts you typed, regardless of how large the corresponding session became. This endpoint reads each project’s `.history.jsonl` file, not the main session message stream. Raw history entries carry these fields: ```24:31:src/ccsinfo/core/parsers/history.py class HistoryEntry(BaseModel): """A single entry in a prompt history file.""" prompt: str | None = None timestamp: str | None = None session_id: str | None = Field(default=None, alias="sessionId") cwd: str | None = None version: str | None = None ``` The actual search is done against `prompt` only, and the API returns a compact result object: ```141:150:src/ccsinfo/core/services/search_service.py matches = search_all_history(query, case_sensitive=False) results: list[dict[str, Any]] = [] for project_path, entry in matches[:limit]: entry_ts = entry.get_timestamp() results.append({ "project_path": project_path, "prompt": entry.prompt, "session_id": entry.session_id, "timestamp": entry_ts.isoformat() if entry_ts else None, }) ``` Each matching history entry produces one result, and unlike message search, the API returns the full stored prompt text rather than a short snippet. ### Search coverage | Field in raw history entry | Searched by `/search/history` | Returned by `/search/history` | | --- | --- | --- | | `prompt` | Yes | Yes | | `timestamp` | No | Yes | | `session_id` / `sessionId` | No | Yes | | `cwd` | No | No | | `version` | No | No | ### Response fields | Field | Type | Meaning | | --- | --- | --- | | `project_path` | string | Decoded project path that owns the history file | | `prompt` | string | Full stored prompt text | | `session_id` | string or `null` | Session UUID linked to the prompt-history entry | | `timestamp` | string or `null` | Prompt timestamp as ISO 8601 | > **Warning:** `/search/history` searches prompts you sent, not assistant replies. If you want to search the conversation text on both sides, use `/search/messages`. ## Choosing the right endpoint Use `/search` when you are trying to locate the right session first. Use `/search/messages` when you remember wording from the conversation. Use `/search/history` when you remember wording from the prompt you typed and want the original prompt text back. A common workflow is: 1. Search sessions with `/search` if you only know project, branch, slug, directory, or session metadata. 2. Use the returned `id` to inspect the session through `/sessions/{session_id}` or `/sessions/{session_id}/messages`. 3. Use `/search/messages` or `/search/history` when you want content-based search across all projects. ## Related Pages - [Searching Sessions, Messages, and History](search-guide.html) - [API Overview](api-overview.html) - [Sessions API](api-sessions.html) - [JSON Output and Automation](json-output-and-automation.html) - [Data Model and Storage](data-model-and-storage.html) --- Source: api-stats-and-health.md # Stats and Health API `ccsinfo` exposes five read-only endpoints for analytics and server status. Use them when you want a quick snapshot of all Claude Code activity, a daily activity series, ranked trend data, or a simple signal that the server is up. | Endpoint | What it is for | | --- | --- | | `GET /stats` | Global totals across all parsed sessions and projects | | `GET /stats/daily` | Day-by-day activity for the last `N` days | | `GET /stats/trends` | Trend summary, top projects, top tools, and average session length | | `GET /health` | Simple liveness check | | `GET /info` | Lightweight server metadata and top-level counts | The routes are mounted directly on the FastAPI app, with stats under `/stats` and health/info at the root: ```8:20:src/ccsinfo/server/app.py app = FastAPI( title="ccsinfo", description="Claude Code Session Info API", version=__version__, ) # Include routers app.include_router(sessions.router, prefix="/sessions", tags=["sessions"]) app.include_router(projects.router, prefix="/projects", tags=["projects"]) app.include_router(tasks.router, prefix="/tasks", tags=["tasks"]) app.include_router(stats.router, prefix="/stats", tags=["stats"]) app.include_router(search.router, prefix="/search", tags=["search"]) app.include_router(health.router, tags=["health"]) ``` The project configuration shows the API stack is built around FastAPI, Uvicorn, and `httpx`: ```52:60:pyproject.toml dependencies = [ "typer>=0.9.0", "rich>=13.0.0", "orjson>=3.9.0", "pydantic>=2.0.0", "pendulum>=3.0.0", "fastapi>=0.109.0", "uvicorn[standard]>=0.27.0", "httpx>=0.27.0", ] ``` > **Warning:** The FastAPI app in this repository does not add authentication, authorization, or custom middleware around these routes. If you expose the server beyond localhost, put it behind your own access controls. ## Where the stats come from All stats are calculated from Claude Code session files under `~/.claude/projects`. Each session is stored as JSONL, with one event per line. The test suite includes a minimal example that matches the format the parser expects: ```26:47:tests/conftest.py return [ { "type": "user", "uuid": "msg-001", "message": { "role": "user", "content": [{"type": "text", "text": "Hello"}], }, "timestamp": "2024-01-15T10:00:00Z", }, { "type": "assistant", "uuid": "msg-002", "parentMessageUuid": "msg-001", "message": { "role": "assistant", "content": [{"type": "text", "text": "Hi there!"}], }, "timestamp": "2024-01-15T10:00:01Z", }, ] ``` A “message” is not a token count or a duration estimate. It is a session entry whose `type` is `user` or `assistant`, and a “tool call” is an assistant content block whose `type` is `tool_use`: ```116:140:src/ccsinfo/core/parsers/sessions.py @property def message_count(self) -> int: """Count of message entries (user + assistant).""" return sum(1 for e in self.entries if e.type in ("user", "assistant")) @property def tool_use_count(self) -> int: """Count of tool use entries.""" count = 0 for entry in self.entries: if entry.type == "assistant" and entry.message: content = entry.message.content if isinstance(content, list): count += sum(1 for c in content if isinstance(c, MessageContent) and c.type == "tool_use") return count ``` ```mermaid flowchart LR A[Client request] --> B[FastAPI router] B --> C[StatsService] C --> D[get_all_sessions()] D --> E[~/.claude/projects] E --> F[Session *.jsonl files] F --> G[Parsed Session objects] G --> C C --> H[JSON response] I[GET /health] --> J[Health router] J --> K[status healthy] ``` ## `GET /stats` Use `GET /stats` when you want top-level counters for dashboards, sanity checks, or a quick “does this server see my data?” overview. The global stats service totals all parsed sessions, tracks the unique projects they belong to, and sums both message counts and tool-use counts: ```20:46:src/ccsinfo/core/services/stats_service.py def get_global_stats(self) -> GlobalStats: """Get global usage statistics across all sessions and projects. Returns: GlobalStats object with totals. """ total_sessions = 0 total_projects = 0 total_messages = 0 total_tool_calls = 0 project_ids = set() for project_path, session in get_all_sessions(): total_sessions += 1 project_ids.add(project_path) total_messages += session.message_count total_tool_calls += session.tool_use_count total_projects = len(project_ids) return GlobalStats( total_sessions=total_sessions, total_projects=total_projects, total_messages=total_messages, total_tool_calls=total_tool_calls, ) ``` Response fields: | Field | Meaning | | --- | --- | | `total_sessions` | Total number of parsed session files | | `total_projects` | Number of unique projects that contributed sessions | | `total_messages` | Sum of all `user` and `assistant` session entries | | `total_tool_calls` | Sum of all assistant `tool_use` content blocks | > **Tip:** If you just need high-level counters, call `/stats`. If you only need version plus project/session counts, `/info` is lighter. ## `GET /stats/daily` Use `GET /stats/daily` for activity charts and recent daily rollups. This endpoint accepts one query parameter: | Parameter | Default | Limits | Meaning | | --- | --- | --- | --- | | `days` | `30` | `1` to `365` | How many trailing days to include | The service groups sessions by the session’s first timestamp, not by every message timestamp, then sorts the results by date: ```57:90:src/ccsinfo/core/services/stats_service.py now = pendulum.now() cutoff = now.subtract(days=days) # Aggregate by date daily_data: dict[str, dict[str, int]] = defaultdict(lambda: {"session_count": 0, "message_count": 0}) for _project_path, session in get_all_sessions(): # Use the session's first timestamp as the activity date ts = session.first_timestamp if ts is None: continue session_dt = pendulum.instance(ts) if session_dt < cutoff: continue date_key = session_dt.format("YYYY-MM-DD") daily_data[date_key]["session_count"] += 1 daily_data[date_key]["message_count"] += session.message_count # Convert to DailyStats objects results: list[DailyStats] = [] for date_str, data in sorted(daily_data.items()): parsed_dt = pendulum.parse(date_str) date = parsed_dt.date() if parsed_dt else None results.append( DailyStats( date=date, session_count=data["session_count"], message_count=data["message_count"], ) ) return results ``` Each item in the response includes: | Field | Meaning | | --- | --- | | `date` | The day bucket for the session start date | | `session_count` | How many sessions started on that day | | `message_count` | Total messages from those sessions | Important behavior to know: - Only days with activity are returned. - Results are ordered from oldest day to newest day. - Sessions without a timestamp are skipped. - All messages from a session are assigned to the day of that session’s first timestamp. > **Warning:** `/stats/daily` is a session-start view, not a true per-calendar-day message distribution. A long session that crosses midnight is still counted on its start date. > **Tip:** If you need a continuous chart, fill in missing dates on the client side. The API does not add zero-value days. ## `GET /stats/trends` Use `GET /stats/trends` when you want a compact analytics summary rather than raw totals. The trend service computes recent 7-day and 30-day counts, ranks projects by message volume, ranks tools by usage, and calculates an average session length: ```119:163:src/ccsinfo/core/services/stats_service.py for project_path, session in get_all_sessions(): total_sessions += 1 total_messages += session.message_count project_activity[project_path] += session.message_count # Collect tool usage for tool in session.get_unique_tools_used(): tool_usage[tool] += 1 ts = session.first_timestamp if ts is not None: session_dt = pendulum.instance(ts) if session_dt >= cutoff_30: sessions_30 += 1 messages_30 += session.message_count if session_dt >= cutoff_7: sessions_7 += 1 messages_7 += session.message_count # Calculate most active projects most_active = sorted( project_activity.items(), key=lambda x: x[1], reverse=True, )[:5] # Calculate most used tools most_used_tools = sorted( tool_usage.items(), key=lambda x: x[1], reverse=True, )[:10] # Average session length avg_length = total_messages / total_sessions if total_sessions > 0 else 0 return { "sessions_last_7_days": sessions_7, "sessions_last_30_days": sessions_30, "messages_last_7_days": messages_7, "messages_last_30_days": messages_30, "most_active_projects": [{"project": p, "message_count": c} for p, c in most_active], "most_used_tools": [{"tool": t, "count": c} for t, c in most_used_tools], "average_session_length": round(avg_length, 2), } ``` Response fields: | Field | Meaning | | --- | --- | | `sessions_last_7_days` | Sessions whose first timestamp is within the last 7 days | | `sessions_last_30_days` | Sessions whose first timestamp is within the last 30 days | | `messages_last_7_days` | Total messages from those 7-day sessions | | `messages_last_30_days` | Total messages from those 30-day sessions | | `most_active_projects` | Top 5 project paths ranked by total message count | | `most_used_tools` | Top 10 tools ranked by usage count | | `average_session_length` | Average number of messages per session, rounded to 2 decimals | Tool usage is intentionally based on unique tools per session, not every repeated call inside the same session: ```205:215:src/ccsinfo/core/parsers/sessions.py def get_unique_tools_used(self) -> set[str]: """Get the set of unique tool names used in the session.""" tools: set[str] = set() for entry in self.entries: if entry.type == "assistant" and entry.message: content = entry.message.content if isinstance(content, list): for c in content: if isinstance(c, MessageContent) and c.type == "tool_use" and c.name: tools.add(c.name) return tools ``` That means: - `most_active_projects` is based on message volume, not session count. - `project` values are project paths, not opaque project IDs. - `most_used_tools` counts whether a tool appeared in a session at least once. - `average_session_length` is a message average, not a time duration. > **Warning:** A session that uses the same tool 20 times still contributes `1` to that tool’s trend count. > **Warning:** `average_session_length` does not measure elapsed time. It is `total_messages / total_sessions`. > **Note:** The 7-day and 30-day windows use each session’s first timestamp. Sessions without timestamps are excluded from those windowed counts but still affect all-time totals like `average_session_length`. ## `GET /health` Use `GET /health` for a simple liveness probe. ## `GET /info` Use `GET /info` for a lightweight status summary that includes the server version plus total session and project counts. Both endpoints are defined in the same router: ```13:27:src/ccsinfo/server/routers/health.py @router.get("/health") async def health() -> dict[str, str]: """Health check endpoint.""" return {"status": "healthy"} @router.get("/info") async def info() -> dict[str, Any]: """Server info endpoint.""" stats = stats_service.get_global_stats() return { "version": __version__, "total_sessions": stats.total_sessions, "total_projects": stats.total_projects, } ``` Behavior summary: | Endpoint | Response | | --- | --- | | `GET /health` | Always returns `{"status": "healthy"}` | | `GET /info` | Returns `version`, `total_sessions`, and `total_projects` | > **Note:** `/health` is a liveness check only. It does not verify that `~/.claude/projects` exists, that session files are readable, or that any stats can actually be computed. > **Tip:** If you want a small “is the service up and does it see data?” check, prefer `/info`. If you need message and tool totals too, call `/stats` instead. ## Related Pages - [Using Statistics and Trends](statistics-guide.html) - [API Overview](api-overview.html) - [Running the Server](server-operations.html) - [Quickstart: Remote Server Mode](remote-server-quickstart.html) - [JSON Output and Automation](json-output-and-automation.html) --- Source: server-operations.md # Running the Server `ccsinfo` can run as a FastAPI service so other machines, shells, or programs can query one central source of Claude Code data over HTTP. In server mode, the API reads the `~/.claude` data on the machine where the service is running and returns JSON from there. There is no database or separate sync layer in this repository. ```mermaid flowchart LR A[Remote CLI] --> D[ccsinfo FastAPI service] B[Python client] --> D C[Other HTTP client] --> D D --> E[Sessions / Projects / Stats / Search routes] E --> F["~/.claude/projects"] E --> G[".history.jsonl files"] ``` > **Note:** Run the server on the machine that actually has the Claude Code data you want to expose. Starting it on a different host does not automatically give that host access to your local sessions. ## Start the server The built-in CLI wraps Uvicorn and exposes two server settings: `--host` and `--port`. ```python @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` Start it with the installed CLI: ```bash ccsinfo serve ``` Make it reachable from other machines: ```bash ccsinfo serve --host 0.0.0.0 --port 8080 ``` Or run it as a module: ```bash python -m ccsinfo serve --host 0.0.0.0 --port 8080 ``` By default, the server listens on `127.0.0.1:8080`, which means only the same machine can connect. That is the full built-in server surface: the bundled `serve` command passes only `host` and `port` into `uvicorn.run(...)`. If you need TLS, authentication, or reverse-proxy features, add those outside `ccsinfo`. > **Note:** If the server host does not have `~/.claude/projects` yet, the API can still start. In that case, list endpoints return empty arrays and summary endpoints such as `/info` report zero counts. ## Choose a host and port | Situation | Recommended host | Port guidance | | --- | --- | --- | | Local-only use on one machine | `127.0.0.1` | Keep the default `8080` unless it is already in use | | Access from other machines on the same LAN or VPN | `0.0.0.0` | Use `8080` or any other free port and allow it through your firewall | | Behind a reverse proxy | `127.0.0.1` or a specific private IP | Choose any internal port your proxy forwards to | If you bind to `0.0.0.0`, clients should connect to the server's real hostname or IP address, such as `http://192.168.1.50:8080`, not `http://0.0.0.0:8080`. > **Warning:** This FastAPI app does not add authentication, TLS, or CORS middleware in this repository. Binding to `0.0.0.0` makes the API reachable on your network, so expose it only on trusted networks or put it behind your own proxy and authentication layer. ## Health checks The API includes a lightweight liveness endpoint and a small info endpoint: ```python @router.get("/health") async def health() -> dict[str, str]: """Health check endpoint.""" return {"status": "healthy"} @router.get("/info") async def info() -> dict[str, Any]: """Server info endpoint.""" stats = stats_service.get_global_stats() return { "version": __version__, "total_sessions": stats.total_sessions, "total_projects": stats.total_projects, } ``` Use them like this: ```bash curl http://127.0.0.1:8080/health curl http://127.0.0.1:8080/info ``` Use `/health` when you want a fast "is the process responding?" check. Use `/info` when you also want a quick sanity check that the service can enumerate local data and report version and high-level totals. > **Tip:** `/health` is the better choice for load balancer or container liveness checks. `/info`, `/stats`, and search routes do more work because they walk local session data to build their responses. ## Remote client usage ### Use the CLI against a remote server The CLI has a global `--server-url` option, and the same value can come from `CCSINFO_SERVER_URL`: ```python server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ``` Once this is set, the normal CLI commands switch from local file reads to HTTP requests. Real command groups in the repository include `projects list`, `sessions active`, `stats global`, and `search history`. ```bash ccsinfo --server-url http://server.example:8080 projects list ccsinfo --server-url http://server.example:8080 sessions active CCSINFO_SERVER_URL=http://server.example:8080 ccsinfo stats global CCSINFO_SERVER_URL=http://server.example:8080 ccsinfo search history "rate limit" ``` If you do not set `--server-url` or `CCSINFO_SERVER_URL`, the CLI falls back to local mode and reads files directly instead of using HTTP. > **Tip:** Use `projects list` first when you need a `project_id` for project-specific routes. The API expects the actual project ID returned by the server, not just the human-friendly project name. ### Use the Python HTTP client The repository also includes a small `httpx` client: ```python def __init__(self, base_url: str) -> None: self.base_url = base_url.rstrip("/") self._client = httpx.Client(base_url=self.base_url, timeout=30.0) ``` It exposes real convenience methods such as `list_sessions()`, `list_projects()`, `get_global_stats()`, `search_history()`, `health()`, and `info()`. ```python from ccsinfo.core.client import CCSInfoClient client = CCSInfoClient("http://server.example:8080") print(client.health()) print(client.info()) print(client.list_sessions(active_only=True, limit=25)) print(client.get_global_stats()) print(client.search_history("rate limit", limit=10)) ``` The client strips any trailing slash from the base URL and uses a 30-second timeout for requests. ## Common request patterns These are the main patterns exposed by the FastAPI routes: | Route | What it is for | Important parameters | | --- | --- | --- | | `GET /health` | Liveness check | None | | `GET /info` | Version plus high-level counts | None | | `GET /sessions` | List sessions | `project_id`, `active_only`, `limit` | | `GET /sessions/active` | Show active sessions | None | | `GET /sessions/{session_id}/messages` | Read message history for one session | `role`, `limit` | | `GET /projects` | Discover valid project IDs | None | | `GET /projects/{project_id}/sessions` | List sessions for one project | `limit` | | `GET /projects/{project_id}/stats` | Project-specific totals | None | | `GET /stats` | Global totals | None | | `GET /stats/daily` | Daily activity breakdown | `days` | | `GET /stats/trends` | Trend summary | None | | `GET /search` | Search sessions | `q`, `limit` | | `GET /search/messages` | Search message text | `q`, `limit` | | `GET /search/history` | Search prompt history | `q`, `limit` | The list and search routes generally default to `limit=50` and cap it at `500`. The session-message route defaults to `limit=100` and also caps at `500`. Daily stats default to `30` days and cap at `365`. > **Note:** Active-session detection is based on local process inspection and uses a 5-second cache, so `/sessions/active` is near-real-time rather than instant. > **Tip:** Because the app uses FastAPI defaults, the interactive API docs are available at `http://HOST:PORT/docs`, and the OpenAPI schema is available at `http://HOST:PORT/openapi.json`. ## Related Pages - [Quickstart: Remote Server Mode](remote-server-quickstart.html) - [Configuration](configuration.html) - [API Overview](api-overview.html) - [Stats and Health API](api-stats-and-health.html) - [Troubleshooting](troubleshooting.html) --- Source: active-session-detection.md # Active Session Detection `ccsinfo` does not store a permanent "active" flag in session files. Instead, it infers activity live by matching each session's UUID against information it can discover from currently running Claude processes. In practice, that means: - A session can be **active** even if it has not written a new message recently. - A session can be **inactive** even if its JSONL file was updated recently, if the Claude process has already exited. - The answer can lag briefly because `ccsinfo` keeps a short in-memory cache. > **Note:** `created_at` and `updated_at` come from timestamps inside the session file. `is_active` comes from a separate live process scan. ## Where ccsinfo looks `ccsinfo` is built around Claude's local data under `~/.claude`. The paths are hard-coded in the project: ```python def get_claude_base_dir() -> Path: """Get the base Claude Code directory (~/.claude).""" return Path.home() / ".claude" def get_projects_dir() -> Path: """Get the projects directory (~/.claude/projects).""" return get_claude_base_dir() / "projects" def get_tasks_dir() -> Path: """Get the tasks directory (~/.claude/tasks).""" return get_claude_base_dir() / "tasks" ``` Those locations matter for active detection because `ccsinfo` uses both of them: - `~/.claude/projects/` for stored session JSONL files - `~/.claude/tasks/` as one of the places it inspects when looking at open file descriptors of running Claude processes ## How activity is inferred Each parsed session asks a live detector whether its UUID is currently active: ```python def is_active(self) -> bool: """Check if this session is currently active.""" return is_session_active(self.session_id) ``` The detector works like this: 1. Find running processes whose command line contains `claude`. 2. For each matching PID, inspect process metadata. 3. Extract any UUID-looking strings it can find. 4. If one of those UUIDs matches a session ID, mark that session as active. Here is the core scan from the codebase: ```python _active_sessions_cache: set[str] | None = None _active_sessions_cache_time: float = 0.0 _CACHE_TTL_SECONDS: float = 5.0 def _get_active_session_ids() -> set[str]: """Get all active session IDs from running Claude processes. This function caches the result for a short time to avoid repeated expensive pgrep calls. """ global _active_sessions_cache, _active_sessions_cache_time current_time = time.monotonic() # Return cached result if still valid if _active_sessions_cache is not None and (current_time - _active_sessions_cache_time) < _CACHE_TTL_SECONDS: return _active_sessions_cache active_ids: set[str] = set() try: # Use pgrep to find claude processes result = subprocess.run( ["pgrep", "-f", "claude"], capture_output=True, text=True, timeout=5, ) ``` And this is how UUIDs are gathered from each PID: ```python # UUID pattern for session IDs uuid_pattern = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE) for pid in pids: if not pid: continue try: cmdline_path = Path(f"/proc/{pid}/cmdline") if cmdline_path.exists(): cmdline = cmdline_path.read_text() # Extract all UUIDs from cmdline active_ids.update(uuid_pattern.findall(cmdline)) # Also check environment variables environ_path = Path(f"/proc/{pid}/environ") if environ_path.exists(): environ = environ_path.read_text() active_ids.update(uuid_pattern.findall(environ)) # Check file descriptors for session UUIDs fd_dir = Path(f"/proc/{pid}/fd") if fd_dir.exists(): for fd_link in fd_dir.iterdir(): try: target = fd_link.resolve() target_str = str(target) # Look for ~/.claude/tasks/{UUID} or ~/.claude/projects/*/{UUID}.jsonl if ".claude/tasks/" in target_str or ".claude/projects/" in target_str: active_ids.update(uuid_pattern.findall(target_str)) except (OSError, PermissionError): continue except (PermissionError, FileNotFoundError, OSError): continue ``` A few important details fall out of this design: - `ccsinfo` is not guessing based on file timestamps alone. - The match key is the **session UUID**, not the project name. - The first process filter is broad (`pgrep -f claude`), but a session is only marked active after a UUID match is found. ## Data flow ```mermaid flowchart TD A[User runs a session command or API request] --> B[ccsinfo parses session JSONL files] B --> C[Session.is_active()] C --> D{Active ID cache younger than 5s?} D -- Yes --> E[Reuse cached set of session UUIDs] D -- No --> F[Run pgrep -f claude] F --> G[Inspect /proc//cmdline] G --> H[Inspect /proc//environ] H --> I[Inspect resolved /proc//fd targets] I --> J[Extract UUIDs] J --> K[Store UUID set in cache] E --> L[Compare session UUID to active UUID set] K --> L L --> M[Expose is_active in CLI and API results] ``` ## The short-lived cache The active-session scan is cached in memory for **5 seconds**. This is one shared cache of active session IDs, not a separate cache per session. That helps in two ways: - Listing many sessions does not run `pgrep` once per session. - Repeated CLI or API calls within a few seconds stay fast. It also creates a few user-visible behaviors: - A newly started or newly ended Claude session can take up to 5 seconds to show the new status. - If a probe returns no active IDs, that empty result is also cached for up to 5 seconds. - The `pgrep` lookup itself has a 5-second timeout, so refreshes are bounded instead of hanging indefinitely. > **Tip:** If a session just started or stopped, wait a few seconds and try again before assuming the status is wrong. > **Note:** There is no user-facing setting in the current codebase to tune the cache TTL or change the process-matching pattern. Both are hard-coded. ## Local mode vs server mode The CLI can work either against local files or against a remote `ccsinfo` server. The switch is `--server-url` or `CCSINFO_SERVER_URL`: ```python server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ) ``` This matters for active detection: - In local mode, `ccsinfo` checks the machine where you run the CLI. - In server mode, the server performs the process scan and returns the result. > **Warning:** If you use `--server-url`, the `is_active` value describes the server host, not your local machine. ## Where you see the result The active flag is surfaced in both the CLI and the API. ### CLI Use either of these commands: ```bash ccsinfo sessions active ccsinfo sessions list --active ``` If you are talking to a server instead of local files: ```bash ccsinfo --server-url http://localhost:8080 sessions active ``` ### API These routes expose active-session results: - `GET /sessions/active` - `GET /sessions?active_only=true` - `GET /projects/{project_id}/sessions/active` ## Platform-specific caveats `ccsinfo` currently depends on two platform behaviors: - `pgrep -f claude` must exist - Linux-style `/proc/` inspection must be available That means support is best on Linux. > **Warning:** On systems without a usable `/proc` filesystem, active-session detection can fall back to reporting no active sessions. ### Linux Linux is the best fit for the current implementation. `pgrep` and `/proc/` are both expected, so `ccsinfo` can inspect command lines, environments, and open file descriptors. ### macOS macOS usually has `pgrep`, but it does not provide Linux-style `/proc/` files. In practice, that means `ccsinfo` may find Claude PIDs but still fail to extract session UUIDs, so active sessions can appear inactive. ### Windows The current detector expects `pgrep` and `/proc`, so Windows does not match the implementation well. If the process scan cannot run, `ccsinfo` quietly returns an empty active-session set for that check. ### Containers and restricted environments Even on Linux, process visibility can be limited by PID namespaces, container boundaries, or permissions. `ccsinfo` skips unreadable PIDs and file descriptors, so the result can be partial rather than all-or-nothing. > **Note:** Detection failures are handled quietly in the current code. If `pgrep` is missing, times out, or `/proc` entries cannot be read, `ccsinfo` does not raise a user-facing error for session listings; it simply reports no matches for that probe. ## Troubleshooting If a session you expect to be active shows up as inactive, check these first: 1. Wait at least 5 seconds and run the command again. 2. Make sure you are checking the same machine that is running Claude. 3. If you are using `--server-url` or `CCSINFO_SERVER_URL`, remember the server host is doing the detection. 4. On macOS, Windows, or locked-down containers, the process scan may not be able to read the data it needs. 5. If Claude's running process does not expose the session UUID in its command line, environment, or relevant open file paths, `ccsinfo` has nothing reliable to match. > **Tip:** If you want the most reliable results today, run `ccsinfo` locally on a Linux machine that is also running Claude. ## Related Pages - [Working with Sessions](sessions-guide.html) - [Sessions API](api-sessions.html) - [Quickstart: Local CLI Mode](local-cli-quickstart.html) - [Running the Server](server-operations.html) - [Troubleshooting](troubleshooting.html) --- Source: troubleshooting.md # Troubleshooting If `ccsinfo` shows no data, odd project names, or missing tasks, the problem is usually one of these: 1. You are in remote mode when you meant to read local files. 2. `ccsinfo` is reading a different user's `~/.claude`. 3. You copied a shortened ID from table output instead of the full ID. 4. You used the wrong search command for the kind of data you want. 5. You looked up a task without the session it belongs to. | Symptom | Most likely cause | First thing to try | | --- | --- | --- | | `No projects found.` | Wrong `~/.claude` directory, or remote server has no data | Confirm local vs remote mode, then check the user/home directory being read | | `Project not found:` | You used a shortened or guessed project ID | Run `ccsinfo projects list --json` and use the full `id` | | `Session not found:` | You copied the 12-character table ID | Run `ccsinfo sessions list --json` and use the full session UUID | | `Task not found:` | Missing or wrong `--session` value | Re-run `ccsinfo tasks list --session ` and reuse that session ID | | Search returns nothing | You searched the wrong index, or the message only contains tool calls | Use `search messages` or `search history`; if needed inspect `sessions tools` | | `No active sessions.` | You are checking the wrong machine, wrong user, or a just-started session | Confirm mode, then wait a few seconds and retry | > **Tip:** When you plan to copy an ID, prefer `--json`. The human-readable tables shorten project IDs and session IDs. ## How `ccsinfo` Finds Data `ccsinfo` has two modes: - Local mode reads files directly from `~/.claude` - Remote mode sends HTTP requests to a `ccsinfo` server ```mermaid flowchart TD A[Run a ccsinfo command] --> B{Is --server-url or CCSINFO_SERVER_URL set?} B -- No --> C[Local mode] C --> D[Read ~/.claude/projects//*.jsonl] C --> E[Read ~/.claude/tasks//*.json] C --> F[Read ~/.claude/projects//.history.jsonl] B -- Yes --> G[Remote mode] G --> H[Send HTTP request to ccsinfo server] H --> I[Server reads its own ~/.claude] I --> J[Results come from the server host and user account] ``` In `src/ccsinfo/cli/main.py`, remote mode is enabled only by `--server-url` or `CCSINFO_SERVER_URL`: ```python server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ``` And in `src/ccsinfo/utils/paths.py`, local mode always starts from the current user's home directory: ```python def get_claude_base_dir() -> Path: """Get the base Claude Code directory (~/.claude).""" return Path.home() / ".claude" def get_projects_dir() -> Path: """Get the projects directory (~/.claude/projects).""" return get_claude_base_dir() / "projects" def get_tasks_dir() -> Path: """Get the tasks directory (~/.claude/tasks).""" return get_claude_base_dir() / "tasks" ``` > **Warning:** In local mode, `ccsinfo` reads the `~/.claude` directory for the user running the command. If you run it with `sudo`, inside a container, over SSH, or from a service, it may be reading a different home directory than you expect. ## Missing `~/.claude` Data If `projects list`, `sessions list`, or `tasks list` comes back empty in local mode, start by checking whether the expected Claude Code files exist for the same user account that is running `ccsinfo`. The code expects these local locations: - `~/.claude/projects//.jsonl` - `~/.claude/projects//.history.jsonl` - `~/.claude/tasks//.json` That layout also appears in the test fixture in `tests/conftest.py`: ```python project_dir = projects_dir / "-home-user-test-project" session_file = project_dir / "abc-123-def-456.jsonl" session_tasks_dir = tasks_dir / "abc-123-def-456" task_file = session_tasks_dir / "1.json" ``` If your data feels "partially missing", check these cases: - `projects list` only looks under `~/.claude/projects` - `tasks list` only looks under `~/.claude/tasks` - `search history` only sees projects that actually have a `.history.jsonl` file - remote mode ignores your local machine entirely and reads the server's `~/.claude` > **Note:** It is normal for `search history` to return nothing if `.history.jsonl` does not exist, even when sessions are present. ## Empty Search Results A common source of confusion is using the wrong search command. ### `search sessions` does not search message text In `src/ccsinfo/core/services/search_service.py`, session search only checks session metadata: ```python searchable_fields = [ session.session_id, session.slug or "", session.cwd or "", session.git_branch or "", project_path, ] ``` That means `ccsinfo search sessions "refactor parser"` will not find a prompt or reply unless that text also appears in the session ID, slug, working directory, branch, or decoded project path. Use these commands based on what you want: - `ccsinfo search sessions ""` for session ID, project path, branch, slug, or working directory - `ccsinfo search messages ""` for user/assistant text content - `ccsinfo search history ""` for prompt history stored in `.history.jsonl` ### `search messages` only indexes text blocks In `src/ccsinfo/core/models/messages.py`, message text is built from `TextContent` blocks only: ```python for block in self.message.content: if isinstance(block, TextContent): texts.append(block.text) ``` That has two practical effects: - tool calls are not searchable as message text - a message with only tool-use content can exist, but still produce an empty text preview If `ccsinfo sessions messages ` shows `Content Preview` as ``, that message will not be matched by `search messages`. When that happens: - use `ccsinfo sessions tools ` to inspect tool activity - use `ccsinfo search history` if you are looking for the original prompt text - increase `--limit` if you expect more matches than the default output size > **Tip:** If you are looking for something you typed, `search history` is often the best first check. If you are looking for something Claude replied with, use `search messages`. ## Project ID Confusion Project IDs in `ccsinfo` are not plain project names. They are encoded directory names derived from the original project path. In `src/ccsinfo/utils/paths.py`: ```python def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' Example: '/home/user/project' -> '-home-user-project' """ return project_path.replace("/", "-").replace(".", "-") def decode_project_path(encoded_path: str) -> str: """Decode a Claude Code directory name back to the original path. Note: This is lossy - we cannot distinguish between original '-' and encoded '/' or '.'. The path returned should be treated as approximate. """ # Handle the pattern where /. becomes -- result = encoded_path.replace("--", "/.") result = result.replace("-", "/") return result ``` This explains several confusing behaviors: - the project `id` is the encoded directory name, not the friendly project name - the decoded `path` is approximate, not guaranteed exact - projects with `-` or `.` in their path can look odd when decoded back - the safest thing to use in commands is the full `id`, not the displayed `path` A real example from `tests/test_utils_paths.py`: ```python path = "/home/user/.config/project" encoded = encode_project_path(path) assert encoded == "-home-user--config-project" ``` ### What to do - For `ccsinfo projects show`, use the exact `id` - For `ccsinfo sessions list --project`, use the same full project `id` - Do not manually reconstruct project IDs from the filesystem path - If the project path shown by `ccsinfo` looks approximate, trust the `id` instead > **Tip:** `ccsinfo projects list` shortens long IDs in table output. Use `ccsinfo projects list --json` when you need a copyable full project ID. ## Task Lookup Issues The most important rule is: task IDs are only unique within a session. That rule is enforced in `src/ccsinfo/server/routers/tasks.py`: ```python @router.get("/{task_id}", response_model=Task) async def get_task( task_id: str, session_id: str = Query(..., description="Session ID (required since task IDs are only unique within a session)"), ) -> Task: ``` And task files are loaded from a session-specific directory in `src/ccsinfo/core/parsers/tasks.py`: ```python tasks_dir = get_tasks_directory() / session_id if not tasks_dir.exists(): logger.debug("No tasks directory found for session %s", session_id) return TaskCollection(session_id=session_id, tasks=[]) ``` ### What this means in practice - `ccsinfo tasks show ` is not enough - you must also provide `--session ` - the same task ID, such as `1`, can appear in multiple sessions - `tasks list` and `tasks pending` do not show a session column, so duplicate task IDs are expected A reliable workflow is: 1. Get the full session UUID with `ccsinfo sessions list --json` 2. Narrow tasks to that session with `ccsinfo tasks list --session ` 3. Show the specific task with `ccsinfo tasks show --session ` If you are using status filters, the valid values are: - `pending` - `in_progress` - `completed` > **Warning:** If you see `Task not found` for a task ID that definitely exists, the wrong session ID is the first thing to check. ## Session Lookup Issues There is one especially confusing edge case: the CLI help says a session ID "can be partial", but the current lookup path uses an exact filename match. In `src/ccsinfo/core/parsers/sessions.py`: ```python session_file = project_dir / f"{session_id}.jsonl" if session_file.exists(): return parse_session_file(session_file) ``` That means the safest approach today is: - use the full session UUID - get it from `ccsinfo sessions list --json` - do not rely on the shortened 12-character table output This matters because the human-friendly tables show only part of the session ID: - `sessions list` shows the first 12 characters - `sessions active` shows the first 12 characters - search result tables also show shortened session IDs > **Tip:** If you copied a session ID from a table and a follow-up command says `Session not found`, try the full UUID from `--json` before assuming the session is missing. ## Local vs Remote Mode Problems Remote mode is useful, but it is also the easiest way to accidentally query the wrong machine. Once `--server-url` or `CCSINFO_SERVER_URL` is set, commands use the HTTP client instead of local file parsing. There is no automatic fallback to your local `~/.claude`. > **Warning:** If a server URL is set, your local Claude data is ignored until you remove `--server-url` or unset `CCSINFO_SERVER_URL`. ### Starting and checking a server In `src/ccsinfo/cli/main.py`, the built-in server defaults to `127.0.0.1:8080`: ```python def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` That means: - `ccsinfo serve` is reachable only from the same machine by default - if you want network access from another machine, start it with `--host 0.0.0.0` Useful checks: ```bash ccsinfo serve ccsinfo --server-url http://127.0.0.1:8080 projects list curl http://127.0.0.1:8080/health curl http://127.0.0.1:8080/info ``` If you are troubleshooting a remote server, remember: - `/health` only tells you the API is up - `/info` also shows basic counts such as total sessions and total projects - the server reads the `~/.claude` directory of the user account running the server process ### Why remote mode can look like "missing data" In remote mode, these situations are common: - the server is healthy, but its own `~/.claude` is empty - the server is running as a different user than the one who has the Claude data - the server is on another machine, so `sessions active` reflects that machine's active Claude processes, not yours - a `show` command may report `not found` even when the real problem is connectivity or a server-side error If a remote `show` command says something is not found and that seems unlikely, verify the server first with `/health` and `/info`. ## Why `sessions active` Can Be Wrong or Empty Active-session detection is different from reading saved session files. In local mode, `ccsinfo` looks for running `claude` processes and scans `/proc` for session UUIDs. That result is cached for 5 seconds. Practical consequences: - a session can exist on disk but not appear in `sessions active` - a newly started or just-finished session can take a few seconds to appear or disappear - if `claude` is running under another user, another container, or another machine, local active-session detection will not see it > **Note:** If `sessions active` looks stale, wait a few seconds and retry before assuming the session tracker is broken. ## When Data Looks Incomplete Instead of Fully Missing `ccsinfo` is deliberately forgiving when it parses Claude files: - malformed JSONL lines are skipped - invalid task JSON files are ignored - parsing continues instead of failing fast That is useful for resilience, but it also means corrupted files can show up as: - missing messages - missing history entries - missing tasks - lower-than-expected counts > **Note:** If only part of a session or task list is missing, inspect the underlying files in `~/.claude` for malformed or partially written JSON. ## Useful Commands When you are troubleshooting, these are the most useful commands to keep handy: ```bash # Get full, copyable project IDs ccsinfo projects list --json # Get full, copyable session IDs ccsinfo sessions list --json # Check whether a specific session exists ccsinfo sessions show # Inspect text messages in a session ccsinfo sessions messages # Inspect tool-only activity in a session ccsinfo sessions tools # List tasks for one known session ccsinfo tasks list --session # Show one task from one session ccsinfo tasks show --session # Search by session metadata ccsinfo search sessions "" # Search by message text ccsinfo search messages "" # Search prompt history ccsinfo search history "" ``` If you are using the API in remote mode, these are the most direct equivalents: ```bash curl http://127.0.0.1:8080/health curl http://127.0.0.1:8080/info curl "http://127.0.0.1:8080/sessions?limit=50" curl "http://127.0.0.1:8080/sessions//tasks" curl "http://127.0.0.1:8080/tasks/?session_id=" ``` When in doubt, start with this checklist: 1. Confirm whether you are in local mode or remote mode. 2. Use `--json` to get full IDs. 3. Use the full session UUID, not the shortened table display. 4. Keep the session ID with any task ID you want to inspect. 5. Use the right search command for the kind of content you want. ## Related Pages - [Configuration](configuration.html) - [Project IDs and Lookups](project-ids-and-lookups.html) - [Active Session Detection](active-session-detection.html) - [Working with Tasks](tasks-guide.html) - [Searching Sessions, Messages, and History](search-guide.html) --- Source: development-setup.md # Development Setup `ccsinfo` is a single Python project with a `src/` layout. The same package powers both the Typer CLI and the optional FastAPI server, and both are designed to work directly with Claude Code data on disk. You do not need a database, a frontend toolchain, or containers to start developing locally. ## Before You Install - Python `3.12` or newer - `uv` for dependency management and command execution - Optional Claude Code data under `~/.claude` if you want to test against real sessions instead of fixtures > **Tip:** Use Python `3.12` if you want your local environment to match the repository's default `tox` setup exactly. The package allows `>=3.12`, but `tox` only defines `py312`. ```31:75:pyproject.toml [project] name = "ccsinfo" version = "0.1.2" description = "Claude Code Session Info CLI and Server" readme = "README.md" license = "MIT" requires-python = ">=3.12" authors = [{ name = "Meni Yakove", email = "myakove@gmail.com" }] keywords = ["claude", "claude-code", "cli", "sessions", "api"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] dependencies = [ "typer>=0.9.0", "rich>=13.0.0", "orjson>=3.9.0", "pydantic>=2.0.0", "pendulum>=3.0.0", "fastapi>=0.109.0", "uvicorn[standard]>=0.27.0", "httpx>=0.27.0", ] [project.optional-dependencies] dev = [ "pytest>=7.4.0", "pytest-cov>=4.1.0", "pytest-asyncio>=0.21.0", "pytest-xdist>=3.5.0", "ruff>=0.1.0", "mypy>=1.5.0", "tox>=4.0.0", ] [project.scripts] ccsinfo = "ccsinfo.cli.main:main" ``` ```1:10:tox.ini [tox] envlist = py312 isolated_build = true [testenv] allowlist_externals = uv commands = uv sync --extra dev uv run pytest -n auto {posargs:tests} ``` ## `uv` And Editable Installs `uv` is the repository's default workflow. Start with a full development sync: ```bash uv sync --extra dev uv run ccsinfo --version ``` That sync is already editable. The lockfile records the local package as `source = { editable = "." }`, so changes under `src/ccsinfo` are picked up immediately without a separate reinstall step. ```46:69:uv.lock [[package]] name = "ccsinfo" version = "0.1.2" source = { editable = "." } dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "orjson" }, { name = "pendulum" }, { name = "pydantic" }, { name = "rich" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] dev = [ { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, { name = "ruff" }, { name = "tox" }, ] ``` ## Source Layout This is a conventional `src/` layout project. The importable package lives in `src/ccsinfo`. - `src/ccsinfo/cli`: the Typer application, subcommands, and CLI state - `src/ccsinfo/core/parsers`: low-level readers for Claude Code JSON and JSONL files - `src/ccsinfo/core/services`: shared business logic used by both CLI and API - `src/ccsinfo/core/models`: Pydantic models returned by services and the API - `src/ccsinfo/server`: the FastAPI application and route handlers - `src/ccsinfo/utils`: path discovery and terminal formatting helpers - `tests`: unit and integration-style tests, including temporary `.claude` fixture builders > **Note:** The top-level `ccsinfo/` directory in the repository is not the runtime package. The Python code you import and edit lives under `src/ccsinfo`. The CLI and server mirror the same main domains: ```13:33:src/ccsinfo/cli/main.py app = typer.Typer( name="ccsinfo", help="Claude Code Session Info CLI", no_args_is_help=True, ) # Add command groups app.add_typer(sessions.app, name="sessions", help="Session management") app.add_typer(projects.app, name="projects", help="Project management") app.add_typer(tasks.app, name="tasks", help="Task management") app.add_typer(stats.app, name="stats", help="Statistics") app.add_typer(search.app, name="search", help="Search") @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ``` ```8:20:src/ccsinfo/server/app.py app = FastAPI( title="ccsinfo", description="Claude Code Session Info API", version=__version__, ) # Include routers app.include_router(sessions.router, prefix="/sessions", tags=["sessions"]) app.include_router(projects.router, prefix="/projects", tags=["projects"]) app.include_router(tasks.router, prefix="/tasks", tags=["tasks"]) app.include_router(stats.router, prefix="/stats", tags=["stats"]) app.include_router(search.router, prefix="/search", tags=["search"]) app.include_router(health.router, tags=["health"]) ``` ```mermaid flowchart TD A["~/.claude/projects//.jsonl"] --> P["Parsers (`core/parsers`)"] B["~/.claude/projects//.history.jsonl"] --> P C["~/.claude/tasks//*.json"] --> P P --> S["Services (`core/services`)"] S --> CLI["Typer CLI (`cli`)"] S --> API["FastAPI routers (`server`)"] CLI -->|when `--server-url` is set| HC["HTTP client (`core/client`)"] HC --> API ``` ## Local Claude Code Data By default, `ccsinfo` is local-first. It looks for Claude Code data in your home directory, not inside the repository. ```8:50:src/ccsinfo/utils/paths.py def get_claude_base_dir() -> Path: """Get the base Claude Code directory (~/.claude).""" return Path.home() / ".claude" def get_projects_dir() -> Path: """Get the projects directory (~/.claude/projects).""" return get_claude_base_dir() / "projects" def get_tasks_dir() -> Path: """Get the tasks directory (~/.claude/tasks).""" return get_claude_base_dir() / "tasks" def encode_project_path(project_path: str) -> str: """Encode a project path to Claude Code's directory name format. Claude Code replaces: - '/' with '-' - '.' with '-' Example: '/home/user/project' -> '-home-user-project' """ return project_path.replace("/", "-").replace(".", "-") def decode_project_path(encoded_path: str) -> str: """Decode a Claude Code directory name back to the original path. ... """ result = encoded_path.replace("--", "/.") result = result.replace("-", "/") return result def get_project_dir(project_path: str) -> Path: """Get the Claude data directory for a project path.""" encoded = encode_project_path(project_path) return get_projects_dir() / encoded ``` That detail matters when you inspect test data or API output: a "project ID" in this repository is usually the encoded directory name from `~/.claude/projects`, not a generated database key. > **Note:** If your machine does not have any Claude Code data yet, local list commands will simply return empty results. The application does not seed demo data for you. The tests intentionally model the same directory layout with temporary fixtures instead of touching your real home directory: ```84:112:tests/conftest.py @pytest.fixture def mock_claude_dir( tmp_path: Path, sample_session_data: list[dict[str, Any]], sample_task_data: dict[str, Any] ) -> Path: """Create a fully populated mock .claude directory.""" claude_dir = tmp_path / ".claude" # Create projects directory with a sample project projects_dir = claude_dir / "projects" project_dir = projects_dir / "-home-user-test-project" project_dir.mkdir(parents=True) # Create a session file in the project session_file = project_dir / "abc-123-def-456.jsonl" ... # Create tasks directory with a session's tasks tasks_dir = claude_dir / "tasks" session_tasks_dir = tasks_dir / "abc-123-def-456" session_tasks_dir.mkdir(parents=True) ... return claude_dir ``` > **Tip:** When you add parser or service tests, follow this fixture pattern. It is easier to reason about than mocking every file read, and it matches the real runtime layout. ## CLI And Server Workflows One codebase supports two development modes: - Local mode: CLI commands read directly from `~/.claude` - Remote mode: the same CLI commands call the HTTP API when `--server-url` or `CCSINFO_SERVER_URL` is set ```27:63:src/ccsinfo/cli/main.py @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"), port: int = typer.Option(8080, "--port", "-p", help="Port to bind"), ) -> None: """Start the API server.""" uvicorn.run(fastapi_app, host=host, port=port) ... @app.callback() def main_callback( _version: bool | None = typer.Option( None, "--version", "-v", help="Show version information.", callback=version_callback, is_eager=True, ), server_url: str | None = typer.Option( None, "--server-url", "-s", envvar="CCSINFO_SERVER_URL", help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.", ), ) -> None: """Claude Code Session Info CLI.""" state.server_url = server_url ``` The command handlers really do switch between the local service layer and the HTTP client: ```39:73:src/ccsinfo/cli/commands/sessions.py client = get_client(state.server_url) if client: # Remote mode - use HTTP client sessions_data = client.list_sessions(project_id=project, active_only=active, limit=limit) ... else: # Local mode - use services session_service = _get_session_service() sessions = session_service.list_sessions(project_id=project, active_only=active, limit=limit) ``` Typical local development commands look like this: ```bash uv run ccsinfo sessions list uv run ccsinfo serve --host 127.0.0.1 --port 8080 uv run ccsinfo --server-url http://127.0.0.1:8080 sessions list ``` If you are working on the API layer, the FastAPI app exposes `sessions`, `projects`, `tasks`, `stats`, `search`, `health`, and `info` endpoints from the same shared service layer. ## Tests, Typing, And Automation The repository's main automation is local. There is no checked-in `.github/workflows/` directory and no Dockerfile, so `tox`, `pytest`, and pre-commit are the practical source of truth for development checks. `tox` is intentionally thin: it uses `uv` to sync the dev environment and then runs `pytest` with `xdist` (`-n auto`). The rest of the quality policy lives in `pyproject.toml` and `.pre-commit-config.yaml`. ```1:25:pyproject.toml [tool.ruff] preview = true line-length = 120 fix = true output-format = "grouped" [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "PLC0415", "ARG", "RUF059"] [tool.ruff.format] exclude = [".git", ".venv", ".mypy_cache", ".tox", "__pycache__"] [tool.mypy] check_untyped_defs = true disallow_any_generics = true disallow_incomplete_defs = true disallow_untyped_defs = true no_implicit_optional = true show_error_codes = true warn_unused_ignores = true strict_equality = true extra_checks = true warn_unused_configs = true warn_redundant_casts = true ``` ```29:60:.pre-commit-config.yaml - repo: https://github.com/PyCQA/flake8 rev: 7.3.0 hooks: - id: flake8 args: [--config=.flake8] additional_dependencies: [git+https://github.com/RedHatQE/flake8-plugins.git, flake8-mutable] - repo: https://github.com/Yelp/detect-secrets rev: v1.5.0 hooks: - id: detect-secrets - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.14 hooks: - id: ruff - id: ruff-format - repo: https://github.com/gitleaks/gitleaks rev: v8.30.0 hooks: - id: gitleaks - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.19.1 hooks: - id: mypy exclude: (tests/) ``` A practical loop is: ```bash uv sync --extra dev tox ``` > **Warning:** `.pre-commit-config.yaml` is present, but `pre-commit` itself is not part of the `dev` extra shown in `pyproject.toml`. Install the `pre-commit` tool separately if you want hooks on your machine. ## Environment Notes Most of the codebase is plain Python and file I/O, but active-session detection is more environment-specific. If you work on the "is this Claude session running right now?" behavior, the implementation shells out to `pgrep` and inspects Linux-style `/proc` paths. ```254:295:src/ccsinfo/core/parsers/sessions.py result = subprocess.run( ["pgrep", "-f", "claude"], capture_output=True, text=True, timeout=5, ) ... cmdline_path = Path(f"/proc/{pid}/cmdline") ... environ_path = Path(f"/proc/{pid}/environ") ... fd_dir = Path(f"/proc/{pid}/fd") ... if ".claude/tasks/" in target_str or ".claude/projects/" in target_str: active_ids.update(uuid_pattern.findall(target_str)) ``` > **Warning:** That part of the code is easiest to develop and debug on Linux. If you are working on parsers, services, models, or docs, you can ignore this and stick to fixture-based tests. This setup is intentionally lightweight: install with `uv`, work inside `src/ccsinfo`, use temporary `.claude` fixtures for repeatable tests, and only bring up the FastAPI server when you want to exercise the HTTP path. ## Related Pages - [Architecture and Project Structure](architecture-and-project-structure.html) - [Data Model and Storage](data-model-and-storage.html) - [Testing and Quality Checks](testing-and-quality.html) - [Automation and CI](automation-and-ci.html) - [Installation](installation.html) --- Source: testing-and-quality.md # Testing and Quality Checks `ccsinfo` uses a layered quality workflow rather than one all-in-one script. `tox` is the repeatable test entry point, `pytest` runs the suite, `pytest-xdist` parallelizes it, `ruff` handles formatting and most linting, `mypy` enforces typing, and `pre-commit` bundles repository hygiene, `flake8`, and secret scanning. ## At a Glance - `tox` runs the canonical automated test environment on Python 3.12. - `pytest` discovers tests under `tests/`. - `pytest-xdist` is enabled by default in tox via `-n auto`. - `ruff` formats code and runs the primary lint pass. - `mypy` uses strict settings for source code. - `flake8` still runs as an additional check inside `pre-commit`. - `pre-commit` also runs secret scanners before code is committed. ```mermaid flowchart TB Dev[Developer] --> Tox[tox] Dev --> PC[pre-commit] Tox --> UV[uv sync --extra dev] Tox --> Pytest[pytest -n auto] Pytest --> Tests[tests/] PC --> Ruff[Ruff lint + format] PC --> Flake8[Flake8 + plugins] PC --> Mypy[mypy] PC --> Secrets[detect-private-key
detect-secrets
gitleaks] Ruff --> Src[src/ccsinfo] Flake8 --> Src Mypy --> Src Secrets --> Repo[repository content] ``` ## Setup and Quick Start The tox definition is intentionally small and easy to reason about: ```1:10:tox.ini [tox] envlist = py312 isolated_build = true [testenv] allowlist_externals = uv commands = uv sync --extra dev uv run pytest -n auto {posargs:tests} ``` The `dev` extra contains the core test and static-analysis tools that tox depends on: ```63:71:pyproject.toml [project.optional-dependencies] dev = [ "pytest>=7.4.0", "pytest-cov>=4.1.0", "pytest-asyncio>=0.21.0", "pytest-xdist>=3.5.0", "ruff>=0.1.0", "mypy>=1.5.0", "tox>=4.0.0", ] ``` A practical starting point is: ```bash uv sync --extra dev tox ``` To narrow the tox run to one part of the suite, pass pytest arguments after `--`, for example `tox -- tests/test_parsers.py`. The project metadata declares Python `>=3.12`, but tox currently standardizes on a single `py312` environment, so that is the canonical automated test target. > **Note:** `pre-commit` is configured in this repository, but it is not part of the `dev` extra shown above. `flake8` is also hook-managed rather than installed by `uv sync --extra dev`. In practice, that means tox is the built-in path for tests, while `pre-commit` must be available separately if you want hook-based runs. ## Pytest Pytest is configured in `pyproject.toml` rather than a separate `pytest.ini`: ```92:107:pyproject.toml [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" addopts = ["-v", "--tb=short", "--strict-markers"] [tool.coverage.run] source = ["src/ccsinfo"] branch = true [tool.coverage.report] exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "if __name__ == .__main__.:", "raise NotImplementedError", ] ``` That configuration has a few practical consequences: - Test discovery is limited to `tests/`. - Runs are verbose by default, with shorter tracebacks. - Unknown pytest markers are treated as errors because of `--strict-markers`. - Coverage settings are ready to use, but coverage is not turned on by default by tox or by `addopts`. The checked-in suite is organized around project boundaries: - `tests/test_models.py` checks Pydantic models, enums, aliases, and helper properties. - `tests/test_parsers.py` checks JSON and JSONL parsing, malformed input handling, and iterator helpers. - `tests/test_services.py` covers service-layer behavior, including patched data sources. - `tests/test_utils_paths.py` checks path encoding, decoding, and filesystem helpers around `~/.claude`. The fixtures are designed to build realistic temporary Claude Code data layouts without touching a real home directory: ```84:112:tests/conftest.py @pytest.fixture def mock_claude_dir( tmp_path: Path, sample_session_data: list[dict[str, Any]], sample_task_data: dict[str, Any] ) -> Path: """Create a fully populated mock .claude directory.""" claude_dir = tmp_path / ".claude" # Create projects directory with a sample project projects_dir = claude_dir / "projects" project_dir = projects_dir / "-home-user-test-project" project_dir.mkdir(parents=True) # Create a session file in the project session_file = project_dir / "abc-123-def-456.jsonl" with session_file.open("w") as f: for entry in sample_session_data: f.write(json.dumps(entry) + "\n") # Create tasks directory with a session's tasks tasks_dir = claude_dir / "tasks" session_tasks_dir = tasks_dir / "abc-123-def-456" session_tasks_dir.mkdir(parents=True) # Create a task file task_file = session_tasks_dir / "1.json" with task_file.open("w") as f: json.dump(sample_task_data, f) return claude_dir ``` A representative parser test shows the kind of failure handling the suite expects: ```135:153:tests/test_parsers.py def test_parse_jsonl_skip_malformed_default(self, tmp_path: Path) -> None: """Test that malformed lines are skipped by default.""" content = '{"a": 1}\nnot json\n{"b": 2}' file_path = tmp_path / "test.jsonl" file_path.write_text(content) results = list(parse_jsonl(file_path)) assert len(results) == 2 assert results[0]["a"] == 1 assert results[1]["b"] == 2 def test_parse_jsonl_raise_on_malformed(self, tmp_path: Path) -> None: """Test that malformed lines raise when skip_malformed=False.""" content = '{"a": 1}\nnot json\n{"b": 2}' file_path = tmp_path / "test.jsonl" file_path.write_text(content) with pytest.raises(orjson.JSONDecodeError): list(parse_jsonl(file_path, skip_malformed=False)) ``` > **Note:** `pytest-asyncio` is installed and `asyncio_mode = "auto"` is enabled, but the current checked-in suite is synchronous. > **Tip:** The current tests are a good fit for parallel execution because they lean on `tmp_path`, temporary files, and mocks instead of shared mutable global state. ### Coverage `pytest-cov` is part of the `dev` extra, and coverage settings are already in `pyproject.toml`, but tox does not add `--cov` by default. If you want coverage output, run `pytest` directly with `pytest-cov` options rather than relying on the default tox command. ## Tox and xdist `tox` currently defines a single environment, `py312`, and always runs `pytest` with `-n auto`. That means parallel test execution is the default behavior whenever you use tox. This is the key xdist integration in the repo: ```7:10:tox.ini commands = uv sync --extra dev uv run pytest -n auto {posargs:tests} ``` In practice: - `-n auto` lets `pytest-xdist` choose a worker count automatically. - `{posargs:tests}` means tox forwards extra pytest arguments. - `tox -- tests/test_parsers.py` is a simple way to narrow a run while keeping the same tox-managed environment. > **Warning:** Because tox hardcodes `-n auto`, it is not the best entry point for debugging order-sensitive or timing-sensitive failures. For that kind of investigation, run `pytest` directly so you can control whether parallelism is enabled. ## Ruff Formatting and Linting Ruff is the primary formatter and linter configured in the repository: ```1:11:pyproject.toml [tool.ruff] preview = true line-length = 120 fix = true output-format = "grouped" [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "PLC0415", "ARG", "RUF059"] [tool.ruff.format] exclude = [".git", ".venv", ".mypy_cache", ".tox", "__pycache__"] ``` This setup means: - Formatting and linting are both handled by Ruff. - The line length target is `120`. - Ruff is allowed to auto-fix many issues because `fix = true` is enabled. - Import sorting is covered by `I`. - Extra rule families such as `B` (bugbear), `UP` (pyupgrade), and `ARG` (unused arguments) are enabled in addition to the usual `E`, `F`, and `W` rules. Once the `dev` extra is installed, the common direct Ruff commands are: ```bash uv run ruff format . uv run ruff check . ``` > **Tip:** Because the repo enables Ruff auto-fixes, a lint run can rewrite files. If Ruff touches a large set of files, run the tests again afterward. ## mypy Type checking is strict in this project. The main mypy settings make it clear that type annotations are part of the expected code quality bar, not an optional extra: ```13:25:pyproject.toml [tool.mypy] check_untyped_defs = true disallow_any_generics = true disallow_incomplete_defs = true disallow_untyped_defs = true no_implicit_optional = true show_error_codes = true warn_unused_ignores = true strict_equality = true extra_checks = true warn_unused_configs = true warn_redundant_casts = true ``` Only a narrow override is applied, and it is limited to decorator-heavy CLI and router modules: ```84:90:pyproject.toml [[tool.mypy.overrides]] module = [ "ccsinfo.cli.commands.*", "ccsinfo.cli.main", "ccsinfo.server.routers.*", ] disallow_untyped_decorators = false ``` The source itself is written in a style that benefits from strict checking. For example, the parser layer uses generic return types instead of falling back to untyped helpers: ```21:26:src/ccsinfo/core/parsers/jsonl.py def parse_jsonl[T: "BaseModel"]( file_path: Path, model: type[T] | None = None, *, skip_malformed: bool = True, ) -> Iterator[T | dict[str, Any]]: ``` If you want to run mypy directly, a typical local command is: ```bash uv run mypy src ``` When mypy runs through `pre-commit`, it focuses on source code and brings in a few extra stub packages: ```53:60:.pre-commit-config.yaml - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.19.1 hooks: - id: mypy exclude: (tests/) additional_dependencies: [types-requests, types-PyYAML, types-colorama, types-aiofiles] ``` > **Note:** The pre-commit mypy hook excludes `tests/`, so the hook is intentionally narrower than an unrestricted project-wide type check. ## Flake8 Ruff is the main linter, but `flake8` is still part of the repository’s quality story through `pre-commit`. The dedicated Flake8 config is small: ```1:12:.flake8 [flake8] max-line-length = 120 extend-ignore = E203, E501, W503 exclude = .git, __pycache__, .venv, venv, build, dist, *.egg-info, ``` And the pre-commit hook adds plugin-based checks on top of base Flake8: ```29:35:.pre-commit-config.yaml - repo: https://github.com/PyCQA/flake8 rev: 7.3.0 hooks: - id: flake8 args: [--config=.flake8] additional_dependencies: [git+https://github.com/RedHatQE/flake8-plugins.git, flake8-mutable] ``` This is important for day-to-day use: - `flake8` is not listed in the project `dev` extra. - The supported repo-defined way to run Flake8 is through `pre-commit`. - If `pre-commit` reports a Flake8 failure even though Ruff is happy, the issue may be coming from the extra Flake8 plugins rather than overlapping style rules. A focused Flake8 run looks like this: ```bash pre-commit run flake8 --all-files ``` ## Pre-commit Hooks and Secret Scanners `pre-commit` is the broadest single quality gate in the repository. It combines repository hygiene, Python quality checks, and multiple secret scanners: ```9:60:.pre-commit-config.yaml repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-added-large-files - id: check-docstring-first - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-symlinks - id: detect-private-key - id: mixed-line-ending - id: debug-statements - id: trailing-whitespace args: [--markdown-linebreak-ext=md] # Do not process Markdown files. - id: end-of-file-fixer - id: check-ast - id: check-builtin-literals - id: check-docstring-first - id: check-toml - repo: https://github.com/PyCQA/flake8 rev: 7.3.0 hooks: - id: flake8 args: [--config=.flake8] additional_dependencies: [git+https://github.com/RedHatQE/flake8-plugins.git, flake8-mutable] - repo: https://github.com/Yelp/detect-secrets rev: v1.5.0 hooks: - id: detect-secrets - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.14 hooks: - id: ruff - id: ruff-format - repo: https://github.com/gitleaks/gitleaks rev: v8.30.0 hooks: - id: gitleaks - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.19.1 hooks: - id: mypy exclude: (tests/) additional_dependencies: [types-requests, types-PyYAML, types-colorama, types-aiofiles] ``` In practical terms, `pre-commit` gives you three layers of protection: - File hygiene checks catch common problems such as merge conflicts, trailing whitespace, missing end-of-file newlines, malformed AST/TOML, debug statements, and accidental large files. - Python quality checks run Ruff, Flake8, and mypy together. - Secret scanning is layered: `detect-private-key` looks for private keys, `detect-secrets` adds general secret detection, and `gitleaks` adds another credential-focused scan. A common local workflow is: ```bash pre-commit install pre-commit run --all-files ``` > **Warning:** Secret-detection failures should be treated seriously. If a hook flags a key, token, or other credential-like string, remove it from the change or rotate it before proceeding. The repository also includes pre-commit.ci metadata: ```5:7:.pre-commit-config.yaml ci: autofix_prs: false autoupdate_commit_msg: "ci: [pre-commit.ci] pre-commit autoupdate" ``` > **Note:** `autofix_prs: false` means pre-commit.ci will not push automatic fix commits if the repository is connected to that service. Local hook runs can still rewrite files when an auto-fixing hook supports it. ## CI/CD Status No checked-in GitHub Actions, GitLab CI, or similar pipeline files are present in this repository. The only CI-related configuration found in the codebase is the `ci:` section in `.pre-commit-config.yaml`. That means the documented quality gates in this repo are primarily local: 1. Run `tox` for the project’s canonical test environment. 2. Run `pre-commit run --all-files` if you want the full hook suite, including Flake8 and secret scanning. 3. Run focused direct tools such as `uv run ruff check .` or `uv run mypy src` when you are iterating on a specific issue. > **Tip:** If you want a “before I commit” routine that matches the repository configuration closely, `tox` plus `pre-commit run --all-files` is the most complete combination available from the checked-in files. ## Related Pages - [Development Setup](development-setup.html) - [Automation and CI](automation-and-ci.html) - [Architecture and Project Structure](architecture-and-project-structure.html) - [Data Model and Storage](data-model-and-storage.html) --- Source: automation-and-ci.md # Automation and CI If you are changing `ccsinfo` locally, there are two checked-in automation layers to know about: `tox` for the test suite and `pre-commit` for code-quality checks. There are currently no checked-in GitHub Actions or other CI pipeline files, so the automation visible in the repository is mainly local. > **Note:** `tox` and `pre-commit` are separate. `tox` runs tests, while `pre-commit` handles linting, formatting, type checking, and secret scanning. ```mermaid flowchart TD A[Change code in src/ccsinfo or tests/] --> B[Run pre-commit] A --> C[Run tox] B --> D[Repository hygiene checks] B --> E[Lint and formatting] B --> F[Type and secret checks] C --> G[uv sync --extra dev] G --> H[uv run pytest -n auto tests] H --> I[tests/ exercises src/ccsinfo] ``` ## `tox`: the test runner The checked-in `tox` configuration is short and focused: ```ini [tox] envlist = py312 isolated_build = true [testenv] allowlist_externals = uv commands = uv sync --extra dev uv run pytest -n auto {posargs:tests} ``` This setup tells you a few important things right away: - only one tox environment is declared: `py312` - `tox` delegates dependency sync and command execution to `uv` - the default test command is `pytest -n auto`, so the suite uses parallel workers through `pytest-xdist` - `tests` is the default target, but `tox` can pass a narrower path through `posargs` The isolated build step matches the `hatchling` build backend declared in `pyproject.toml`, and the development dependencies come from the checked-in `dev` extra: ```toml [project.optional-dependencies] dev = [ "pytest>=7.4.0", "pytest-cov>=4.1.0", "pytest-asyncio>=0.21.0", "pytest-xdist>=3.5.0", "ruff>=0.1.0", "mypy>=1.5.0", "tox>=4.0.0", ] ``` `pytest` is also configured in `pyproject.toml`: ```toml [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" addopts = ["-v", "--tb=short", "--strict-markers"] ``` In practice, that means: - the default suite lives under `tests/` - test output is verbose - tracebacks are shortened - marker handling is strict The current suite is organized as ordinary `pytest` modules such as `tests/test_parsers.py`, `tests/test_models.py`, `tests/test_utils_paths.py`, and `tests/test_services.py`, with shared fixtures in `tests/conftest.py`. Those tests exercise code under the `src/ccsinfo` layout. The repository also checks in `uv.lock`, so `uv sync --extra dev` has a lockfile available when syncing the development environment. > **Tip:** Because `tox` uses `{posargs:tests}`, you can point it at a smaller target when you only want to run one part of the suite. ## What `tox` does not currently cover The test automation is useful, but its scope is intentionally narrow. - There is no multi-version tox matrix beyond `py312`. - Linting, formatting, typing, and secret scanning are not part of the default `tox` command. - Coverage settings exist, but the default `tox` command does not turn them on. Coverage is already configured in `pyproject.toml`: ```toml [tool.coverage.run] source = ["src/ccsinfo"] branch = true [tool.coverage.report] exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "if __name__ == .__main__.:", "raise NotImplementedError", ] ``` > **Note:** `pytest-cov` and coverage settings are checked in, but `tox` currently runs plain `pytest` rather than `pytest --cov ...`. ## `pre-commit`: linting, formatting, typing, and secret checks Most of the repository’s non-test automation lives in `.pre-commit-config.yaml`. At the top of that file, the project sets a general Python hook version and defines optional `pre-commit.ci` behavior: ```yaml default_language_version: python: python3 ci: autofix_prs: false autoupdate_commit_msg: "ci: [pre-commit.ci] pre-commit autoupdate" ``` The hook set is broad. A selected excerpt shows the main categories: ```yaml repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-added-large-files - id: check-merge-conflict - id: detect-private-key - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - id: check-toml - repo: https://github.com/PyCQA/flake8 rev: 7.3.0 hooks: - id: flake8 args: [--config=.flake8] additional_dependencies: [git+https://github.com/RedHatQE/flake8-plugins.git, flake8-mutable] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.14 hooks: - id: ruff - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.19.1 hooks: - id: mypy exclude: (tests/) additional_dependencies: [types-requests, types-PyYAML, types-colorama, types-aiofiles] - repo: https://github.com/Yelp/detect-secrets rev: v1.5.0 hooks: - id: detect-secrets - repo: https://github.com/gitleaks/gitleaks rev: v8.30.0 hooks: - id: gitleaks ``` In plain language, the checked-in hooks cover four major areas: - repository hygiene, such as merge conflicts, file endings, whitespace, and large files - Python linting and formatting through `flake8`, `ruff`, and `ruff-format` - static type checking through `mypy` - secret and credential scanning through `detect-private-key`, `detect-secrets`, and `gitleaks` Those hooks are backed by checked-in tool settings. `flake8` uses `.flake8`: ```ini [flake8] max-line-length = 120 extend-ignore = E203, E501, W503 ``` `ruff` and `mypy` are configured in `pyproject.toml`: ```toml [tool.ruff] preview = true line-length = 120 fix = true output-format = "grouped" [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "PLC0415", "ARG", "RUF059"] [tool.mypy] check_untyped_defs = true disallow_any_generics = true disallow_incomplete_defs = true disallow_untyped_defs = true no_implicit_optional = true show_error_codes = true warn_unused_ignores = true strict_equality = true extra_checks = true warn_unused_configs = true warn_redundant_casts = true ``` A few practical implications follow from that setup: - the lint stack is layered rather than minimal, because both `flake8` and `ruff` run - `mypy` is configured with a strict baseline - the pre-commit type-check step targets application code and excludes `tests/` - secret scanning is also layered, because multiple tools look for different kinds of leaks - some hooks can rewrite files for you, especially formatting and whitespace-related hooks > **Tip:** If a hook rewrites files, rerun the hooks and restage the results before committing. One small gap is worth knowing about: the `dev` extra includes `pytest`, `ruff`, `mypy`, and `tox`, but it does **not** include `pre-commit` itself. If you want to use the checked-in hook configuration locally, you will need `pre-commit` installed separately. ## Current CI status No checked-in CI pipeline definitions are present in the repository. There is no `.github/workflows/` directory, and there are no checked-in files such as `.gitlab-ci.yml`, `.circleci/`, `Jenkinsfile`, `azure-pipelines.yml`, or similar pipeline definitions. That has two practical consequences: - nothing in the repository itself shows `tox` or `pre-commit` being run automatically on push or pull request - the visible automation story is local development automation, not repo-defined CI orchestration > **Warning:** The `ci:` block in `.pre-commit-config.yaml` is only configuration for `pre-commit.ci` if that external service has been enabled elsewhere. By itself, it does not create a GitHub Actions workflow or any other checked-in CI pipeline. ## Practical local workflow Given the current setup, the safest way to validate changes locally is to run both the test path and the pre-commit path: ```bash uv sync --extra dev tox pre-commit run --all-files ``` If you want checks to run automatically at commit time, install `pre-commit` separately and enable its git hook in your local clone. Until a checked-in CI pipeline is added, passing these local checks is the best representation of the automation the repository currently defines. ## Related Pages - [Testing and Quality Checks](testing-and-quality.html) - [Development Setup](development-setup.html) - [Architecture and Project Structure](architecture-and-project-structure.html) - [Installation](installation.html) ---