Skip to content

Commit 045d8b3

Browse files
authored
fix: attribute Cursor-hosted hooks correctly (#139)
* fix: attribute Cursor-hosted hooks correctly * chore: address cursor attribution review feedback * test: cover Codex host precedence
1 parent 60a2bd8 commit 045d8b3

6 files changed

Lines changed: 229 additions & 9 deletions

File tree

plugin/dashboard/components/common/host-badge.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { cn } from "@/lib/utils";
55
import type { Host } from "@/lib/types";
66

77
// Exact product colors from the current official app assets. Label colors meet
8-
// WCAG AA for the badge's small text: Claude 5.65:1, Codex 5.39:1, OpenCode 18.93:1.
8+
// WCAG AA for small text: Claude 5.65:1, Codex 5.39:1, Cursor 10.48:1,
9+
// OpenCode 18.93:1.
910
const HOST_META: Record<
1011
Exclude<Host, "unknown">,
1112
{ label: string; badgeClass: string; dotClass: string }
@@ -20,6 +21,11 @@ const HOST_META: Record<
2021
badgeClass: "border-[#0169CC] bg-[#0169CC] text-white",
2122
dotClass: "bg-[#0169CC]",
2223
},
24+
cursor: {
25+
label: "Cursor",
26+
badgeClass: "border-[#72716D] bg-[#D6D5D2] text-[#26251E]",
27+
dotClass: "bg-[#72716D]",
28+
},
2329
opencode: {
2430
label: "OpenCode",
2531
badgeClass:

plugin/dashboard/lib/session-reader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
const VALID_HOSTS: ReadonlySet<Host> = new Set([
2323
"claude-code",
2424
"codex",
25+
"cursor",
2526
"opencode",
2627
"unknown",
2728
]);

plugin/dashboard/lib/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface Interaction {
4141
tools_used: ToolUsed[];
4242
}
4343

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

4646
export interface UserPlaybook {
4747
user_playbook_id: number;

plugin/src/claude_smart/hook.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import json
2020
import logging
21+
import os
2122
import sys
2223
from collections.abc import Callable
2324
from contextlib import redirect_stdout
@@ -111,15 +112,22 @@ def _parse_args(argv: list[str]) -> tuple[str, str]:
111112
def main(argv: list[str] | None = None) -> int:
112113
"""Entry point used by ``python -m claude_smart.hook`` and the console script."""
113114
argv = argv if argv is not None else sys.argv[1:]
114-
host, event = _parse_args(argv)
115-
runtime.set_host(host)
116-
env_config.load_reflexio_env()
115+
declared_host, event = _parse_args(argv)
117116
if not event:
117+
runtime.set_host(declared_host)
118+
env_config.load_reflexio_env()
118119
_LOGGER.warning("hook dispatcher called with no event name")
119120
emit_continue()
120121
return 0
121122

122123
payload = _read_stdin_json()
124+
host = runtime.resolve_hook_host(declared_host, payload)
125+
runtime.set_host(host)
126+
if host == runtime.HOST_CURSOR and not payload.get("cwd"):
127+
cursor_project_dir = os.environ.get("CURSOR_PROJECT_DIR")
128+
if cursor_project_dir:
129+
payload["cwd"] = cursor_project_dir
130+
env_config.load_reflexio_env()
123131

124132
# Self-feedback guard: when this hook fires inside reflexio's own
125133
# `claude -p` subprocess (the claude-code LLM provider), skip all

plugin/src/claude_smart/runtime.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
"""Host/runtime state shared by claude-smart entrypoints.
22
3-
The plugin can be loaded by Claude Code or Codex, but v1 intentionally keeps
4-
one memory namespace. The host value is for payload quirks and install UX; the
5-
Reflexio agent version remains shared so both hosts see the same learned rules.
3+
The plugin intentionally keeps one memory namespace across supported hosts.
4+
The host value is for payload quirks, attribution, and install UX; the Reflexio
5+
agent version remains shared so every host sees the same learned rules.
66
"""
77

88
from __future__ import annotations
99

1010
import os
11+
from collections.abc import Mapping
12+
from pathlib import Path
13+
from typing import Any
1114

1215
HOST_ENV = "CLAUDE_SMART_HOST"
1316
INTERNAL_ENV = "CLAUDE_SMART_INTERNAL"
1417

1518
HOST_CLAUDE_CODE = "claude-code"
1619
HOST_CODEX = "codex"
20+
HOST_CURSOR = "cursor"
1721
HOST_OPENCODE = "opencode"
1822
HOST_UNKNOWN = "unknown"
19-
VALID_HOSTS = frozenset({HOST_CLAUDE_CODE, HOST_CODEX, HOST_OPENCODE})
23+
VALID_HOSTS = frozenset(
24+
{HOST_CLAUDE_CODE, HOST_CODEX, HOST_CURSOR, HOST_OPENCODE}
25+
)
2026

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

2834

35+
def resolve_hook_host(
36+
declared_host: str, payload: Mapping[str, Any]
37+
) -> str:
38+
"""Resolve the actual host behind a normalized hook invocation.
39+
40+
Cursor loads the Claude Code plugin directly, so its hook command declares
41+
``claude-code`` even though Cursor launched the subprocess. Positive Cursor
42+
signals may refine that declaration. Explicit Codex/OpenCode invocations
43+
and real Claude Code entrypoints always retain their declared host.
44+
45+
Args:
46+
declared_host (str): Host selected by the hook command arguments.
47+
payload (Mapping[str, Any]): Normalized hook payload.
48+
49+
Returns:
50+
str: The effective host used for local attribution and hook telemetry.
51+
"""
52+
if declared_host != HOST_CLAUDE_CODE:
53+
return declared_host
54+
if os.environ.get("CLAUDE_CODE_ENTRYPOINT"):
55+
return declared_host
56+
if os.environ.get("CURSOR_TRANSCRIPT_PATH"):
57+
return HOST_CURSOR
58+
if os.environ.get("CURSOR_VERSION") and os.environ.get("CURSOR_PROJECT_DIR"):
59+
return HOST_CURSOR
60+
61+
transcript_path = payload.get("transcript_path")
62+
if not isinstance(transcript_path, str) or not transcript_path:
63+
return declared_host
64+
try:
65+
cursor_root = (Path.home() / ".cursor").resolve()
66+
Path(transcript_path).expanduser().resolve().relative_to(cursor_root)
67+
except (OSError, RuntimeError, ValueError):
68+
return declared_host
69+
return HOST_CURSOR
70+
71+
2972
def set_host(value: str | None) -> str:
3073
"""Set the current host, returning the normalized value."""
3174
global _current_host

tests/test_cursor_support.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Regression coverage for Cursor host attribution."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
from typing import Any
8+
9+
import pytest
10+
from claude_smart import hook, hook_log, runtime, state
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def _clear_cursor_detection_env(monkeypatch: pytest.MonkeyPatch) -> None:
15+
"""Keep Cursor host signals local to each test."""
16+
for name in (
17+
"CLAUDE_CODE_ENTRYPOINT",
18+
"CURSOR_PROJECT_DIR",
19+
"CURSOR_TRANSCRIPT_PATH",
20+
"CURSOR_VERSION",
21+
):
22+
monkeypatch.delenv(name, raising=False)
23+
24+
25+
@pytest.fixture
26+
def cursor_hook_log(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
27+
"""Redirect hook telemetry away from the user's real state."""
28+
path = tmp_path / "hook.log"
29+
monkeypatch.setattr(hook_log, "_LOG_PATH", path)
30+
monkeypatch.delenv("CLAUDE_SMART_HOOK_LOG", raising=False)
31+
return path
32+
33+
34+
def _log_records(path: Path) -> list[dict[str, Any]]:
35+
return [json.loads(line) for line in path.read_text().splitlines() if line]
36+
37+
38+
def test_cursor_hook_records_cursor_in_session_and_hook_log(
39+
session_dir: Path,
40+
cursor_hook_log: Path,
41+
tmp_path: Path,
42+
monkeypatch: pytest.MonkeyPatch,
43+
) -> None:
44+
"""A Cursor-fired Claude Code plugin hook keeps Cursor provenance."""
45+
monkeypatch.setenv("CURSOR_VERSION", "3.11.0")
46+
monkeypatch.setenv("CURSOR_PROJECT_DIR", str(tmp_path))
47+
monkeypatch.setattr(
48+
hook,
49+
"_read_stdin_json",
50+
lambda: {
51+
"session_id": "cursor-session",
52+
"tool_name": "Read",
53+
"tool_input": {"file_path": "/tmp/example.py"},
54+
"tool_response": {"ok": True},
55+
},
56+
)
57+
58+
assert hook.main(["claude-code", "post-tool"]) == 0
59+
60+
assert state.read_all("cursor-session")[0]["host"] == "cursor"
61+
log_record = _log_records(cursor_hook_log)[0]
62+
assert log_record["host"] == "cursor"
63+
assert log_record["cwd"] == str(tmp_path)
64+
assert log_record["project_id"] == tmp_path.name
65+
66+
67+
def test_claude_code_entrypoint_wins_over_cursor_environment(
68+
session_dir: Path,
69+
cursor_hook_log: Path,
70+
tmp_path: Path,
71+
monkeypatch: pytest.MonkeyPatch,
72+
) -> None:
73+
"""Claude Code launched inside Cursor stays attributed to Claude Code."""
74+
monkeypatch.setenv("CURSOR_VERSION", "3.11.0")
75+
monkeypatch.setenv("CURSOR_PROJECT_DIR", str(tmp_path))
76+
monkeypatch.setenv("CLAUDE_CODE_ENTRYPOINT", "cli")
77+
monkeypatch.setattr(
78+
hook,
79+
"_read_stdin_json",
80+
lambda: {
81+
"session_id": "claude-session",
82+
"cwd": str(tmp_path),
83+
"tool_name": "Read",
84+
"tool_response": {"ok": True},
85+
},
86+
)
87+
88+
assert hook.main(["claude-code", "post-tool"]) == 0
89+
90+
assert state.read_all("claude-session")[0]["host"] == "claude-code"
91+
assert _log_records(cursor_hook_log)[0]["host"] == "claude-code"
92+
93+
94+
@pytest.mark.parametrize(
95+
("env", "payload", "expected"),
96+
[
97+
({"CURSOR_TRANSCRIPT_PATH": "/tmp/cursor.jsonl"}, {}, "cursor"),
98+
(
99+
{"CURSOR_VERSION": "3.11", "CURSOR_PROJECT_DIR": "/tmp/project"},
100+
{},
101+
"cursor",
102+
),
103+
({"CURSOR_VERSION": "3.11"}, {}, "claude-code"),
104+
(
105+
{
106+
"CURSOR_TRANSCRIPT_PATH": "/tmp/cursor.jsonl",
107+
"CLAUDE_CODE_ENTRYPOINT": "cli",
108+
},
109+
{},
110+
"claude-code",
111+
),
112+
(
113+
{},
114+
{
115+
"transcript_path": str(
116+
Path.home() / ".cursor" / "projects" / "session.jsonl"
117+
)
118+
},
119+
"cursor",
120+
),
121+
(
122+
{},
123+
{
124+
"transcript_path": str(
125+
Path.home() / ".claude" / "projects" / "session.jsonl"
126+
)
127+
},
128+
"claude-code",
129+
),
130+
],
131+
)
132+
def test_resolve_hook_host_uses_layered_cursor_signals(
133+
env: dict[str, str],
134+
payload: dict[str, str],
135+
expected: str,
136+
monkeypatch: pytest.MonkeyPatch,
137+
) -> None:
138+
"""Layered Cursor signals refine only ambiguous Claude Code hooks."""
139+
for name, value in env.items():
140+
monkeypatch.setenv(name, value)
141+
142+
assert runtime.resolve_hook_host("claude-code", payload) == expected
143+
144+
145+
@pytest.mark.parametrize("host", ["codex", "opencode"])
146+
def test_explicit_non_claude_hosts_are_not_reclassified_as_cursor(
147+
host: str,
148+
tmp_path: Path,
149+
monkeypatch: pytest.MonkeyPatch,
150+
) -> None:
151+
"""Explicit non-Claude hosts remain authoritative under Cursor signals."""
152+
monkeypatch.setenv("CURSOR_TRANSCRIPT_PATH", str(tmp_path / "cursor.jsonl"))
153+
154+
assert runtime.resolve_hook_host(host, {}) == host
155+
156+
157+
def test_cursor_shares_learnings_without_losing_attribution() -> None:
158+
"""Cursor keeps local attribution while sharing the learning namespace."""
159+
assert runtime.set_host("cursor") == "cursor"
160+
assert runtime.host() == "cursor"
161+
assert runtime.attribution_host() == "cursor"
162+
assert runtime.agent_version() == "claude-code"

0 commit comments

Comments
 (0)