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/__init__.py +20 -0
- agentcli/_agents.py +93 -0
- agentcli/_app.py +432 -0
- agentcli/_context.py +39 -0
- agentcli/_errors.py +140 -0
- agentcli/_help.py +65 -0
- agentcli/_output.py +282 -0
- agentcli/_parser.py +417 -0
- agentcli/_schema.py +142 -0
- agentcli/_types.py +142 -0
- agentcli/_wizard.py +83 -0
- agentcli/prompt.py +71 -0
- agentcli/py.typed +0 -0
- agentcli/testing.py +55 -0
- humancli-0.1.0.dist-info/METADATA +178 -0
- humancli-0.1.0.dist-info/RECORD +18 -0
- humancli-0.1.0.dist-info/WHEEL +4 -0
- humancli-0.1.0.dist-info/licenses/LICENSE +21 -0
agentcli/_parser.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Mapping, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
from ._errors import AgentCliError, ParseError, ValidationError, suggest_matches
|
|
9
|
+
from ._types import BuiltinFlags, CommandSchema, MISSING, ParameterSpec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ParsedCommand:
|
|
14
|
+
values: dict[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_builtins(argv: list[str]) -> tuple[BuiltinFlags, list[str]]:
|
|
18
|
+
flags = BuiltinFlags()
|
|
19
|
+
remaining: list[str] = []
|
|
20
|
+
index = 0
|
|
21
|
+
while index < len(argv):
|
|
22
|
+
token = argv[index]
|
|
23
|
+
if token == "--format":
|
|
24
|
+
if index + 1 >= len(argv):
|
|
25
|
+
raise ParseError("Missing value for --format", code="MISSING_OPTION")
|
|
26
|
+
flags = BuiltinFlags(
|
|
27
|
+
format=argv[index + 1],
|
|
28
|
+
verbose=flags.verbose,
|
|
29
|
+
help=flags.help,
|
|
30
|
+
version=flags.version,
|
|
31
|
+
llms=flags.llms,
|
|
32
|
+
llms_full=flags.llms_full,
|
|
33
|
+
mcp=flags.mcp,
|
|
34
|
+
wizard=flags.wizard,
|
|
35
|
+
)
|
|
36
|
+
index += 2
|
|
37
|
+
continue
|
|
38
|
+
if token.startswith("--format="):
|
|
39
|
+
flags = BuiltinFlags(
|
|
40
|
+
format=token.split("=", 1)[1],
|
|
41
|
+
verbose=flags.verbose,
|
|
42
|
+
help=flags.help,
|
|
43
|
+
version=flags.version,
|
|
44
|
+
llms=flags.llms,
|
|
45
|
+
llms_full=flags.llms_full,
|
|
46
|
+
mcp=flags.mcp,
|
|
47
|
+
wizard=flags.wizard,
|
|
48
|
+
)
|
|
49
|
+
index += 1
|
|
50
|
+
continue
|
|
51
|
+
if token == "--json":
|
|
52
|
+
flags = BuiltinFlags(
|
|
53
|
+
format="json",
|
|
54
|
+
verbose=flags.verbose,
|
|
55
|
+
help=flags.help,
|
|
56
|
+
version=flags.version,
|
|
57
|
+
llms=flags.llms,
|
|
58
|
+
llms_full=flags.llms_full,
|
|
59
|
+
mcp=flags.mcp,
|
|
60
|
+
wizard=flags.wizard,
|
|
61
|
+
)
|
|
62
|
+
index += 1
|
|
63
|
+
continue
|
|
64
|
+
if token == "--verbose":
|
|
65
|
+
flags = BuiltinFlags(
|
|
66
|
+
format=flags.format,
|
|
67
|
+
verbose=True,
|
|
68
|
+
help=flags.help,
|
|
69
|
+
version=flags.version,
|
|
70
|
+
llms=flags.llms,
|
|
71
|
+
llms_full=flags.llms_full,
|
|
72
|
+
mcp=flags.mcp,
|
|
73
|
+
wizard=flags.wizard,
|
|
74
|
+
)
|
|
75
|
+
index += 1
|
|
76
|
+
continue
|
|
77
|
+
if token in {"--help", "-h"}:
|
|
78
|
+
flags = BuiltinFlags(
|
|
79
|
+
format=flags.format,
|
|
80
|
+
verbose=flags.verbose,
|
|
81
|
+
help=True,
|
|
82
|
+
version=flags.version,
|
|
83
|
+
llms=flags.llms,
|
|
84
|
+
llms_full=flags.llms_full,
|
|
85
|
+
mcp=flags.mcp,
|
|
86
|
+
wizard=flags.wizard,
|
|
87
|
+
)
|
|
88
|
+
index += 1
|
|
89
|
+
continue
|
|
90
|
+
if token == "--version":
|
|
91
|
+
flags = BuiltinFlags(
|
|
92
|
+
format=flags.format,
|
|
93
|
+
verbose=flags.verbose,
|
|
94
|
+
help=flags.help,
|
|
95
|
+
version=True,
|
|
96
|
+
llms=flags.llms,
|
|
97
|
+
llms_full=flags.llms_full,
|
|
98
|
+
mcp=flags.mcp,
|
|
99
|
+
wizard=flags.wizard,
|
|
100
|
+
)
|
|
101
|
+
index += 1
|
|
102
|
+
continue
|
|
103
|
+
if token == "--llms":
|
|
104
|
+
flags = BuiltinFlags(
|
|
105
|
+
format=flags.format,
|
|
106
|
+
verbose=flags.verbose,
|
|
107
|
+
help=flags.help,
|
|
108
|
+
version=flags.version,
|
|
109
|
+
llms=True,
|
|
110
|
+
llms_full=flags.llms_full,
|
|
111
|
+
mcp=flags.mcp,
|
|
112
|
+
wizard=flags.wizard,
|
|
113
|
+
)
|
|
114
|
+
index += 1
|
|
115
|
+
continue
|
|
116
|
+
if token == "--llms-full":
|
|
117
|
+
flags = BuiltinFlags(
|
|
118
|
+
format=flags.format,
|
|
119
|
+
verbose=flags.verbose,
|
|
120
|
+
help=flags.help,
|
|
121
|
+
version=flags.version,
|
|
122
|
+
llms=flags.llms,
|
|
123
|
+
llms_full=True,
|
|
124
|
+
mcp=flags.mcp,
|
|
125
|
+
wizard=flags.wizard,
|
|
126
|
+
)
|
|
127
|
+
index += 1
|
|
128
|
+
continue
|
|
129
|
+
if token == "--mcp":
|
|
130
|
+
flags = BuiltinFlags(
|
|
131
|
+
format=flags.format,
|
|
132
|
+
verbose=flags.verbose,
|
|
133
|
+
help=flags.help,
|
|
134
|
+
version=flags.version,
|
|
135
|
+
llms=flags.llms,
|
|
136
|
+
llms_full=flags.llms_full,
|
|
137
|
+
mcp=True,
|
|
138
|
+
wizard=flags.wizard,
|
|
139
|
+
)
|
|
140
|
+
index += 1
|
|
141
|
+
continue
|
|
142
|
+
if token == "--wizard":
|
|
143
|
+
flags = BuiltinFlags(
|
|
144
|
+
format=flags.format,
|
|
145
|
+
verbose=flags.verbose,
|
|
146
|
+
help=flags.help,
|
|
147
|
+
version=flags.version,
|
|
148
|
+
llms=flags.llms,
|
|
149
|
+
llms_full=flags.llms_full,
|
|
150
|
+
mcp=flags.mcp,
|
|
151
|
+
wizard=True,
|
|
152
|
+
)
|
|
153
|
+
index += 1
|
|
154
|
+
continue
|
|
155
|
+
remaining.append(token)
|
|
156
|
+
index += 1
|
|
157
|
+
return flags, remaining
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_command(
|
|
161
|
+
schema: CommandSchema,
|
|
162
|
+
argv: list[str],
|
|
163
|
+
*,
|
|
164
|
+
env: Mapping[str, str] | None = None,
|
|
165
|
+
is_tty: bool,
|
|
166
|
+
resolve_prompt_value: Callable[..., Any] | None = None,
|
|
167
|
+
) -> ParsedCommand:
|
|
168
|
+
env_map = env or {}
|
|
169
|
+
option_by_name = {spec.cli_name: spec for spec in schema.options}
|
|
170
|
+
option_by_alias = {spec.alias: spec for spec in schema.options if spec.alias}
|
|
171
|
+
raw_values: dict[str, Any] = {}
|
|
172
|
+
positionals: list[str] = []
|
|
173
|
+
index = 0
|
|
174
|
+
literal_mode = False
|
|
175
|
+
|
|
176
|
+
while index < len(argv):
|
|
177
|
+
token = argv[index]
|
|
178
|
+
if literal_mode:
|
|
179
|
+
positionals.append(token)
|
|
180
|
+
index += 1
|
|
181
|
+
continue
|
|
182
|
+
if token == "--":
|
|
183
|
+
literal_mode = True
|
|
184
|
+
index += 1
|
|
185
|
+
continue
|
|
186
|
+
if token.startswith("--"):
|
|
187
|
+
name, has_inline, inline_value = token[2:].partition("=")
|
|
188
|
+
negate = False
|
|
189
|
+
if name.startswith("no-"):
|
|
190
|
+
name = name[3:]
|
|
191
|
+
negate = True
|
|
192
|
+
spec = option_by_name.get(name)
|
|
193
|
+
if spec is None:
|
|
194
|
+
suggestions = suggest_matches(name, option_by_name)
|
|
195
|
+
hint = (
|
|
196
|
+
f" Did you mean: {', '.join(f'--{item}' for item in suggestions)}?"
|
|
197
|
+
if suggestions
|
|
198
|
+
else ""
|
|
199
|
+
)
|
|
200
|
+
raise ParseError(
|
|
201
|
+
f'Unknown option "--{name}".{hint}', code="UNKNOWN_OPTION"
|
|
202
|
+
)
|
|
203
|
+
if negate:
|
|
204
|
+
if not spec.is_bool:
|
|
205
|
+
raise ParseError(
|
|
206
|
+
f'Option "--{name}" does not support --no- form',
|
|
207
|
+
code="UNKNOWN_OPTION",
|
|
208
|
+
)
|
|
209
|
+
raw_values[spec.name] = False
|
|
210
|
+
index += 1
|
|
211
|
+
continue
|
|
212
|
+
if spec.is_bool and not has_inline:
|
|
213
|
+
raw_values[spec.name] = True
|
|
214
|
+
index += 1
|
|
215
|
+
continue
|
|
216
|
+
value = (
|
|
217
|
+
inline_value if has_inline else _next_value(argv, index, f"--{name}")
|
|
218
|
+
)
|
|
219
|
+
_store_value(raw_values, spec, coerce_value(spec, value))
|
|
220
|
+
index += 1 if has_inline else 2
|
|
221
|
+
continue
|
|
222
|
+
if token.startswith("-") and token != "-":
|
|
223
|
+
short = token[1:]
|
|
224
|
+
if "=" in short:
|
|
225
|
+
alias, value = short.split("=", 1)
|
|
226
|
+
spec = option_by_alias.get(alias)
|
|
227
|
+
if spec is None:
|
|
228
|
+
raise ParseError(
|
|
229
|
+
f'Unknown option "-{alias}"', code="UNKNOWN_OPTION"
|
|
230
|
+
)
|
|
231
|
+
_store_value(raw_values, spec, coerce_value(spec, value))
|
|
232
|
+
index += 1
|
|
233
|
+
continue
|
|
234
|
+
if len(short) > 1 and all(
|
|
235
|
+
option_by_alias.get(char) and option_by_alias[char].is_bool
|
|
236
|
+
for char in short
|
|
237
|
+
):
|
|
238
|
+
for char in short:
|
|
239
|
+
raw_values[option_by_alias[char].name] = True
|
|
240
|
+
index += 1
|
|
241
|
+
continue
|
|
242
|
+
spec = option_by_alias.get(short)
|
|
243
|
+
if spec is None:
|
|
244
|
+
raise ParseError(f'Unknown option "-{short}"', code="UNKNOWN_OPTION")
|
|
245
|
+
if spec.is_bool:
|
|
246
|
+
raw_values[spec.name] = True
|
|
247
|
+
index += 1
|
|
248
|
+
continue
|
|
249
|
+
value = _next_value(argv, index, f"-{short}")
|
|
250
|
+
_store_value(raw_values, spec, coerce_value(spec, value))
|
|
251
|
+
index += 2
|
|
252
|
+
continue
|
|
253
|
+
positionals.append(token)
|
|
254
|
+
index += 1
|
|
255
|
+
|
|
256
|
+
if len(positionals) > len(schema.arguments):
|
|
257
|
+
raise ParseError("Too many positional arguments", code="TOO_MANY_ARGS")
|
|
258
|
+
|
|
259
|
+
values: dict[str, Any] = {}
|
|
260
|
+
for spec, raw in zip(schema.arguments, positionals):
|
|
261
|
+
values[spec.name] = coerce_value(spec, raw)
|
|
262
|
+
for spec in schema.arguments[len(positionals) :]:
|
|
263
|
+
values[spec.name] = resolve_missing(
|
|
264
|
+
spec,
|
|
265
|
+
env_map,
|
|
266
|
+
is_tty=is_tty,
|
|
267
|
+
resolve_prompt_value=resolve_prompt_value,
|
|
268
|
+
)
|
|
269
|
+
for spec in schema.options:
|
|
270
|
+
if spec.name in raw_values:
|
|
271
|
+
values[spec.name] = raw_values[spec.name]
|
|
272
|
+
continue
|
|
273
|
+
values[spec.name] = resolve_missing(
|
|
274
|
+
spec,
|
|
275
|
+
env_map,
|
|
276
|
+
is_tty=is_tty,
|
|
277
|
+
resolve_prompt_value=resolve_prompt_value,
|
|
278
|
+
)
|
|
279
|
+
return ParsedCommand(values=values)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def resolve_missing(
|
|
283
|
+
spec: ParameterSpec,
|
|
284
|
+
env: Mapping[str, str],
|
|
285
|
+
*,
|
|
286
|
+
is_tty: bool,
|
|
287
|
+
resolve_prompt_value: Callable[..., Any] | None,
|
|
288
|
+
) -> Any:
|
|
289
|
+
if spec.env and spec.env in env:
|
|
290
|
+
return coerce_value(spec, env[spec.env])
|
|
291
|
+
value = spec.default
|
|
292
|
+
if resolve_prompt_value is not None:
|
|
293
|
+
value = resolve_prompt_value(
|
|
294
|
+
spec,
|
|
295
|
+
current_value=value,
|
|
296
|
+
is_tty=is_tty,
|
|
297
|
+
)
|
|
298
|
+
if value is not MISSING:
|
|
299
|
+
return value
|
|
300
|
+
code = "MISSING_OPTION" if spec.kind == "option" else "MISSING_ARG"
|
|
301
|
+
subject = "option" if spec.kind == "option" else "argument"
|
|
302
|
+
label = f"--{spec.cli_name}" if spec.kind == "option" else spec.name
|
|
303
|
+
raise ParseError(f'Missing required {subject} "{label}"', code=code)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def coerce_value(spec: ParameterSpec, value: Any) -> Any:
|
|
307
|
+
annotation = spec.annotation
|
|
308
|
+
if spec.is_list:
|
|
309
|
+
inner = get_args(annotation)
|
|
310
|
+
inner_type = inner[0] if inner else str
|
|
311
|
+
raw_values = value if isinstance(value, list) else [value]
|
|
312
|
+
return [_coerce_with_type(item, inner_type, spec.name) for item in raw_values]
|
|
313
|
+
return _coerce_with_type(value, annotation, spec.name, choices=spec.choices)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _coerce_with_type(
|
|
317
|
+
value: Any,
|
|
318
|
+
annotation: Any,
|
|
319
|
+
field_name: str,
|
|
320
|
+
*,
|
|
321
|
+
choices: tuple[Any, ...] = (),
|
|
322
|
+
) -> Any:
|
|
323
|
+
if choices:
|
|
324
|
+
mapping = {str(item).lower(): item for item in choices}
|
|
325
|
+
lowered = str(value).lower()
|
|
326
|
+
if lowered in mapping:
|
|
327
|
+
return mapping[lowered]
|
|
328
|
+
joined = ", ".join(str(item) for item in choices)
|
|
329
|
+
raise ValidationError(
|
|
330
|
+
message=f'Invalid value "{value}" for {field_name}. Expected one of: {joined}'
|
|
331
|
+
)
|
|
332
|
+
if annotation in (Any, str):
|
|
333
|
+
return value
|
|
334
|
+
if annotation is Path:
|
|
335
|
+
return Path(value)
|
|
336
|
+
if annotation is int:
|
|
337
|
+
try:
|
|
338
|
+
return int(str(value), 0)
|
|
339
|
+
except ValueError as exc:
|
|
340
|
+
raise ValidationError(
|
|
341
|
+
message=f"Invalid integer for {field_name}: {value}"
|
|
342
|
+
) from exc
|
|
343
|
+
if annotation is float:
|
|
344
|
+
try:
|
|
345
|
+
return float(str(value))
|
|
346
|
+
except ValueError as exc:
|
|
347
|
+
raise ValidationError(
|
|
348
|
+
message=f"Invalid float for {field_name}: {value}"
|
|
349
|
+
) from exc
|
|
350
|
+
if annotation is bool:
|
|
351
|
+
if isinstance(value, bool):
|
|
352
|
+
return value
|
|
353
|
+
lowered = str(value).strip().lower()
|
|
354
|
+
if lowered in {"1", "true", "yes", "y", "on"}:
|
|
355
|
+
return True
|
|
356
|
+
if lowered in {"0", "false", "no", "n", "off"}:
|
|
357
|
+
return False
|
|
358
|
+
raise ValidationError(message=f"Invalid boolean for {field_name}: {value}")
|
|
359
|
+
origin = get_origin(annotation)
|
|
360
|
+
if origin and "Union" in str(origin):
|
|
361
|
+
for branch in get_args(annotation):
|
|
362
|
+
if branch is type(None):
|
|
363
|
+
continue
|
|
364
|
+
try:
|
|
365
|
+
return _coerce_with_type(value, branch, field_name)
|
|
366
|
+
except AgentCliError:
|
|
367
|
+
continue
|
|
368
|
+
raise ValidationError(message=f'Invalid value "{value}" for {field_name}')
|
|
369
|
+
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
|
370
|
+
lowered = str(value).lower()
|
|
371
|
+
for member in annotation:
|
|
372
|
+
if member.name.lower() == lowered:
|
|
373
|
+
return member
|
|
374
|
+
raise ValidationError(message=f'Invalid value "{value}" for {field_name}')
|
|
375
|
+
return value
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _store_value(target: dict[str, Any], spec: ParameterSpec, value: Any) -> None:
|
|
379
|
+
if spec.is_list:
|
|
380
|
+
target.setdefault(spec.name, []).extend(
|
|
381
|
+
value if isinstance(value, list) else [value]
|
|
382
|
+
)
|
|
383
|
+
return
|
|
384
|
+
target[spec.name] = value
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _next_value(argv: list[str], index: int, label: str) -> str:
|
|
388
|
+
if index + 1 >= len(argv):
|
|
389
|
+
raise ParseError(f"Missing value for {label}", code="MISSING_OPTION")
|
|
390
|
+
return argv[index + 1]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def strip_builtin_flags(argv: list[str]) -> tuple[BuiltinFlags, list[str]]:
|
|
394
|
+
return extract_builtins(argv)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def normalize_format(explicit: str | None, *, tty: bool) -> str:
|
|
398
|
+
if explicit:
|
|
399
|
+
return explicit
|
|
400
|
+
return "human" if tty else "toon"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def parse_values(
|
|
404
|
+
argv: list[str],
|
|
405
|
+
schema: CommandSchema,
|
|
406
|
+
*,
|
|
407
|
+
env: Mapping[str, str] | None = None,
|
|
408
|
+
tty: bool,
|
|
409
|
+
prompt_value: Callable[..., Any] | None,
|
|
410
|
+
) -> dict[str, Any]:
|
|
411
|
+
return parse_command(
|
|
412
|
+
schema,
|
|
413
|
+
argv,
|
|
414
|
+
env=env,
|
|
415
|
+
is_tty=tty,
|
|
416
|
+
resolve_prompt_value=prompt_value,
|
|
417
|
+
).values
|
agentcli/_schema.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Annotated, Any, get_args, get_origin, get_type_hints
|
|
5
|
+
|
|
6
|
+
import docstring_parser
|
|
7
|
+
|
|
8
|
+
from ._context import Context
|
|
9
|
+
from ._errors import ConfigError
|
|
10
|
+
from ._types import CommandSchema, MISSING, Param, ParameterSpec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def snake_to_kebab(value: str) -> str:
|
|
14
|
+
return value.rstrip("_").replace("_", "-")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def command_name_for(value: str) -> str:
|
|
18
|
+
return snake_to_kebab(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_command_schema(
|
|
22
|
+
fn: Any,
|
|
23
|
+
*,
|
|
24
|
+
name: str | None = None,
|
|
25
|
+
output: Any = None,
|
|
26
|
+
path: tuple[str, ...] = (),
|
|
27
|
+
) -> CommandSchema:
|
|
28
|
+
signature = inspect.signature(fn)
|
|
29
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
30
|
+
descriptions = _docstring_param_descriptions(fn)
|
|
31
|
+
arguments: list[ParameterSpec] = []
|
|
32
|
+
options: list[ParameterSpec] = []
|
|
33
|
+
positional_names: list[str] = []
|
|
34
|
+
keyword_names: list[str] = []
|
|
35
|
+
context_name: str | None = None
|
|
36
|
+
|
|
37
|
+
for parameter in signature.parameters.values():
|
|
38
|
+
if parameter.kind in (
|
|
39
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
40
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
41
|
+
):
|
|
42
|
+
raise ConfigError(f"Unsupported parameter kind for {parameter.name}")
|
|
43
|
+
annotation = hints.get(parameter.name, parameter.annotation)
|
|
44
|
+
annotation, metadata = unwrap_annotation(annotation)
|
|
45
|
+
if annotation is Context:
|
|
46
|
+
context_name = parameter.name
|
|
47
|
+
if parameter.kind is inspect.Parameter.KEYWORD_ONLY:
|
|
48
|
+
keyword_names.append(parameter.name)
|
|
49
|
+
else:
|
|
50
|
+
positional_names.append(parameter.name)
|
|
51
|
+
continue
|
|
52
|
+
default = (
|
|
53
|
+
parameter.default if parameter.default is not inspect._empty else MISSING
|
|
54
|
+
)
|
|
55
|
+
help_text = (
|
|
56
|
+
metadata.help
|
|
57
|
+
if metadata and metadata.help
|
|
58
|
+
else descriptions.get(parameter.name)
|
|
59
|
+
)
|
|
60
|
+
spec = ParameterSpec(
|
|
61
|
+
name=parameter.name,
|
|
62
|
+
kind="option"
|
|
63
|
+
if parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
|
64
|
+
else "argument",
|
|
65
|
+
annotation=annotation,
|
|
66
|
+
cli_name=snake_to_kebab(parameter.name),
|
|
67
|
+
default=default,
|
|
68
|
+
required=default is MISSING,
|
|
69
|
+
help=help_text,
|
|
70
|
+
alias=metadata.alias if metadata else None,
|
|
71
|
+
env=metadata.env if metadata else None,
|
|
72
|
+
prompt=metadata.prompt if metadata else None,
|
|
73
|
+
secret=metadata.secret if metadata else False,
|
|
74
|
+
deprecated=metadata.deprecated if metadata else False,
|
|
75
|
+
hidden=metadata.hidden if metadata else False,
|
|
76
|
+
choices=literal_choices(annotation),
|
|
77
|
+
is_list=is_list(annotation),
|
|
78
|
+
is_bool=annotation is bool,
|
|
79
|
+
)
|
|
80
|
+
if spec.kind == "argument":
|
|
81
|
+
arguments.append(spec)
|
|
82
|
+
positional_names.append(parameter.name)
|
|
83
|
+
else:
|
|
84
|
+
options.append(spec)
|
|
85
|
+
keyword_names.append(parameter.name)
|
|
86
|
+
|
|
87
|
+
description = inspect.getdoc(fn)
|
|
88
|
+
short_description = description.splitlines()[0] if description else None
|
|
89
|
+
return CommandSchema(
|
|
90
|
+
name=snake_to_kebab(name or fn.__name__),
|
|
91
|
+
full_path=path + (snake_to_kebab(name or fn.__name__),),
|
|
92
|
+
description=short_description,
|
|
93
|
+
handler=fn,
|
|
94
|
+
positionals=tuple(arguments),
|
|
95
|
+
options=tuple(options),
|
|
96
|
+
positional_names=tuple(positional_names),
|
|
97
|
+
keyword_names=tuple(keyword_names),
|
|
98
|
+
context_name=context_name,
|
|
99
|
+
output=output,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_command_schema(
|
|
104
|
+
fn: Any,
|
|
105
|
+
*,
|
|
106
|
+
name: str | None = None,
|
|
107
|
+
full_path: tuple[str, ...] = (),
|
|
108
|
+
output_type: Any = None,
|
|
109
|
+
) -> CommandSchema:
|
|
110
|
+
path = full_path[:-1] if full_path else ()
|
|
111
|
+
return extract_command_schema(fn, name=name, output=output_type, path=path)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def unwrap_annotation(annotation: Any) -> tuple[Any, Param | None]:
|
|
115
|
+
if get_origin(annotation) is Annotated:
|
|
116
|
+
base, *metadata = get_args(annotation)
|
|
117
|
+
param = next((item for item in metadata if isinstance(item, Param)), None)
|
|
118
|
+
return base, param
|
|
119
|
+
return annotation, None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def literal_choices(annotation: Any) -> tuple[Any, ...]:
|
|
123
|
+
origin = get_origin(annotation)
|
|
124
|
+
if origin is None or "Literal" not in str(origin):
|
|
125
|
+
return ()
|
|
126
|
+
return tuple(get_args(annotation))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_list(annotation: Any) -> bool:
|
|
130
|
+
return get_origin(annotation) in (list, tuple)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _docstring_param_descriptions(fn: Any) -> dict[str, str]:
|
|
134
|
+
docstring = inspect.getdoc(fn)
|
|
135
|
+
if not docstring:
|
|
136
|
+
return {}
|
|
137
|
+
parsed = docstring_parser.parse(docstring)
|
|
138
|
+
return {
|
|
139
|
+
item.arg_name: item.description
|
|
140
|
+
for item in parsed.params
|
|
141
|
+
if item.arg_name and item.description
|
|
142
|
+
}
|
agentcli/_types.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Callable, Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _Missing:
|
|
8
|
+
def __bool__(self) -> bool:
|
|
9
|
+
return False
|
|
10
|
+
|
|
11
|
+
def __repr__(self) -> str:
|
|
12
|
+
return "MISSING"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
MISSING = _Missing()
|
|
16
|
+
|
|
17
|
+
OutputFormat = Literal["human", "toon", "json", "yaml", "md", "jsonl"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Param:
|
|
22
|
+
help: str | None = None
|
|
23
|
+
alias: str | None = None
|
|
24
|
+
env: str | None = None
|
|
25
|
+
prompt: str | None = None
|
|
26
|
+
secret: bool = False
|
|
27
|
+
deprecated: bool = False
|
|
28
|
+
hidden: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Result:
|
|
33
|
+
data: Any
|
|
34
|
+
cta: list[str] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ParameterSpec:
|
|
39
|
+
name: str
|
|
40
|
+
kind: str
|
|
41
|
+
annotation: Any
|
|
42
|
+
cli_name: str
|
|
43
|
+
default: Any = MISSING
|
|
44
|
+
required: bool = True
|
|
45
|
+
help: str | None = None
|
|
46
|
+
alias: str | None = None
|
|
47
|
+
env: str | None = None
|
|
48
|
+
prompt: str | None = None
|
|
49
|
+
secret: bool = False
|
|
50
|
+
deprecated: bool = False
|
|
51
|
+
hidden: bool = False
|
|
52
|
+
choices: tuple[Any, ...] = ()
|
|
53
|
+
is_list: bool = False
|
|
54
|
+
is_bool: bool = False
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def description(self) -> str | None:
|
|
58
|
+
return self.help
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def has_default(self) -> bool:
|
|
62
|
+
return self.default is not MISSING
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def display_name(self) -> str:
|
|
66
|
+
return self.name if self.kind == "argument" else f"--{self.cli_name}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class CommandSchema:
|
|
71
|
+
name: str
|
|
72
|
+
full_path: tuple[str, ...]
|
|
73
|
+
description: str | None
|
|
74
|
+
handler: Callable[..., Any]
|
|
75
|
+
positionals: tuple[ParameterSpec, ...]
|
|
76
|
+
options: tuple[ParameterSpec, ...]
|
|
77
|
+
positional_names: tuple[str, ...]
|
|
78
|
+
keyword_names: tuple[str, ...]
|
|
79
|
+
context_name: str | None = None
|
|
80
|
+
output: Any = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def path(self) -> tuple[str, ...]:
|
|
84
|
+
return self.full_path
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def arguments(self) -> tuple[ParameterSpec, ...]:
|
|
88
|
+
return self.positionals
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def context_parameter(self) -> str | None:
|
|
92
|
+
return self.context_name
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def callback(self) -> Callable[..., Any]:
|
|
96
|
+
return self.handler
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def command(self) -> str:
|
|
100
|
+
return " ".join(self.full_path)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def all_parameters(self) -> list[ParameterSpec]:
|
|
104
|
+
return [*self.positionals, *self.options]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class BuiltinFlags:
|
|
109
|
+
format: str | None = None
|
|
110
|
+
verbose: bool = False
|
|
111
|
+
help: bool = False
|
|
112
|
+
version: bool = False
|
|
113
|
+
llms: bool = False
|
|
114
|
+
llms_full: bool = False
|
|
115
|
+
mcp: bool = False
|
|
116
|
+
wizard: bool = False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class InvocationResult:
|
|
121
|
+
exit_code: int
|
|
122
|
+
output: str = ""
|
|
123
|
+
envelope: Any | None = None
|
|
124
|
+
data: Any = None
|
|
125
|
+
error: Any = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class ExecutionResult:
|
|
130
|
+
exit_code: int
|
|
131
|
+
envelope: Any
|
|
132
|
+
output: str
|
|
133
|
+
format: OutputFormat
|
|
134
|
+
streamed: list[Any] = field(default_factory=list)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def data(self) -> Any:
|
|
138
|
+
return None if self.envelope is None else self.envelope.data
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def error(self) -> Any:
|
|
142
|
+
return None if self.envelope is None else self.envelope.error
|