humancli 0.1.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.
agentcli/_errors.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Iterable
5
+
6
+ if TYPE_CHECKING:
7
+ from ._output import ErrorInfo
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class FieldError:
12
+ field: str
13
+ message: str
14
+
15
+
16
+ class AgentCliError(Exception):
17
+ def __init__(
18
+ self,
19
+ *,
20
+ code: str,
21
+ message: str,
22
+ retryable: bool = False,
23
+ cta: list[str] | None = None,
24
+ exit_code: int = 1,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.code = code
28
+ self.message = message
29
+ self.retryable = retryable
30
+ self.cta = cta
31
+ self.exit_code = exit_code
32
+
33
+ def to_error_info(self) -> ErrorInfo:
34
+ from ._output import ErrorInfo
35
+
36
+ return ErrorInfo(
37
+ code=self.code,
38
+ message=self.message,
39
+ retryable=self.retryable,
40
+ cta=self.cta,
41
+ )
42
+
43
+
44
+ class ParseError(AgentCliError):
45
+ def __init__(
46
+ self,
47
+ message: str,
48
+ *,
49
+ code: str = "PARSE",
50
+ retryable: bool = True,
51
+ cta: list[str] | None = None,
52
+ ) -> None:
53
+ super().__init__(
54
+ code=code,
55
+ message=message,
56
+ retryable=retryable,
57
+ cta=cta,
58
+ exit_code=1,
59
+ )
60
+
61
+
62
+ class ValidationError(AgentCliError):
63
+ def __init__(
64
+ self,
65
+ *,
66
+ message: str,
67
+ field_errors: list[FieldError] | None = None,
68
+ cta: list[str] | None = None,
69
+ ) -> None:
70
+ super().__init__(
71
+ code="VALIDATION",
72
+ message=message,
73
+ retryable=True,
74
+ cta=cta,
75
+ exit_code=1,
76
+ )
77
+ self.field_errors = field_errors or []
78
+
79
+
80
+ class ConfigError(AgentCliError):
81
+ def __init__(self, message: str) -> None:
82
+ super().__init__(
83
+ code="CONFIG",
84
+ message=message,
85
+ retryable=False,
86
+ exit_code=1,
87
+ )
88
+
89
+
90
+ def levenshtein(left: str, right: str) -> int:
91
+ if left == right:
92
+ return 0
93
+ if not left:
94
+ return len(right)
95
+ if not right:
96
+ return len(left)
97
+ previous = list(range(len(right) + 1))
98
+ for i, left_char in enumerate(left, start=1):
99
+ current = [i]
100
+ for j, right_char in enumerate(right, start=1):
101
+ current.append(
102
+ min(
103
+ current[j - 1] + 1,
104
+ previous[j] + 1,
105
+ previous[j - 1] + (left_char != right_char),
106
+ )
107
+ )
108
+ previous = current
109
+ return previous[-1]
110
+
111
+
112
+ def suggest_matches(
113
+ value: str,
114
+ choices: Iterable[str],
115
+ *,
116
+ threshold: int = 2,
117
+ limit: int = 3,
118
+ ) -> list[str]:
119
+ scored: list[tuple[int, str]] = []
120
+ lowered = value.lower()
121
+ for choice in choices:
122
+ distance = levenshtein(lowered, choice.lower())
123
+ if distance <= threshold:
124
+ scored.append((distance, choice))
125
+ scored.sort(key=lambda item: (item[0], item[1]))
126
+ return [choice for _, choice in scored[:limit]]
127
+
128
+
129
+ def unknown_name_error(*, kind: str, value: str, choices: Iterable[str]) -> ParseError:
130
+ suggestions = suggest_matches(value, choices)
131
+ hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
132
+ return ParseError(
133
+ f'Unknown {kind} "{value}".{hint}', code=f"UNKNOWN_{kind.upper()}"
134
+ )
135
+
136
+
137
+ def normalize_exception(error: Exception) -> AgentCliError:
138
+ if isinstance(error, AgentCliError):
139
+ return error
140
+ return AgentCliError(code="UNKNOWN", message=str(error) or error.__class__.__name__)
agentcli/_help.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from ._types import CommandSchema, ParameterSpec
4
+
5
+
6
+ def render_app_help(app, *, path: tuple[str, ...] = ()) -> str:
7
+ title = " ".join(path or (app.name,))
8
+ lines = [title]
9
+ if app.description:
10
+ lines.extend(["", app.description])
11
+ lines.extend(["", f"Usage: {title} <command>"])
12
+ if app.subapps:
13
+ lines.extend(["", "Groups:"])
14
+ for name, subapp in sorted(app.subapps.items()):
15
+ lines.append(f" {name:<16} {(subapp.description or '')}".rstrip())
16
+ if app.commands:
17
+ lines.extend(["", "Commands:"])
18
+ for name, command in sorted(app.commands.items()):
19
+ lines.append(f" {name:<16} {(command.description or '')}".rstrip())
20
+ return "\n".join(lines)
21
+
22
+
23
+ def render_command_help(command: CommandSchema) -> str:
24
+ path = " ".join(command.path)
25
+ lines = [path]
26
+ if command.description:
27
+ lines.extend(["", command.description])
28
+ lines.extend(["", f"Usage: {path}{_usage(command)}"])
29
+ if command.positionals:
30
+ lines.extend(["", "Arguments:"])
31
+ lines.extend(_parameters(command.positionals))
32
+ visible_options = [
33
+ parameter for parameter in command.options if not parameter.hidden
34
+ ]
35
+ if visible_options:
36
+ lines.extend(["", "Options:"])
37
+ lines.extend(_parameters(visible_options))
38
+ return "\n".join(lines)
39
+
40
+
41
+ def _usage(command: CommandSchema) -> str:
42
+ parts = []
43
+ for parameter in command.positionals:
44
+ parts.append(
45
+ f"<{parameter.name}>" if parameter.required else f"[{parameter.name}]"
46
+ )
47
+ for parameter in command.options:
48
+ if parameter.hidden:
49
+ continue
50
+ parts.append(
51
+ f"[--{parameter.cli_name}]"
52
+ if parameter.is_bool
53
+ else f"[--{parameter.cli_name} VALUE]"
54
+ )
55
+ return (" " + " ".join(parts)) if parts else ""
56
+
57
+
58
+ def _parameters(parameters: list[ParameterSpec]) -> list[str]:
59
+ lines = []
60
+ for parameter in parameters:
61
+ label = parameter.display_name
62
+ if parameter.alias:
63
+ label = f"{label}, -{parameter.alias}"
64
+ lines.append(f" {label:<24} {(parameter.description or '')}".rstrip())
65
+ return lines
agentcli/_output.py ADDED
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from ._errors import AgentCliError
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ErrorInfo:
13
+ code: str
14
+ message: str
15
+ retryable: bool = False
16
+ cta: list[str] | None = None
17
+
18
+
19
+ @dataclass
20
+ class Meta:
21
+ command: str
22
+ cta: list[str] | None = None
23
+ duration_ms: float | None = None
24
+ streamed: bool = False
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Envelope:
29
+ ok: bool
30
+ data: Any | None = None
31
+ error: ErrorInfo | None = None
32
+ meta: Meta | None = None
33
+
34
+
35
+ def make_success_envelope(
36
+ data: Any,
37
+ *,
38
+ command: str,
39
+ cta: list[str] | None = None,
40
+ ) -> Envelope:
41
+ return Envelope(ok=True, data=data, meta=Meta(command=command, cta=cta))
42
+
43
+
44
+ def make_error_envelope(error: Exception, *, command: str) -> Envelope:
45
+ if isinstance(error, AgentCliError):
46
+ info = ErrorInfo(
47
+ code=error.code,
48
+ message=error.message,
49
+ retryable=error.retryable,
50
+ cta=error.cta,
51
+ )
52
+ else:
53
+ info = ErrorInfo(code="UNKNOWN", message=str(error), retryable=False)
54
+ return Envelope(ok=False, error=info, meta=Meta(command=command, cta=info.cta))
55
+
56
+
57
+ def make_envelope(
58
+ *,
59
+ ok: bool,
60
+ command: str,
61
+ format: str,
62
+ data: Any = None,
63
+ error: ErrorInfo | None = None,
64
+ duration_ms: float | None = None,
65
+ cta: list[str] | None = None,
66
+ streamed: bool = False,
67
+ ) -> Envelope:
68
+ if ok:
69
+ return Envelope(
70
+ ok=True,
71
+ data=data,
72
+ meta=Meta(
73
+ command=command, cta=cta, duration_ms=duration_ms, streamed=streamed
74
+ ),
75
+ )
76
+ return Envelope(
77
+ ok=False,
78
+ error=error,
79
+ meta=Meta(command=command, cta=cta, duration_ms=duration_ms, streamed=streamed),
80
+ )
81
+
82
+
83
+ def normalize_value(value: Any) -> Any:
84
+ if dataclasses.is_dataclass(value):
85
+ return normalize_value(dataclasses.asdict(value))
86
+ if hasattr(value, "to_dict") and callable(value.to_dict):
87
+ return normalize_value(value.to_dict())
88
+ if hasattr(value, "model_dump") and callable(value.model_dump):
89
+ return normalize_value(value.model_dump())
90
+ if isinstance(value, dict):
91
+ return {
92
+ str(key): normalize_value(item)
93
+ for key, item in value.items()
94
+ if item is not None
95
+ }
96
+ if isinstance(value, (list, tuple)):
97
+ return [normalize_value(item) for item in value]
98
+ return value
99
+
100
+
101
+ def envelope_payload(envelope: Envelope, *, verbose: bool) -> Any:
102
+ if envelope.ok and not verbose:
103
+ return normalize_value(envelope.data)
104
+ return normalize_value(dataclasses.asdict(envelope))
105
+
106
+
107
+ def render_output(
108
+ envelope: Envelope,
109
+ *,
110
+ format_name: str | None,
111
+ is_tty: bool,
112
+ verbose: bool = False,
113
+ ) -> str:
114
+ actual_format = format_name or ("pretty" if is_tty else "toon")
115
+ payload = envelope_payload(envelope, verbose=verbose)
116
+ if actual_format == "json":
117
+ return json.dumps(payload, indent=2, default=str)
118
+ if actual_format == "jsonl":
119
+ return json.dumps(payload, default=str)
120
+ if actual_format == "md":
121
+ return render_markdown(payload)
122
+ if actual_format == "yaml":
123
+ return render_yaml(payload)
124
+ if actual_format == "pretty":
125
+ return render_plain(payload)
126
+ return render_toon(payload)
127
+
128
+
129
+ def render_stream_item(
130
+ item: Any,
131
+ *,
132
+ format_name: str | None = None,
133
+ output_format: str | None = None,
134
+ is_tty: bool | None = None,
135
+ ) -> str:
136
+ format_name = output_format or format_name
137
+ payload = normalize_value(item)
138
+ if format_name == "jsonl":
139
+ return json.dumps(payload, default=str)
140
+ if format_name == "json":
141
+ return json.dumps(payload, indent=2, default=str)
142
+ if format_name == "md":
143
+ return render_markdown(payload)
144
+ return render_toon(payload)
145
+
146
+
147
+ def render_envelope(envelope: Envelope, *, format: str, verbose: bool = False) -> str:
148
+ return render_output(
149
+ envelope, format_name=format, is_tty=(format == "human"), verbose=verbose
150
+ )
151
+
152
+
153
+ def render_plain(value: Any) -> str:
154
+ value = normalize_value(value)
155
+ if isinstance(value, dict):
156
+ return "\n".join(_plain_lines(value))
157
+ if isinstance(value, list):
158
+ return "\n".join(f"- {item}" for item in value)
159
+ return str(value)
160
+
161
+
162
+ def _plain_lines(value: dict[str, Any], indent: int = 0) -> list[str]:
163
+ lines: list[str] = []
164
+ prefix = " " * indent
165
+ for key, item in value.items():
166
+ if isinstance(item, dict):
167
+ lines.append(f"{prefix}{key}:")
168
+ lines.extend(_plain_lines(item, indent + 2))
169
+ continue
170
+ if isinstance(item, list):
171
+ lines.append(f"{prefix}{key}:")
172
+ lines.extend(f"{prefix} - {entry}" for entry in item)
173
+ continue
174
+ lines.append(f"{prefix}{key}: {item}")
175
+ return lines
176
+
177
+
178
+ def render_toon(value: Any) -> str:
179
+ value = normalize_value(value)
180
+ if isinstance(value, dict):
181
+ return "\n".join(_toon_dict(value))
182
+ if isinstance(value, list):
183
+ return _toon_list("items", value)
184
+ return _toon_scalar(value)
185
+
186
+
187
+ def _toon_dict(value: dict[str, Any], indent: int = 0) -> list[str]:
188
+ lines: list[str] = []
189
+ prefix = " " * indent
190
+ for key, item in value.items():
191
+ if item is None:
192
+ continue
193
+ if isinstance(item, dict):
194
+ if not item:
195
+ lines.append(f"{prefix}{key}: (empty)")
196
+ continue
197
+ lines.append(f"{prefix}{key}:")
198
+ lines.extend(_toon_dict(item, indent + 2))
199
+ continue
200
+ if isinstance(item, list):
201
+ lines.extend(_toon_list_lines(key, item, indent))
202
+ continue
203
+ lines.append(f"{prefix}{key}: {_toon_scalar(item)}")
204
+ return lines
205
+
206
+
207
+ def _toon_list(key: str, value: list[Any]) -> str:
208
+ return "\n".join(_toon_list_lines(key, value, 0))
209
+
210
+
211
+ def _toon_list_lines(key: str, value: list[Any], indent: int) -> list[str]:
212
+ prefix = " " * indent
213
+ if not value:
214
+ return [f"{prefix}{key}: (empty)"]
215
+ if all(not isinstance(item, (dict, list)) for item in value):
216
+ joined = ",".join(_toon_scalar(item) for item in value)
217
+ return [f"{prefix}{key}[{len(value)}]: {joined}"]
218
+ if all(isinstance(item, dict) and item for item in value):
219
+ headers = list(value[0].keys())
220
+ if all(list(item.keys()) == headers for item in value):
221
+ lines = [f"{prefix}{key}[{len(value)}]{{{','.join(headers)}}}:"]
222
+ for item in value:
223
+ row = ",".join(_toon_scalar(item[column]) for column in headers)
224
+ lines.append(f"{prefix} {row}")
225
+ return lines
226
+ lines = [f"{prefix}{key}:"]
227
+ for item in value:
228
+ lines.append(f"{prefix} - {_toon_scalar(item)}")
229
+ return lines
230
+
231
+
232
+ def _toon_scalar(value: Any) -> str:
233
+ if value is True:
234
+ return "true"
235
+ if value is False:
236
+ return "false"
237
+ if value is None:
238
+ return "(empty)"
239
+ if isinstance(value, (int, float)):
240
+ return str(value)
241
+ text = str(value)
242
+ if not text:
243
+ return '""'
244
+ if any(char in text for char in [":", ",", "\n", '"']):
245
+ return json.dumps(text)
246
+ return text
247
+
248
+
249
+ def render_markdown(value: Any) -> str:
250
+ value = normalize_value(value)
251
+ if (
252
+ isinstance(value, list)
253
+ and value
254
+ and all(isinstance(item, dict) for item in value)
255
+ ):
256
+ headers = list(value[0].keys())
257
+ lines = [
258
+ f"| {' | '.join(headers)} |",
259
+ f"| {' | '.join('---' for _ in headers)} |",
260
+ ]
261
+ for item in value:
262
+ lines.append(
263
+ f"| {' | '.join(str(item.get(header, '')) for header in headers)} |"
264
+ )
265
+ return "\n".join(lines)
266
+ if isinstance(value, dict):
267
+ return "\n".join(f"- **{key}**: {item}" for key, item in value.items())
268
+ if isinstance(value, list):
269
+ return "\n".join(f"- {item}" for item in value)
270
+ return str(value)
271
+
272
+
273
+ def render_yaml(value: Any) -> str:
274
+ try:
275
+ import yaml
276
+ except ImportError as exc:
277
+ raise AgentCliError(
278
+ code="MISSING_DEP",
279
+ message="YAML output requires agentcli[yaml]",
280
+ retryable=False,
281
+ ) from exc
282
+ return yaml.safe_dump(normalize_value(value), sort_keys=False)