oh-langfuse 0.1.43 → 0.1.45

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.
@@ -61,6 +61,7 @@ LOG_FILE = STATE_DIR / "codex_langfuse_notify.log"
61
61
 
62
62
  DEBUG = os.environ.get("CODEX_LANGFUSE_DEBUG", "").lower() == "true"
63
63
  MAX_CHARS = int(os.environ.get("CODEX_LANGFUSE_MAX_CHARS", "20000"))
64
+ MAX_SKILL_SCAN_CHARS = int(os.environ.get("CODEX_LANGFUSE_SKILL_SCAN_MAX_CHARS", "200000"))
64
65
  METRICS_SCHEMA_VERSION = "1.1"
65
66
  AGENT_NAME = "codex"
66
67
 
@@ -460,6 +461,7 @@ def build_interaction_metadata(
460
461
  def discover_known_skills(extra_roots: Optional[List[Path]] = None) -> set:
461
462
  roots = [
462
463
  CODEX_DIR / "skills",
464
+ CODEX_DIR / "plugins" / "cache",
463
465
  Path.home() / ".claude" / "skills",
464
466
  Path.home() / ".config" / "opencode" / "skill",
465
467
  ]
@@ -490,6 +492,71 @@ def _skill_id_segment(name: str) -> str:
490
492
  return (segment or "unknown")[:96]
491
493
 
492
494
 
495
+ def _skill_usage(name: str, detected_by: str, skill_call_id: str = "") -> Dict[str, str]:
496
+ clean = str(name or "").strip()
497
+ return {
498
+ "name": clean,
499
+ "skill_namespace": _skill_namespace(clean),
500
+ "detected_by": detected_by,
501
+ "skill_call_id": str(skill_call_id or "").strip(),
502
+ }
503
+
504
+
505
+ def _accept_skill_candidate(name: Any, known_skills: set, trusted: bool = False) -> str:
506
+ clean = str(name or "").strip()
507
+ if not clean:
508
+ return ""
509
+ if trusted or not known_skills or clean in known_skills:
510
+ return clean
511
+ return ""
512
+
513
+
514
+ def _collect_strings_limited(value: Any, out: List[str], remaining: List[int]) -> None:
515
+ if remaining[0] <= 0 or value is None:
516
+ return
517
+ if isinstance(value, str):
518
+ text = value[: remaining[0]]
519
+ if text:
520
+ out.append(text)
521
+ remaining[0] -= len(text)
522
+ return
523
+ if isinstance(value, (int, float, bool)):
524
+ return
525
+ if isinstance(value, list):
526
+ for item in value:
527
+ _collect_strings_limited(item, out, remaining)
528
+ if remaining[0] <= 0:
529
+ break
530
+ return
531
+ if isinstance(value, dict):
532
+ for item in value.values():
533
+ _collect_strings_limited(item, out, remaining)
534
+ if remaining[0] <= 0:
535
+ break
536
+
537
+
538
+ def _detect_skill_usages_from_text(text: str, known_skills: set) -> List[Dict[str, str]]:
539
+ found: List[Dict[str, str]] = []
540
+ if not text:
541
+ return found
542
+ seen: set = set()
543
+ for match in re.finditer(r"([A-Za-z]:)?[^\"'\n\r]*[\\/]+([^\\/\"'\n\r]+)[\\/]+SKILL\.md", text, re.IGNORECASE):
544
+ name = _accept_skill_candidate(match.group(2), known_skills)
545
+ if name and name not in seen:
546
+ seen.add(name)
547
+ found.append(_skill_usage(name, "skill_file_path"))
548
+ for match in re.finditer(r"Base directory for this skill:\s*([^\r\n]+)", text, re.IGNORECASE):
549
+ path_text = match.group(1)
550
+ path_match = re.search(r"[\\/](?:skills|skill)[\\/]([^\\/\"'\r\n]+)", path_text, re.IGNORECASE)
551
+ if not path_match:
552
+ continue
553
+ name = _accept_skill_candidate(path_match.group(1), known_skills)
554
+ if name and name not in seen:
555
+ seen.add(name)
556
+ found.append(_skill_usage(name, "skill_file_path"))
557
+ return found
558
+
559
+
493
560
  def detect_skill_usages(tool_calls: List[Dict[str, Any]], known_skills: set) -> List[Dict[str, str]]:
494
561
  found: List[Dict[str, str]] = []
495
562
  seen_call_ids: set = set()
@@ -507,19 +574,59 @@ def detect_skill_usages(tool_calls: List[Dict[str, Any]], known_skills: set) ->
507
574
  if dedupe_key in seen_call_ids:
508
575
  break
509
576
  seen_call_ids.add(dedupe_key)
510
- found.append({"name": name, "skill_namespace": _skill_namespace(name), "detected_by": "tool_call", "skill_call_id": call_id})
577
+ found.append(_skill_usage(name, "tool_call", call_id))
511
578
  break
512
579
  try:
513
580
  text = json.dumps(input_obj, ensure_ascii=False)
514
581
  except Exception:
515
582
  text = str(input_obj)
516
- for match in re.finditer(r"([A-Za-z]:)?[^\"'\n\r]*[\\/]+([^\\/\"'\n\r]+)[\\/]+SKILL\.md", text, re.IGNORECASE):
517
- candidate = match.group(2)
518
- if candidate and (candidate in known_skills or not known_skills):
519
- found.append({"name": candidate, "skill_namespace": _skill_namespace(candidate), "detected_by": "skill_file_path"})
583
+ found.extend(_detect_skill_usages_from_text(text, known_skills))
520
584
  return found
521
585
 
522
586
 
587
+ def _dedupe_turn_skill_usages(usages: List[Dict[str, str]]) -> List[Dict[str, str]]:
588
+ out: List[Dict[str, str]] = []
589
+ seen_call_ids: set = set()
590
+ seen_detected: set = set()
591
+ for usage in usages or []:
592
+ name = str(usage.get("name") or "").strip()
593
+ if not name:
594
+ continue
595
+ call_id = str(usage.get("skill_call_id") or "").strip()
596
+ if call_id:
597
+ key = f"call:{call_id}"
598
+ if key in seen_call_ids:
599
+ continue
600
+ seen_call_ids.add(key)
601
+ out.append(usage)
602
+ continue
603
+ detected_by = str(usage.get("detected_by") or "")
604
+ if detected_by == "skill_file_path":
605
+ key = f"{name}:{detected_by}"
606
+ if key in seen_detected:
607
+ continue
608
+ seen_detected.add(key)
609
+ out.append(usage)
610
+ return out
611
+
612
+
613
+ def detect_turn_skill_usages(material: Dict[str, Any], known_skills: set) -> List[Dict[str, str]]:
614
+ found = list(detect_skill_usages(material.get("tool_calls") or [], known_skills))
615
+ sources = [
616
+ material.get("user_text"),
617
+ material.get("assistant_text"),
618
+ material.get("skill_detection_sources"),
619
+ ]
620
+ strings: List[str] = []
621
+ remaining = [max(0, MAX_SKILL_SCAN_CHARS)]
622
+ for source in sources:
623
+ _collect_strings_limited(source, strings, remaining)
624
+ if remaining[0] <= 0:
625
+ break
626
+ found.extend(_detect_skill_usages_from_text("\n".join(strings), known_skills))
627
+ return _dedupe_turn_skill_usages(found)
628
+
629
+
523
630
  def build_skill_use_events(interaction_id: str, skill_usages: List[Dict[str, str]]) -> List[Dict[str, Any]]:
524
631
  events: List[Dict[str, Any]] = []
525
632
  deduped: List[Dict[str, str]] = []
@@ -615,14 +722,16 @@ def usage_details_from_codex(usage: Dict[str, Any]) -> Dict[str, int]:
615
722
 
616
723
 
617
724
  def collect_turn_material(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
618
- user_texts: List[str] = []
619
- assistant_texts: List[str] = []
620
- tool_calls: List[Dict[str, Any]] = []
621
- tool_results: List[Dict[str, Any]] = []
622
-
623
- for row in rows:
624
- row_type = row.get("type")
625
- payload = get_payload(row)
725
+ user_texts: List[str] = []
726
+ assistant_texts: List[str] = []
727
+ tool_calls: List[Dict[str, Any]] = []
728
+ tool_results: List[Dict[str, Any]] = []
729
+ skill_detection_sources: List[Any] = []
730
+
731
+ for row in rows:
732
+ row_type = row.get("type")
733
+ payload = get_payload(row)
734
+ skill_detection_sources.append(payload or row)
626
735
 
627
736
  if row_type == "response_item":
628
737
  item_type = payload.get("type")
@@ -664,10 +773,11 @@ def collect_turn_material(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
664
773
 
665
774
  return {
666
775
  "user_text": "\n\n".join(user_texts[-3:]),
667
- "assistant_text": "\n\n".join(assistant_texts),
668
- "tool_calls": tool_calls,
669
- "tool_results": tool_results,
670
- }
776
+ "assistant_text": "\n\n".join(assistant_texts),
777
+ "tool_calls": tool_calls,
778
+ "tool_results": tool_results,
779
+ "skill_detection_sources": skill_detection_sources,
780
+ }
671
781
 
672
782
 
673
783
  def emit_codex_turn(
@@ -686,7 +796,7 @@ def emit_codex_turn(
686
796
  model = first_string(meta.get("model"), meta.get("model_provider")) or "codex"
687
797
  tool_calls = material.get("tool_calls") or []
688
798
  tool_results = material.get("tool_results") or []
689
- skill_usages = detect_skill_usages(tool_calls, discover_known_skills())
799
+ skill_usages = detect_turn_skill_usages(material, discover_known_skills())
690
800
  interaction_id = build_interaction_id("codex", session_id, turn_num)
691
801
  skill_use_events = build_skill_use_events(interaction_id, skill_usages)
692
802
  interaction_meta = build_interaction_metadata(
package/langfuse_hook.py CHANGED
@@ -10,7 +10,7 @@ import re
10
10
  import sys
11
11
  import time
12
12
  import hashlib
13
- from dataclasses import dataclass
13
+ from dataclasses import dataclass, field
14
14
  from datetime import datetime, timezone
15
15
  from pathlib import Path
16
16
  from typing import Any, Dict, List, Optional, Tuple
@@ -526,7 +526,7 @@ def _dedupe_turn_skill_usages(usages: List[Dict[str, str]]) -> List[Dict[str, st
526
526
 
527
527
  def detect_turn_skill_usages(turn: "Turn", tool_calls: List[Dict[str, Any]], known_skills: set) -> List[Dict[str, str]]:
528
528
  found = list(detect_skill_usages(tool_calls, known_skills))
529
- rows = [turn.user_msg, *turn.assistant_msgs]
529
+ rows = [turn.user_msg, *getattr(turn, "context_msgs", []), *turn.assistant_msgs]
530
530
 
531
531
  for row in rows:
532
532
  attributed = _accept_skill_candidate(_attribution_skill_from_row(row), known_skills, trusted=True)
@@ -694,13 +694,20 @@ def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[s
694
694
  return msgs, ss
695
695
 
696
696
  # ----------------- Turn assembly -----------------
697
- @dataclass
698
- class Turn:
699
- user_msg: Dict[str, Any]
700
- assistant_msgs: List[Dict[str, Any]]
701
- tool_results_by_id: Dict[str, Any]
702
-
703
- def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]:
697
+ @dataclass
698
+ class Turn:
699
+ user_msg: Dict[str, Any]
700
+ assistant_msgs: List[Dict[str, Any]]
701
+ tool_results_by_id: Dict[str, Any]
702
+ context_msgs: List[Dict[str, Any]] = field(default_factory=list)
703
+
704
+ def is_skill_context_user_msg(msg: Dict[str, Any]) -> bool:
705
+ if get_role(msg) != "user" or is_tool_result(msg):
706
+ return False
707
+ text = extract_text(get_content(msg)).lstrip()
708
+ return text.startswith("Base directory for this skill:")
709
+
710
+ def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]:
704
711
  """
705
712
  Groups incremental transcript rows into turns:
706
713
  user (non-tool-result) -> assistant messages -> (tool_result rows, possibly interleaved)
@@ -715,38 +722,50 @@ def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]:
715
722
  assistant_order: List[str] = [] # message ids in order of first appearance (or synthetic)
716
723
  assistant_latest: Dict[str, Dict[str, Any]] = {} # id -> latest msg
717
724
 
718
- tool_results_by_id: Dict[str, Any] = {} # tool_use_id -> content
719
-
720
- def flush_turn():
721
- nonlocal current_user, assistant_order, assistant_latest, tool_results_by_id, turns
722
- if current_user is None:
723
- return
724
- if not assistant_latest:
725
- return
726
- assistants = [assistant_latest[mid] for mid in assistant_order if mid in assistant_latest]
727
- turns.append(Turn(user_msg=current_user, assistant_msgs=assistants, tool_results_by_id=dict(tool_results_by_id)))
725
+ tool_results_by_id: Dict[str, Any] = {} # tool_use_id -> content
726
+ context_msgs: List[Dict[str, Any]] = []
727
+
728
+ def flush_turn():
729
+ nonlocal current_user, assistant_order, assistant_latest, tool_results_by_id, context_msgs, turns
730
+ if current_user is None:
731
+ return
732
+ if not assistant_latest:
733
+ return
734
+ assistants = [assistant_latest[mid] for mid in assistant_order if mid in assistant_latest]
735
+ turns.append(Turn(
736
+ user_msg=current_user,
737
+ assistant_msgs=assistants,
738
+ tool_results_by_id=dict(tool_results_by_id),
739
+ context_msgs=list(context_msgs),
740
+ ))
728
741
 
729
742
  for msg in messages:
730
743
  role = get_role(msg)
731
744
 
732
745
  # tool_result rows show up as role=user with content blocks of type tool_result
733
- if is_tool_result(msg):
734
- for tr in iter_tool_results(get_content(msg)):
735
- tid = tr.get("tool_use_id")
736
- if tid:
737
- tool_results_by_id[str(tid)] = tr.get("content")
738
- continue
739
-
740
- if role == "user":
741
- # new user message -> finalize previous turn
742
- flush_turn()
746
+ if is_tool_result(msg):
747
+ for tr in iter_tool_results(get_content(msg)):
748
+ tid = tr.get("tool_use_id")
749
+ if tid:
750
+ tool_results_by_id[str(tid)] = tr.get("content")
751
+ continue
752
+
753
+ if is_skill_context_user_msg(msg):
754
+ if current_user is not None:
755
+ context_msgs.append(msg)
756
+ continue
757
+
758
+ if role == "user":
759
+ # new user message -> finalize previous turn
760
+ flush_turn()
743
761
 
744
762
  # start a new turn
745
763
  current_user = msg
746
- assistant_order = []
747
- assistant_latest = {}
748
- tool_results_by_id = {}
749
- continue
764
+ assistant_order = []
765
+ assistant_latest = {}
766
+ tool_results_by_id = {}
767
+ context_msgs = []
768
+ continue
750
769
 
751
770
  if role == "assistant":
752
771
  if current_user is None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-langfuse",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
@@ -10,6 +10,21 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
10
10
  const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
11
11
  const ALLOWED_TARGETS = new Set(["claude", "opencode", "codex"]);
12
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
+ cyan: ansi("96"),
19
+ green: ansi("92"),
20
+ gold: ansi("93"),
21
+ };
22
+
23
+ function paint(text, ...styles) {
24
+ if (!colorEnabled) return text;
25
+ return `${styles.join("")}${text}${t.reset}`;
26
+ }
27
+
13
28
  function parseArgs(argv) {
14
29
  const args = { _: [] };
15
30
  for (const raw of argv) {
@@ -45,6 +60,26 @@ function npxCommand() {
45
60
  return process.platform === "win32" ? "npx.cmd" : "npx";
46
61
  }
47
62
 
63
+ function targetLabel(target) {
64
+ if (target === "claude") return "Claude";
65
+ if (target === "codex") return "Codex";
66
+ if (target === "opencode") return "OpenCode";
67
+ return target;
68
+ }
69
+
70
+ function printUpdateAvailable(target, message, updateCommand) {
71
+ const label = targetLabel(target);
72
+ console.log(paint(`[UPDATE] \u68c0\u6d4b\u5230 ${label} Langfuse \u53ef\u66f4\u65b0`, t.bold, t.gold));
73
+ console.log(`[INFO] ${message}`);
74
+ console.log(`${paint("[CMD]", t.bold, t.cyan)} ${updateCommand}`);
75
+ }
76
+
77
+ function printUpdateCommand(target, updateCommand) {
78
+ const label = targetLabel(target);
79
+ console.log(paint(`[UPDATE] \u6b63\u5728\u6267\u884c\u66f4\u65b0\u547d\u4ee4\uff1a${label} Langfuse`, t.bold, t.cyan));
80
+ console.log(`${paint("[CMD]", t.bold, t.cyan)} ${updateCommand}`);
81
+ }
82
+
48
83
  function runUpdate(target, args) {
49
84
  const updateArgs = ["-y", "oh-langfuse@latest", "update", target];
50
85
  if (args["skip-check"]) updateArgs.push("--skip-check");
@@ -89,24 +124,27 @@ async function main() {
89
124
  const updateCommand = `npx oh-langfuse@latest update ${target}`;
90
125
 
91
126
  if (args["notify-only"] || args.notifyOnly) {
92
- console.log(`[INFO] ${message}`);
127
+ printUpdateAvailable(target, message, updateCommand);
93
128
  console.log(`[INFO] To update safely, close the agent and run: ${updateCommand}`);
94
129
  return 0;
95
130
  }
96
131
 
97
132
  if (args.yes || args.y) {
98
- console.log(`[INFO] ${message}`);
133
+ printUpdateAvailable(target, message, updateCommand);
134
+ printUpdateCommand(target, updateCommand);
99
135
  const code = runUpdate(target, args);
100
136
  return args.strict ? code : 0;
101
137
  }
102
138
 
103
139
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
104
- console.log(`[INFO] ${message} Run: ${updateCommand}`);
140
+ printUpdateAvailable(target, message, updateCommand);
105
141
  return 0;
106
142
  }
107
143
 
108
- const answer = await question(`${message}\nUpdate now? (y/N) `);
144
+ printUpdateAvailable(target, message, updateCommand);
145
+ const answer = await question(`${paint("[CONFIRM]", t.bold, t.gold)} Update now? (y/N) `);
109
146
  if (!/^(y|yes)$/i.test(answer)) return 0;
147
+ printUpdateCommand(target, updateCommand);
110
148
  const code = runUpdate(target, args);
111
149
  return args.strict ? code : 0;
112
150
  }
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
- import { extractVersionFromNpmMetadata, selectUpdateTargets } from "./update-utils.mjs";
6
+ import { buildUpdatePlan, extractVersionFromNpmMetadata } from "./update-utils.mjs";
7
7
  import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
8
8
 
9
9
  const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -13,6 +13,23 @@ const DEFAULT_LANGFUSE_BASE_URL = "http://120.46.221.227:3000";
13
13
  const DEFAULT_LANGFUSE_PUBLIC_KEY = "pk-lf-da0c90a7-6e93-4eb7-bb86-c1047c8d187d";
14
14
  const DEFAULT_LANGFUSE_SECRET_KEY = "sk-lf-0269b85d-bfdc-442c-bfa3-e737954e3315";
15
15
 
16
+ const colorEnabled = process.stdout.isTTY && process.env.NO_COLOR !== "1";
17
+ const ansi = (code) => (colorEnabled ? `\x1b[${code}m` : "");
18
+ const t = {
19
+ reset: ansi(0),
20
+ bold: ansi(1),
21
+ dim: ansi(2),
22
+ cyan: ansi("96"),
23
+ green: ansi("92"),
24
+ gold: ansi("93"),
25
+ red: ansi("91"),
26
+ };
27
+
28
+ function paint(text, ...styles) {
29
+ if (!colorEnabled) return text;
30
+ return `${styles.join("")}${text}${t.reset}`;
31
+ }
32
+
16
33
  function parseArgs(argv) {
17
34
  const args = { _: [] };
18
35
  for (const raw of argv) {
@@ -142,14 +159,72 @@ function checkScript(target) {
142
159
  return "opencode-langfuse-check.mjs";
143
160
  }
144
161
 
162
+ function targetLabel(target) {
163
+ if (target === "claude") return "Claude";
164
+ if (target === "codex") return "Codex";
165
+ if (target === "opencode") return "OpenCode";
166
+ return target;
167
+ }
168
+
169
+ function updatedItems(target, args) {
170
+ const items = [];
171
+ if (target === "claude") {
172
+ items.push("Claude Code Langfuse hook", "direct claude auto-update command shim", "Claude launcher", "runtime version record");
173
+ } else if (target === "codex") {
174
+ items.push("Codex notify hook", "direct codex auto-update command shim", "Codex launcher", "runtime version record");
175
+ } else {
176
+ items.push("OpenCode Langfuse plugin/config", "direct opencode auto-update command shim", "OpenCode launcher", "runtime version record");
177
+ if (args["skip-plugin-install"] || args.skipPluginInstall) {
178
+ items[0] = "OpenCode Langfuse config";
179
+ }
180
+ }
181
+ if (!args["skip-check"]) items.push("post-update configuration check");
182
+ return items;
183
+ }
184
+
185
+ function printSkippedTarget(target) {
186
+ const label = targetLabel(target);
187
+ console.log(paint(`[WARN] \u5f53\u524d\u672a\u68c0\u6d4b\u5230 ${label} \u5b89\u88c5\uff0c\u8df3\u8fc7\u66f4\u65b0 ${label} Langfuse \u63d2\u4ef6\u3002`, t.bold, t.gold));
188
+ }
189
+
190
+ function printUpdateStart(target) {
191
+ const label = targetLabel(target);
192
+ console.log("");
193
+ console.log(paint(`[UPDATE] \u6b63\u5728\u66f4\u65b0 ${label} Langfuse ...`, t.bold, t.cyan));
194
+ }
195
+
196
+ function printUpdateCheck(target) {
197
+ const label = targetLabel(target);
198
+ console.log(paint(`[CHECK] \u6b63\u5728\u6821\u9a8c ${label} Langfuse \u914d\u7f6e ...`, t.bold, t.gold));
199
+ }
200
+
201
+ function printUpdateSuccess(target, args) {
202
+ const label = targetLabel(target);
203
+ console.log(paint(`[SUCCESS] ${label} Langfuse \u66f4\u65b0\u6210\u529f\uff1a${packageJson.name}@${packageJson.version}`, t.bold, t.green));
204
+ console.log(`${paint("[DETAIL]", t.bold, t.gold)} \u66f4\u65b0\u5185\u5bb9\uff1a${updatedItems(target, args).join("\u3001")}`);
205
+ }
206
+
207
+ function printUpdateSummary(targets) {
208
+ console.log("");
209
+ console.log(paint(`[SUCCESS] Langfuse \u66f4\u65b0\u5b8c\u6210\uff1a${targets.map(targetLabel).join("\u3001")} -> ${packageJson.name}@${packageJson.version}`, t.bold, t.green));
210
+ }
211
+
145
212
  async function main() {
146
213
  const args = parseArgs(process.argv.slice(2));
147
214
  const targetArg = args._[0] || "all";
148
215
  const installed = detectInstalledTargets();
149
- const targets = selectUpdateTargets(targetArg, installed);
150
- if (!targets.length) {
216
+ const plan = buildUpdatePlan(targetArg, installed);
217
+ const targets = plan.targets;
218
+ for (const item of plan.skipped) {
219
+ if (item.reason === "not_detected") printSkippedTarget(item.target);
220
+ }
221
+ if (!targets.length && !plan.skipped.length) {
151
222
  throw new Error("No installed oh-langfuse targets were detected. Run setup first, or specify a target with credentials.");
152
223
  }
224
+ if (!targets.length) {
225
+ console.log("[INFO] \u6ca1\u6709\u53ef\u66f4\u65b0\u7684 Langfuse agent runtime\u3002");
226
+ return;
227
+ }
153
228
 
154
229
  console.log(`[INFO] Running package: ${packageJson.name}@${packageJson.version}`);
155
230
  try {
@@ -163,8 +238,7 @@ async function main() {
163
238
  }
164
239
 
165
240
  for (const target of targets) {
166
- console.log("");
167
- console.log(`[INFO] Updating ${target} runtime...`);
241
+ printUpdateStart(target);
168
242
  const config = mergedConfig(target, args);
169
243
  runNodeScript(setupScript(target), setupArgs(target, config, args));
170
244
  writeRuntimeInstallRecord(target, {
@@ -172,9 +246,12 @@ async function main() {
172
246
  packageVersion: packageJson.version,
173
247
  });
174
248
  if (!args["skip-check"]) {
249
+ printUpdateCheck(target);
175
250
  runNodeScript(checkScript(target), []);
176
251
  }
252
+ printUpdateSuccess(target, args);
177
253
  }
254
+ printUpdateSummary(targets);
178
255
  }
179
256
 
180
257
  main().catch((error) => {
@@ -1,4 +1,5 @@
1
1
  const ALLOWED_TARGETS = new Set(["claude", "opencode", "codex"]);
2
+ const ALL_TARGETS = ["claude", "opencode", "codex"];
2
3
 
3
4
  export function extractVersionFromNpmMetadata(metadata) {
4
5
  const latest = metadata?.["dist-tags"]?.latest;
@@ -38,10 +39,35 @@ export function isNewerVersion(candidate, current) {
38
39
  export function selectUpdateTargets(target = "all", installed = {}) {
39
40
  const normalized = String(target || "all").trim().toLowerCase();
40
41
  if (normalized === "all") {
41
- return ["claude", "opencode", "codex"].filter((name) => Boolean(installed[name]));
42
+ return ALL_TARGETS.filter((name) => Boolean(installed[name]));
42
43
  }
43
44
  if (!ALLOWED_TARGETS.has(normalized)) {
44
45
  throw new Error(`Unsupported update target: ${target}`);
45
46
  }
46
47
  return [normalized];
47
48
  }
49
+
50
+ export function buildUpdatePlan(target = "all", installed = {}) {
51
+ const normalized = String(target || "all").trim().toLowerCase();
52
+ if (normalized === "all") {
53
+ return {
54
+ targets: ALL_TARGETS.filter((name) => Boolean(installed[name])),
55
+ skipped: ALL_TARGETS
56
+ .filter((name) => !installed[name])
57
+ .map((name) => ({ target: name, reason: "not_detected" })),
58
+ };
59
+ }
60
+ if (!ALLOWED_TARGETS.has(normalized)) {
61
+ throw new Error(`Unsupported update target: ${target}`);
62
+ }
63
+ if (!installed[normalized]) {
64
+ return {
65
+ targets: [],
66
+ skipped: [{ target: normalized, reason: "not_detected" }],
67
+ };
68
+ }
69
+ return {
70
+ targets: [normalized],
71
+ skipped: [],
72
+ };
73
+ }