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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion plugin/dashboard/components/common/host-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { cn } from "@/lib/utils";
import type { Host } from "@/lib/types";

// Exact product colors from the current official app assets. Label colors meet
// WCAG AA for the badge's small text: Claude 5.65:1, Codex 5.39:1, OpenCode 18.93:1.
// WCAG AA for small text: Claude 5.65:1, Codex 5.39:1, Cursor 10.48:1,
// OpenCode 18.93:1.
const HOST_META: Record<
Exclude<Host, "unknown">,
{ label: string; badgeClass: string; dotClass: string }
Expand All @@ -20,6 +21,11 @@ const HOST_META: Record<
badgeClass: "border-[#0169CC] bg-[#0169CC] text-white",
dotClass: "bg-[#0169CC]",
},
cursor: {
label: "Cursor",
badgeClass: "border-[#72716D] bg-[#D6D5D2] text-[#26251E]",
dotClass: "bg-[#72716D]",
},
opencode: {
label: "OpenCode",
badgeClass:
Expand Down
1 change: 1 addition & 0 deletions plugin/dashboard/lib/session-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
const VALID_HOSTS: ReadonlySet<Host> = new Set([
"claude-code",
"codex",
"cursor",
"opencode",
"unknown",
]);
Expand Down
2 changes: 1 addition & 1 deletion plugin/dashboard/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export interface Interaction {
tools_used: ToolUsed[];
}

export type Host = "claude-code" | "codex" | "opencode" | "unknown";
export type Host = "claude-code" | "codex" | "cursor" | "opencode" | "unknown";

export interface UserPlaybook {
user_playbook_id: number;
Expand Down
14 changes: 11 additions & 3 deletions plugin/src/claude_smart/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import json
import logging
import os
import sys
from collections.abc import Callable
from contextlib import redirect_stdout
Expand Down Expand Up @@ -111,15 +112,22 @@ def _parse_args(argv: list[str]) -> tuple[str, str]:
def main(argv: list[str] | None = None) -> int:
"""Entry point used by ``python -m claude_smart.hook`` and the console script."""
argv = argv if argv is not None else sys.argv[1:]
host, event = _parse_args(argv)
runtime.set_host(host)
env_config.load_reflexio_env()
declared_host, event = _parse_args(argv)
if not event:
runtime.set_host(declared_host)
env_config.load_reflexio_env()
_LOGGER.warning("hook dispatcher called with no event name")
emit_continue()
return 0

payload = _read_stdin_json()
host = runtime.resolve_hook_host(declared_host, payload)
runtime.set_host(host)
if host == runtime.HOST_CURSOR and not payload.get("cwd"):
cursor_project_dir = os.environ.get("CURSOR_PROJECT_DIR")
if cursor_project_dir:
payload["cwd"] = cursor_project_dir
env_config.load_reflexio_env()

# Self-feedback guard: when this hook fires inside reflexio's own
# `claude -p` subprocess (the claude-code LLM provider), skip all
Expand Down
51 changes: 47 additions & 4 deletions plugin/src/claude_smart/runtime.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
"""Host/runtime state shared by claude-smart entrypoints.

The plugin can be loaded by Claude Code or Codex, but v1 intentionally keeps
one memory namespace. The host value is for payload quirks and install UX; the
Reflexio agent version remains shared so both hosts see the same learned rules.
The plugin intentionally keeps one memory namespace across supported hosts.
The host value is for payload quirks, attribution, and install UX; the Reflexio
agent version remains shared so every host sees the same learned rules.
"""

from __future__ import annotations

import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any

HOST_ENV = "CLAUDE_SMART_HOST"
INTERNAL_ENV = "CLAUDE_SMART_INTERNAL"

HOST_CLAUDE_CODE = "claude-code"
HOST_CODEX = "codex"
HOST_CURSOR = "cursor"
HOST_OPENCODE = "opencode"
HOST_UNKNOWN = "unknown"
VALID_HOSTS = frozenset({HOST_CLAUDE_CODE, HOST_CODEX, HOST_OPENCODE})
VALID_HOSTS = frozenset(
{HOST_CLAUDE_CODE, HOST_CODEX, HOST_CURSOR, HOST_OPENCODE}
)

_SHARED_AGENT_VERSION = "claude-code"
_current_host: str | None = None
Expand All @@ -26,6 +32,43 @@ def _resolve_host(value: str | None, fallback: str) -> str:
return value if value in VALID_HOSTS else fallback


def resolve_hook_host(
declared_host: str, payload: Mapping[str, Any]
) -> str:
"""Resolve the actual host behind a normalized hook invocation.

Cursor loads the Claude Code plugin directly, so its hook command declares
``claude-code`` even though Cursor launched the subprocess. Positive Cursor
signals may refine that declaration. Explicit Codex/OpenCode invocations
and real Claude Code entrypoints always retain their declared host.

Args:
declared_host (str): Host selected by the hook command arguments.
payload (Mapping[str, Any]): Normalized hook payload.

Returns:
str: The effective host used for local attribution and hook telemetry.
"""
if declared_host != HOST_CLAUDE_CODE:
return declared_host
if os.environ.get("CLAUDE_CODE_ENTRYPOINT"):
return declared_host
if os.environ.get("CURSOR_TRANSCRIPT_PATH"):
return HOST_CURSOR
if os.environ.get("CURSOR_VERSION") and os.environ.get("CURSOR_PROJECT_DIR"):
return HOST_CURSOR

transcript_path = payload.get("transcript_path")
if not isinstance(transcript_path, str) or not transcript_path:
return declared_host
try:
cursor_root = (Path.home() / ".cursor").resolve()
Path(transcript_path).expanduser().resolve().relative_to(cursor_root)
except (OSError, RuntimeError, ValueError):
return declared_host
return HOST_CURSOR


def set_host(value: str | None) -> str:
"""Set the current host, returning the normalized value."""
global _current_host
Expand Down
162 changes: 162 additions & 0 deletions tests/test_cursor_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""Regression coverage for Cursor host attribution."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import pytest
from claude_smart import hook, hook_log, runtime, state


@pytest.fixture(autouse=True)
def _clear_cursor_detection_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep Cursor host signals local to each test."""
for name in (
"CLAUDE_CODE_ENTRYPOINT",
"CURSOR_PROJECT_DIR",
"CURSOR_TRANSCRIPT_PATH",
"CURSOR_VERSION",
):
monkeypatch.delenv(name, raising=False)


@pytest.fixture
def cursor_hook_log(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect hook telemetry away from the user's real state."""
path = tmp_path / "hook.log"
monkeypatch.setattr(hook_log, "_LOG_PATH", path)
monkeypatch.delenv("CLAUDE_SMART_HOOK_LOG", raising=False)
return path


def _log_records(path: Path) -> list[dict[str, Any]]:
return [json.loads(line) for line in path.read_text().splitlines() if line]


def test_cursor_hook_records_cursor_in_session_and_hook_log(
session_dir: Path,
cursor_hook_log: Path,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A Cursor-fired Claude Code plugin hook keeps Cursor provenance."""
monkeypatch.setenv("CURSOR_VERSION", "3.11.0")
monkeypatch.setenv("CURSOR_PROJECT_DIR", str(tmp_path))
monkeypatch.setattr(
hook,
"_read_stdin_json",
lambda: {
"session_id": "cursor-session",
"tool_name": "Read",
"tool_input": {"file_path": "/tmp/example.py"},
"tool_response": {"ok": True},
},
)

assert hook.main(["claude-code", "post-tool"]) == 0

assert state.read_all("cursor-session")[0]["host"] == "cursor"
log_record = _log_records(cursor_hook_log)[0]
assert log_record["host"] == "cursor"
assert log_record["cwd"] == str(tmp_path)
assert log_record["project_id"] == tmp_path.name


def test_claude_code_entrypoint_wins_over_cursor_environment(
session_dir: Path,
cursor_hook_log: Path,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Claude Code launched inside Cursor stays attributed to Claude Code."""
monkeypatch.setenv("CURSOR_VERSION", "3.11.0")
monkeypatch.setenv("CURSOR_PROJECT_DIR", str(tmp_path))
monkeypatch.setenv("CLAUDE_CODE_ENTRYPOINT", "cli")
monkeypatch.setattr(
hook,
"_read_stdin_json",
lambda: {
"session_id": "claude-session",
"cwd": str(tmp_path),
"tool_name": "Read",
"tool_response": {"ok": True},
},
)

assert hook.main(["claude-code", "post-tool"]) == 0

assert state.read_all("claude-session")[0]["host"] == "claude-code"
assert _log_records(cursor_hook_log)[0]["host"] == "claude-code"


@pytest.mark.parametrize(
("env", "payload", "expected"),
[
({"CURSOR_TRANSCRIPT_PATH": "/tmp/cursor.jsonl"}, {}, "cursor"),
(
{"CURSOR_VERSION": "3.11", "CURSOR_PROJECT_DIR": "/tmp/project"},
{},
"cursor",
),
({"CURSOR_VERSION": "3.11"}, {}, "claude-code"),
(
{
"CURSOR_TRANSCRIPT_PATH": "/tmp/cursor.jsonl",
"CLAUDE_CODE_ENTRYPOINT": "cli",
},
{},
"claude-code",
),
(
{},
{
"transcript_path": str(
Path.home() / ".cursor" / "projects" / "session.jsonl"
)
},
"cursor",
),
(
{},
{
"transcript_path": str(
Path.home() / ".claude" / "projects" / "session.jsonl"
)
},
"claude-code",
),
],
)
def test_resolve_hook_host_uses_layered_cursor_signals(
env: dict[str, str],
payload: dict[str, str],
expected: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Layered Cursor signals refine only ambiguous Claude Code hooks."""
for name, value in env.items():
monkeypatch.setenv(name, value)

assert runtime.resolve_hook_host("claude-code", payload) == expected


@pytest.mark.parametrize("host", ["codex", "opencode"])
def test_explicit_non_claude_hosts_are_not_reclassified_as_cursor(
host: str,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Explicit non-Claude hosts remain authoritative under Cursor signals."""
monkeypatch.setenv("CURSOR_TRANSCRIPT_PATH", str(tmp_path / "cursor.jsonl"))

assert runtime.resolve_hook_host(host, {}) == host


def test_cursor_shares_learnings_without_losing_attribution() -> None:
"""Cursor keeps local attribution while sharing the learning namespace."""
assert runtime.set_host("cursor") == "cursor"
assert runtime.host() == "cursor"
assert runtime.attribution_host() == "cursor"
assert runtime.agent_version() == "claude-code"
Loading