From e71343fad3bed94ff15aa62152775b3c408306dc Mon Sep 17 00:00:00 2001 From: wenchanghan Date: Thu, 16 Jul 2026 20:25:23 -0700 Subject: [PATCH 1/3] fix: attribute Cursor-hosted hooks correctly --- .../components/common/host-badge.tsx | 7 +- plugin/dashboard/lib/session-reader.ts | 1 + plugin/dashboard/lib/types.ts | 2 +- plugin/src/claude_smart/hook.py | 14 +- plugin/src/claude_smart/runtime.py | 51 +++++- tests/test_cursor_support.py | 157 ++++++++++++++++++ 6 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 tests/test_cursor_support.py diff --git a/plugin/dashboard/components/common/host-badge.tsx b/plugin/dashboard/components/common/host-badge.tsx index 85c887e..d7586f3 100644 --- a/plugin/dashboard/components/common/host-badge.tsx +++ b/plugin/dashboard/components/common/host-badge.tsx @@ -5,7 +5,7 @@ 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 the badge's small text. const HOST_META: Record< Exclude, { label: string; badgeClass: string; dotClass: string } @@ -20,6 +20,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: diff --git a/plugin/dashboard/lib/session-reader.ts b/plugin/dashboard/lib/session-reader.ts index 45c3de1..2c43ecb 100644 --- a/plugin/dashboard/lib/session-reader.ts +++ b/plugin/dashboard/lib/session-reader.ts @@ -22,6 +22,7 @@ import type { const VALID_HOSTS: ReadonlySet = new Set([ "claude-code", "codex", + "cursor", "opencode", "unknown", ]); diff --git a/plugin/dashboard/lib/types.ts b/plugin/dashboard/lib/types.ts index efda775..e79df15 100644 --- a/plugin/dashboard/lib/types.ts +++ b/plugin/dashboard/lib/types.ts @@ -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; diff --git a/plugin/src/claude_smart/hook.py b/plugin/src/claude_smart/hook.py index cb42c3f..0f35e4a 100644 --- a/plugin/src/claude_smart/hook.py +++ b/plugin/src/claude_smart/hook.py @@ -18,6 +18,7 @@ import json import logging +import os import sys from collections.abc import Callable from contextlib import redirect_stdout @@ -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 diff --git a/plugin/src/claude_smart/runtime.py b/plugin/src/claude_smart/runtime.py index 85677a9..d7afb2a 100644 --- a/plugin/src/claude_smart/runtime.py +++ b/plugin/src/claude_smart/runtime.py @@ -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 @@ -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 diff --git a/tests/test_cursor_support.py b/tests/test_cursor_support.py new file mode 100644 index 0000000..9f75734 --- /dev/null +++ b/tests/test_cursor_support.py @@ -0,0 +1,157 @@ +"""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_USER_EMAIL", + "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: + for name, value in env.items(): + monkeypatch.setenv(name, value) + + assert runtime.resolve_hook_host("claude-code", payload) == expected + + +def test_explicit_non_claude_host_is_not_reclassified_as_cursor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CURSOR_TRANSCRIPT_PATH", "/tmp/cursor.jsonl") + + assert runtime.resolve_hook_host("opencode", {}) == "opencode" + + +def test_cursor_shares_learnings_without_losing_attribution() -> None: + assert runtime.set_host("cursor") == "cursor" + assert runtime.host() == "cursor" + assert runtime.attribution_host() == "cursor" + assert runtime.agent_version() == "claude-code" From 0cf4a42e0ee8e44898d4333851123355c41cdaa9 Mon Sep 17 00:00:00 2001 From: wenchanghan Date: Thu, 16 Jul 2026 21:10:27 -0700 Subject: [PATCH 2/3] chore: address cursor attribution review feedback --- plugin/dashboard/components/common/host-badge.tsx | 3 ++- tests/test_cursor_support.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugin/dashboard/components/common/host-badge.tsx b/plugin/dashboard/components/common/host-badge.tsx index d7586f3..98131ef 100644 --- a/plugin/dashboard/components/common/host-badge.tsx +++ b/plugin/dashboard/components/common/host-badge.tsx @@ -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. +// 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, { label: string; badgeClass: string; dotClass: string } diff --git a/tests/test_cursor_support.py b/tests/test_cursor_support.py index 9f75734..70354f9 100644 --- a/tests/test_cursor_support.py +++ b/tests/test_cursor_support.py @@ -17,7 +17,6 @@ def _clear_cursor_detection_env(monkeypatch: pytest.MonkeyPatch) -> None: "CLAUDE_CODE_ENTRYPOINT", "CURSOR_PROJECT_DIR", "CURSOR_TRANSCRIPT_PATH", - "CURSOR_USER_EMAIL", "CURSOR_VERSION", ): monkeypatch.delenv(name, raising=False) @@ -136,6 +135,7 @@ def test_resolve_hook_host_uses_layered_cursor_signals( 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) @@ -145,12 +145,14 @@ def test_resolve_hook_host_uses_layered_cursor_signals( def test_explicit_non_claude_host_is_not_reclassified_as_cursor( monkeypatch: pytest.MonkeyPatch, ) -> None: + """Explicit non-Claude hosts remain authoritative under Cursor signals.""" monkeypatch.setenv("CURSOR_TRANSCRIPT_PATH", "/tmp/cursor.jsonl") assert runtime.resolve_hook_host("opencode", {}) == "opencode" 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" From a7262af4be6a758226060bff0037d1369404a6c6 Mon Sep 17 00:00:00 2001 From: wenchanghan Date: Thu, 16 Jul 2026 21:16:14 -0700 Subject: [PATCH 3/3] test: cover Codex host precedence --- tests/test_cursor_support.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_cursor_support.py b/tests/test_cursor_support.py index 70354f9..d5f49c1 100644 --- a/tests/test_cursor_support.py +++ b/tests/test_cursor_support.py @@ -142,13 +142,16 @@ def test_resolve_hook_host_uses_layered_cursor_signals( assert runtime.resolve_hook_host("claude-code", payload) == expected -def test_explicit_non_claude_host_is_not_reclassified_as_cursor( +@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", "/tmp/cursor.jsonl") + monkeypatch.setenv("CURSOR_TRANSCRIPT_PATH", str(tmp_path / "cursor.jsonl")) - assert runtime.resolve_hook_host("opencode", {}) == "opencode" + assert runtime.resolve_hook_host(host, {}) == host def test_cursor_shares_learnings_without_losing_attribution() -> None: