lightcone-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lightcone/cli/__init__.py +16 -0
- lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
- lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
- lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
- lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
- lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
- lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
- lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
- lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
- lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
- lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
- lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
- lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
- lightcone/cli/commands.py +2327 -0
- lightcone/cli/plugin.py +34 -0
- lightcone/engine/__init__.py +42 -0
- lightcone/engine/assets.py +418 -0
- lightcone/engine/container.py +370 -0
- lightcone/engine/io_manager.py +27 -0
- lightcone/engine/runner.py +1017 -0
- lightcone/engine/site_registry.py +142 -0
- lightcone/engine/status.py +135 -0
- lightcone/engine/targets.py +68 -0
- lightcone/engine/tree.py +245 -0
- lightcone/eval/__init__.py +25 -0
- lightcone/eval/build.py +148 -0
- lightcone/eval/cli.py +176 -0
- lightcone/eval/graders.py +192 -0
- lightcone/eval/harness.py +265 -0
- lightcone/eval/models.py +117 -0
- lightcone/eval/report.py +214 -0
- lightcone/eval/sandbox.py +394 -0
- lightcone_cli-0.2.0.dist-info/METADATA +16 -0
- lightcone_cli-0.2.0.dist-info/RECORD +46 -0
- lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
- lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
- lightcone_cli-0.2.0.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copied from langfuse-cli (https://github.com/langfuse/langfuse-cli)
|
|
3
|
+
# Copyright (c) 2023-2026 Langfuse GmbH — MIT License
|
|
4
|
+
# See NOTICE file in the project root for full license text.
|
|
5
|
+
"""
|
|
6
|
+
prepare-commit-msg hook: appends a Langfuse-Trace trailer to commit messages.
|
|
7
|
+
Installed by langfuse-cli.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from langfuse_utils import (
|
|
18
|
+
LAST_TRACE_FILE,
|
|
19
|
+
read_recent_trace,
|
|
20
|
+
resolve_repo_root,
|
|
21
|
+
tracing_enabled,
|
|
22
|
+
)
|
|
23
|
+
except ImportError:
|
|
24
|
+
LAST_TRACE_FILE = Path.home() / ".claude" / "state" / "langfuse_last_trace.json"
|
|
25
|
+
import json
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
|
|
28
|
+
def tracing_enabled() -> bool:
|
|
29
|
+
return os.environ.get("TRACE_TO_LANGFUSE", "").lower() == "true"
|
|
30
|
+
|
|
31
|
+
def read_recent_trace(
|
|
32
|
+
path: Path, max_age_hours: float, expected_session_id: str | None = None
|
|
33
|
+
) -> dict | None:
|
|
34
|
+
if not path.exists():
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
if not isinstance(data, dict):
|
|
41
|
+
return None
|
|
42
|
+
trace_url = data.get("trace_url")
|
|
43
|
+
trace_id = data.get("trace_id")
|
|
44
|
+
if not isinstance(trace_url, str) or not trace_url:
|
|
45
|
+
return None
|
|
46
|
+
if not isinstance(trace_id, str) or not trace_id:
|
|
47
|
+
return None
|
|
48
|
+
if expected_session_id and data.get("session_id") != expected_session_id:
|
|
49
|
+
return None
|
|
50
|
+
updated_at = data.get("updated_at")
|
|
51
|
+
if isinstance(updated_at, str):
|
|
52
|
+
try:
|
|
53
|
+
ts = datetime.fromisoformat(updated_at)
|
|
54
|
+
if ts.tzinfo is None:
|
|
55
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
56
|
+
age_hours = (datetime.now(timezone.utc) - ts).total_seconds() / 3600
|
|
57
|
+
if age_hours > max_age_hours:
|
|
58
|
+
return None
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
else:
|
|
62
|
+
return None
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
def resolve_repo_root(search_path: Path) -> Path | None:
|
|
66
|
+
_ = search_path
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
MAX_AGE_HOURS = 4
|
|
71
|
+
SESSION_TRAILER_KEY = "Langfuse-Session"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _append_trailers(content: str, trailers: list[str]) -> str:
|
|
75
|
+
"""Append one or more trailers to a commit message, preserving existing trailer blocks."""
|
|
76
|
+
lines = content.rstrip("\n").split("\n")
|
|
77
|
+
|
|
78
|
+
has_existing_trailers = False
|
|
79
|
+
for line in reversed(lines):
|
|
80
|
+
stripped = line.strip()
|
|
81
|
+
if not stripped:
|
|
82
|
+
break
|
|
83
|
+
if ": " in stripped and not stripped.startswith("#"):
|
|
84
|
+
has_existing_trailers = True
|
|
85
|
+
break
|
|
86
|
+
else:
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
joined = "\n".join(trailers)
|
|
90
|
+
if has_existing_trailers:
|
|
91
|
+
return "\n".join(lines) + "\n" + joined + "\n"
|
|
92
|
+
return "\n".join(lines) + "\n\n" + joined + "\n"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> int:
|
|
96
|
+
try:
|
|
97
|
+
if not tracing_enabled():
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
if len(sys.argv) < 2:
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
msg_file = sys.argv[1]
|
|
104
|
+
commit_source = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
105
|
+
|
|
106
|
+
if commit_source in ("merge", "squash"):
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
# Prefer the global last-trace file (updated eagerly by the PreToolUse
|
|
110
|
+
# session-init hook for the *current* session) over the per-repo
|
|
111
|
+
# current-session file (only updated by the Stop hook, which runs
|
|
112
|
+
# *after* the commit finishes).
|
|
113
|
+
data = read_recent_trace(LAST_TRACE_FILE, MAX_AGE_HOURS)
|
|
114
|
+
if not data:
|
|
115
|
+
repo_root = resolve_repo_root(Path.cwd()) or Path.cwd()
|
|
116
|
+
local_trace_path = repo_root / ".langfuse" / "current-session.json"
|
|
117
|
+
data = read_recent_trace(local_trace_path, MAX_AGE_HOURS)
|
|
118
|
+
if not data:
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
session_url = data.get("session_url")
|
|
122
|
+
if not isinstance(session_url, str) or not session_url:
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
content = Path(msg_file).read_text(encoding="utf-8")
|
|
127
|
+
except Exception:
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
if f"{SESSION_TRAILER_KEY}:" in content:
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
result = _append_trailers(content, [f"{SESSION_TRAILER_KEY}: {session_url}"])
|
|
134
|
+
Path(msg_file).write_text(result, encoding="utf-8")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
except Exception:
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
sys.exit(main())
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copied from langfuse-cli (https://github.com/langfuse/langfuse-cli)
|
|
3
|
+
# Copyright (c) 2023-2026 Langfuse GmbH — MIT License
|
|
4
|
+
# See NOTICE file in the project root for full license text.
|
|
5
|
+
"""
|
|
6
|
+
PreToolUse hook: eagerly initializes the Langfuse trace ID for the current
|
|
7
|
+
Claude Code session so that prepare-commit-msg can reference it immediately.
|
|
8
|
+
|
|
9
|
+
On the first tool use of a session, this hook:
|
|
10
|
+
1. Generates a deterministic trace_id from the session_id
|
|
11
|
+
2. Writes it to ~/.claude/state/langfuse_last_trace.json
|
|
12
|
+
|
|
13
|
+
Subsequent invocations detect the matching session_id and exit immediately.
|
|
14
|
+
Installed by langfuse-cli.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from langfuse_utils import (
|
|
23
|
+
debug,
|
|
24
|
+
extract_session_id,
|
|
25
|
+
get_langfuse_credentials,
|
|
26
|
+
read_hook_payload,
|
|
27
|
+
read_last_trace,
|
|
28
|
+
save_last_trace,
|
|
29
|
+
tracing_enabled,
|
|
30
|
+
)
|
|
31
|
+
except ImportError:
|
|
32
|
+
sys.exit(0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main() -> int:
|
|
36
|
+
try:
|
|
37
|
+
if not tracing_enabled():
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
payload = read_hook_payload()
|
|
41
|
+
session_id = extract_session_id(payload)
|
|
42
|
+
if not session_id:
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
existing = read_last_trace(expected_session_id=session_id)
|
|
46
|
+
if existing:
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
creds = get_langfuse_credentials()
|
|
50
|
+
if not creds:
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
# Generate a deterministic trace_id from the session_id.
|
|
54
|
+
# Prefer the Langfuse SDK's create_trace_id (W3C-compatible 32-char hex)
|
|
55
|
+
# with a fallback to SHA-256 for environments without the SDK or older versions.
|
|
56
|
+
trace_id = None
|
|
57
|
+
try:
|
|
58
|
+
from langfuse import Langfuse
|
|
59
|
+
|
|
60
|
+
lf = Langfuse(
|
|
61
|
+
public_key=creds["public_key"],
|
|
62
|
+
secret_key=creds["secret_key"],
|
|
63
|
+
host=creds["host"],
|
|
64
|
+
)
|
|
65
|
+
trace_id = lf.create_trace_id(seed=session_id)
|
|
66
|
+
lf.shutdown()
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
if not trace_id:
|
|
71
|
+
trace_id = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
|
|
72
|
+
|
|
73
|
+
save_last_trace(session_id, trace_id, creds["host"])
|
|
74
|
+
debug(f"Initialized trace_id {trace_id} for session {session_id}")
|
|
75
|
+
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
except Exception:
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
sys.exit(main())
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copied from langfuse-cli (https://github.com/langfuse/langfuse-cli)
|
|
3
|
+
# Copyright (c) 2023-2026 Langfuse GmbH — MIT License
|
|
4
|
+
# See NOTICE file in the project root for full license text.
|
|
5
|
+
"""Shared utilities for Langfuse Claude Code hooks.
|
|
6
|
+
|
|
7
|
+
Installed by langfuse-cli. All hook scripts import from this module.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
# --------------- Configuration ---------------
|
|
21
|
+
HOOK_DEBUG_ENV = "LANGFUSE_HOOK_DEBUG"
|
|
22
|
+
DEBUG = os.environ.get(HOOK_DEBUG_ENV, "").lower() == "true"
|
|
23
|
+
|
|
24
|
+
STATE_DIR = Path.home() / ".claude" / "state"
|
|
25
|
+
LOG_FILE = STATE_DIR / "langfuse_hook.log"
|
|
26
|
+
STATE_FILE = STATE_DIR / "langfuse_state.json"
|
|
27
|
+
LOCK_FILE = STATE_DIR / "langfuse_state.lock"
|
|
28
|
+
LAST_TRACE_FILE = STATE_DIR / "langfuse_last_trace.json"
|
|
29
|
+
|
|
30
|
+
MAX_CHARS = int(os.environ.get("LANGFUSE_HOOK_MAX_CHARS", "20000"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --------------- Logging ---------------
|
|
34
|
+
def _log(level: str, message: str) -> None:
|
|
35
|
+
try:
|
|
36
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
38
|
+
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
|
39
|
+
f.write(f"{ts} [{level}] {message}\n")
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def debug(msg: str) -> None:
|
|
45
|
+
if DEBUG:
|
|
46
|
+
_log("DEBUG", msg)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def info(msg: str) -> None:
|
|
50
|
+
_log("INFO", msg)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def warn(msg: str) -> None:
|
|
54
|
+
_log("WARN", msg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def error(msg: str) -> None:
|
|
58
|
+
_log("ERROR", msg)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------- Environment ---------------
|
|
62
|
+
def tracing_enabled() -> bool:
|
|
63
|
+
return os.environ.get("TRACE_TO_LANGFUSE", "").lower() == "true"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_langfuse_credentials() -> Optional[Dict[str, str]]:
|
|
67
|
+
public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
|
|
68
|
+
secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
|
|
69
|
+
if not public_key or not secret_key:
|
|
70
|
+
return None
|
|
71
|
+
host = (
|
|
72
|
+
os.environ.get("LANGFUSE_BASE_URL")
|
|
73
|
+
or os.environ.get("LANGFUSE_HOST")
|
|
74
|
+
or "https://cloud.langfuse.com"
|
|
75
|
+
).rstrip("/")
|
|
76
|
+
return {"public_key": public_key, "secret_key": secret_key, "host": host}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------- Hook payload ---------------
|
|
80
|
+
def read_hook_payload() -> Dict[str, Any]:
|
|
81
|
+
try:
|
|
82
|
+
data = sys.stdin.read()
|
|
83
|
+
if not data.strip():
|
|
84
|
+
return {}
|
|
85
|
+
return json.loads(data)
|
|
86
|
+
except Exception:
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extract_session_id(payload: Dict[str, Any]) -> Optional[str]:
|
|
91
|
+
return (
|
|
92
|
+
payload.get("sessionId")
|
|
93
|
+
or payload.get("session_id")
|
|
94
|
+
or (payload.get("session") or {}).get("id")
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def extract_transcript_path(payload: Dict[str, Any]) -> Optional[Path]:
|
|
99
|
+
transcript = (
|
|
100
|
+
payload.get("transcriptPath")
|
|
101
|
+
or payload.get("transcript_path")
|
|
102
|
+
or (payload.get("transcript") or {}).get("path")
|
|
103
|
+
)
|
|
104
|
+
if not transcript:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
return Path(transcript).expanduser().resolve()
|
|
108
|
+
except Exception:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------- Git helpers ---------------
|
|
113
|
+
def run_git(cwd: Path, args: List[str]) -> Optional[str]:
|
|
114
|
+
try:
|
|
115
|
+
output = subprocess.check_output(
|
|
116
|
+
["git", *args],
|
|
117
|
+
cwd=str(cwd),
|
|
118
|
+
stderr=subprocess.DEVNULL,
|
|
119
|
+
text=True,
|
|
120
|
+
)
|
|
121
|
+
value = output.strip()
|
|
122
|
+
return value or None
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_repo_root(search_path: Path) -> Optional[Path]:
|
|
128
|
+
cwd = search_path.parent if search_path.is_file() else search_path
|
|
129
|
+
root = run_git(cwd, ["rev-parse", "--show-toplevel"])
|
|
130
|
+
if not root:
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
return Path(root).expanduser().resolve()
|
|
134
|
+
except Exception:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def first_remote(repo_root: Path) -> Optional[str]:
|
|
139
|
+
remotes = run_git(repo_root, ["remote"])
|
|
140
|
+
if not remotes:
|
|
141
|
+
return None
|
|
142
|
+
for line in remotes.splitlines():
|
|
143
|
+
remote = line.strip()
|
|
144
|
+
if remote:
|
|
145
|
+
return remote
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_github_commit_url(remote_url: Optional[str], commit_sha: str) -> Optional[str]:
|
|
150
|
+
if not remote_url:
|
|
151
|
+
return None
|
|
152
|
+
remote = remote_url.strip()
|
|
153
|
+
if not remote:
|
|
154
|
+
return None
|
|
155
|
+
patterns = [
|
|
156
|
+
r"^https?://github\.com/(.+?)(?:\.git)?/?$",
|
|
157
|
+
r"^git@github\.com:(.+?)(?:\.git)?$",
|
|
158
|
+
r"^ssh://git@github\.com/(.+?)(?:\.git)?/?$",
|
|
159
|
+
]
|
|
160
|
+
for pattern in patterns:
|
|
161
|
+
match = re.match(pattern, remote, re.IGNORECASE)
|
|
162
|
+
if match and match.group(1):
|
|
163
|
+
return f"https://github.com/{match.group(1)}/commit/{commit_sha}"
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_remote_url(repo_root: Path) -> Optional[str]:
|
|
168
|
+
remote_url = run_git(repo_root, ["remote", "get-url", "origin"])
|
|
169
|
+
if not remote_url:
|
|
170
|
+
remote_name = first_remote(repo_root)
|
|
171
|
+
if remote_name:
|
|
172
|
+
remote_url = run_git(repo_root, ["remote", "get-url", remote_name])
|
|
173
|
+
return remote_url
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def resolve_repo_root_with_fallback(*paths: Path) -> Optional[Path]:
|
|
177
|
+
"""Try each path in order, returning the first that resolves to a git repo root."""
|
|
178
|
+
for p in paths:
|
|
179
|
+
root = resolve_repo_root(p)
|
|
180
|
+
if root:
|
|
181
|
+
return root
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_git_metadata(*search_paths: Path) -> Dict[str, Any]:
|
|
186
|
+
"""Build git metadata from the first search path that resolves to a repo.
|
|
187
|
+
|
|
188
|
+
Pass multiple candidates (e.g. transcript path, then cwd) so we find
|
|
189
|
+
the repo even when the transcript lives outside the working tree.
|
|
190
|
+
"""
|
|
191
|
+
repo_root = resolve_repo_root_with_fallback(*search_paths)
|
|
192
|
+
if not repo_root:
|
|
193
|
+
return {}
|
|
194
|
+
commit_sha = run_git(repo_root, ["rev-parse", "HEAD"])
|
|
195
|
+
if not commit_sha:
|
|
196
|
+
return {}
|
|
197
|
+
remote_url = get_remote_url(repo_root)
|
|
198
|
+
commit_url = build_github_commit_url(remote_url, commit_sha)
|
|
199
|
+
metadata: Dict[str, Any] = {
|
|
200
|
+
"git_commit_sha": commit_sha,
|
|
201
|
+
"git_remote_url": remote_url,
|
|
202
|
+
}
|
|
203
|
+
if commit_url:
|
|
204
|
+
metadata["git_commit_url"] = commit_url
|
|
205
|
+
return metadata
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --------------- Claude Code identity ---------------
|
|
209
|
+
_cached_user_email: Optional[str] = None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_claude_user_email() -> Optional[str]:
|
|
213
|
+
"""Resolve the Claude Code user's email address.
|
|
214
|
+
|
|
215
|
+
Checks ~/.claude.json for stored auth data (oauthAccount.emailAddress),
|
|
216
|
+
then falls back to running ``claude auth status`` and parsing the output.
|
|
217
|
+
"""
|
|
218
|
+
global _cached_user_email
|
|
219
|
+
if _cached_user_email is not None:
|
|
220
|
+
return _cached_user_email or None
|
|
221
|
+
|
|
222
|
+
email_keys = ("emailAddress", "email", "userEmail", "user_email")
|
|
223
|
+
|
|
224
|
+
# 1) Try ~/.claude.json (Claude Code stores oauthAccount here)
|
|
225
|
+
try:
|
|
226
|
+
claude_json_path = Path.home() / ".claude.json"
|
|
227
|
+
if claude_json_path.exists():
|
|
228
|
+
data = json.loads(claude_json_path.read_text(encoding="utf-8"))
|
|
229
|
+
if isinstance(data, dict):
|
|
230
|
+
# Check top-level keys
|
|
231
|
+
for key in email_keys:
|
|
232
|
+
val = data.get(key)
|
|
233
|
+
if isinstance(val, str) and "@" in val:
|
|
234
|
+
_cached_user_email = val
|
|
235
|
+
return val
|
|
236
|
+
# Check nested objects (oauthAccount, auth, user, etc.)
|
|
237
|
+
for outer in ("oauthAccount", "auth", "oauth", "user", "account"):
|
|
238
|
+
nested = data.get(outer)
|
|
239
|
+
if isinstance(nested, dict):
|
|
240
|
+
for key in email_keys:
|
|
241
|
+
val = nested.get(key)
|
|
242
|
+
if isinstance(val, str) and "@" in val:
|
|
243
|
+
_cached_user_email = val
|
|
244
|
+
return val
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
# 2) Fallback: ``claude auth status``
|
|
249
|
+
try:
|
|
250
|
+
out = subprocess.check_output(
|
|
251
|
+
["claude", "auth", "status"],
|
|
252
|
+
stderr=subprocess.DEVNULL,
|
|
253
|
+
text=True,
|
|
254
|
+
timeout=5,
|
|
255
|
+
)
|
|
256
|
+
# Try JSON output first
|
|
257
|
+
try:
|
|
258
|
+
status = json.loads(out.strip())
|
|
259
|
+
if isinstance(status, dict):
|
|
260
|
+
for key in email_keys:
|
|
261
|
+
val = status.get(key)
|
|
262
|
+
if isinstance(val, str) and "@" in val:
|
|
263
|
+
_cached_user_email = val
|
|
264
|
+
return val
|
|
265
|
+
except (json.JSONDecodeError, ValueError):
|
|
266
|
+
pass
|
|
267
|
+
# Try plain text: "Logged in as user@example.com"
|
|
268
|
+
import re as _re
|
|
269
|
+
match = _re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", out)
|
|
270
|
+
if match:
|
|
271
|
+
_cached_user_email = match.group(0)
|
|
272
|
+
return _cached_user_email
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
_cached_user_email = ""
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# --------------- File I/O ---------------
|
|
281
|
+
def atomic_write_json(path: Path, data: dict) -> None:
|
|
282
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
fd, tmp_path = tempfile.mkstemp(prefix=f"{path.name}.", dir=str(path.parent))
|
|
284
|
+
try:
|
|
285
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
286
|
+
json.dump(data, f, indent=2)
|
|
287
|
+
f.write("\n")
|
|
288
|
+
os.replace(tmp_path, path)
|
|
289
|
+
finally:
|
|
290
|
+
if os.path.exists(tmp_path):
|
|
291
|
+
os.unlink(tmp_path)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def save_last_trace(session_id: str, trace_id: str, host: str) -> None:
|
|
295
|
+
try:
|
|
296
|
+
project_id = os.environ.get("LANGFUSE_PROJECT_ID", "")
|
|
297
|
+
|
|
298
|
+
data: Dict[str, Any] = {
|
|
299
|
+
"session_id": session_id,
|
|
300
|
+
"trace_id": trace_id,
|
|
301
|
+
"trace_url": f"{host}/trace/{trace_id}",
|
|
302
|
+
"host": host,
|
|
303
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if project_id:
|
|
307
|
+
data["project_id"] = project_id
|
|
308
|
+
data["session_url"] = f"{host}/project/{project_id}/sessions/{session_id}"
|
|
309
|
+
|
|
310
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
tmp = LAST_TRACE_FILE.with_suffix(".tmp")
|
|
312
|
+
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
313
|
+
os.replace(tmp, LAST_TRACE_FILE)
|
|
314
|
+
except Exception as e:
|
|
315
|
+
debug(f"save_last_trace failed: {e}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def read_last_trace(expected_session_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
319
|
+
"""Read the last trace file, optionally validating the session_id matches."""
|
|
320
|
+
if not LAST_TRACE_FILE.exists():
|
|
321
|
+
return None
|
|
322
|
+
try:
|
|
323
|
+
data = json.loads(LAST_TRACE_FILE.read_text(encoding="utf-8"))
|
|
324
|
+
if not isinstance(data, dict) or not data.get("trace_id"):
|
|
325
|
+
return None
|
|
326
|
+
if expected_session_id and data.get("session_id") != expected_session_id:
|
|
327
|
+
return None
|
|
328
|
+
return data
|
|
329
|
+
except Exception:
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _parse_iso_utc(value: Any) -> Optional[datetime]:
|
|
334
|
+
if not isinstance(value, str) or not value:
|
|
335
|
+
return None
|
|
336
|
+
try:
|
|
337
|
+
ts = datetime.fromisoformat(value)
|
|
338
|
+
if ts.tzinfo is None:
|
|
339
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
340
|
+
return ts.astimezone(timezone.utc)
|
|
341
|
+
except Exception:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def read_recent_trace(
|
|
346
|
+
path: Path,
|
|
347
|
+
max_age_hours: float,
|
|
348
|
+
expected_session_id: Optional[str] = None,
|
|
349
|
+
) -> Optional[Dict[str, Any]]:
|
|
350
|
+
"""Read a trace file if present and recent enough."""
|
|
351
|
+
if not path.exists():
|
|
352
|
+
return None
|
|
353
|
+
try:
|
|
354
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
355
|
+
except Exception:
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
if not isinstance(data, dict):
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
trace_id = data.get("trace_id")
|
|
362
|
+
trace_url = data.get("trace_url")
|
|
363
|
+
if not isinstance(trace_id, str) or not trace_id:
|
|
364
|
+
return None
|
|
365
|
+
if not isinstance(trace_url, str) or not trace_url:
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
if expected_session_id and data.get("session_id") != expected_session_id:
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
updated_at = _parse_iso_utc(data.get("updated_at"))
|
|
372
|
+
if updated_at is None:
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
age_hours = (datetime.now(timezone.utc) - updated_at).total_seconds() / 3600
|
|
376
|
+
if age_hours > max_age_hours:
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
return data
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# --------------- Trace manifest ---------------
|
|
383
|
+
def write_trace_manifest(
|
|
384
|
+
repo_root: Path,
|
|
385
|
+
session_id: str,
|
|
386
|
+
trace_id: str,
|
|
387
|
+
host: str,
|
|
388
|
+
git_metadata: Optional[Dict[str, Any]] = None,
|
|
389
|
+
) -> None:
|
|
390
|
+
try:
|
|
391
|
+
safe_sid = re.sub(r"[^A-Za-z0-9._-]", "_", session_id)
|
|
392
|
+
manifest_dir = repo_root / ".langfuse" / "traces"
|
|
393
|
+
manifest_path = manifest_dir / f"{safe_sid}.json"
|
|
394
|
+
|
|
395
|
+
existing: Dict[str, Any] = {}
|
|
396
|
+
if manifest_path.exists():
|
|
397
|
+
try:
|
|
398
|
+
existing = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
399
|
+
except Exception:
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
trace_url = f"{host}/trace/{trace_id}"
|
|
403
|
+
project_id = os.environ.get("LANGFUSE_PROJECT_ID", "")
|
|
404
|
+
session_url = f"{host}/project/{project_id}/sessions/{session_id}" if project_id else ""
|
|
405
|
+
|
|
406
|
+
commit_sha = (git_metadata or {}).get("git_commit_sha", "")
|
|
407
|
+
remote_url = (git_metadata or {}).get("git_remote_url")
|
|
408
|
+
commit_url = (git_metadata or {}).get("git_commit_url")
|
|
409
|
+
|
|
410
|
+
git_block = existing.get("git", {}) if isinstance(existing.get("git"), dict) else {}
|
|
411
|
+
if commit_sha:
|
|
412
|
+
git_block["commit_sha"] = commit_sha
|
|
413
|
+
if commit_url:
|
|
414
|
+
git_block["commit_url"] = commit_url
|
|
415
|
+
if remote_url:
|
|
416
|
+
git_block["remote_url"] = remote_url
|
|
417
|
+
branch = run_git(repo_root, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
418
|
+
if branch:
|
|
419
|
+
git_block["branch"] = branch
|
|
420
|
+
msg = run_git(repo_root, ["log", "-1", "--pretty=%s"])
|
|
421
|
+
if msg:
|
|
422
|
+
git_block["commit_message"] = msg
|
|
423
|
+
|
|
424
|
+
langfuse_block: Dict[str, Any] = {
|
|
425
|
+
"trace_id": trace_id,
|
|
426
|
+
"trace_url": trace_url,
|
|
427
|
+
"session_id": session_id,
|
|
428
|
+
"host": host.rstrip("/"),
|
|
429
|
+
}
|
|
430
|
+
if session_url:
|
|
431
|
+
langfuse_block["session_url"] = session_url
|
|
432
|
+
|
|
433
|
+
manifest = {
|
|
434
|
+
"schema_version": 1,
|
|
435
|
+
"langfuse": langfuse_block,
|
|
436
|
+
"git": git_block,
|
|
437
|
+
"created_at": existing.get("created_at", datetime.now(timezone.utc).isoformat()),
|
|
438
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
atomic_write_json(manifest_path, manifest)
|
|
442
|
+
|
|
443
|
+
current_session_data: Dict[str, Any] = {
|
|
444
|
+
"session_id": session_id,
|
|
445
|
+
"trace_id": trace_id,
|
|
446
|
+
"trace_url": trace_url,
|
|
447
|
+
"host": host.rstrip("/"),
|
|
448
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
449
|
+
}
|
|
450
|
+
if session_url:
|
|
451
|
+
current_session_data["session_url"] = session_url
|
|
452
|
+
|
|
453
|
+
current_session_path = repo_root / ".langfuse" / "current-session.json"
|
|
454
|
+
atomic_write_json(current_session_path, current_session_data)
|
|
455
|
+
debug(f"Wrote trace manifest to {manifest_path}")
|
|
456
|
+
except Exception as exc:
|
|
457
|
+
debug(f"write_trace_manifest failed: {exc}")
|