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_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