langfuse-agent-cli 0.3.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.
@@ -0,0 +1,123 @@
1
+ """Prompt management commands for Langfuse."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import sys
8
+ from typing import Any, Dict
9
+
10
+ from langfuse_cli.client import LangfuseClient
11
+ from langfuse_cli.config import resolve_from_args
12
+ from langfuse_cli.formatters.agent import print_json
13
+
14
+
15
+ def compile_mustache(template: str, vars_dict: Dict[str, Any]) -> str:
16
+ """Basic mustache variable substitution for {{var}}."""
17
+ result = template
18
+ for key, val in vars_dict.items():
19
+ pattern = r"\{\{\s*" + re.escape(key) + r"\s*\}\}"
20
+ # A function replacement inserts the value as-is; a string one would expand \1, \n and \U in it.
21
+ result = re.sub(pattern, lambda _match, value=str(val): value, result)
22
+ return result
23
+
24
+
25
+ def compile_prompt(prompt_data: Any, vars_dict: Dict[str, Any]) -> Any:
26
+ if isinstance(prompt_data, str):
27
+ return compile_mustache(prompt_data, vars_dict)
28
+ elif isinstance(prompt_data, list):
29
+ # Chat messages: [{"role": "user", "content": "..."}]
30
+ out = []
31
+ for msg in prompt_data:
32
+ if isinstance(msg, dict) and "content" in msg and isinstance(msg["content"], str):
33
+ c = dict(msg)
34
+ c["content"] = compile_mustache(msg["content"], vars_dict)
35
+ out.append(c)
36
+ else:
37
+ out.append(msg)
38
+ return out
39
+ return prompt_data
40
+
41
+
42
+ def handle_prompt(args: argparse.Namespace) -> int:
43
+ client = LangfuseClient(resolve_from_args(args))
44
+
45
+ if args.prompt_action == "list":
46
+ prompts = client.list_prompts(limit=args.limit, tag=args.tag).get("data", [])
47
+ if args.output == "json":
48
+ print_json(args, prompts)
49
+ elif not prompts:
50
+ print("No prompts found.")
51
+ else:
52
+ print(f"Found {len(prompts)} prompts:")
53
+ for p in prompts:
54
+ versions = p.get("versions") or []
55
+ latest = max(versions) if versions else "?"
56
+ labels = ", ".join(p.get("labels", []))
57
+ labels_str = f" [labels: {labels}]" if labels else ""
58
+ print(f" - {p.get('name')} (v{latest}){labels_str} - type: {p.get('type', 'text')}")
59
+ return 0
60
+
61
+ if args.prompt_action == "get":
62
+ vars_dict = None
63
+ if args.vars:
64
+ try:
65
+ vars_dict = json.loads(args.vars)
66
+ except ValueError as e:
67
+ print(f"Error: --vars is not valid JSON: {e}", file=sys.stderr)
68
+ return 1
69
+ if not isinstance(vars_dict, dict):
70
+ print('Error: --vars must be a JSON object, for example \'{"user_name": "Alice"}\'', file=sys.stderr)
71
+ return 1
72
+ prompt = client.get_prompt(name=args.name, version=args.version, label=args.label)
73
+ if vars_dict is not None:
74
+ prompt["compiled_prompt"] = compile_prompt(prompt.get("prompt"), vars_dict)
75
+ prompt["applied_vars"] = vars_dict
76
+
77
+ if args.output == "json":
78
+ print_json(args, prompt)
79
+ else:
80
+ print(f"Prompt: {prompt.get('name')} (v{prompt.get('version')})")
81
+ print(f"Type: {prompt.get('type', 'text')}")
82
+ if prompt.get("labels"):
83
+ print(f"Labels: {', '.join(prompt['labels'])}")
84
+ if prompt.get("tags"):
85
+ print(f"Tags: {', '.join(prompt['tags'])}")
86
+ print("-" * 40)
87
+ display_content = prompt.get("compiled_prompt") or prompt.get("prompt")
88
+ if isinstance(display_content, (dict, list)):
89
+ print(json.dumps(display_content, indent=2))
90
+ else:
91
+ print(display_content)
92
+ return 0
93
+
94
+ # push
95
+ if args.file:
96
+ with open(args.file, "r", encoding="utf-8") as f:
97
+ content = f.read()
98
+ if args.type == "chat":
99
+ try:
100
+ prompt_content = json.loads(content)
101
+ except ValueError as e:
102
+ print(f"Error: --file must hold a JSON list of messages for --type chat: {e}", file=sys.stderr)
103
+ return 1
104
+ else:
105
+ prompt_content = content # a text prompt is sent as written, even when it looks like JSON
106
+ elif args.text:
107
+ prompt_content = args.text
108
+ else:
109
+ print("Error: Specify either --file <path> or --text '<prompt string>'", file=sys.stderr)
110
+ return 1
111
+
112
+ created = client.create_prompt(
113
+ name=args.name,
114
+ prompt_content=prompt_content,
115
+ prompt_type=args.type,
116
+ labels=[label.strip() for label in args.labels.split(",")] if args.labels else None,
117
+ tags=[tag.strip() for tag in args.tags.split(",")] if args.tags else None,
118
+ )
119
+ if args.output == "json":
120
+ print_json(args, created)
121
+ else:
122
+ print(f"✓ Prompt '{args.name}' (v{created.get('version', 'new')}) published successfully.")
123
+ return 0
@@ -0,0 +1,24 @@
1
+ """Score and evaluation metric commands."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+
6
+ from langfuse_cli.client import LangfuseClient
7
+ from langfuse_cli.config import resolve_from_args
8
+ from langfuse_cli.formatters.agent import print_json
9
+
10
+
11
+ def handle_score(args: argparse.Namespace) -> int:
12
+ client = LangfuseClient(resolve_from_args(args))
13
+ res = client.create_score(
14
+ trace_id=args.trace_id,
15
+ name=args.name,
16
+ value=args.value,
17
+ comment=args.comment,
18
+ observation_id=args.observation_id,
19
+ )
20
+ if args.output == "json":
21
+ print_json(args, res)
22
+ else:
23
+ print(f"✓ Score '{args.name}'={args.value} recorded for trace '{args.trace_id}'.")
24
+ return 0
@@ -0,0 +1,101 @@
1
+ """One-step setup: credentials, the agent skill, then doctor."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+ from langfuse_cli.client import LangfuseAPIError, LangfuseClient
11
+ from langfuse_cli.commands.auth import login
12
+ from langfuse_cli.commands.doctor import SKILL_NAME, SKILL_SOURCE, handle_doctor
13
+ from langfuse_cli.config import ConfigError, ConfigManager, resolve_from_args
14
+
15
+ INSTALL_LATER = f" To install it later: npx skills@1 add {SKILL_SOURCE} -g"
16
+ SOURCE_NAMES = {"env": "the LANGFUSE_* environment variables", "env-file": "the --env-file file",
17
+ "flags": "--public-key and --secret-key"}
18
+
19
+
20
+ def _credentials(args: argparse.Namespace, interactive: bool) -> None:
21
+ try:
22
+ prof = resolve_from_args(args)
23
+ except ConfigError as e: # doctor reports it again; the skill step still runs
24
+ print(f" ✗ {e}")
25
+ return
26
+ rejected = True # missing keys count as rejected
27
+ if prof.is_complete():
28
+ try:
29
+ projects = LangfuseClient(prof).get_project().get("data") or []
30
+ except LangfuseAPIError as e:
31
+ print(f" ✗ {e}")
32
+ rejected = e.status_code in (401, 403)
33
+ else:
34
+ names = ", ".join(p.get("name", "?") for p in projects) or "(none)"
35
+ print(f" ✓ Using {prof.source} credentials for {prof.host} (project: {names})")
36
+ return
37
+ if not rejected: # new keys would not help; asking for them invites overwriting good ones
38
+ print(" – Login skipped: the keys were not rejected; check the host and that the server is up.")
39
+ elif prof.source != "profile": # these sources outrank saved profiles, so a login here would change nothing
40
+ print(f" – Login skipped: these keys come from {SOURCE_NAMES[prof.source]}, which take precedence over "
41
+ "saved profiles. Fix or unset them, then run lf setup again.")
42
+ elif not interactive:
43
+ print(" – Login skipped: lf setup never asks for keys with --yes or without a terminal. "
44
+ "Ask the user to run 'lf setup' in their own terminal.")
45
+ else:
46
+ host = input(f" Langfuse host [{prof.host}]: ").strip() or prof.host
47
+ login(ConfigManager(), prof.name, host, None, None, set_default=True) # keys that failed are asked for again
48
+
49
+
50
+ def _install_skill(interactive: bool) -> bool:
51
+ """Install the skill with the skills CLI; False when it could not be installed (declining is not a failure)."""
52
+ npx = shutil.which("npx")
53
+ if npx is None:
54
+ print(" ✗ npx not found; the skills CLI needs Node.js 22.20 or later.")
55
+ print(INSTALL_LATER)
56
+ return False
57
+ # On Windows, which() searches the current folder first; never run an npx that a project folder supplies.
58
+ if os.path.normcase(os.path.realpath(os.path.dirname(npx))) == os.path.normcase(os.path.realpath(os.getcwd())):
59
+ print(f" ✗ Not running {npx}: it is in the current folder. Run lf setup from another folder.")
60
+ print(INSTALL_LATER)
61
+ return False
62
+ if interactive and input(f" Install the {SKILL_NAME} skill for your coding agents? [Y/n] ").strip().lower() in (
63
+ "n", "no"):
64
+ print(INSTALL_LATER)
65
+ return True
66
+ # npx --yes skips npm's install prompt. The skills CLI's own -y skips its questions: without it, it asks which
67
+ # agents to use when it finds several; with it, it takes the agents it finds, or every agent it supports when it
68
+ # finds none. Run from inside a coding agent, it targets that agent either way.
69
+ cmd = [npx, "--yes", "skills@1", "add", SKILL_SOURCE, "-g"]
70
+ if not interactive:
71
+ cmd.append("-y")
72
+ # No LANGFUSE_* keys for npx and the install scripts it runs; git must fail rather than wait for a password.
73
+ env = {k: v for k, v in os.environ.items() if not k.startswith("LANGFUSE_")}
74
+ env["GIT_TERMINAL_PROMPT"] = "0"
75
+ print(f" Running: npx {' '.join(cmd[1:])}", flush=True)
76
+ try:
77
+ # Unattended runs get no stdin and 90 s, so they end inside an agent's usual 2-minute command limit.
78
+ result = subprocess.run(cmd, env=env, stdin=None if interactive else subprocess.DEVNULL,
79
+ timeout=None if interactive else 90)
80
+ reason = f"npx exited with code {result.returncode}" if result.returncode else ""
81
+ except (subprocess.TimeoutExpired, OSError) as e: # OSError: npx was found but could not be started
82
+ reason = str(e)
83
+ if reason:
84
+ print(f" ✗ The skill was not installed: {reason}")
85
+ print(INSTALL_LATER)
86
+ return not reason
87
+
88
+
89
+ def handle_setup(args: argparse.Namespace) -> int:
90
+ interactive = not args.yes and sys.stdin is not None and sys.stdin.isatty()
91
+ print("\nLangfuse Agent CLI Setup")
92
+ print("\n1. Credentials")
93
+ _credentials(args, interactive)
94
+ print("\n2. Agent Skill")
95
+ installed = _install_skill(interactive)
96
+ print("\n3. Check")
97
+ code = handle_doctor(args)
98
+ if not installed:
99
+ print(" ✗ lf setup did not install the skill (see step 2); any copy listed above is from an earlier install.")
100
+ return 1
101
+ return code
@@ -0,0 +1,83 @@
1
+ """Trace commands, read through the observations v2 API (the only trace read path on Langfuse v4)."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from typing import Any, Dict, Iterable, List
7
+
8
+ from langfuse_cli.client import LangfuseClient
9
+ from langfuse_cli.config import resolve_from_args
10
+ from langfuse_cli.formatters.agent import print_json
11
+ from langfuse_cli.formatters.timeline import render_trace_timeline
12
+
13
+ # Field groups of GET /api/public/v2/observations. "metrics" carries latency; the spec puts it there.
14
+ # Langfuse 4.70 returns latency null instead of 0 for zero-length observations when "metrics" is requested.
15
+ ROOT_FIELDS = "core,basic,metrics" # a trace is listed as its root observation
16
+ TREE_FIELDS = "core,basic,time,model,usage,metrics" # leaves out io (--io adds it), metadata, prompt, trace_context
17
+
18
+
19
+ def group_errors(observations: Iterable[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]:
20
+ """ERROR observations grouped by trace in first-seen order, at most `limit` traces."""
21
+ groups: Dict[Any, Dict[str, Any]] = {}
22
+ for obs in observations:
23
+ trace_id = obs.get("traceId")
24
+ if trace_id not in groups:
25
+ if len(groups) == limit:
26
+ break # stops the lazy page iterator too
27
+ groups[trace_id] = {"traceId": trace_id, "sessionId": obs.get("sessionId"),
28
+ "userId": obs.get("userId"), "errors": []}
29
+ groups[trace_id]["errors"].append({k: obs.get(k) for k in ("id", "name", "type", "statusMessage", "startTime")})
30
+ return list(groups.values())
31
+
32
+
33
+ def _trace_line(row: Dict[str, Any]) -> str:
34
+ latency = f" ({float(row['latency']):.2f}s)" if row.get("latency") is not None else ""
35
+ return f" - [{row.get('traceId')}] {row.get('name') or 'unnamed'}{latency} @ {row.get('startTime')}"
36
+
37
+
38
+ def _emit(args: argparse.Namespace, rows: List[Dict[str, Any]], lines: List[str], empty: str) -> int:
39
+ if args.output == "json":
40
+ print_json(args, rows)
41
+ else:
42
+ print("\n".join(lines) if lines else empty)
43
+ return 0
44
+
45
+
46
+ def _by_start(obs: Dict[str, Any]) -> str:
47
+ return obs.get("startTime") or ""
48
+
49
+
50
+ def handle_trace(args: argparse.Namespace) -> int:
51
+ client = LangfuseClient(resolve_from_args(args))
52
+
53
+ if args.trace_action == "list":
54
+ filters = {"sessionId": args.session_id, "userId": args.user_id,
55
+ "fromStartTime": args.from_ts, "toStartTime": args.to_ts}
56
+ if args.status == "error":
57
+ # shortcut: scans at most 5000 error rows; raise the cap if busy projects need longer windows
58
+ errors = client.iter_observations({**filters, "level": "ERROR", "fields": ROOT_FIELDS}, max_items=5000)
59
+ rows = group_errors(errors, args.limit)
60
+ lines = [f" - [{g['traceId']}] {len(g['errors'])} error(s): "
61
+ + "; ".join(f"{e['name']}: {e['statusMessage']}" for e in g["errors"]) for g in rows]
62
+ return _emit(args, rows, lines, "No traces with errors found.")
63
+ rows = client.list_observations(**filters, isRootObservation=True, fields=ROOT_FIELDS, limit=args.limit)
64
+ rows = rows.get("data", [])
65
+ return _emit(args, rows, [_trace_line(r) for r in rows], "No traces found.")
66
+
67
+ if args.trace_action == "get":
68
+ fields = TREE_FIELDS + (",io" if args.io else "")
69
+ observations = sorted(client.iter_observations({"traceId": args.trace_id, "fields": fields}), key=_by_start)
70
+ if not observations:
71
+ print(f"Error: trace {args.trace_id} not found", file=sys.stderr)
72
+ return 1
73
+ if args.timeline or args.output == "text":
74
+ print(render_trace_timeline(args.trace_id, observations))
75
+ else:
76
+ print_json(args, observations)
77
+ return 0
78
+
79
+ # session: its traces, oldest first, so a conversation reads top to bottom
80
+ # shortcut: keeps the newest 1000 traces (the API returns newest first); page further if sessions grow
81
+ params = {"sessionId": args.session_id, "isRootObservation": True, "fields": ROOT_FIELDS}
82
+ rows = sorted(client.iter_observations(params, max_items=1000), key=_by_start)
83
+ return _emit(args, rows, [_trace_line(r) for r in rows], f"No traces in session {args.session_id}.")
langfuse_cli/config.py ADDED
@@ -0,0 +1,187 @@
1
+ """Configuration and profile management for Langfuse CLI."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "langfuse"
13
+ DEFAULT_PROFILES_FILE = DEFAULT_CONFIG_DIR / "profiles.json"
14
+
15
+
16
+ class ConfigError(Exception):
17
+ """Credentials or the profiles file cannot be used as given."""
18
+
19
+
20
+ @dataclass
21
+ class ProfileConfig:
22
+ name: str
23
+ host: str
24
+ public_key: str
25
+ secret_key: str
26
+ source: str = "" # "profile", "env" or "env-file"; "flags" when both keys come from flags
27
+
28
+ def is_complete(self) -> bool:
29
+ return bool(self.host and self.public_key and self.secret_key)
30
+
31
+ def masked_public_key(self) -> str:
32
+ if not self.public_key:
33
+ return "(not set)"
34
+ if len(self.public_key) <= 8:
35
+ return "******"
36
+ return f"{self.public_key[:6]}...{self.public_key[-4:]}"
37
+
38
+ def masked_secret_key(self) -> str:
39
+ if not self.secret_key:
40
+ return "(not set)"
41
+ if len(self.secret_key) <= 8:
42
+ return "******"
43
+ return f"{self.secret_key[:6]}...{self.secret_key[-4:]}"
44
+
45
+
46
+ class ConfigManager:
47
+ """Manages persistent profiles and active session configurations."""
48
+
49
+ def __init__(self, config_path: Optional[Path] = None):
50
+ self.config_path = config_path or DEFAULT_PROFILES_FILE
51
+ self._cache: Optional[Dict[str, Any]] = None
52
+
53
+ def _load_raw(self) -> Dict[str, Any]:
54
+ if self._cache is not None:
55
+ return self._cache
56
+ data: Any = {}
57
+ if self.config_path.exists():
58
+ try:
59
+ data = json.loads(self.config_path.read_text(encoding="utf-8"))
60
+ except ValueError as e:
61
+ # Refuse to continue: the next save would silently drop every saved profile.
62
+ raise ConfigError(f"{self.config_path} is not valid JSON; fix or delete it") from e
63
+ if not isinstance(data, dict):
64
+ raise ConfigError(f"{self.config_path} is not a profiles file; fix or delete it")
65
+ data.setdefault("current_profile", "default")
66
+ data.setdefault("profiles", {})
67
+ self._cache = data
68
+ return data
69
+
70
+ def _save_raw(self, data: Dict[str, Any]) -> None:
71
+ # The file holds secret keys: create directory and file private from the start, then swap it in.
72
+ self.config_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
73
+ fd, tmp = tempfile.mkstemp(dir=self.config_path.parent, prefix=".profiles-")
74
+ try:
75
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
76
+ json.dump(data, f, indent=2)
77
+ os.replace(tmp, self.config_path)
78
+ except BaseException:
79
+ os.unlink(tmp)
80
+ raise
81
+ self._cache = data
82
+
83
+ def list_profiles(self) -> Dict[str, ProfileConfig]:
84
+ data = self._load_raw()
85
+ profiles = {}
86
+ for name, p in data.get("profiles", {}).items():
87
+ profiles[name] = ProfileConfig(
88
+ name=name,
89
+ host=p.get("host", ""),
90
+ public_key=p.get("public_key", ""),
91
+ secret_key=p.get("secret_key", ""),
92
+ source="profile",
93
+ )
94
+ return profiles
95
+
96
+ def get_current_profile_name(self) -> str:
97
+ return self._load_raw().get("current_profile", "default")
98
+
99
+ def set_current_profile(self, name: str) -> None:
100
+ data = self._load_raw()
101
+ data["current_profile"] = name
102
+ self._save_raw(data)
103
+
104
+ def save_profile(
105
+ self,
106
+ name: str,
107
+ host: str,
108
+ public_key: str,
109
+ secret_key: str,
110
+ set_default: bool = False,
111
+ ) -> None:
112
+ data = self._load_raw()
113
+ clean_host = host.rstrip("/")
114
+ data["profiles"][name] = {
115
+ "host": clean_host,
116
+ "public_key": public_key,
117
+ "secret_key": secret_key,
118
+ }
119
+ if set_default or len(data["profiles"]) == 1:
120
+ data["current_profile"] = name
121
+ self._save_raw(data)
122
+
123
+ def resolve_profile(
124
+ self,
125
+ name: Optional[str] = None,
126
+ host_override: Optional[str] = None,
127
+ pk_override: Optional[str] = None,
128
+ sk_override: Optional[str] = None,
129
+ env_file: Optional[str] = None,
130
+ ) -> ProfileConfig:
131
+ """Take host and keys from one source, then apply explicit flag overrides.
132
+
133
+ Sources, first match wins: --env-file, --profile / LANGFUSE_PROFILE, the environment (when
134
+ LANGFUSE_PUBLIC_KEY or LANGFUSE_SECRET_KEY is set), the saved current profile. Sources are
135
+ never mixed, so a host found in one place (a cloned repo's .env, say) cannot receive keys that
136
+ came from another.
137
+ """
138
+ explicit = name or os.environ.get("LANGFUSE_PROFILE")
139
+ if env_file:
140
+ base = self._from_vars(self._parse_dotenv(env_file), "env-file")
141
+ elif explicit:
142
+ base = self.list_profiles().get(explicit)
143
+ if base is None:
144
+ raise ConfigError(f"profile '{explicit}' not found; see 'lf auth list'")
145
+ elif os.environ.get("LANGFUSE_PUBLIC_KEY") or os.environ.get("LANGFUSE_SECRET_KEY"):
146
+ base = self._from_vars(os.environ, "env")
147
+ else:
148
+ current = self.get_current_profile_name()
149
+ base = self.list_profiles().get(current) or ProfileConfig(current, "", "", "", "profile")
150
+
151
+ return ProfileConfig(
152
+ name=base.name,
153
+ host=(host_override or base.host or "https://cloud.langfuse.com").rstrip("/"),
154
+ public_key=pk_override or base.public_key,
155
+ secret_key=sk_override or base.secret_key,
156
+ source="flags" if pk_override and sk_override else base.source,
157
+ )
158
+
159
+ @staticmethod
160
+ def _from_vars(values: Any, source: str) -> ProfileConfig:
161
+ return ProfileConfig(
162
+ name="",
163
+ host=values.get("LANGFUSE_BASE_URL") or values.get("LANGFUSE_HOST") or "",
164
+ public_key=values.get("LANGFUSE_PUBLIC_KEY", ""),
165
+ secret_key=values.get("LANGFUSE_SECRET_KEY", ""),
166
+ source=source,
167
+ )
168
+
169
+ @staticmethod
170
+ def _parse_dotenv(path: str) -> Dict[str, str]:
171
+ try:
172
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
173
+ except OSError as e:
174
+ raise ConfigError(f"cannot read env file {path}: {e}") from e
175
+ res = {}
176
+ for line in lines:
177
+ line = line.strip()
178
+ if not line or line.startswith("#") or "=" not in line:
179
+ continue
180
+ k, v = line.split("=", 1)
181
+ res[k.strip()] = v.strip().strip("'\"")
182
+ return res
183
+
184
+
185
+ def resolve_from_args(args: argparse.Namespace) -> ProfileConfig:
186
+ """Credentials for one command, from its parsed common options."""
187
+ return ConfigManager().resolve_profile(args.profile, args.host, args.public_key, args.secret_key, args.env_file)
@@ -0,0 +1,15 @@
1
+ """Formatters for human terminal and agent-compact views."""
2
+ import re
3
+
4
+ _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f\ud800-\udfff]")
5
+
6
+
7
+ def clean(text: str) -> str:
8
+ """Escape terminal control characters (except newline and tab) and lone UTF-16 surrogates.
9
+
10
+ Trace names, inputs and error messages come from end users of the traced app; printed raw,
11
+ an escape sequence in them could rewrite the terminal. A lone surrogate (text cut inside an
12
+ emoji) cannot be encoded as UTF-8 at all.
13
+ """
14
+ # \uXXXX rather than \xXX: it is also a valid JSON escape, so JSON output stays parseable and lossless.
15
+ return _CONTROL_CHARS.sub(lambda m: f"\\u{ord(m.group()):04x}", text)
@@ -0,0 +1,95 @@
1
+ """Token-efficient output formatters for AI coding assistants."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from langfuse_cli.formatters import clean
11
+
12
+ SUMMARY_THRESHOLD_BYTES = 16 * 1024
13
+ SUMMARY_HINT = ("The JSON output is larger than 16 KB, so it was written to outputFile. Read only the parts you need, "
14
+ "for example with jq, or narrow the command with --agent-fields.")
15
+
16
+
17
+ def extract_field_path(data: Any, path: str) -> Any:
18
+ """Value at a dotted path like 'usageDetails.input'; a list on the way maps the rest over its items."""
19
+ if isinstance(data, list):
20
+ return [extract_field_path(item, path) for item in data]
21
+ if not isinstance(data, dict):
22
+ return None
23
+ head, _, rest = path.partition(".")
24
+ value = data.get(head)
25
+ return extract_field_path(value, rest) if rest else value
26
+
27
+
28
+ def filter_dict_fields(item: Dict[str, Any], fields: List[str]) -> Dict[str, Any]:
29
+ out: Dict[str, Any] = {}
30
+ for f in fields:
31
+ val = extract_field_path(item, f)
32
+ if val is not None:
33
+ out[f] = val
34
+ return out
35
+
36
+
37
+ def prune(data: Any) -> Any:
38
+ """Drop None, "", [] and {} values at every level; this is what --compact does."""
39
+ empty = (None, "", [], {})
40
+ if isinstance(data, dict):
41
+ pruned = {k: prune(v) for k, v in data.items()}
42
+ return {k: v for k, v in pruned.items() if v not in empty}
43
+ if isinstance(data, list):
44
+ return [v for v in map(prune, data) if v not in empty]
45
+ return data
46
+
47
+
48
+ def format_agent_output(
49
+ data: Any,
50
+ compact: bool = False,
51
+ agent_fields: Optional[str] = None,
52
+ summary: bool = False,
53
+ ) -> str:
54
+ """JSON for agents: optional field projection (applied to each list item), then optional pruning.
55
+
56
+ With `summary`, output over SUMMARY_THRESHOLD_BYTES goes to a file and a short envelope is returned instead.
57
+ """
58
+ if agent_fields:
59
+ fields = [f.strip() for f in agent_fields.split(",") if f.strip()]
60
+ if isinstance(data, list):
61
+ data = [filter_dict_fields(d, fields) for d in data if isinstance(d, dict)]
62
+ elif isinstance(data, dict):
63
+ data = filter_dict_fields(data, fields)
64
+ if compact:
65
+ # Prune inside each row but keep the rows: dropping emptied rows would print [] for real results.
66
+ data = [prune(row) for row in data] if isinstance(data, list) else prune(data)
67
+ text = json.dumps(data, indent=2, ensure_ascii=False)
68
+ if summary and len(text.encode("utf-8", "surrogatepass")) > SUMMARY_THRESHOLD_BYTES:
69
+ return json.dumps(_write_summary(data, text), indent=2)
70
+ return text
71
+
72
+
73
+ def _write_summary(data: Any, text: str) -> Dict[str, Any]:
74
+ """Write `text` to a new private file and describe it."""
75
+ content = (clean(text) + "\n").encode("utf-8") # the file gets the same escaping as the terminal
76
+ fd, path = tempfile.mkstemp(prefix="lf-", suffix=".json") # mode 0600; kept, the caller reads it after lf exits
77
+ try:
78
+ with os.fdopen(fd, "wb") as f:
79
+ f.write(content)
80
+ except BaseException:
81
+ os.unlink(path) # no half-written file left behind
82
+ raise
83
+ envelope: Dict[str, Any] = {"summary": True, "outputFile": path, "bytes": len(content)}
84
+ if isinstance(data, list):
85
+ envelope["rows"] = len(data)
86
+ envelope["rowKeys"] = list(dict.fromkeys(k for row in data if isinstance(row, dict) for k in row))
87
+ elif isinstance(data, dict):
88
+ envelope["keys"] = list(data)
89
+ envelope["hint"] = SUMMARY_HINT
90
+ return envelope
91
+
92
+
93
+ def print_json(args: argparse.Namespace, data: Any) -> None:
94
+ """Print a data command's JSON result, honouring --agent-fields, --compact and --output-summary."""
95
+ print(format_agent_output(data, compact=args.compact, agent_fields=args.agent_fields, summary=args.output_summary))