oh-langfuse 0.1.71 → 0.1.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-langfuse",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
@@ -16,6 +16,7 @@
16
16
  "scripts/auto-update-runtime.mjs",
17
17
  "scripts/codex-langfuse-check.mjs",
18
18
  "scripts/codex-langfuse-setup.mjs",
19
+ "scripts/cli-detection-utils.mjs",
19
20
  "scripts/json-utils.mjs",
20
21
  "scripts/langfuse-check.mjs",
21
22
  "scripts/langfuse-setup.mjs",
@@ -0,0 +1,114 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ function unique(values) {
7
+ return [...new Set(values.filter(Boolean))];
8
+ }
9
+
10
+ function stripExecutableExtension(name) {
11
+ return String(name || "").replace(/\.(cmd|bat|exe)$/i, "");
12
+ }
13
+
14
+ function isTargetBinary(target, candidate) {
15
+ const base = stripExecutableExtension(path.basename(String(candidate || ""))).toLowerCase();
16
+ return base === String(target || "").toLowerCase();
17
+ }
18
+
19
+ function sortWindowsCliCandidates(candidates) {
20
+ const extPriority = (candidate) => {
21
+ const ext = path.extname(String(candidate || "")).toLowerCase();
22
+ if (ext === ".cmd" || ext === ".bat" || ext === ".exe") return 0;
23
+ return 1;
24
+ };
25
+ return [...candidates].sort((a, b) => extPriority(a) - extPriority(b));
26
+ }
27
+
28
+ export function parseWhereisBinaryOutput(target, stdout) {
29
+ const candidates = [];
30
+ for (const line of String(stdout || "").split(/\r?\n/)) {
31
+ const colon = line.indexOf(":");
32
+ const body = colon === -1 ? line : line.slice(colon + 1);
33
+ for (const token of body.split(/\s+/)) {
34
+ const candidate = token.trim();
35
+ if (candidate && path.isAbsolute(candidate) && isTargetBinary(target, candidate)) {
36
+ candidates.push(candidate);
37
+ }
38
+ }
39
+ }
40
+ return unique(candidates);
41
+ }
42
+
43
+ function isLikelyNodePrefix(name) {
44
+ return /^(node|nodejs)(?:[-_.].*)?$/i.test(String(name || ""));
45
+ }
46
+
47
+ export function listCommonUnixCliCandidates(target, roots = ["/usr/local", "/opt"]) {
48
+ if (process.platform === "win32") return [];
49
+ const candidates = [
50
+ path.join("/usr/local", "bin", target),
51
+ path.join("/usr", "bin", target),
52
+ path.join("/bin", target),
53
+ ];
54
+ for (const root of roots) {
55
+ try {
56
+ if (!fs.existsSync(root)) continue;
57
+ for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
58
+ if (ent.isDirectory() && isLikelyNodePrefix(ent.name)) {
59
+ candidates.push(path.join(root, ent.name, "bin", target));
60
+ }
61
+ }
62
+ } catch {
63
+ // Best effort only; command lookups and other candidates may still work.
64
+ }
65
+ }
66
+ return unique(candidates);
67
+ }
68
+
69
+ export function listSystemCliCandidates(target) {
70
+ if (!target) return [];
71
+ const candidates = [];
72
+ if (process.platform === "win32") {
73
+ const result = spawnSync("where.exe", [target], { encoding: "utf8", windowsHide: true });
74
+ if (result.status === 0) {
75
+ candidates.push(...String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
76
+ }
77
+ return sortWindowsCliCandidates(unique(candidates));
78
+ }
79
+
80
+ const which = spawnSync("which", ["-a", target], { encoding: "utf8", windowsHide: true });
81
+ if (which.status === 0) {
82
+ candidates.push(...String(which.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
83
+ }
84
+
85
+ const whereis = spawnSync("whereis", ["-b", target], { encoding: "utf8", windowsHide: true });
86
+ if (whereis.status === 0) {
87
+ candidates.push(...parseWhereisBinaryOutput(target, whereis.stdout));
88
+ }
89
+
90
+ candidates.push(...listCommonUnixCliCandidates(target));
91
+ return unique(candidates);
92
+ }
93
+
94
+ export function candidateExists(candidate) {
95
+ try {
96
+ return Boolean(candidate) && fs.existsSync(candidate);
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ export function hasSystemCli(target) {
103
+ return listSystemCliCandidates(target).some(candidateExists);
104
+ }
105
+
106
+ export function defaultWindowsNpmCliCandidates(target) {
107
+ const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
108
+ if (process.platform !== "win32") return [];
109
+ return [
110
+ path.join(appData, "npm", `${target}.cmd`),
111
+ path.join(appData, "npm", `${target}.exe`),
112
+ path.join(appData, "npm", target),
113
+ ];
114
+ }
@@ -38,8 +38,8 @@ function findLatestSession(sessionsDir) {
38
38
  return latest;
39
39
  }
40
40
 
41
- function commandOk(cmd, args) {
42
- const r = spawnSync(cmd, args, { encoding: "utf8" });
41
+ function commandOk(cmd, args, env = process.env) {
42
+ const r = spawnSync(cmd, args, { encoding: "utf8", env });
43
43
  return { ok: !r.error && r.status === 0, detail: (r.stdout || r.stderr || "").trim() };
44
44
  }
45
45
 
@@ -63,8 +63,22 @@ function readCodexShimTarget(codexHome) {
63
63
  function venvPython(codexHome) {
64
64
  return process.platform === "win32"
65
65
  ? path.join(codexHome, "langfuse-venv", "Scripts", "python.exe")
66
- : path.join(codexHome, "langfuse-venv", "bin", "python");
67
- }
66
+ : path.join(codexHome, "langfuse-venv", "bin", "python");
67
+ }
68
+
69
+ function fallbackPythonCommand() {
70
+ return process.platform === "win32" ? "python" : "python3";
71
+ }
72
+
73
+ function isolatedPythonPath(codexHome) {
74
+ return path.join(codexHome, "langfuse-python");
75
+ }
76
+
77
+ function langfuseImportOk(hookPython, fallbackTarget) {
78
+ if (fs.existsSync(hookPython)) return commandOk(hookPython, ["-c", "import langfuse; print('langfuse ok')"]);
79
+ const env = { ...process.env, PYTHONPATH: process.env.PYTHONPATH ? `${fallbackTarget}${path.delimiter}${process.env.PYTHONPATH}` : fallbackTarget };
80
+ return commandOk(fallbackPythonCommand(), ["-c", "import langfuse; print('langfuse ok')"], env);
81
+ }
68
82
 
69
83
  function configHasNotify(configText) {
70
84
  const firstSection = configText.search(/^\s*\[/m);
@@ -84,10 +98,11 @@ function main() {
84
98
  const sessionsDir = path.join(codexHome, "sessions");
85
99
  const latestSession = findLatestSession(sessionsDir);
86
100
  const langfuseConfig = readJsonIfExists(langfuseConfigPath);
87
- const configText = fs.existsSync(configPath) ? stripBom(fs.readFileSync(configPath, "utf8")) : "";
101
+ const configText = fs.existsSync(configPath) ? stripBom(fs.readFileSync(configPath, "utf8")) : "";
88
102
  const hookPython = venvPython(codexHome);
103
+ const fallbackTarget = isolatedPythonPath(codexHome);
89
104
  const python = commandOk(process.platform === "win32" ? "python" : "python3", ["--version"]);
90
- const langfuseImport = commandOk(hookPython, ["-c", "import langfuse; print('langfuse ok')"]);
105
+ const langfuseImport = langfuseImportOk(hookPython, fallbackTarget);
91
106
  const codexShimTarget = readCodexShimTarget(codexHome);
92
107
  const codexShimUsesWindowsApps = !!codexShimTarget && isWindowsAppsPath(codexShimTarget);
93
108
 
@@ -138,7 +153,13 @@ function main() {
138
153
  codexShimTarget || "not installed",
139
154
  "Run setup/update again with a fixed oh-langfuse version; WindowsApps Codex paths cannot be called directly."
140
155
  );
141
- addResult(results, "Langfuse venv Python", fs.existsSync(hookPython), hookPython, "Run setup again; on Linux install python3-venv if venv creation fails.");
156
+ addResult(
157
+ results,
158
+ "Langfuse hook Python",
159
+ fs.existsSync(hookPython) || fs.existsSync(fallbackTarget),
160
+ fs.existsSync(hookPython) ? hookPython : `${fallbackPythonCommand()} with PYTHONPATH=${fallbackTarget}`,
161
+ "Run setup again; on Linux the installer can fall back to an isolated PYTHONPATH target."
162
+ );
142
163
  addResult(
143
164
  results,
144
165
  "Python langfuse package",
@@ -2,9 +2,10 @@ import fs from "node:fs";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
- import { execFileSync, spawnSync } from "node:child_process";
6
- import { fileURLToPath } from "node:url";
7
- import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
5
+ import { execFileSync, spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { defaultWindowsNpmCliCandidates, listSystemCliCandidates } from "./cli-detection-utils.mjs";
8
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
8
9
 
9
10
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
11
  const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
@@ -67,46 +68,63 @@ function pythonExecutableInVenv(venvDir) {
67
68
  : path.join(venvDir, "bin", "python");
68
69
  }
69
70
 
70
- function pythonCanImport(pythonCmd, moduleName) {
71
- try {
72
- execFileSync(pythonCmd, ["-c", `import ${moduleName}`], { stdio: "ignore", encoding: "utf8", env: { ...process.env, PYTHONUTF8: "1" } });
73
- return true;
74
- } catch {
75
- return false;
76
- }
77
- }
78
-
79
- function runPipInstallWithFallback({ pythonCmd, pipIndexUrl }) {
80
- const attempts = [
81
- { command: pythonCmd, args: ["-m", "pip", "install", "--user", "-U", "langfuse", "-i", pipIndexUrl] },
82
- { command: "pip3", args: ["install", "--user", "-U", "langfuse", "-i", pipIndexUrl] },
83
- { command: "pip", args: ["install", "--user", "-U", "langfuse", "-i", pipIndexUrl] }
84
- ];
85
-
86
- const errors = [];
87
- for (const attempt of attempts) {
88
- try {
89
- console.log(`Trying Python package install: ${attempt.command} ${attempt.args.join(" ")}`);
90
- execFileSync(attempt.command, attempt.args, { stdio: "inherit", encoding: "utf8", env: { ...process.env, PYTHONUTF8: "1" } });
91
- return;
92
- } catch (error) {
93
- errors.push(`${attempt.command}: ${error?.message || error}`);
94
- }
95
- }
96
-
97
- throw new Error(
98
- `Failed to install langfuse with system Python/pip. Tried python -m pip, pip3, and pip. Last errors: ${errors.join(" | ")}`
99
- );
100
- }
101
-
102
- function installLangfuseWithSystemPython({ pythonCmd, pipIndexUrl }) {
103
- console.log("Python venv is unavailable; falling back to system Python user install for langfuse.");
104
- runPipInstallWithFallback({ pythonCmd, pipIndexUrl });
105
- if (!pythonCanImport(pythonCmd, "langfuse")) {
106
- throw new Error("langfuse was installed with pip, but python3 still cannot import it. Install python3-venv and rerun setup, for example: sudo apt install python3-venv");
107
- }
108
- return pythonCmd;
109
- }
71
+ function pythonEnv(extraPythonPath = "") {
72
+ const env = { ...process.env, PYTHONUTF8: "1" };
73
+ if (extraPythonPath) {
74
+ env.PYTHONPATH = process.env.PYTHONPATH ? `${extraPythonPath}${path.delimiter}${process.env.PYTHONPATH}` : extraPythonPath;
75
+ }
76
+ return env;
77
+ }
78
+
79
+ function pythonCanImport(pythonCmd, moduleName, extraPythonPath = "") {
80
+ try {
81
+ execFileSync(pythonCmd, ["-c", `import ${moduleName}`], { stdio: "ignore", encoding: "utf8", env: pythonEnv(extraPythonPath) });
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ function installLangfuseIntoTargetDir({ pythonCmd, baseDir, pipIndexUrl }) {
89
+ const targetDir = path.join(baseDir, "langfuse-python");
90
+ ensureDir(targetDir);
91
+ console.log(`Installing Python package into isolated target: ${targetDir}`);
92
+ execFileSync(
93
+ pythonCmd,
94
+ ["-m", "pip", "install", "--target", targetDir, "--upgrade", "langfuse", "-i", pipIndexUrl],
95
+ { stdio: "inherit", encoding: "utf8", env: pythonEnv() }
96
+ );
97
+ if (!pythonCanImport(pythonCmd, "langfuse", targetDir)) {
98
+ throw new Error(`langfuse was installed into ${targetDir}, but ${pythonCmd} still cannot import it with PYTHONPATH.`);
99
+ }
100
+ return { python: pythonCmd, pythonPath: targetDir };
101
+ }
102
+
103
+ function runPipInstallWithFallback({ pythonCmd, baseDir, pipIndexUrl }) {
104
+ const attempts = [
105
+ () => installLangfuseIntoTargetDir({ pythonCmd, baseDir, pipIndexUrl }),
106
+ () => installLangfuseIntoTargetDir({ pythonCmd: "python3", baseDir, pipIndexUrl }),
107
+ () => installLangfuseIntoTargetDir({ pythonCmd: "python", baseDir, pipIndexUrl })
108
+ ];
109
+
110
+ const errors = [];
111
+ for (const attempt of attempts) {
112
+ try {
113
+ return attempt();
114
+ } catch (error) {
115
+ errors.push(error?.message || String(error));
116
+ }
117
+ }
118
+
119
+ throw new Error(
120
+ `Failed to install langfuse into isolated Python target. Last errors: ${errors.join(" | ")}`
121
+ );
122
+ }
123
+
124
+ function installLangfuseWithSystemPython({ pythonCmd, baseDir, pipIndexUrl }) {
125
+ console.log("Python venv is unavailable; falling back to isolated PYTHONPATH install for langfuse.");
126
+ return runPipInstallWithFallback({ pythonCmd, baseDir, pipIndexUrl });
127
+ }
110
128
 
111
129
  function shQuote(s) {
112
130
  return `'${String(s).replace(/'/g, "'\"'\"'")}'`;
@@ -217,19 +235,11 @@ function listLocalCodexCliCandidates() {
217
235
  return candidates;
218
236
  }
219
237
 
220
- function listCliCandidatesFromPath(target) {
221
- const cmd = process.platform === "win32" ? "where.exe" : "which";
222
- const args = process.platform === "win32" ? [target] : ["-a", target];
223
- const result = spawnSync(cmd, args, { encoding: "utf8", windowsHide: true });
224
- if (result.status !== 0) return [];
225
- const candidates = String(result.stdout || "")
226
- .split(/\r?\n/)
227
- .map((line) => line.trim())
228
- .filter(Boolean);
229
- return process.platform === "win32" ? sortWindowsCliCandidates(candidates) : candidates;
230
- }
231
-
232
- function sortWindowsCliCandidates(candidates) {
238
+ function listCliCandidatesFromPath(target) {
239
+ return listSystemCliCandidates(target);
240
+ }
241
+
242
+ function sortWindowsCliCandidates(candidates) {
233
243
  const extPriority = (candidate) => {
234
244
  const ext = path.extname(String(candidate || "")).toLowerCase();
235
245
  if (ext === ".cmd" || ext === ".bat" || ext === ".exe") return 0;
@@ -238,16 +248,13 @@ function sortWindowsCliCandidates(candidates) {
238
248
  return [...candidates].sort((a, b) => extPriority(a) - extPriority(b));
239
249
  }
240
250
 
241
- function resolveAgentCli({ target, preferred = "", shimDir = "" }) {
242
- const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
243
- const candidates = [
244
- preferred,
245
- ...(target === "codex" ? listLocalCodexCliCandidates() : []),
246
- process.platform === "win32" ? path.join(appData, "npm", `${target}.cmd`) : "",
247
- process.platform === "win32" ? path.join(appData, "npm", `${target}.exe`) : "",
248
- process.platform === "win32" ? path.join(appData, "npm", target) : "",
249
- ...listCliCandidatesFromPath(target)
250
- ];
251
+ function resolveAgentCli({ target, preferred = "", shimDir = "" }) {
252
+ const candidates = [
253
+ preferred,
254
+ ...(target === "codex" ? listLocalCodexCliCandidates() : []),
255
+ ...defaultWindowsNpmCliCandidates(target),
256
+ ...listCliCandidatesFromPath(target)
257
+ ];
251
258
  for (const candidate of candidates) {
252
259
  const found = existingCliCandidate(candidate, shimDir);
253
260
  if (found) return found;
@@ -358,13 +365,13 @@ function createOrUpdateLangfuseVenv({ baseDir, pipIndexUrl = "https://pypi.tuna.
358
365
  if (!fs.existsSync(venvPython)) {
359
366
  console.log(`Creating Python virtual environment: ${venvDir}`);
360
367
  try {
361
- execFileSync(pythonCmd, ["-m", "venv", venvDir], { stdio: "inherit", encoding: "utf8", env: { ...process.env, PYTHONUTF8: "1" } });
362
- } catch (e) {
363
- if (process.platform !== "win32") {
364
- return installLangfuseWithSystemPython({ pythonCmd, pipIndexUrl });
365
- }
366
- throw new Error("Failed to create Python venv. Please confirm Python venv support is available.");
367
- }
368
+ execFileSync(pythonCmd, ["-m", "venv", venvDir], { stdio: "inherit", encoding: "utf8", env: { ...process.env, PYTHONUTF8: "1" } });
369
+ } catch (e) {
370
+ if (process.platform !== "win32") {
371
+ return installLangfuseWithSystemPython({ pythonCmd, baseDir, pipIndexUrl });
372
+ }
373
+ throw new Error("Failed to create Python venv. Please confirm Python venv support is available.");
374
+ }
368
375
  }
369
376
 
370
377
  console.log("Installing/updating Python package in venv: langfuse");
@@ -373,16 +380,31 @@ function createOrUpdateLangfuseVenv({ baseDir, pipIndexUrl = "https://pypi.tuna.
373
380
  venvPython,
374
381
  ["-m", "pip", "install", "-U", "langfuse", "-i", pipIndexUrl],
375
382
  { stdio: "inherit", encoding: "utf8", env: { ...process.env, PYTHONUTF8: "1" } }
376
- );
377
- } catch (e) {
378
- if (process.platform !== "win32") {
379
- return installLangfuseWithSystemPython({ pythonCmd, pipIndexUrl });
380
- }
381
- throw new Error(`Failed to install langfuse in venv: ${venvPython} -m pip install -U langfuse -i ${pipIndexUrl}`);
382
- }
383
-
384
- return venvPython;
385
- }
383
+ );
384
+ } catch (e) {
385
+ if (process.platform !== "win32") {
386
+ return installLangfuseWithSystemPython({ pythonCmd, baseDir, pipIndexUrl });
387
+ }
388
+ throw new Error(`Failed to install langfuse in venv: ${venvPython} -m pip install -U langfuse -i ${pipIndexUrl}`);
389
+ }
390
+
391
+ if (!pythonCanImport(venvPython, "langfuse")) {
392
+ if (process.platform !== "win32") {
393
+ return installLangfuseWithSystemPython({ pythonCmd, baseDir, pipIndexUrl });
394
+ }
395
+ throw new Error(`langfuse was installed in venv, but cannot be imported by ${venvPython}`);
396
+ }
397
+
398
+ return { python: venvPython, pythonPath: "" };
399
+ }
400
+
401
+ function codexNotifyCommand({ python, pythonPath = "", hookPath }) {
402
+ if (pythonPath && process.platform !== "win32") {
403
+ const script = `if [ -n "\${PYTHONPATH:-}" ]; then export PYTHONPATH=${shQuote(pythonPath)}:"$PYTHONPATH"; else export PYTHONPATH=${shQuote(pythonPath)}; fi; exec ${shQuote(python)} ${shQuote(hookPath)}`;
404
+ return ["sh", "-c", script];
405
+ }
406
+ return [python, hookPath];
407
+ }
386
408
 
387
409
  function updateCodexNotify(configPath, notifyCommand) {
388
410
  let content = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
@@ -483,11 +505,11 @@ async function main() {
483
505
  userId,
484
506
  });
485
507
 
486
- const pythonCmd = process.platform === "win32" ? "python" : "python3";
487
- const notifyPython = args["skip-pip-install"]
488
- ? pythonCmd
489
- : createOrUpdateLangfuseVenv({ baseDir: codexHome, pipIndexUrl });
490
- updateCodexNotify(configPath, [notifyPython, destHook]);
508
+ const pythonCmd = process.platform === "win32" ? "python" : "python3";
509
+ const notifyRuntime = args["skip-pip-install"]
510
+ ? { python: pythonCmd, pythonPath: "" }
511
+ : createOrUpdateLangfuseVenv({ baseDir: codexHome, pipIndexUrl });
512
+ updateCodexNotify(configPath, codexNotifyCommand({ python: notifyRuntime.python, pythonPath: notifyRuntime.pythonPath, hookPath: destHook }));
491
513
  const codexShimDir = path.join(codexHome, "bin");
492
514
  const realCodexCli = resolveAgentCli({ target: "codex", preferred: args.cmd || "", shimDir: codexShimDir });
493
515
  const directShim = writeAgentCommandShim({
@@ -14,11 +14,19 @@ function addResult(results, item, ok, detail, fix = "") {
14
14
  results.push({ item, ok, detail, fix });
15
15
  }
16
16
 
17
- function pythonExecutableInVenv(claudeDir) {
18
- return process.platform === "win32"
19
- ? path.join(claudeDir, "langfuse-venv", "Scripts", "python.exe")
20
- : path.join(claudeDir, "langfuse-venv", "bin", "python");
21
- }
17
+ function pythonExecutableInVenv(claudeDir) {
18
+ return process.platform === "win32"
19
+ ? path.join(claudeDir, "langfuse-venv", "Scripts", "python.exe")
20
+ : path.join(claudeDir, "langfuse-venv", "bin", "python");
21
+ }
22
+
23
+ function fallbackPythonCommand() {
24
+ return process.platform === "win32" ? "python" : "python3";
25
+ }
26
+
27
+ function isolatedPythonPath(claudeDir) {
28
+ return path.join(claudeDir, "langfuse-python");
29
+ }
22
30
 
23
31
  function launcherPath(hooksDir) {
24
32
  return process.platform === "win32"
@@ -63,13 +71,18 @@ function pathExists(p) {
63
71
  return typeof p === "string" && p.length > 0 && fs.existsSync(p);
64
72
  }
65
73
 
66
- function checkPythonLangfuseImport(pythonPath) {
67
- if (!pathExists(pythonPath)) return { ok: false, detail: "python executable missing" };
68
- const result = spawnSync(pythonPath, ["-c", "import langfuse; print('OK')"], {
69
- encoding: "utf8",
70
- timeout: 15000,
71
- windowsHide: true
72
- });
74
+ function checkPythonLangfuseImport(pythonPath, extraPythonPath = "") {
75
+ if (!pathExists(pythonPath) && pythonPath !== "python3" && pythonPath !== "python") return { ok: false, detail: "python executable missing" };
76
+ const env = { ...process.env };
77
+ if (extraPythonPath) {
78
+ env.PYTHONPATH = process.env.PYTHONPATH ? `${extraPythonPath}${path.delimiter}${process.env.PYTHONPATH}` : extraPythonPath;
79
+ }
80
+ const result = spawnSync(pythonPath, ["-c", "import langfuse; print('OK')"], {
81
+ encoding: "utf8",
82
+ timeout: 15000,
83
+ windowsHide: true,
84
+ env
85
+ });
73
86
  if (result.status === 0) return { ok: true, detail: "OK" };
74
87
  const err = `${result.stderr || result.stdout || result.error?.message || "unknown error"}`.trim();
75
88
  return { ok: false, detail: err || "import failed" };
@@ -80,10 +93,11 @@ function main() {
80
93
  const claudeDir = path.join(userHome, ".claude");
81
94
  const hooksDir = path.join(claudeDir, "hooks");
82
95
  const settingsPath = path.join(claudeDir, "settings.json");
83
- const pyPath = path.join(hooksDir, "langfuse_hook.py");
84
- const expectedLauncher = launcherPath(hooksDir);
85
- const expectedPython = pythonExecutableInVenv(claudeDir);
86
- const logPath = path.join(claudeDir, "state", "langfuse_hook.log");
96
+ const pyPath = path.join(hooksDir, "langfuse_hook.py");
97
+ const expectedLauncher = launcherPath(hooksDir);
98
+ const expectedPython = pythonExecutableInVenv(claudeDir);
99
+ const fallbackTarget = isolatedPythonPath(claudeDir);
100
+ const logPath = path.join(claudeDir, "state", "langfuse_hook.log");
87
101
 
88
102
  const results = [];
89
103
 
@@ -138,22 +152,26 @@ function main() {
138
152
  "Run setup again. Claude Code should execute the generated Python hook command."
139
153
  );
140
154
 
141
- addResult(
142
- results,
143
- "hook python executable",
144
- pathExists(expectedPython),
145
- expectedPython,
146
- "Run setup again to recreate the Python venv."
147
- );
148
-
149
- const importCheck = checkPythonLangfuseImport(expectedPython);
150
- addResult(
151
- results,
152
- "python package langfuse",
153
- importCheck.ok,
154
- importCheck.detail,
155
- `Run: ${expectedPython} -m pip install -U langfuse`
156
- );
155
+ addResult(
156
+ results,
157
+ "hook python executable",
158
+ pathExists(expectedPython) || fs.existsSync(fallbackTarget),
159
+ pathExists(expectedPython) ? expectedPython : `${fallbackPythonCommand()} with PYTHONPATH=${fallbackTarget}`,
160
+ "Run setup again to recreate the Python venv."
161
+ );
162
+
163
+ const importCheck = pathExists(expectedPython)
164
+ ? checkPythonLangfuseImport(expectedPython)
165
+ : checkPythonLangfuseImport(fallbackPythonCommand(), fallbackTarget);
166
+ addResult(
167
+ results,
168
+ "python package langfuse",
169
+ importCheck.ok,
170
+ importCheck.detail,
171
+ pathExists(expectedPython)
172
+ ? `Run: ${expectedPython} -m pip install -U langfuse`
173
+ : "Run setup again to install langfuse into the isolated PYTHONPATH target."
174
+ );
157
175
 
158
176
  addResult(results, "hook log path", true, logPath);
159
177
 
@@ -2,9 +2,10 @@ import fs from "node:fs";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
- import { execFileSync, spawnSync } from "node:child_process";
6
- import { fileURLToPath } from "node:url";
7
- import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
5
+ import { execFileSync, spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { defaultWindowsNpmCliCandidates, listSystemCliCandidates } from "./cli-detection-utils.mjs";
8
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
8
9
 
9
10
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
11
  const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
@@ -221,26 +222,16 @@ function existingCliCandidate(candidate, shimDir) {
221
222
  }
222
223
  }
223
224
 
224
- function listCliCandidatesFromPath(target) {
225
- const cmd = process.platform === "win32" ? "where.exe" : "which";
226
- const args = process.platform === "win32" ? [target] : ["-a", target];
227
- const result = spawnSync(cmd, args, { encoding: "utf8", windowsHide: true });
228
- if (result.status !== 0) return [];
229
- return String(result.stdout || "")
230
- .split(/\r?\n/)
231
- .map((line) => line.trim())
232
- .filter(Boolean);
233
- }
234
-
235
- function resolveAgentCli({ target, preferred = "", shimDir = "" }) {
236
- const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
237
- const candidates = [
238
- preferred,
239
- process.platform === "win32" ? path.join(appData, "npm", `${target}.cmd`) : "",
240
- process.platform === "win32" ? path.join(appData, "npm", `${target}.exe`) : "",
241
- process.platform === "win32" ? path.join(appData, "npm", target) : "",
242
- ...listCliCandidatesFromPath(target)
243
- ];
225
+ function listCliCandidatesFromPath(target) {
226
+ return listSystemCliCandidates(target);
227
+ }
228
+
229
+ function resolveAgentCli({ target, preferred = "", shimDir = "" }) {
230
+ const candidates = [
231
+ preferred,
232
+ ...defaultWindowsNpmCliCandidates(target),
233
+ ...listCliCandidatesFromPath(target)
234
+ ];
244
235
  for (const candidate of candidates) {
245
236
  const found = existingCliCandidate(candidate, shimDir);
246
237
  if (found) return found;
@@ -349,68 +340,87 @@ function pythonExecutableInVenv(venvDir) {
349
340
  : path.join(venvDir, "bin", "python");
350
341
  }
351
342
 
352
- function pythonCanImport(pythonCmd, moduleName) {
353
- try {
354
- execFileSync(pythonCmd, ["-c", `import ${moduleName}`], { stdio: "ignore", encoding: "utf8", env: { ...process.env, PYTHONUTF8: "1" } });
355
- return true;
356
- } catch {
357
- return false;
358
- }
359
- }
360
-
361
- function runPipInstallWithFallback({ pythonCmd, pipIndexUrl }) {
362
- const attempts = [
363
- { command: pythonCmd, args: ["-m", "pip", "install", "--user", "-U", "langfuse", "-i", pipIndexUrl] },
364
- { command: "pip3", args: ["install", "--user", "-U", "langfuse", "-i", pipIndexUrl] },
365
- { command: "pip", args: ["install", "--user", "-U", "langfuse", "-i", pipIndexUrl] }
366
- ];
367
-
368
- const errors = [];
369
- for (const attempt of attempts) {
370
- try {
371
- console.log(`Trying Python package install: ${attempt.command} ${attempt.args.join(" ")}`);
372
- execFileSync(attempt.command, attempt.args, { stdio: "inherit", encoding: "utf8", env: { ...process.env, PYTHONUTF8: "1" } });
373
- return;
374
- } catch (error) {
375
- errors.push(`${attempt.command}: ${error?.message || error}`);
376
- }
377
- }
378
-
379
- throw new Error(
380
- `Failed to install langfuse with system Python/pip. Tried python -m pip, pip3, and pip. Last errors: ${errors.join(" | ")}`
381
- );
382
- }
383
-
384
- function installLangfuseWithSystemPython({ pythonCmd, pipIndexUrl }) {
385
- console.log("Python venv is unavailable; falling back to system Python user install for langfuse.");
386
- runPipInstallWithFallback({ pythonCmd, pipIndexUrl });
387
- if (!pythonCanImport(pythonCmd, "langfuse")) {
388
- throw new Error("langfuse was installed with pip, but python3 still cannot import it. Install python3-venv and rerun setup, for example: sudo apt install python3-venv");
389
- }
390
- return pythonCmd;
391
- }
392
-
393
- async function createHookLauncher({ hooksDir, hookPython, pyPath }) {
394
- if (process.platform === "win32") {
395
- const launcher = path.join(hooksDir, "run-langfuse-hook.cmd");
396
- const content = [
397
- "@echo off",
398
- `${cmdQuote(hookPython)} ${cmdQuote(pyPath)}`,
399
- ""
400
- ].join(os.EOL);
343
+ function pythonEnv(extraPythonPath = "") {
344
+ const env = { ...process.env, PYTHONUTF8: "1" };
345
+ if (extraPythonPath) {
346
+ env.PYTHONPATH = process.env.PYTHONPATH ? `${extraPythonPath}${path.delimiter}${process.env.PYTHONPATH}` : extraPythonPath;
347
+ }
348
+ return env;
349
+ }
350
+
351
+ function pythonCanImport(pythonCmd, moduleName, extraPythonPath = "") {
352
+ try {
353
+ execFileSync(pythonCmd, ["-c", `import ${moduleName}`], { stdio: "ignore", encoding: "utf8", env: pythonEnv(extraPythonPath) });
354
+ return true;
355
+ } catch {
356
+ return false;
357
+ }
358
+ }
359
+
360
+ function installLangfuseIntoTargetDir({ pythonCmd, baseDir, pipIndexUrl }) {
361
+ const targetDir = path.join(baseDir, "langfuse-python");
362
+ ensureDir(targetDir);
363
+ console.log(`Installing Python package into isolated target: ${targetDir}`);
364
+ execFileSync(
365
+ pythonCmd,
366
+ ["-m", "pip", "install", "--target", targetDir, "--upgrade", "langfuse", "-i", pipIndexUrl],
367
+ { stdio: "inherit", encoding: "utf8", env: pythonEnv() }
368
+ );
369
+ if (!pythonCanImport(pythonCmd, "langfuse", targetDir)) {
370
+ throw new Error(`langfuse was installed into ${targetDir}, but ${pythonCmd} still cannot import it with PYTHONPATH.`);
371
+ }
372
+ return { python: pythonCmd, pythonPath: targetDir };
373
+ }
374
+
375
+ function runPipInstallWithFallback({ pythonCmd, baseDir, pipIndexUrl }) {
376
+ const attempts = [
377
+ () => installLangfuseIntoTargetDir({ pythonCmd, baseDir, pipIndexUrl }),
378
+ () => installLangfuseIntoTargetDir({ pythonCmd: "python3", baseDir, pipIndexUrl }),
379
+ () => installLangfuseIntoTargetDir({ pythonCmd: "python", baseDir, pipIndexUrl })
380
+ ];
381
+
382
+ const errors = [];
383
+ for (const attempt of attempts) {
384
+ try {
385
+ return attempt();
386
+ } catch (error) {
387
+ errors.push(error?.message || String(error));
388
+ }
389
+ }
390
+
391
+ throw new Error(
392
+ `Failed to install langfuse into isolated Python target. Last errors: ${errors.join(" | ")}`
393
+ );
394
+ }
395
+
396
+ function installLangfuseWithSystemPython({ pythonCmd, baseDir, pipIndexUrl }) {
397
+ console.log("Python venv is unavailable; falling back to isolated PYTHONPATH install for langfuse.");
398
+ return runPipInstallWithFallback({ pythonCmd, baseDir, pipIndexUrl });
399
+ }
400
+
401
+ async function createHookLauncher({ hooksDir, hookPython, hookPythonPath = "", pyPath }) {
402
+ if (process.platform === "win32") {
403
+ const launcher = path.join(hooksDir, "run-langfuse-hook.cmd");
404
+ const content = [
405
+ "@echo off",
406
+ hookPythonPath ? `set PYTHONPATH=${hookPythonPath};%PYTHONPATH%` : null,
407
+ `${cmdQuote(hookPython)} ${cmdQuote(pyPath)}`,
408
+ ""
409
+ ].filter(Boolean).join(os.EOL);
401
410
  await fsp.writeFile(launcher, content, "utf8");
402
- return launcher;
403
- }
404
-
405
- const launcher = path.join(hooksDir, "run-langfuse-hook.sh");
406
- const content = [
407
- "#!/usr/bin/env sh",
408
- `exec ${shQuote(hookPython)} ${shQuote(pyPath)}`,
409
- ""
410
- ].join("\n");
411
- await fsp.writeFile(launcher, content, "utf8");
412
- fs.chmodSync(launcher, 0o755);
413
- return launcher;
411
+ return launcher;
412
+ }
413
+
414
+ const launcher = path.join(hooksDir, "run-langfuse-hook.sh");
415
+ const content = [
416
+ "#!/usr/bin/env sh",
417
+ hookPythonPath ? `if [ -n "\${PYTHONPATH:-}" ]; then export PYTHONPATH=${shQuote(hookPythonPath)}:"$PYTHONPATH"; else export PYTHONPATH=${shQuote(hookPythonPath)}; fi` : null,
418
+ `exec ${shQuote(hookPython)} ${shQuote(pyPath)}`,
419
+ ""
420
+ ].filter(Boolean).join("\n");
421
+ await fsp.writeFile(launcher, content, "utf8");
422
+ fs.chmodSync(launcher, 0o755);
423
+ return launcher;
414
424
  }
415
425
 
416
426
  function createOrUpdateLangfuseVenv({ baseDir, pipIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple" }) {
@@ -421,13 +431,13 @@ function createOrUpdateLangfuseVenv({ baseDir, pipIndexUrl = "https://pypi.tuna.
421
431
  if (!fs.existsSync(venvPython)) {
422
432
  console.log(`Creating Python virtual environment: ${venvDir}`);
423
433
  try {
424
- execFileSync(pythonCmd, ["-m", "venv", venvDir], { stdio: "inherit", encoding: "utf8", env: { ...process.env, PYTHONUTF8: "1" } });
425
- } catch (e) {
426
- if (process.platform !== "win32") {
427
- return installLangfuseWithSystemPython({ pythonCmd, pipIndexUrl });
428
- }
429
- throw new Error("Failed to create Python venv. Please confirm Python venv support is available.");
430
- }
434
+ execFileSync(pythonCmd, ["-m", "venv", venvDir], { stdio: "inherit", encoding: "utf8", env: { ...process.env, PYTHONUTF8: "1" } });
435
+ } catch (e) {
436
+ if (process.platform !== "win32") {
437
+ return installLangfuseWithSystemPython({ pythonCmd, baseDir, pipIndexUrl });
438
+ }
439
+ throw new Error("Failed to create Python venv. Please confirm Python venv support is available.");
440
+ }
431
441
  }
432
442
 
433
443
  console.log("Installing/updating Python package in venv: langfuse");
@@ -436,16 +446,23 @@ function createOrUpdateLangfuseVenv({ baseDir, pipIndexUrl = "https://pypi.tuna.
436
446
  venvPython,
437
447
  ["-m", "pip", "install", "-U", "langfuse", "-i", pipIndexUrl],
438
448
  { stdio: "inherit", encoding: "utf8", env: { ...process.env, PYTHONUTF8: "1" } }
439
- );
440
- } catch (e) {
441
- if (process.platform !== "win32") {
442
- return installLangfuseWithSystemPython({ pythonCmd, pipIndexUrl });
443
- }
444
- throw new Error(`Failed to install langfuse in venv: ${venvPython} -m pip install -U langfuse -i ${pipIndexUrl}`);
445
- }
446
-
447
- return venvPython;
448
- }
449
+ );
450
+ } catch (e) {
451
+ if (process.platform !== "win32") {
452
+ return installLangfuseWithSystemPython({ pythonCmd, baseDir, pipIndexUrl });
453
+ }
454
+ throw new Error(`Failed to install langfuse in venv: ${venvPython} -m pip install -U langfuse -i ${pipIndexUrl}`);
455
+ }
456
+
457
+ if (!pythonCanImport(venvPython, "langfuse")) {
458
+ if (process.platform !== "win32") {
459
+ return installLangfuseWithSystemPython({ pythonCmd, baseDir, pipIndexUrl });
460
+ }
461
+ throw new Error(`langfuse was installed in venv, but cannot be imported by ${venvPython}`);
462
+ }
463
+
464
+ return { python: venvPython, pythonPath: "" };
465
+ }
449
466
 
450
467
  async function main() {
451
468
  const args = parseArgs(process.argv.slice(2));
@@ -526,8 +543,9 @@ async function main() {
526
543
  if (nextPyText !== pyText) {
527
544
  await fsp.writeFile(pyPath, nextPyText, "utf8");
528
545
  }
529
- const hookPython = createOrUpdateLangfuseVenv({ baseDir: claudeDir, pipIndexUrl });
530
- const hookLauncher = await createHookLauncher({ hooksDir, hookPython, pyPath });
546
+ const hookRuntime = createOrUpdateLangfuseVenv({ baseDir: claudeDir, pipIndexUrl });
547
+ const hookPython = hookRuntime.python;
548
+ const hookLauncher = await createHookLauncher({ hooksDir, hookPython, hookPythonPath: hookRuntime.pythonPath, pyPath });
531
549
  const claudeShimDir = path.join(claudeDir, "bin");
532
550
  const realClaudeCli = resolveAgentCli({ target: "claude", preferred: args.cmd || "", shimDir: claudeShimDir });
533
551
  const directShim = writeAgentCommandShim({
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
- import os from "node:os";
4
- import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { spawnSync } from "node:child_process";
5
+ import { listSystemCliCandidates } from "./cli-detection-utils.mjs";
5
6
 
6
7
  /**
7
8
  * OpenCode CLI 的官方安装脚本默认会把二进制放到 ~/.opencode/bin;
@@ -36,10 +37,11 @@ export function resolveOpencodeCli(preferred) {
36
37
  candidates.push(path.join(process.env.APPDATA, "npm", "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"));
37
38
  }
38
39
  candidates.push(path.join(home, "scoop", "shims", "opencode.exe"));
39
- } else {
40
- candidates.push(path.join(home, ".opencode", "bin", "opencode"));
41
- candidates.push(path.join(home, ".local", "bin", "opencode"));
42
- }
40
+ } else {
41
+ candidates.push(path.join(home, ".opencode", "bin", "opencode"));
42
+ candidates.push(path.join(home, ".local", "bin", "opencode"));
43
+ candidates.push(...listSystemCliCandidates("opencode"));
44
+ }
43
45
 
44
46
  for (const c of candidates) {
45
47
  if (c && fs.existsSync(c)) return c;
@@ -1,10 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { spawnSync } from "node:child_process";
5
- import { fileURLToPath } from "node:url";
6
- import { buildUpdatePlan, extractVersionFromNpmMetadata } from "./update-utils.mjs";
7
- import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { hasSystemCli } from "./cli-detection-utils.mjs";
7
+ import { buildUpdatePlan, extractVersionFromNpmMetadata } from "./update-utils.mjs";
8
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
8
9
 
9
10
  const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
11
  const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
@@ -59,16 +60,21 @@ function readJsonIfExists(p) {
59
60
  }
60
61
  }
61
62
 
62
- function detectInstalledTargets(home = os.homedir()) {
63
- const codexHome = process.env.CODEX_HOME || path.join(home, ".codex");
64
- return {
65
- claude: fs.existsSync(path.join(home, ".claude", "hooks", "langfuse_hook.py")),
66
- opencode:
67
- fs.existsSync(path.join(home, ".config", "opencode", "opencode.json")) ||
68
- fs.existsSync(path.join(home, ".config", "opencode", "plugins", "opencode-plugin-langfuse")),
69
- codex: fs.existsSync(path.join(codexHome, "hooks", "codex_langfuse_notify.py")),
70
- };
71
- }
63
+ function detectInstalledTargets(home = os.homedir()) {
64
+ const codexHome = process.env.CODEX_HOME || path.join(home, ".codex");
65
+ return {
66
+ claude:
67
+ fs.existsSync(path.join(home, ".claude", "hooks", "langfuse_hook.py")) ||
68
+ hasSystemCli("claude"),
69
+ opencode:
70
+ fs.existsSync(path.join(home, ".config", "opencode", "opencode.json")) ||
71
+ fs.existsSync(path.join(home, ".config", "opencode", "plugins", "opencode-plugin-langfuse")) ||
72
+ hasSystemCli("opencode"),
73
+ codex:
74
+ fs.existsSync(path.join(codexHome, "hooks", "codex_langfuse_notify.py")) ||
75
+ hasSystemCli("codex"),
76
+ };
77
+ }
72
78
 
73
79
  function claudeConfig(home) {
74
80
  const settings = readJsonIfExists(path.join(home, ".claude", "settings.json")) || {};