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.
- langfuse_agent_cli-0.3.0.dist-info/METADATA +235 -0
- langfuse_agent_cli-0.3.0.dist-info/RECORD +23 -0
- langfuse_agent_cli-0.3.0.dist-info/WHEEL +5 -0
- langfuse_agent_cli-0.3.0.dist-info/entry_points.txt +2 -0
- langfuse_agent_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- langfuse_agent_cli-0.3.0.dist-info/top_level.txt +1 -0
- langfuse_cli/__init__.py +2 -0
- langfuse_cli/__main__.py +7 -0
- langfuse_cli/cli.py +197 -0
- langfuse_cli/client.py +196 -0
- langfuse_cli/commands/__init__.py +1 -0
- langfuse_cli/commands/auth.py +93 -0
- langfuse_cli/commands/dataset.py +37 -0
- langfuse_cli/commands/doctor.py +90 -0
- langfuse_cli/commands/help_map.py +167 -0
- langfuse_cli/commands/prompt.py +123 -0
- langfuse_cli/commands/score.py +24 -0
- langfuse_cli/commands/setup.py +101 -0
- langfuse_cli/commands/trace.py +83 -0
- langfuse_cli/config.py +187 -0
- langfuse_cli/formatters/__init__.py +15 -0
- langfuse_cli/formatters/agent.py +95 -0
- langfuse_cli/formatters/timeline.py +50 -0
langfuse_cli/client.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""REST API client for Langfuse."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from requests.auth import HTTPBasicAuth
|
|
9
|
+
|
|
10
|
+
from langfuse_cli import __version__
|
|
11
|
+
from langfuse_cli.config import ProfileConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LangfuseAPIError(Exception):
|
|
15
|
+
def __init__(self, message: str, status_code: Optional[int] = None, response_body: Optional[str] = None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.response_body = response_body
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LangfuseClient:
|
|
22
|
+
"""Client for Langfuse Public REST API v2."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, profile: ProfileConfig, timeout: float = 15.0):
|
|
25
|
+
self.profile = profile
|
|
26
|
+
self.host = profile.host.rstrip("/")
|
|
27
|
+
self.timeout = timeout
|
|
28
|
+
self.session = requests.Session()
|
|
29
|
+
self.session.headers.update({
|
|
30
|
+
"User-Agent": f"langfuse-agent-cli/{__version__}",
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
"Accept": "application/json",
|
|
33
|
+
})
|
|
34
|
+
if profile.public_key and profile.secret_key:
|
|
35
|
+
self.session.auth = HTTPBasicAuth(profile.public_key, profile.secret_key)
|
|
36
|
+
|
|
37
|
+
def _request(
|
|
38
|
+
self,
|
|
39
|
+
method: str,
|
|
40
|
+
path: str,
|
|
41
|
+
params: Optional[Dict[str, Any]] = None,
|
|
42
|
+
data: Optional[Dict[str, Any]] = None,
|
|
43
|
+
) -> Dict[str, Any]:
|
|
44
|
+
url = f"{self.host}{path}"
|
|
45
|
+
try:
|
|
46
|
+
resp = self.session.request(
|
|
47
|
+
method=method,
|
|
48
|
+
url=url,
|
|
49
|
+
params=params,
|
|
50
|
+
json=data if data else None,
|
|
51
|
+
timeout=self.timeout,
|
|
52
|
+
allow_redirects=method == "GET", # requests would replay a redirected POST as a GET
|
|
53
|
+
)
|
|
54
|
+
except requests.exceptions.ConnectionError as e:
|
|
55
|
+
raise LangfuseAPIError(f"Connection failed to {self.host}: {e}") from e
|
|
56
|
+
except requests.exceptions.Timeout as e:
|
|
57
|
+
raise LangfuseAPIError(f"Request timed out connecting to {self.host} ({self.timeout}s)") from e
|
|
58
|
+
except requests.exceptions.RequestException as e: # e.g. a host without http(s)://
|
|
59
|
+
raise LangfuseAPIError(f"Request to {url} failed: {e}") from e
|
|
60
|
+
|
|
61
|
+
if resp.is_redirect: # only writes get here; a 3xx also counts as resp.ok
|
|
62
|
+
location = resp.headers.get("Location")
|
|
63
|
+
raise LangfuseAPIError(f"{method} {path} was redirected to {location}; set the host to the final URL",
|
|
64
|
+
status_code=resp.status_code)
|
|
65
|
+
if not resp.ok:
|
|
66
|
+
try:
|
|
67
|
+
message = resp.json().get("message")
|
|
68
|
+
except (ValueError, AttributeError):
|
|
69
|
+
message = None
|
|
70
|
+
if message:
|
|
71
|
+
error_msg = f"HTTP {resp.status_code}: {message}"
|
|
72
|
+
elif "html" in resp.headers.get("Content-Type", ""):
|
|
73
|
+
error_msg = f"HTTP {resp.status_code} on {path}" # framework error pages carry no detail
|
|
74
|
+
else:
|
|
75
|
+
error_msg = f"HTTP {resp.status_code} on {path}: {resp.text[:200]}"
|
|
76
|
+
raise LangfuseAPIError(error_msg, status_code=resp.status_code, response_body=resp.text)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
return resp.json() # every endpoint lf calls answers 2xx with a JSON body
|
|
80
|
+
except ValueError as e:
|
|
81
|
+
kind = resp.headers.get("Content-Type") or "no content type"
|
|
82
|
+
raise LangfuseAPIError(f"HTTP {resp.status_code} on {path} is not JSON ({kind}); is a login page or proxy "
|
|
83
|
+
"answering instead of Langfuse?", status_code=resp.status_code,
|
|
84
|
+
response_body=resp.text) from e
|
|
85
|
+
|
|
86
|
+
# 1. System Health
|
|
87
|
+
def health(self) -> Dict[str, Any]:
|
|
88
|
+
return self._request("GET", "/api/public/health")
|
|
89
|
+
|
|
90
|
+
def get_project(self) -> Dict[str, Any]:
|
|
91
|
+
"""Project the API key belongs to; a cheap check that the keys are valid."""
|
|
92
|
+
return self._request("GET", "/api/public/projects")
|
|
93
|
+
|
|
94
|
+
# 2. Prompts API
|
|
95
|
+
def list_prompts(
|
|
96
|
+
self,
|
|
97
|
+
page: int = 1,
|
|
98
|
+
limit: int = 50,
|
|
99
|
+
tag: Optional[str] = None,
|
|
100
|
+
name: Optional[str] = None,
|
|
101
|
+
) -> Dict[str, Any]:
|
|
102
|
+
params: Dict[str, Any] = {"page": page, "limit": limit}
|
|
103
|
+
if tag:
|
|
104
|
+
params["tag"] = tag
|
|
105
|
+
if name:
|
|
106
|
+
params["name"] = name
|
|
107
|
+
return self._request("GET", "/api/public/v2/prompts", params=params)
|
|
108
|
+
|
|
109
|
+
def get_prompt(
|
|
110
|
+
self,
|
|
111
|
+
name: str,
|
|
112
|
+
version: Optional[int] = None,
|
|
113
|
+
label: Optional[str] = None,
|
|
114
|
+
) -> Dict[str, Any]:
|
|
115
|
+
params: Dict[str, Any] = {}
|
|
116
|
+
if version is not None:
|
|
117
|
+
params["version"] = version
|
|
118
|
+
if label:
|
|
119
|
+
params["label"] = label
|
|
120
|
+
# Prompt names may contain "/" (folders); the API expects them encoded as one path segment.
|
|
121
|
+
return self._request("GET", f"/api/public/v2/prompts/{quote(name, safe='')}", params=params)
|
|
122
|
+
|
|
123
|
+
def create_prompt(
|
|
124
|
+
self,
|
|
125
|
+
name: str,
|
|
126
|
+
prompt_content: Any,
|
|
127
|
+
prompt_type: str = "text",
|
|
128
|
+
config: Optional[Dict[str, Any]] = None,
|
|
129
|
+
labels: Optional[List[str]] = None,
|
|
130
|
+
tags: Optional[List[str]] = None,
|
|
131
|
+
) -> Dict[str, Any]:
|
|
132
|
+
body: Dict[str, Any] = {
|
|
133
|
+
"name": name,
|
|
134
|
+
"type": prompt_type,
|
|
135
|
+
"prompt": prompt_content,
|
|
136
|
+
}
|
|
137
|
+
if config:
|
|
138
|
+
body["config"] = config
|
|
139
|
+
if labels:
|
|
140
|
+
body["labels"] = labels
|
|
141
|
+
if tags:
|
|
142
|
+
body["tags"] = tags
|
|
143
|
+
return self._request("POST", "/api/public/v2/prompts", data=body)
|
|
144
|
+
|
|
145
|
+
# 3. Observations API v2: the v4 read path for traces, sessions and errors
|
|
146
|
+
def list_observations(self, **params: Any) -> Dict[str, Any]:
|
|
147
|
+
query = {k: str(v).lower() if isinstance(v, bool) else v for k, v in params.items() if v is not None}
|
|
148
|
+
return self._request("GET", "/api/public/v2/observations", params=query)
|
|
149
|
+
|
|
150
|
+
def iter_observations(self, params: Dict[str, Any], max_items: Optional[int] = None) -> Iterator[Dict[str, Any]]:
|
|
151
|
+
"""Yield observations across cursor pages, stopping after max_items when given."""
|
|
152
|
+
remaining = max_items
|
|
153
|
+
cursor = None
|
|
154
|
+
while remaining is None or remaining > 0:
|
|
155
|
+
page = self.list_observations(
|
|
156
|
+
**params, limit=1000 if remaining is None else min(1000, remaining), cursor=cursor
|
|
157
|
+
)
|
|
158
|
+
data = page.get("data", [])
|
|
159
|
+
if remaining is not None:
|
|
160
|
+
data = data[:remaining]
|
|
161
|
+
remaining -= len(data)
|
|
162
|
+
yield from data
|
|
163
|
+
cursor = (page.get("meta") or {}).get("cursor")
|
|
164
|
+
if not cursor:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# 4. Datasets API
|
|
168
|
+
def list_datasets(self, page: int = 1, limit: int = 50) -> Dict[str, Any]:
|
|
169
|
+
return self._request("GET", "/api/public/v2/datasets", params={"page": page, "limit": limit})
|
|
170
|
+
|
|
171
|
+
def get_dataset(self, name: str) -> Dict[str, Any]:
|
|
172
|
+
return self._request("GET", f"/api/public/v2/datasets/{quote(name, safe='')}")
|
|
173
|
+
|
|
174
|
+
def list_dataset_items(self, dataset_name: str, page: int = 1, limit: int = 50) -> Dict[str, Any]:
|
|
175
|
+
params = {"datasetName": dataset_name, "page": page, "limit": limit}
|
|
176
|
+
return self._request("GET", "/api/public/dataset-items", params=params)
|
|
177
|
+
|
|
178
|
+
# 5. Scores API
|
|
179
|
+
def create_score(
|
|
180
|
+
self,
|
|
181
|
+
trace_id: str,
|
|
182
|
+
name: str,
|
|
183
|
+
value: float,
|
|
184
|
+
comment: Optional[str] = None,
|
|
185
|
+
observation_id: Optional[str] = None,
|
|
186
|
+
) -> Dict[str, Any]:
|
|
187
|
+
body: Dict[str, Any] = {
|
|
188
|
+
"traceId": trace_id,
|
|
189
|
+
"name": name,
|
|
190
|
+
"value": value,
|
|
191
|
+
}
|
|
192
|
+
if comment:
|
|
193
|
+
body["comment"] = comment
|
|
194
|
+
if observation_id:
|
|
195
|
+
body["observationId"] = observation_id
|
|
196
|
+
return self._request("POST", "/api/public/scores", data=body)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Commands package for Langfuse CLI."""
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Authentication and profile management commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import getpass
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from langfuse_cli.client import LangfuseAPIError, LangfuseClient
|
|
10
|
+
from langfuse_cli.config import ConfigManager, resolve_from_args
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def login(cfg: ConfigManager, profile_name: str, host: str, public_key: Optional[str], secret_key: Optional[str],
|
|
14
|
+
set_default: bool) -> int:
|
|
15
|
+
"""Prompt for missing keys, save the profile, then verify the keys against the server."""
|
|
16
|
+
if not public_key:
|
|
17
|
+
public_key = input("Enter Langfuse Public Key (pk-lf-...): ").strip()
|
|
18
|
+
if not secret_key:
|
|
19
|
+
secret_key = getpass.getpass("Enter Langfuse Secret Key (sk-lf-...): ").strip()
|
|
20
|
+
if not public_key or not secret_key: # an empty answer must never replace saved keys
|
|
21
|
+
print("✗ Both keys are needed; nothing was saved.", file=sys.stderr)
|
|
22
|
+
return 1
|
|
23
|
+
|
|
24
|
+
cfg.save_profile(
|
|
25
|
+
name=profile_name,
|
|
26
|
+
host=host,
|
|
27
|
+
public_key=public_key,
|
|
28
|
+
secret_key=secret_key,
|
|
29
|
+
set_default=set_default,
|
|
30
|
+
)
|
|
31
|
+
print(f"✓ Profile '{profile_name}' saved successfully ({host}).")
|
|
32
|
+
|
|
33
|
+
client = LangfuseClient(cfg.resolve_profile(name=profile_name))
|
|
34
|
+
try:
|
|
35
|
+
projects = client.get_project().get("data") or []
|
|
36
|
+
except LangfuseAPIError as e:
|
|
37
|
+
print(f"⚠ Profile saved, but the keys could not be verified: {e}", file=sys.stderr)
|
|
38
|
+
return 1
|
|
39
|
+
print(f"✓ Keys verified for project: {', '.join(p.get('name', '?') for p in projects) or '(none)'}")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def handle_auth(args: argparse.Namespace) -> int:
|
|
44
|
+
cfg = ConfigManager()
|
|
45
|
+
subaction = getattr(args, "auth_action", None)
|
|
46
|
+
|
|
47
|
+
if subaction == "login":
|
|
48
|
+
return login(cfg, args.profile_name or "default", args.host or "https://cloud.langfuse.com",
|
|
49
|
+
args.public_key, args.secret_key, args.default)
|
|
50
|
+
|
|
51
|
+
elif subaction == "switch":
|
|
52
|
+
target = args.target_profile
|
|
53
|
+
profiles = cfg.list_profiles()
|
|
54
|
+
if target not in profiles:
|
|
55
|
+
print(f"Error: Profile '{target}' not found. Available profiles: {list(profiles.keys())}", file=sys.stderr)
|
|
56
|
+
return 1
|
|
57
|
+
cfg.set_current_profile(target)
|
|
58
|
+
print(f"✓ Active profile switched to '{target}'.")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
elif subaction == "list":
|
|
62
|
+
current = cfg.get_current_profile_name()
|
|
63
|
+
profiles = cfg.list_profiles()
|
|
64
|
+
if not profiles:
|
|
65
|
+
print("No profiles configured yet. Run 'lf auth login' to add one.")
|
|
66
|
+
return 0
|
|
67
|
+
print("Configured Profiles:")
|
|
68
|
+
for name, p in profiles.items():
|
|
69
|
+
active_marker = "* " if name == current else " "
|
|
70
|
+
print(f"{active_marker}{name}:")
|
|
71
|
+
print(f" Host: {p.host}")
|
|
72
|
+
print(f" Public Key: {p.masked_public_key()}")
|
|
73
|
+
print(f" Secret Key: {p.masked_secret_key()}")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
# Default: status
|
|
77
|
+
prof = resolve_from_args(args)
|
|
78
|
+
print(f"Source: {prof.source}" + (f" ({prof.name})" if prof.source == "profile" else ""))
|
|
79
|
+
print(f"Host: {prof.host}")
|
|
80
|
+
print(f"Public Key: {prof.masked_public_key()}")
|
|
81
|
+
print(f"Secret Key: {prof.masked_secret_key()}")
|
|
82
|
+
|
|
83
|
+
if not prof.is_complete():
|
|
84
|
+
print("⚠ Incomplete credentials. Configure with 'lf auth login'.")
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
projects = LangfuseClient(prof).get_project().get("data") or []
|
|
89
|
+
except LangfuseAPIError as e:
|
|
90
|
+
print(f"✗ {e}")
|
|
91
|
+
return 1
|
|
92
|
+
print(f"✓ Keys valid for project: {', '.join(p.get('name', '?') for p in projects) or '(none)'}")
|
|
93
|
+
return 0
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Dataset commands for Langfuse."""
|
|
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_dataset(args: argparse.Namespace) -> int:
|
|
12
|
+
client = LangfuseClient(resolve_from_args(args))
|
|
13
|
+
|
|
14
|
+
if args.dataset_action == "list":
|
|
15
|
+
datasets = client.list_datasets().get("data", [])
|
|
16
|
+
if args.output == "json":
|
|
17
|
+
print_json(args, datasets)
|
|
18
|
+
elif not datasets:
|
|
19
|
+
print("No datasets found.")
|
|
20
|
+
else:
|
|
21
|
+
print(f"Datasets ({len(datasets)}):")
|
|
22
|
+
for d in datasets:
|
|
23
|
+
print(f" - {d.get('name')}: {d.get('description') or '(no description)'}")
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
# get: the v2 API serves a dataset and its items separately; show the first page of items
|
|
27
|
+
dataset = client.get_dataset(args.name)
|
|
28
|
+
dataset["items"] = client.list_dataset_items(args.name).get("data", [])
|
|
29
|
+
if args.output == "json":
|
|
30
|
+
print_json(args, dataset)
|
|
31
|
+
else:
|
|
32
|
+
print(f"Dataset: {dataset.get('name')}")
|
|
33
|
+
print(f"Description: {dataset.get('description')}")
|
|
34
|
+
print(f"Items: {len(dataset['items'])} (first page)")
|
|
35
|
+
for i, item in enumerate(dataset["items"][:5], 1):
|
|
36
|
+
print(f" Item {i}: input={item.get('input')} expected={item.get('expectedOutput')}")
|
|
37
|
+
return 0
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Diagnostic command for Langfuse CLI, auth, and skills."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from langfuse_cli import __version__
|
|
9
|
+
from langfuse_cli.client import LangfuseAPIError, LangfuseClient
|
|
10
|
+
from langfuse_cli.config import ConfigManager, resolve_from_args
|
|
11
|
+
|
|
12
|
+
SKILL_NAME = "langfuse-agent-cli"
|
|
13
|
+
# The skill as of this CLI's release tag, so an installed skill never documents flags the CLI lacks
|
|
14
|
+
SKILL_SOURCE = f"https://github.com/tns15june/langfuse-agent-cli/tree/v{__version__}/skills/{SKILL_NAME}"
|
|
15
|
+
KNOWN_SKILL_TARGETS = [
|
|
16
|
+
("Claude Code", Path.home() / ".claude" / "skills"),
|
|
17
|
+
("Antigravity", Path.home() / ".gemini" / "config" / "skills"),
|
|
18
|
+
("Universal Agents", Path.home() / ".agents" / "skills"),
|
|
19
|
+
("Cursor", Path.home() / ".cursor" / "skills"),
|
|
20
|
+
("OpenCode", Path.home() / ".config" / "opencode" / "skills"),
|
|
21
|
+
]
|
|
22
|
+
# `npx skills add` installs into the current project unless given -g
|
|
23
|
+
PROJECT_SKILL_DIRS = [Path(".claude") / "skills", Path(".agents") / "skills"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_server(client: LangfuseClient) -> bool:
|
|
27
|
+
try:
|
|
28
|
+
health = client.health()
|
|
29
|
+
print(f" ✓ Connectivity: OK (server version {health.get('version', 'unknown')})")
|
|
30
|
+
projects = client.get_project().get("data") or []
|
|
31
|
+
print(f" ✓ API Authorization: Valid (project: {', '.join(p.get('name', '?') for p in projects)})")
|
|
32
|
+
except LangfuseAPIError as e:
|
|
33
|
+
hint = {401: " (verify public/secret keys)", 403: " (check project access)"}.get(e.status_code or 0, "")
|
|
34
|
+
print(f" ✗ {e}{hint}")
|
|
35
|
+
return False
|
|
36
|
+
try:
|
|
37
|
+
client.list_observations(limit=1, fields="core")
|
|
38
|
+
print(" ✓ Observations API v2: available")
|
|
39
|
+
except LangfuseAPIError as e:
|
|
40
|
+
if e.status_code == 404:
|
|
41
|
+
print(" ✗ This server has no /api/public/v2/observations; trace commands need Langfuse v4")
|
|
42
|
+
else:
|
|
43
|
+
print(f" ✗ Observations API v2: {e}")
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def handle_doctor(args: argparse.Namespace) -> int:
|
|
49
|
+
cfg = ConfigManager()
|
|
50
|
+
prof = resolve_from_args(args)
|
|
51
|
+
|
|
52
|
+
print("\nLangfuse Agent CLI System Diagnostics")
|
|
53
|
+
print("=" * 45)
|
|
54
|
+
|
|
55
|
+
print("\n1. Build & Runtime")
|
|
56
|
+
print(f" CLI Version: {__version__}")
|
|
57
|
+
print(f" Python Version: {sys.version.split()[0]}")
|
|
58
|
+
print(f" Config File: {cfg.config_path} ({'exists' if cfg.config_path.exists() else 'not created yet'})")
|
|
59
|
+
|
|
60
|
+
print("\n2. Resolved Authentication")
|
|
61
|
+
print(f" Source: {prof.source}" + (f" ({prof.name})" if prof.source == "profile" else ""))
|
|
62
|
+
print(f" Host Endpoint: {prof.host}")
|
|
63
|
+
print(f" Public Key: {prof.masked_public_key()}")
|
|
64
|
+
print(f" Secret Key: {prof.masked_secret_key()}")
|
|
65
|
+
if not prof.is_complete():
|
|
66
|
+
print(" ✗ Incomplete credentials. Run 'lf auth login' or set LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY.")
|
|
67
|
+
auth_ok = False
|
|
68
|
+
else:
|
|
69
|
+
auth_ok = _check_server(LangfuseClient(prof))
|
|
70
|
+
|
|
71
|
+
print("\n3. Agent Skill")
|
|
72
|
+
skills_found = 0
|
|
73
|
+
project_targets = [(f"Project {d.parent}", Path.cwd() / d) for d in PROJECT_SKILL_DIRS]
|
|
74
|
+
for name, target_dir in KNOWN_SKILL_TARGETS + project_targets:
|
|
75
|
+
skill_path = target_dir / SKILL_NAME
|
|
76
|
+
if skill_path.exists():
|
|
77
|
+
skills_found += 1
|
|
78
|
+
print(f" ✓ {name:<18} Installed at {skill_path}")
|
|
79
|
+
else:
|
|
80
|
+
print(f" – {name:<18} Not found in {target_dir}")
|
|
81
|
+
|
|
82
|
+
print("\nSummary:")
|
|
83
|
+
if not auth_ok:
|
|
84
|
+
print(" ⚠ Fix the authentication or server problem above.")
|
|
85
|
+
return 1
|
|
86
|
+
if skills_found:
|
|
87
|
+
print(" ✓ All checks passed.")
|
|
88
|
+
else:
|
|
89
|
+
print(f" ℹ Skill not installed. Run 'lf setup', or: npx skills@1 add {SKILL_SOURCE} -g")
|
|
90
|
+
return 0
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Progressive disclosure and machine-readable command schemas for AI agents."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
# One entry per leaf command of the parser; tests/test_help.py fails on a missing or extra command
|
|
10
|
+
# and on a catalog option the parser does not have.
|
|
11
|
+
COMMAND_CATALOG: Dict[str, Dict[str, Any]] = {
|
|
12
|
+
"auth login": {
|
|
13
|
+
"desc": "Save a connection profile and verify its keys",
|
|
14
|
+
"write": True,
|
|
15
|
+
"opts": ["--profile-name <name>", "--host <url>", "--public-key <pk>", "--secret-key <sk>", "--default"],
|
|
16
|
+
"rules": [
|
|
17
|
+
"Prompts for missing keys (the secret is read hidden); ask the user to run it.",
|
|
18
|
+
"Stores ~/.config/langfuse/profiles.json with mode 0600.",
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
"auth switch": {
|
|
22
|
+
"desc": "Make a saved profile the current one",
|
|
23
|
+
"write": True,
|
|
24
|
+
"opts": ["<profile>"],
|
|
25
|
+
"rules": [],
|
|
26
|
+
},
|
|
27
|
+
"auth list": {
|
|
28
|
+
"desc": "List saved profiles with masked keys",
|
|
29
|
+
"write": False,
|
|
30
|
+
"opts": [],
|
|
31
|
+
"rules": ["Never prints full secret keys."],
|
|
32
|
+
},
|
|
33
|
+
"auth status": {
|
|
34
|
+
"desc": "Show where credentials come from and check the keys",
|
|
35
|
+
"write": False,
|
|
36
|
+
"opts": ["--profile <name>", "--env-file <path>"],
|
|
37
|
+
"rules": [],
|
|
38
|
+
},
|
|
39
|
+
"doctor": {
|
|
40
|
+
"desc": "Check credentials, server version, the v2 observations API and the agent skill",
|
|
41
|
+
"write": False,
|
|
42
|
+
"opts": ["--profile <name>", "--env-file <path>"],
|
|
43
|
+
"rules": ["Exit code 1 means an authentication or server problem."],
|
|
44
|
+
},
|
|
45
|
+
"setup": {
|
|
46
|
+
"desc": "Log in, install the agent skill and run doctor",
|
|
47
|
+
"write": True,
|
|
48
|
+
"opts": ["--yes", "--host <url>"],
|
|
49
|
+
"rules": [
|
|
50
|
+
"Asks for the host and keys when a saved profile is missing or rejected, and before installing the "
|
|
51
|
+
"skill, unless --yes is given or stdin is not a terminal; agents run it with --yes.",
|
|
52
|
+
"Installs the skill version that matches the CLI; run it again after upgrading.",
|
|
53
|
+
"Exit code 1 means an authentication or server problem, or that the skill was not installed.",
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
"prompt list": {
|
|
57
|
+
"desc": "List prompts with their versions, labels and tags",
|
|
58
|
+
"write": False,
|
|
59
|
+
"opts": ["--tag <tag>", "--limit <n>", "--output <json|text>", "--compact"],
|
|
60
|
+
"rules": [],
|
|
61
|
+
},
|
|
62
|
+
"prompt get": {
|
|
63
|
+
"desc": "Fetch a prompt, optionally filling its {{variables}}",
|
|
64
|
+
"write": False,
|
|
65
|
+
"opts": ["<name>", "--version <n>", "--label <label>", "--vars '<json>'", "--output <json|text>"],
|
|
66
|
+
"rules": [
|
|
67
|
+
"Without --version or --label the server returns the 'production' label.",
|
|
68
|
+
"Folder names like 'support/greeter' work as-is.",
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
"prompt push": {
|
|
72
|
+
"desc": "Create a new prompt version",
|
|
73
|
+
"write": True,
|
|
74
|
+
"opts": ["<name>", "--text '<prompt>'", "--file <path>", "--type <text|chat>", "--labels <a,b>",
|
|
75
|
+
"--tags <a,b>"],
|
|
76
|
+
"rules": ["Read the current version with 'prompt get' first and tell the user what will change."],
|
|
77
|
+
},
|
|
78
|
+
"trace list": {
|
|
79
|
+
"desc": "List traces newest first; --status error lists failing traces with their errors",
|
|
80
|
+
"write": False,
|
|
81
|
+
"opts": ["--status <all|error>", "--session-id <id>", "--user-id <id>", "--from-ts <iso>", "--to-ts <iso>",
|
|
82
|
+
"--limit <n>", "--compact", "--agent-fields <paths>", "--output-summary"],
|
|
83
|
+
"rules": ["--limit counts traces, also with --status error."],
|
|
84
|
+
},
|
|
85
|
+
"trace get": {
|
|
86
|
+
"desc": "All observations of one trace; --timeline renders them as a tree",
|
|
87
|
+
"write": False,
|
|
88
|
+
"opts": ["<trace_id>", "--timeline", "--io", "--compact", "--agent-fields <paths>", "--output-summary"],
|
|
89
|
+
"rules": [
|
|
90
|
+
"Inputs and outputs are left out unless --io is given; they are often large.",
|
|
91
|
+
"Metadata, prompt links and trace tags are not fetched.",
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
"trace session": {
|
|
95
|
+
"desc": "Traces of one session, oldest first (the newest 1000)",
|
|
96
|
+
"write": False,
|
|
97
|
+
"opts": ["<session_id>", "--compact", "--agent-fields <paths>", "--output-summary"],
|
|
98
|
+
"rules": [],
|
|
99
|
+
},
|
|
100
|
+
"dataset list": {
|
|
101
|
+
"desc": "List datasets",
|
|
102
|
+
"write": False,
|
|
103
|
+
"opts": ["--output <json|text>"],
|
|
104
|
+
"rules": [],
|
|
105
|
+
},
|
|
106
|
+
"dataset get": {
|
|
107
|
+
"desc": "A dataset with the first page of its items",
|
|
108
|
+
"write": False,
|
|
109
|
+
"opts": ["<name>", "--compact", "--output-summary"],
|
|
110
|
+
"rules": [],
|
|
111
|
+
},
|
|
112
|
+
"score create": {
|
|
113
|
+
"desc": "Record a numeric score on a trace or one of its observations",
|
|
114
|
+
"write": True,
|
|
115
|
+
"opts": ["--trace-id <id>", "--name <name>", "--value <number>", "--comment <text>", "--observation-id <id>"],
|
|
116
|
+
"rules": [],
|
|
117
|
+
},
|
|
118
|
+
"help-map": {
|
|
119
|
+
"desc": "This command map (* marks commands that write)",
|
|
120
|
+
"write": False,
|
|
121
|
+
"opts": [],
|
|
122
|
+
"rules": [],
|
|
123
|
+
},
|
|
124
|
+
"help": {
|
|
125
|
+
"desc": "Describe one command as JSON: options and rules",
|
|
126
|
+
"write": False,
|
|
127
|
+
"opts": ["<command...>"],
|
|
128
|
+
"rules": ["Give the exact command, e.g. lf help \"trace get\"."],
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def handle_help_map(args: argparse.Namespace) -> int:
|
|
134
|
+
print("Langfuse Agent CLI Command Map (* indicates state mutation):")
|
|
135
|
+
for cmd, info in sorted(COMMAND_CATALOG.items()):
|
|
136
|
+
write_flag = "* " if info["write"] else " "
|
|
137
|
+
print(f"{write_flag}lf {cmd:<18} — {info['desc']}")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def handle_help_describe(args: argparse.Namespace) -> int:
|
|
142
|
+
words = list(args.target_command)
|
|
143
|
+
if words[:1] == ["describe"]: # accept the `help describe "<cmd>"` spelling too
|
|
144
|
+
words = words[1:]
|
|
145
|
+
query = " ".join(words).strip()
|
|
146
|
+
info = COMMAND_CATALOG.get(query)
|
|
147
|
+
if info is None:
|
|
148
|
+
suggestions = [cmd for cmd in COMMAND_CATALOG if query and query in cmd]
|
|
149
|
+
hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
|
|
150
|
+
print(f"Unknown command: '{query}'.{hint} Run 'lf help-map' to view all commands.", file=sys.stderr)
|
|
151
|
+
return 1
|
|
152
|
+
|
|
153
|
+
payload = {"cmd": f"lf {query}", "desc": info["desc"], "write": info["write"], "opts": info["opts"],
|
|
154
|
+
"rules": info["rules"]}
|
|
155
|
+
if args.output == "json":
|
|
156
|
+
print(json.dumps(payload, indent=2))
|
|
157
|
+
else:
|
|
158
|
+
print(f"Command: lf {query}")
|
|
159
|
+
print(f"Description: {info['desc']} (Mutation: {info['write']})")
|
|
160
|
+
print("Options:")
|
|
161
|
+
for opt in info["opts"]:
|
|
162
|
+
print(f" {opt}")
|
|
163
|
+
if info["rules"]:
|
|
164
|
+
print("Rules:")
|
|
165
|
+
for r in info["rules"]:
|
|
166
|
+
print(f" • {r}")
|
|
167
|
+
return 0
|