oh-langfuse 0.1.56 → 0.1.58
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.
- package/README.md +36 -4
- package/bin/cli.js +22 -14
- package/codex_langfuse_notify.py +39 -15
- package/langfuse_hook.py +7 -6
- package/package.json +6 -4
- package/scripts/opencode-langfuse-setup.mjs +1 -1
- package/scripts/real-self-verify.mjs +31 -18
- package/scripts/verify-update-runtime.mjs +162 -0
- package/scripts/verify-update-utils.mjs +60 -0
package/README.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
`oh-langfuse` 是用于给 Claude Code、OpenCode、Codex 配置 Langfuse 追踪的命令行工具。它既提供交互式安装向导,也支持 `setup`、`check`、`update`、`auto-update` 等直接命令。
|
|
4
4
|
|
|
5
|
-
当前 npm 版本:`0.1.
|
|
5
|
+
当前 npm 版本:`0.1.58`
|
|
6
6
|
|
|
7
7
|
## 能做什么
|
|
8
8
|
|
|
9
9
|
- 为 Claude Code 安装 `Stop` hook,把会话事件写入 Langfuse。
|
|
10
|
-
- 为 OpenCode 安装并 patch `opencode-plugin-langfuse`,开启 OpenTelemetry,并输出 `Agent Turn` 指标。
|
|
10
|
+
- 为 OpenCode 安装并 patch `opencode-plugin-langfuse`,开启 OpenTelemetry,并输出 `OpenCode Agent Turn` 指标。
|
|
11
11
|
- 为 Codex 安装 `notify` hook,增量读取 session JSONL 并写入 Langfuse。
|
|
12
12
|
- 记录本地 runtime 版本,支持启动前更新检查和手动更新。
|
|
13
13
|
- 校验员工号、环境变量、插件、hook、launcher、命令 shim 是否安装完整。
|
|
@@ -51,6 +51,13 @@ npx oh-langfuse@latest update opencode
|
|
|
51
51
|
npx oh-langfuse@latest update codex
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
确认是否已经更新到 npm 最新版本:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx oh-langfuse@latest verify-update all
|
|
58
|
+
npx oh-langfuse@latest verify-update opencode
|
|
59
|
+
```
|
|
60
|
+
|
|
54
61
|
## 交互式菜单
|
|
55
62
|
|
|
56
63
|
运行 `npx oh-langfuse@latest` 会打开菜单:
|
|
@@ -72,6 +79,23 @@ npx oh-langfuse@latest update codex
|
|
|
72
79
|
|
|
73
80
|
记录 Claude Code / OpenCode / Codex 当前写入本机 runtime 的包名和版本。
|
|
74
81
|
|
|
82
|
+
`verify-update` 会读取这份记录,并对比 npm `oh-langfuse@latest`:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx oh-langfuse@latest verify-update all
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
输出示例:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
[OK] OpenCode Langfuse runtime is current: 0.1.58
|
|
92
|
+
[FAIL] Codex Langfuse runtime is outdated: installed 0.1.57, latest 0.1.58
|
|
93
|
+
[FIX] Run: npx oh-langfuse@latest update codex
|
|
94
|
+
[SKIP] Claude not installed
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`check` 用于检查配置完整性;`verify-update` 才用于确认本机 runtime 是否已经更新到 npm 最新版本。
|
|
98
|
+
|
|
75
99
|
OpenCode setup 会生成直接命令 shim:
|
|
76
100
|
|
|
77
101
|
- Windows:`~/.config/opencode/bin/opencode.cmd`
|
|
@@ -98,7 +122,13 @@ npx oh-langfuse@latest auto-update codex
|
|
|
98
122
|
|
|
99
123
|
## Skill 使用统计
|
|
100
124
|
|
|
101
|
-
当前版本把 skill 使用汇总写入每次交互的 `Agent Turn` observation,不再额外生成独立 `Skill Use` observation。
|
|
125
|
+
当前版本把 skill 使用汇总写入每次交互的 agent-specific `Agent Turn` observation,不再额外生成独立 `Skill Use` observation。
|
|
126
|
+
|
|
127
|
+
Agent Turn 的 observation name 会带上来源,方便在 Langfuse 后台直接区分:
|
|
128
|
+
|
|
129
|
+
- `Claude Agent Turn`
|
|
130
|
+
- `OpenCode Agent Turn`
|
|
131
|
+
- `Codex Agent Turn`
|
|
102
132
|
|
|
103
133
|
常见字段:
|
|
104
134
|
|
|
@@ -116,7 +146,7 @@ Dashboard 统计建议:
|
|
|
116
146
|
|
|
117
147
|
```text
|
|
118
148
|
View: Observations
|
|
119
|
-
Filter: Observation Name
|
|
149
|
+
Filter: Observation Name contains Agent Turn
|
|
120
150
|
Metric: Sum skill_use_count
|
|
121
151
|
```
|
|
122
152
|
|
|
@@ -161,6 +191,8 @@ Linux 环境缺少 `python3-venv/ensurepip` 时,安装器会降级尝试 `pyth
|
|
|
161
191
|
|
|
162
192
|
配置后需要重启 Codex,让新的 notify 命令加载。notify hook 会增量读取 `~/.codex/sessions/**/*.jsonl`,并把读取偏移记录到 `~/.codex/langfuse/state.json`。
|
|
163
193
|
|
|
194
|
+
notify hook 会读取 `~/.codex/langfuse/config.json`(或 `CODEX_HOME/langfuse/config.json`)中的 `baseUrl` / `host`,并把该主机补入 `NO_PROXY`,避免自建 Langfuse 服务被代理干扰。
|
|
195
|
+
|
|
164
196
|
## 环境变量
|
|
165
197
|
|
|
166
198
|
| 变量 | 作用 |
|
package/bin/cli.js
CHANGED
|
@@ -794,11 +794,13 @@ function printHelp() {
|
|
|
794
794
|
"oh-langfuse check codex",
|
|
795
795
|
"oh-langfuse update",
|
|
796
796
|
"oh-langfuse update all",
|
|
797
|
-
"oh-langfuse update claude",
|
|
798
|
-
"oh-langfuse update opencode",
|
|
799
|
-
"oh-langfuse update codex",
|
|
800
|
-
"oh-langfuse
|
|
801
|
-
|
|
797
|
+
"oh-langfuse update claude",
|
|
798
|
+
"oh-langfuse update opencode",
|
|
799
|
+
"oh-langfuse update codex",
|
|
800
|
+
"oh-langfuse verify-update all",
|
|
801
|
+
"oh-langfuse verify-update opencode",
|
|
802
|
+
"oh-langfuse auto-update opencode"
|
|
803
|
+
]);
|
|
802
804
|
renderSection("Options", [
|
|
803
805
|
`${paint("--dry-run", t.gold)} Preview actions without writing files or installing packages.`,
|
|
804
806
|
`${paint("--userId=ID", t.gold)} Provide the Langfuse user id without prompting.`,
|
|
@@ -865,17 +867,23 @@ async function main() {
|
|
|
865
867
|
];
|
|
866
868
|
return runNodeScript("update-langfuse-runtime.mjs", updateArgs, options);
|
|
867
869
|
}
|
|
868
|
-
if (cmd === "auto-update") {
|
|
869
|
-
return runNodeScript("auto-update-runtime.mjs", [
|
|
870
|
-
target || "all",
|
|
871
|
-
...(hasValue(options.npmRegistry) ? [`--npmRegistry=${options.npmRegistry}`] : []),
|
|
872
|
-
...(hasValue(options.pipIndexUrl) ? [`--pipIndexUrl=${options.pipIndexUrl}`] : []),
|
|
870
|
+
if (cmd === "auto-update") {
|
|
871
|
+
return runNodeScript("auto-update-runtime.mjs", [
|
|
872
|
+
target || "all",
|
|
873
|
+
...(hasValue(options.npmRegistry) ? [`--npmRegistry=${options.npmRegistry}`] : []),
|
|
874
|
+
...(hasValue(options.pipIndexUrl) ? [`--pipIndexUrl=${options.pipIndexUrl}`] : []),
|
|
873
875
|
...(options.skipCheck ? ["--skip-check"] : []),
|
|
874
876
|
...(options.startupStatus ? ["--startup-status"] : []),
|
|
875
|
-
...(options.yes ? ["--yes"] : []),
|
|
876
|
-
], { ...options, quiet: true });
|
|
877
|
-
}
|
|
878
|
-
if (cmd === "
|
|
877
|
+
...(options.yes ? ["--yes"] : []),
|
|
878
|
+
], { ...options, quiet: true });
|
|
879
|
+
}
|
|
880
|
+
if (cmd === "verify-update") {
|
|
881
|
+
return runNodeScript("verify-update-runtime.mjs", [
|
|
882
|
+
target || "all",
|
|
883
|
+
...(hasValue(options.npmRegistry) ? [`--npmRegistry=${options.npmRegistry}`] : []),
|
|
884
|
+
], { ...options, quiet: true });
|
|
885
|
+
}
|
|
886
|
+
if (cmd === "check" && target === "claude") return checkClaude(options);
|
|
879
887
|
if (cmd === "check" && target === "opencode") return checkOpenCode(options);
|
|
880
888
|
if (cmd === "check" && target === "codex") return checkCodex(options);
|
|
881
889
|
if (cmd === "check" && target === "environment") {
|
package/codex_langfuse_notify.py
CHANGED
|
@@ -17,15 +17,38 @@ from dataclasses import dataclass
|
|
|
17
17
|
from datetime import datetime, timezone
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
-
from urllib.parse import urlparse
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def codex_langfuse_config_path() -> Path:
|
|
24
|
+
codex_dir = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
25
|
+
return codex_dir / "langfuse" / "config.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_langfuse_base_url_from_config() -> Optional[str]:
|
|
29
|
+
try:
|
|
30
|
+
path = codex_langfuse_config_path()
|
|
31
|
+
if not path.exists():
|
|
32
|
+
return None
|
|
33
|
+
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
34
|
+
if isinstance(data, dict):
|
|
35
|
+
value = data.get("baseUrl") or data.get("host")
|
|
36
|
+
if isinstance(value, str) and value.strip():
|
|
37
|
+
return value.strip()
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def configure_langfuse_no_proxy() -> None:
|
|
44
|
+
hosts = ["localhost", "127.0.0.1"]
|
|
45
|
+
values = [
|
|
46
|
+
*(os.environ.get(key) for key in ("LANGFUSE_HOST", "LANGFUSE_BASEURL", "CODEX_LANGFUSE_BASE_URL")),
|
|
47
|
+
read_langfuse_base_url_from_config(),
|
|
48
|
+
]
|
|
49
|
+
for value in values:
|
|
50
|
+
if not value:
|
|
51
|
+
continue
|
|
29
52
|
parsed = urlparse(value if "://" in value else f"http://{value}")
|
|
30
53
|
if parsed.hostname:
|
|
31
54
|
hosts.append(parsed.hostname)
|
|
@@ -62,7 +85,8 @@ LOG_FILE = STATE_DIR / "codex_langfuse_notify.log"
|
|
|
62
85
|
DEBUG = os.environ.get("CODEX_LANGFUSE_DEBUG", "").lower() == "true"
|
|
63
86
|
MAX_CHARS = int(os.environ.get("CODEX_LANGFUSE_MAX_CHARS", "20000"))
|
|
64
87
|
METRICS_SCHEMA_VERSION = "1.1"
|
|
65
|
-
AGENT_NAME = "codex"
|
|
88
|
+
AGENT_NAME = "codex"
|
|
89
|
+
AGENT_TURN_NAME = "Codex Agent Turn"
|
|
66
90
|
|
|
67
91
|
|
|
68
92
|
def log(level: str, message: str) -> None:
|
|
@@ -839,11 +863,11 @@ def emit_codex_turn(
|
|
|
839
863
|
with propagate_attributes(
|
|
840
864
|
user_id=user_id,
|
|
841
865
|
session_id=session_id,
|
|
842
|
-
trace_name=
|
|
843
|
-
tags=[AGENT_NAME],
|
|
844
|
-
):
|
|
845
|
-
with langfuse.start_as_current_observation(
|
|
846
|
-
name=
|
|
866
|
+
trace_name=AGENT_TURN_NAME,
|
|
867
|
+
tags=[AGENT_NAME],
|
|
868
|
+
):
|
|
869
|
+
with langfuse.start_as_current_observation(
|
|
870
|
+
name=AGENT_TURN_NAME,
|
|
847
871
|
input={"role": "user", "content": user_text},
|
|
848
872
|
output={"role": "assistant", "content": assistant_text},
|
|
849
873
|
metadata={
|
package/langfuse_hook.py
CHANGED
|
@@ -66,7 +66,8 @@ LOCK_FILE = STATE_DIR / "langfuse_state.lock"
|
|
|
66
66
|
DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
|
|
67
67
|
MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
|
|
68
68
|
METRICS_SCHEMA_VERSION = "1.1"
|
|
69
|
-
AGENT_NAME = "claude"
|
|
69
|
+
AGENT_NAME = "claude"
|
|
70
|
+
AGENT_TURN_NAME = "Claude Agent Turn"
|
|
70
71
|
|
|
71
72
|
# ----------------- Logging -----------------
|
|
72
73
|
def _log(level: str, message: str) -> None:
|
|
@@ -882,11 +883,11 @@ def emit_turn(
|
|
|
882
883
|
with propagate_attributes(
|
|
883
884
|
user_id=user_id,
|
|
884
885
|
session_id=session_id,
|
|
885
|
-
trace_name=
|
|
886
|
-
tags=[AGENT_NAME],
|
|
887
|
-
):
|
|
888
|
-
with langfuse.start_as_current_observation(
|
|
889
|
-
name=
|
|
886
|
+
trace_name=AGENT_TURN_NAME,
|
|
887
|
+
tags=[AGENT_NAME],
|
|
888
|
+
):
|
|
889
|
+
with langfuse.start_as_current_observation(
|
|
890
|
+
name=AGENT_TURN_NAME,
|
|
890
891
|
input={"role": "user", "content": user_text},
|
|
891
892
|
output={"role": "assistant", "content": assistant_text},
|
|
892
893
|
metadata={
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-langfuse",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.58",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
|
|
@@ -27,9 +27,11 @@
|
|
|
27
27
|
"scripts/log-filter-utils.mjs",
|
|
28
28
|
"scripts/metrics-utils.mjs",
|
|
29
29
|
"scripts/runtime-state-utils.mjs",
|
|
30
|
-
"scripts/update-langfuse-runtime.mjs",
|
|
31
|
-
"scripts/update-utils.mjs",
|
|
32
|
-
"
|
|
30
|
+
"scripts/update-langfuse-runtime.mjs",
|
|
31
|
+
"scripts/update-utils.mjs",
|
|
32
|
+
"scripts/verify-update-runtime.mjs",
|
|
33
|
+
"scripts/verify-update-utils.mjs",
|
|
34
|
+
"langfuse_hook.py",
|
|
33
35
|
"codex_langfuse_notify.py",
|
|
34
36
|
"README.md",
|
|
35
37
|
"SELF_VERIFY.md",
|
|
@@ -618,7 +618,7 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
618
618
|
" const total = tokenMetrics.total ?? (tokenMetrics.input !== undefined && tokenMetrics.output !== undefined ? tokenMetrics.input + tokenMetrics.output : undefined);",
|
|
619
619
|
" const tokenAvailable = [tokenMetrics.input, tokenMetrics.output, total, tokenMetrics.cacheRead, tokenMetrics.reasoning].some((value) => value !== undefined);",
|
|
620
620
|
" await sdkStartPromise;",
|
|
621
|
-
" const span = getMetricsTracer().startSpan('Agent Turn');",
|
|
621
|
+
" const span = getMetricsTracer().startSpan('OpenCode Agent Turn');",
|
|
622
622
|
" const text = messageTextById.get(messageId) || '';",
|
|
623
623
|
" const skillUsages = dedupeSkillUsages([...(skillUsagesByMessageId.get(messageId) ?? []), ...(skillUsagesBySessionId.get(sessionId) ?? [])]);",
|
|
624
624
|
" const interactionId = `opencode:${userId || \"unknown\"}:${sessionId || \"unknown\"}:${messageId}`;",
|
|
@@ -365,18 +365,25 @@ function metricInteractionId(item, target) {
|
|
|
365
365
|
: metadataValue(item, "interaction_id");
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
-
function isAgentTurnObservation(item) {
|
|
369
|
-
if (item?.name === "Agent Turn" || metadataValue(item, "interaction_count") === 1 || metadataValue(item, "interaction_count") === "1") {
|
|
370
|
-
return true;
|
|
371
|
-
}
|
|
368
|
+
function isAgentTurnObservation(item) {
|
|
369
|
+
if (/^(Claude|OpenCode|Codex) Agent Turn$/.test(String(item?.name || "")) || item?.name === "Agent Turn" || metadataValue(item, "interaction_count") === 1 || metadataValue(item, "interaction_count") === "1") {
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
372
|
const metadata = item?.metadata || {};
|
|
373
373
|
const attrs = metadata.attributes || {};
|
|
374
374
|
const isOpencode = metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode";
|
|
375
375
|
const hasModel = attrs["ai.model.id"] !== undefined || attrs["ai.model.provider"] !== undefined;
|
|
376
376
|
const isTool = attrs["ai.toolCall.name"] !== undefined || attrs["ai.toolCall.id"] !== undefined;
|
|
377
|
-
return isOpencode && hasModel && !isTool && item?.type === "GENERATION";
|
|
378
|
-
}
|
|
379
|
-
|
|
377
|
+
return isOpencode && hasModel && !isTool && item?.type === "GENERATION";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function expectedAgentTurnName(target) {
|
|
381
|
+
if (target === "claude") return "Claude Agent Turn";
|
|
382
|
+
if (target === "opencode") return "OpenCode Agent Turn";
|
|
383
|
+
if (target === "codex") return "Codex Agent Turn";
|
|
384
|
+
return "Agent Turn";
|
|
385
|
+
}
|
|
386
|
+
|
|
380
387
|
async function observationsForTrace(config, traceId, since) {
|
|
381
388
|
if (!traceId) return [];
|
|
382
389
|
const params = { limit: 100, traceId };
|
|
@@ -445,11 +452,17 @@ async function verifyMetricObservations(config, found, { since, target, marker =
|
|
|
445
452
|
throw new Error(`Metric verification failed for ${target}: Skill Use observations should not be emitted as standalone metrics.`);
|
|
446
453
|
}
|
|
447
454
|
|
|
448
|
-
if (!interactions.length) {
|
|
449
|
-
throw new Error(`Metric verification failed for ${target}: Agent Turn observation was not found for trace ${traceId || found.id}.`);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
const
|
|
455
|
+
if (!interactions.length) {
|
|
456
|
+
throw new Error(`Metric verification failed for ${target}: Agent Turn observation was not found for trace ${traceId || found.id}.`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const expectedName = expectedAgentTurnName(target);
|
|
460
|
+
if (!interactions.some((item) => item?.name === expectedName)) {
|
|
461
|
+
const names = interactions.map((item) => item?.name || "<unnamed>").join(", ");
|
|
462
|
+
throw new Error(`Metric verification failed for ${target}: expected observation name ${expectedName}, found ${names}.`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const byInteractionId = new Map();
|
|
453
466
|
const seenInteractionIds = new Set();
|
|
454
467
|
for (const item of interactions) {
|
|
455
468
|
const interactionId = metricInteractionId(item, target);
|
|
@@ -619,12 +632,12 @@ async function main() {
|
|
|
619
632
|
}
|
|
620
633
|
}
|
|
621
634
|
|
|
622
|
-
const found = await pollLangfuse(config, marker, { ...args, since, target });
|
|
623
|
-
console.log(`[OK] Langfuse marker found for ${target}: ${found.kind} ${found.id || ""}`.trim());
|
|
624
|
-
const metrics = await verifyMetricObservations(config, found, { since, target, marker });
|
|
625
|
-
console.log(`[OK] Langfuse metrics found for ${target}:
|
|
626
|
-
results.push({ target, marker, langfuse: { kind: found.kind, id: found.id || "", traceId: metrics.traceId }, metrics });
|
|
627
|
-
}
|
|
635
|
+
const found = await pollLangfuse(config, marker, { ...args, since, target });
|
|
636
|
+
console.log(`[OK] Langfuse marker found for ${target}: ${found.kind} ${found.id || ""}`.trim());
|
|
637
|
+
const metrics = await verifyMetricObservations(config, found, { since, target, marker });
|
|
638
|
+
console.log(`[OK] Langfuse metrics found for ${target}: ${expectedAgentTurnName(target)} x${metrics.interactionCount}`);
|
|
639
|
+
results.push({ target, marker, langfuse: { kind: found.kind, id: found.id || "", traceId: metrics.traceId }, metrics });
|
|
640
|
+
}
|
|
628
641
|
|
|
629
642
|
console.log("");
|
|
630
643
|
console.log(JSON.stringify({ ok: true, package: packageJson.name, marker, results }, null, 2));
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { extractVersionFromNpmMetadata } from "./update-utils.mjs";
|
|
5
|
+
import { readRuntimeState, runtimeStatePath } from "./runtime-state-utils.mjs";
|
|
6
|
+
import {
|
|
7
|
+
buildVerifyUpdateResults,
|
|
8
|
+
normalizeVerifyTargets,
|
|
9
|
+
targetLabel,
|
|
10
|
+
verifyUpdateExitCode,
|
|
11
|
+
} from "./verify-update-utils.mjs";
|
|
12
|
+
|
|
13
|
+
const colorEnabled = process.stdout.isTTY && process.env.NO_COLOR !== "1";
|
|
14
|
+
const ansi = (code) => (colorEnabled ? `\x1b[${code}m` : "");
|
|
15
|
+
const t = {
|
|
16
|
+
reset: ansi(0),
|
|
17
|
+
bold: ansi(1),
|
|
18
|
+
green: ansi("92"),
|
|
19
|
+
gold: ansi("93"),
|
|
20
|
+
red: ansi("91"),
|
|
21
|
+
cyan: ansi("96"),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function paint(text, ...styles) {
|
|
25
|
+
if (!colorEnabled) return text;
|
|
26
|
+
return `${styles.join("")}${text}${t.reset}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const args = { _: [] };
|
|
31
|
+
for (const raw of argv) {
|
|
32
|
+
if (!raw.startsWith("--")) {
|
|
33
|
+
args._.push(raw);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const eq = raw.indexOf("=");
|
|
37
|
+
if (eq === -1) args[raw.slice(2)] = true;
|
|
38
|
+
else args[raw.slice(2, eq)] = raw.slice(eq + 1);
|
|
39
|
+
}
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripBom(s) {
|
|
44
|
+
return typeof s === "string" && s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readJsonIfExists(p) {
|
|
48
|
+
try {
|
|
49
|
+
if (!fs.existsSync(p)) return null;
|
|
50
|
+
const text = stripBom(fs.readFileSync(p, "utf8"));
|
|
51
|
+
if (!text.trim()) return null;
|
|
52
|
+
return JSON.parse(text);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function codexHome(home = os.homedir()) {
|
|
59
|
+
return process.env.CODEX_HOME || path.join(home, ".codex");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function detectInstalledTargets(home = os.homedir()) {
|
|
63
|
+
return {
|
|
64
|
+
claude: fs.existsSync(path.join(home, ".claude", "hooks", "langfuse_hook.py")),
|
|
65
|
+
opencode:
|
|
66
|
+
fs.existsSync(path.join(home, ".config", "opencode", "opencode.json")) ||
|
|
67
|
+
fs.existsSync(path.join(home, ".config", "opencode", "plugins", "opencode-plugin-langfuse")),
|
|
68
|
+
codex: fs.existsSync(path.join(codexHome(home), "hooks", "codex_langfuse_notify.py")),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function latestVersion(packageName, registry = "https://registry.npmjs.org") {
|
|
73
|
+
const base = registry.replace(/\/+$/, "");
|
|
74
|
+
const response = await fetch(`${base}/${packageName}`, { headers: { accept: "application/json" } });
|
|
75
|
+
if (!response.ok) throw new Error(`npm registry ${response.status} ${response.statusText}`);
|
|
76
|
+
return extractVersionFromNpmMetadata(await response.json());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function targetsToVerify(targetArg, installed, runtimeTargets) {
|
|
80
|
+
const requested = normalizeVerifyTargets(targetArg);
|
|
81
|
+
if (String(targetArg || "all").trim().toLowerCase() !== "all") return { targets: requested, skipped: [] };
|
|
82
|
+
|
|
83
|
+
const targets = requested.filter((target) => installed[target] || runtimeTargets[target]);
|
|
84
|
+
const skipped = requested
|
|
85
|
+
.filter((target) => !installed[target] && !runtimeTargets[target])
|
|
86
|
+
.map((target) => ({ target, reason: "not_detected" }));
|
|
87
|
+
return { targets, skipped };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function printResult(result) {
|
|
91
|
+
const label = targetLabel(result.target);
|
|
92
|
+
if (result.status === "current") {
|
|
93
|
+
console.log(paint(`[OK] ${label} Langfuse runtime is current: ${result.packageName}@${result.installedVersion}`, t.bold, t.green));
|
|
94
|
+
if (result.updatedAt) console.log(`[INFO] ${label} updatedAt: ${result.updatedAt}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (result.status === "outdated") {
|
|
98
|
+
console.log(
|
|
99
|
+
paint(
|
|
100
|
+
`[FAIL] ${label} Langfuse runtime is outdated: installed ${result.packageName}@${result.installedVersion}, latest ${result.packageName}@${result.latestVersion}`,
|
|
101
|
+
t.bold,
|
|
102
|
+
t.red,
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
const fix =
|
|
106
|
+
result.packageName === "oh-aicoding-tool"
|
|
107
|
+
? `npx oh-aicoding-tool@latest langfuse update ${result.target}`
|
|
108
|
+
: `npx oh-langfuse@latest update ${result.target}`;
|
|
109
|
+
console.log(paint(`[FIX] Run: ${fix}`, t.bold, t.cyan));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
console.log(paint(`[FAIL] ${label} Langfuse runtime record is missing.`, t.bold, t.red));
|
|
113
|
+
console.log(`[INFO] Runtime state: ${runtimeStatePath()}`);
|
|
114
|
+
console.log(paint(`[FIX] Run: npx oh-langfuse@latest update ${result.target}`, t.bold, t.cyan));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function main() {
|
|
118
|
+
const args = parseArgs(process.argv.slice(2));
|
|
119
|
+
const targetArg = args._[0] || "all";
|
|
120
|
+
const state = readRuntimeState();
|
|
121
|
+
const runtimeTargets = state.targets || {};
|
|
122
|
+
const installed = detectInstalledTargets();
|
|
123
|
+
const { targets, skipped } = targetsToVerify(targetArg, installed, runtimeTargets);
|
|
124
|
+
|
|
125
|
+
for (const item of skipped) {
|
|
126
|
+
console.log(paint(`[SKIP] ${targetLabel(item.target)} not installed`, t.bold, t.gold));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!targets.length) {
|
|
130
|
+
console.log(paint("[FAIL] No installed Langfuse runtime targets were found.", t.bold, t.red));
|
|
131
|
+
console.log(paint("[FIX] Run setup or update first: npx oh-langfuse@latest update all", t.bold, t.cyan));
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let latestVersions = {};
|
|
136
|
+
try {
|
|
137
|
+
const packageNames = new Set(["oh-langfuse"]);
|
|
138
|
+
for (const target of targets) {
|
|
139
|
+
const packageName = String(runtimeTargets[target]?.packageName || "oh-langfuse").trim() || "oh-langfuse";
|
|
140
|
+
packageNames.add(packageName);
|
|
141
|
+
}
|
|
142
|
+
for (const packageName of packageNames) {
|
|
143
|
+
latestVersions[packageName] = await latestVersion(packageName, args.npmRegistry);
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error(paint(`[FAIL] Could not query npm latest version: ${error.message}`, t.bold, t.red));
|
|
147
|
+
return 3;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const results = buildVerifyUpdateResults({ targets, runtimeTargets, latestVersions });
|
|
151
|
+
for (const result of results) printResult(result);
|
|
152
|
+
return verifyUpdateExitCode(results);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
main()
|
|
156
|
+
.then((code) => {
|
|
157
|
+
process.exitCode = code;
|
|
158
|
+
})
|
|
159
|
+
.catch((error) => {
|
|
160
|
+
console.error(paint(`[FAIL] ${error?.message || String(error)}`, t.bold, t.red));
|
|
161
|
+
process.exitCode = 3;
|
|
162
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { compareSemver } from "./update-utils.mjs";
|
|
2
|
+
|
|
3
|
+
export const VERIFY_TARGETS = ["claude", "opencode", "codex"];
|
|
4
|
+
|
|
5
|
+
export function targetLabel(target) {
|
|
6
|
+
if (target === "claude") return "Claude";
|
|
7
|
+
if (target === "opencode") return "OpenCode";
|
|
8
|
+
if (target === "codex") return "Codex";
|
|
9
|
+
return target;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeVerifyTargets(target = "all") {
|
|
13
|
+
const normalized = String(target || "all").trim().toLowerCase();
|
|
14
|
+
if (normalized === "all") return [...VERIFY_TARGETS];
|
|
15
|
+
if (!VERIFY_TARGETS.includes(normalized)) {
|
|
16
|
+
throw new Error(`Unsupported verify-update target: ${target}`);
|
|
17
|
+
}
|
|
18
|
+
return [normalized];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildVerifyUpdateResults({
|
|
22
|
+
targets = [],
|
|
23
|
+
runtimeTargets = {},
|
|
24
|
+
latestVersion = "",
|
|
25
|
+
latestVersions = {},
|
|
26
|
+
defaultPackageName = "oh-langfuse",
|
|
27
|
+
} = {}) {
|
|
28
|
+
return targets.map((target) => {
|
|
29
|
+
const record = runtimeTargets?.[target] || {};
|
|
30
|
+
const packageName = String(record.packageName || defaultPackageName).trim() || defaultPackageName;
|
|
31
|
+
const installedVersion = String(record.packageVersion || "").trim();
|
|
32
|
+
const packageLatestVersion = String(latestVersions[packageName] || latestVersion || "").trim();
|
|
33
|
+
const updatedAt = String(record.updatedAt || "").trim();
|
|
34
|
+
if (!installedVersion) {
|
|
35
|
+
return {
|
|
36
|
+
target,
|
|
37
|
+
packageName,
|
|
38
|
+
status: "missing",
|
|
39
|
+
installedVersion: "",
|
|
40
|
+
latestVersion: packageLatestVersion,
|
|
41
|
+
updatedAt,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const cmp = compareSemver(installedVersion, packageLatestVersion);
|
|
45
|
+
return {
|
|
46
|
+
target,
|
|
47
|
+
packageName,
|
|
48
|
+
status: cmp < 0 ? "outdated" : "current",
|
|
49
|
+
installedVersion,
|
|
50
|
+
latestVersion: packageLatestVersion,
|
|
51
|
+
updatedAt,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function verifyUpdateExitCode(results = []) {
|
|
57
|
+
if (results.some((item) => item.status === "missing")) return 2;
|
|
58
|
+
if (results.some((item) => item.status === "outdated")) return 1;
|
|
59
|
+
return 0;
|
|
60
|
+
}
|