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