diff --git a/docs/integrations/claude-code.md b/docs/integrations/claude-code.md new file mode 100644 index 000000000..3625ab330 --- /dev/null +++ b/docs/integrations/claude-code.md @@ -0,0 +1,254 @@ + + + +# Claude Code + +Claude Code is a first-class integration in Observal with full support for agents, skills, +hooks, MCP servers, session parsing, and OTLP telemetry. + +--- + +## Overview + +When Claude Code is connected to Observal, every session is captured automatically. +Two lightweight hooks (`UserPromptSubmit` and `Stop`) fire at the start and end of each +conversation. Each hook invokes `observal_cli.hooks.session_push`, which reads the session +JSONL file **incrementally** (byte-cursor based, not event-by-event) and ships new lines +to the Observal ingest endpoint. The result is a full trace of prompts, tool calls, +thinking blocks, and token usage visible in the Observal dashboard with no manual +instrumentation. + +--- + +## Supported features + +| Feature | Supported | +|---|---| +| Agents (subagents) | project and user scope | +| Skills | project and user scope | +| Hooks (event bridge) | `UserPromptSubmit`, `Stop`, `PreToolUse`, `PostToolUse`, `Notification`, `SubagentStop` | +| MCP servers | via `~/.claude.json` global config | +| Session parsing | full JSONL parser (prompts, tool use, tool results, thinking, token usage) | +| Subagent sessions | pushed incrementally from `subagents/` subdirectory | +| Agent marker attribution | sessions tagged to pulled agent via `.observal/agent` | +| Model selection | `accepts_model_choice: true` | + +--- + +## Setup + +### 1. Install the Observal CLI + +```bash +pip install observal-cli +``` + +### 2. Authenticate + +```bash +observal auth login +``` + +This writes credentials to `~/.observal/config.json`. + +### 3. Install hooks + +```bash +observal doctor --patch +``` + +This performs a non-destructive merge into `.claude/settings.json` (project scope) or +`~/.claude/settings.json` (user scope), adding the two Observal hook entries under +`UserPromptSubmit` and `Stop`. Existing hooks are preserved. + +Verify the installation status at any time: + +```bash +observal doctor +``` + +The doctor reports `installed` when at least 3 Observal-managed hook entries are found, +`partial` if some are present but not all, and `missing` otherwise. + +### 4. Pull an agent (optional) + +```bash +# Project scope (default) +observal pull --ide claude-code + +# User scope +observal pull --ide claude-code --scope user +``` + +Project-scope agents are written to `.claude/agents/.md`. +User-scope agents are written to `~/.claude/agents/.md`. + +After pulling, a marker file is written to `/.observal/agent` so that sessions +started after the pull are automatically attributed to the agent in the Observal UI. + +--- + +## Config file paths + +| Purpose | Project scope | User scope | +|---|---|---| +| Agent definition | `.claude/agents/{name}.md` | `~/.claude/agents/{name}.md` | +| Skill definition | `.claude/skills/{name}/SKILL.md` | `~/.claude/skills/{name}/SKILL.md` | +| Hook config | `.claude/settings.json` | `~/.claude/settings.json` | +| Hook scripts dir | `.claude/hooks/` | — | +| MCP global config | `~/.claude.json` | `~/.claude.json` | +| Session JSONL files | `~/.claude/projects//.jsonl` | — | +| Subagent JSONL files | `~/.claude/projects///subagents/agent-.jsonl` | — | +| Observal credentials | `~/.observal/config.json` | — | +| Agent marker | `/.observal/agent` | — | + +The project key is derived from the working directory path by replacing `/` with `-`: + +``` +/home/user/code/myproject → -home-user-code-myproject +``` + +--- + +## Hook spec + +Observal installs exactly two hook events into `settings.json`. Both invoke the same +session push command: + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "_observal": { "version": "10" }, + "hooks": [ + { "type": "command", "command": "python -m observal_cli.hooks.session_push" } + ] + } + ], + "Stop": [ + { + "_observal": { "version": "10" }, + "hooks": [ + { "type": "command", "command": "python -m observal_cli.hooks.session_push" } + ] + } + ] + } +} +``` + +The `_observal.version` field is the `HOOKS_SPEC_VERSION` constant in +`observal_cli/ide_specs/claude_code_hooks_spec.py` (currently `"10"`). When the hook +definition changes, this version is bumped and `observal doctor --patch` upgrades the +installed hooks automatically. + +### Available hook events + +The full event map supported by Claude Code (used if you configure custom hooks): + +| Observal event name | Claude Code event name | +|---|---| +| `PreToolUse` | `PreToolUse` | +| `PostToolUse` | `PostToolUse` | +| `Stop` | `Stop` | +| `SessionStart` | `SessionStart` | +| `UserPromptSubmit` | `UserPromptSubmit` | +| `Notification` | `Notification` | +| `SubagentStop` | `SubagentStop` | + +--- + +## Session push behavior + +The `session_push` hook does **not** parse individual hook event payloads. Instead it: + +1. Resolves the session JSONL file path from `~/.claude/projects//.jsonl`. +2. Reads a persistent byte-cursor from `~/.observal/cursors/` to find the + last-processed position. +3. Reads only the **new bytes** since the last cursor position (`read_new_lines`). +4. POSTs the new lines as a batch to the Observal `/ingest` endpoint. +5. Advances the cursor on success. + +For subagent sessions, after pushing the main session the hook also scans +`~/.claude/projects///subagents/agent-*.jsonl` and pushes each +incrementally using a composite cursor key `__sub__`. + +--- + +## Agent file format + +Claude Code agents are Markdown files with a YAML frontmatter header: + +```markdown +--- +name: my-agent +description: "Does X and Y" +model: claude-opus-4-5 +tools: bash,read_file,write_file +mcpServers: + - my-mcp-server +--- + +Agent system prompt content goes here. +``` + +The `model` field accepts Anthropic model names. If omitted, Claude Code uses its default +model. The `mcpServers` list references servers already registered in `~/.claude.json`. + +--- + +## Skill file format + +Skills live at `.claude/skills//SKILL.md` and use YAML frontmatter: + +```markdown +--- +description: "Runs the project test suite" +task_type: testing +--- + +# Run Tests + +Run `pytest -q` from the project root... +``` + +`task_type` is used by Observal's skill search. Common values: `general`, `testing`, +`documentation`, `refactoring`. + +--- + +## Caveats and known limitations + +**`PYTHONPATH` on first install.** If `observal_cli` is not on the system Python path, the +hook command is prefixed with `PYTHONPATH=` automatically. If you move the +package installation directory after running `observal doctor --patch`, re-run +`observal doctor --patch` to update the path in `settings.json`. + +**Hook detection threshold.** The doctor marks hooks as `installed` only when 3 or more +Observal-managed hook entries are present. A fresh install writes exactly 2 entries +(`UserPromptSubmit` + `Stop`). If the doctor reports `partial` after a fresh install, +run `observal doctor --patch` again. + +**Project-scope MCP config.** Claude Code reads project-level MCP servers from `.mcp.json` +at the project root (not `.claude/mcp.json`). Observal's scan reads `.mcp.json` +accordingly. The global MCP config path is `~/.claude.json`. + +**Agent marker timing guard.** If a session was started before `observal pull` was run, +the `.observal/agent` marker is ignored for that session (the cursor offset is 0 check). +Sessions started after the pull are attributed correctly. + +**Subagent JSONL path.** Claude Code writes subagent files to: + +``` +~/.claude/projects///subagents/agent-.jsonl +``` + +If Claude Code changes this layout in a future release, subagent push will silently +produce no events (the `subagents/` directory check returns early). No data is lost; +the main session is still pushed normally. + +**No `SessionStart` hook in default install.** The default two-hook install only covers +`UserPromptSubmit` and `Stop`. `SessionStart`, `PreToolUse`, `PostToolUse`, +`Notification`, and `SubagentStop` are available for custom hook configs but are not +installed by `observal doctor --patch` by default. diff --git a/tests/fixtures/cursor_session.jsonl b/tests/fixtures/cursor_session.jsonl new file mode 100644 index 000000000..264e632e1 --- /dev/null +++ b/tests/fixtures/cursor_session.jsonl @@ -0,0 +1,9 @@ +{"role":"user","timestamp":"2026-05-01T10:00:00.000Z","message":{"content":[{"type":"text","text":"\nRefactor the auth middleware to use async/await\n\n\nYou are a coding assistant.\n"}]}} +{"role":"user","timestamp":"2026-05-01T10:01:00.000Z","message":{"content":[{"type":"text","text":"2026-05-01T10:01:00.000Z\n\nWhat files are in the project?\n\n\nSome file contents here\n"}]}} +{"role":"assistant","timestamp":"2026-05-01T10:01:05.000Z","message":{"content":[{"type":"text","text":"I'll start by reading the directory structure."}],"usage":{"input_tokens":512,"output_tokens":18,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"model":"claude-sonnet-4-5","stop_reason":"tool_use"}} +{"role":"assistant","timestamp":"2026-05-01T10:01:06.000Z","message":{"content":[{"type":"tool_use","id":"toolu_01AbCdEf","name":"list_directory","input":{"path":"."}}],"usage":{"input_tokens":512,"output_tokens":34},"model":"claude-sonnet-4-5","stop_reason":"tool_use"}} +{"role":"user","timestamp":"2026-05-01T10:01:07.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_01AbCdEf","content":"src/\n auth/\n middleware.py\n main.py\ntests/\n test_auth.py"}]}} +{"role":"assistant","timestamp":"2026-05-01T10:01:08.000Z","message":{"content":[{"type":"thinking","thinking":"The user wants to refactor auth middleware. Let me read the current file first."},{"type":"text","text":"Let me read the current auth middleware."}],"usage":{"input_tokens":600,"output_tokens":30},"model":"claude-sonnet-4-5","stop_reason":"tool_use"}} +{"role":"assistant","timestamp":"2026-05-01T10:01:09.000Z","message":{"content":[{"type":"tool_use","id":"toolu_02BcDeGh","name":"read_file","input":{"path":"src/auth/middleware.py"}}],"model":"claude-sonnet-4-5","stop_reason":"tool_use"}} +{"role":"user","timestamp":"2026-05-01T10:01:10.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_02BcDeGh","content":"def auth_middleware(request):\n token = request.headers.get('Authorization')\n if not token:\n raise Unauthorized()"}]}} +{"role":"assistant","timestamp":"2026-05-01T10:02:00.000Z","message":{"content":[{"type":"text","text":"Here is the refactored async version:\n\n```python\nasync def auth_middleware(request):\n token = request.headers.get('Authorization')\n if not token:\n raise Unauthorized()\n```\n\nThis adds `async` to the function signature."}],"usage":{"input_tokens":700,"output_tokens":60},"model":"claude-sonnet-4-5","stop_reason":"end_turn"}} \ No newline at end of file diff --git a/tests/test_cursor_parser.py b/tests/test_cursor_parser.py new file mode 100644 index 000000000..2f4c70e97 --- /dev/null +++ b/tests/test_cursor_parser.py @@ -0,0 +1,331 @@ +# SPDX-FileCopyrightText: 2026 BlazeUp-AI contributors +# SPDX-License-Identifier: AGPL-3.0-only + +"""Tests for the Cursor JSONL session parser.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "observal-server")) + +import pytest + +from services.session_parsers.cursor import parse_rows + +FIXTURE = Path(__file__).parent / "fixtures" / "cursor_session.jsonl" +IDE = "cursor" + + +def _rows_from_fixture() -> list[dict]: + rows = [] + for line in FIXTURE.read_text().splitlines(): + line = line.strip() + if not line: + continue + rows.append({ + "raw_line": line, + "ide": IDE, + "timestamp": "2026-05-01 10:00:00.000", + "ingested_at": "2026-05-01 10:00:01.000", + "event_type": "", + "content_preview": "", + "tool_name": None, + "tool_id": None, + "uuid": None, + "parent_uuid": None, + "content_length": 0, + }) + return rows + +def _row(raw: dict, **overrides) -> dict: + """Build a single ClickHouse-style row from a raw dict.""" + base = { + "raw_line": json.dumps(raw), + "ide": IDE, + "timestamp": "2026-05-01 10:00:00.000", + "ingested_at": "2026-05-01 10:00:01.000", + "event_type": "", + "content_preview": "", + "tool_name": None, + "tool_id": None, + "uuid": None, + "parent_uuid": None, + "content_length": 0, + } + base.update(overrides) + return base + + +def test_fixture_parsing(): + """Verify that parsing the complete fixture produces expected trace events.""" + rows = _rows_from_fixture() + events = parse_rows(rows) + assert len(events) >= 8 + for ev in events: + assert ev.get("event_name"), f"Event missing event_name: {ev}" + assert ev.get("service_name") == IDE + assert ev.get("timestamp") + + +def test_user_plain_text(): + raw = { + "role": "user", + "timestamp": "2026-05-01T10:00:00.000Z", + "message": {"content": [{"type": "text", "text": "Hello world"}]}, + } + events = parse_rows([_row(raw)]) + assert len(events) == 1 + assert events[0]["event_name"] == "hook_userpromptsubmit" + assert events[0]["attributes"]["tool_input"] == "Hello world" + assert events[0]["body"] == "Hello world" + + +@pytest.mark.parametrize( + "text, expected_in, expected_not_in", + [ + ("\nRefactor the auth middleware\n", "Refactor the auth middleware", ""), + ("Do X\nYou are helpful.", "Do X", ""), + ("2026-05-01T10:00:00.000Z\nWhat time is it?", "What time is it?", ""), + ("Review this\n\nfile contents\n", "Review this", ""), + ("\nLine one\nLine two\n", "Line one\nLine two", ""), + ] +) +def test_xml_tag_stripping(text, expected_in, expected_not_in): + raw = { + "role": "user", + "timestamp": "2026-05-01T10:00:00.000Z", + "message": {"content": [{"type": "text", "text": text}]}, + } + events = parse_rows([_row(raw)]) + assert len(events) == 1 + body = events[0]["attributes"]["tool_input"] + assert expected_not_in not in body + assert expected_in in body + + +def test_user_plain_string_fallback(): + raw = { + "role": "user", + "timestamp": "2026-05-01T10:00:00.000Z", + "message": {"content": "plain string content"}, + } + events = parse_rows([_row(raw)]) + assert len(events) == 1 + assert events[0]["event_name"] == "hook_userpromptsubmit" + assert events[0]["attributes"]["tool_input"] == "plain string content" + + +def test_assistant_response_and_tokens(): + raw = { + "role": "assistant", + "timestamp": "2026-05-01T10:01:00.000Z", + "message": { + "content": [{"type": "text", "text": "Here is the answer."}], + "usage": { + "input_tokens": 100, + "output_tokens": 10, + "cache_read_input_tokens": 300, + "cache_creation_input_tokens": 100, + }, + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + }, + } + events = parse_rows([_row(raw)]) + assert len(events) == 1 + ev = events[0] + assert ev["event_name"] == "hook_assistant_response" + assert "Here is the answer." in ev["attributes"]["tool_response"] + attrs = ev["attributes"] + assert attrs["input_tokens"] == "100" + assert attrs["output_tokens"] == "10" + assert attrs["model"] == "claude-sonnet-4-5" + assert attrs["stop_reason"] == "end_turn" + assert attrs["cache_read_tokens"] == "300" + assert attrs["cache_creation_tokens"] == "100" + + +def test_assistant_thinking(): + long_thought = "x" * 200 + raw = { + "role": "assistant", + "timestamp": "2026-05-01T10:01:00.000Z", + "message": { + "content": [ + {"type": "thinking", "thinking": long_thought}, + {"type": "text", "text": "The result is 42."}, + ], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + }, + } + events = parse_rows([_row(raw)]) + names = [e["event_name"] for e in events] + assert "hook_assistant_thinking" in names + assert "hook_assistant_response" in names + + thinking_event = next(e for e in events if e["event_name"] == "hook_assistant_thinking") + assert len(thinking_event["body"]) <= 100 + assert thinking_event["attributes"]["tool_response"] == long_thought + + +def test_tool_use_and_result_merging(): + # 1. Tool Use + tool_use_row = _row({ + "role": "assistant", + "timestamp": "2026-05-01T10:01:00.000Z", + "message": { + "content": [{"type": "tool_use", "id": "toolu_xyz", "name": "bash", "input": {"command": "ls"}}], + "model": "claude-sonnet-4-5", + "stop_reason": "tool_use", + }, + }) + # 2. Tool Result (String) + tool_result_row = _row({ + "role": "user", + "timestamp": "2026-05-01T10:01:01.000Z", + "message": { + "content": [{"type": "tool_result", "tool_use_id": "toolu_xyz", "content": "file1.txt"}] + }, + }) + events = parse_rows([tool_use_row, tool_result_row]) + assert len(events) == 1 + assert events[0]["event_name"] == "hook_posttooluse" + assert events[0]["body"] == "bash" + assert json.loads(events[0]["attributes"]["tool_input"]) == {"command": "ls"} + assert "file1.txt" in events[0]["attributes"]["tool_response"] + + +def test_tool_result_list_content_and_orphans(): + tool_use_row = _row({ + "role": "assistant", + "timestamp": "2026-05-01T10:01:00.000Z", + "message": { + "content": [{"type": "tool_use", "id": "toolu_list", "name": "search", "input": {"q": "test"}}], + "model": "claude-sonnet-4-5", + "stop_reason": "tool_use", + }, + }) + tool_result_row = _row({ + "role": "user", + "timestamp": "2026-05-01T10:01:01.000Z", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_list", + "content": [{"type": "text", "text": "Result one"}], + } + ] + }, + }) + # Orphan result + orphan_result_row = _row({ + "role": "user", + "timestamp": "2026-05-01T10:01:01.000Z", + "message": { + "content": [{"type": "tool_result", "tool_use_id": "toolu_orphan", "content": "some output"}] + }, + }) + events = parse_rows([tool_use_row, tool_result_row, orphan_result_row]) + tool_events = [e for e in events if e["event_name"] == "hook_posttooluse"] + assert len(tool_events) == 1 + assert "Result one" in tool_events[0]["attributes"]["tool_response"] + + +def test_tool_turn_token_usage_and_multiple_tools(): + raw_tok = { + "role": "assistant", + "timestamp": "2026-05-01T10:01:00.000Z", + "message": { + "content": [{"type": "tool_use", "id": "toolu_tok", "name": "bash", "input": {"cmd": "pwd"}}], + "usage": {"input_tokens": 400, "output_tokens": 25}, + "model": "claude-sonnet-4-5", + "stop_reason": "tool_use", + }, + } + events_tok = parse_rows([_row(raw_tok)]) + assert "hook_token_usage" in [e["event_name"] for e in events_tok] + tok_event = next(e for e in events_tok if e["event_name"] == "hook_token_usage") + assert tok_event["attributes"]["input_tokens"] == "400" + + raw_mult = { + "role": "assistant", + "timestamp": "2026-05-01T10:01:00.000Z", + "message": { + "content": [ + {"type": "tool_use", "id": "toolu_a", "name": "read_file", "input": {"path": "a.txt"}}, + {"type": "tool_use", "id": "toolu_b", "name": "read_file", "input": {"path": "b.txt"}}, + ], + "model": "claude-sonnet-4-5", + "stop_reason": "tool_use", + }, + } + events_mult = parse_rows([_row(raw_mult)]) + tool_events = [e for e in events_mult if e["event_name"] == "hook_posttooluse"] + assert len(tool_events) == 2 + assert {e["attributes"]["tool_use_id"] for e in tool_events} == {"toolu_a", "toolu_b"} + + +def test_edge_cases(): + assert parse_rows([]) == [] + + # Missing raw line fallback + row_missing = _row({"role": "user", "message": {"content": []}}, raw_line="") + assert len(parse_rows([row_missing])) == 1 + + # Invalid JSON fallback + row_invalid = { + "raw_line": "invalid json {", + "ide": IDE, + "timestamp": "2026-05-01 10:00:00.000", + "ingested_at": "2026-05-01 10:00:01.000", + "event_type": "fallback", + "content_preview": "preview", + "tool_name": None, + "tool_id": None, + "uuid": None, + "parent_uuid": None, + "content_length": 0, + } + events = parse_rows([row_invalid]) + assert len(events) == 1 + assert events[0]["body"] == "preview" + + # Unknown role fallback + raw_unknown = { + "role": "system", + "timestamp": "2026-05-01T10:00:00.000Z", + "message": {"content": "initializing"}, + } + assert len(parse_rows([_row(raw_unknown)])) == 1 + + +def test_timestamps(): + # ISO normalisation + raw_iso = { + "role": "user", + "timestamp": "2026-05-01T09:30:45.123Z", + "message": {"content": [{"type": "text", "text": "hi"}]}, + } + assert parse_rows([_row(raw_iso)])[0]["timestamp"] == "2026-05-01 09:30:45.123" + + raw_no_ts = { + "role": "user", + "message": {"content": [{"type": "text", "text": "hi"}]}, + } + row = _row(raw_no_ts, timestamp="2026-05-02 08:00:00.000") + assert parse_rows([row])[0]["timestamp"] == "2026-05-02 08:00:00.000" + + +def test_registry_integration(): + import importlib + from services.session_parsers import _PARSERS + from services.session_parsers.cursor import parse_rows as cursor_parse + + server_reg = importlib.import_module("schemas.ide_registry") + assert server_reg.IDE_REGISTRY["cursor"]["session_parser"] == "cursor" + assert _PARSERS["cursor"] is cursor_parse