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 ADDED
@@ -0,0 +1,20 @@
1
+ """agentcli: Python CLIs for agents and humans."""
2
+
3
+ from ._app import App, run
4
+ from ._context import Context
5
+ from ._errors import AgentCliError, ConfigError, ParseError, ValidationError
6
+ from ._types import Param, Result
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "AgentCliError",
12
+ "App",
13
+ "ConfigError",
14
+ "Context",
15
+ "Param",
16
+ "ParseError",
17
+ "Result",
18
+ "ValidationError",
19
+ "run",
20
+ ]
agentcli/_agents.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from ._errors import AgentCliError
7
+
8
+
9
+ def iter_commands(app, prefix: tuple[str, ...] | None = None):
10
+ current = prefix or (app.name,)
11
+ if app._default_command is not None:
12
+ command = app._default_command
13
+ command.full_path = current
14
+ yield current, command
15
+ for name, command in sorted(app.commands.items()):
16
+ path = (*current, name)
17
+ command.full_path = path
18
+ yield path, command
19
+ for name, subapp in sorted(app.subapps.items()):
20
+ yield from iter_commands(subapp, (*current, name))
21
+
22
+
23
+ def render_llms_index(app) -> str:
24
+ lines = [f"# {app.name}" + (f" v{app.version}" if app.version else "")]
25
+ if app.description:
26
+ lines.extend(["", app.description])
27
+ lines.extend(["", "| Command | Description |", "|---------|-------------|"])
28
+ for path, command in iter_commands(app):
29
+ lines.append(f"| `{' '.join(path)}` | {command.description or ''} |")
30
+ lines.extend(["", f"Run `{app.name} <command> --help` for details."])
31
+ return "\n".join(lines)
32
+
33
+
34
+ def render_llms_full(app) -> str:
35
+ commands = []
36
+ for path, command in iter_commands(app):
37
+ commands.append(
38
+ {
39
+ "command": " ".join(path),
40
+ "description": command.description,
41
+ "arguments": [parameter.name for parameter in command.positionals],
42
+ "options": [
43
+ parameter.cli_name
44
+ for parameter in command.options
45
+ if not parameter.hidden
46
+ ],
47
+ }
48
+ )
49
+ return json.dumps(
50
+ {
51
+ "name": app.name,
52
+ "version": app.version,
53
+ "description": app.description,
54
+ "commands": commands,
55
+ },
56
+ indent=2,
57
+ )
58
+
59
+
60
+ def start_mcp(app) -> None:
61
+ try:
62
+ from mcp.server.fastmcp import FastMCP
63
+ except ImportError as error:
64
+ raise AgentCliError(
65
+ "MISSING_DEPENDENCY",
66
+ "MCP mode requires agentcli[mcp]",
67
+ cta=["pip install agentcli[mcp]"],
68
+ ) from error
69
+
70
+ server = FastMCP(app.name)
71
+ for path, command in iter_commands(app):
72
+ tool_name = "_".join(path[1:] if path and path[0] == app.name else path)
73
+
74
+ @server.tool(name=tool_name, description=command.description or tool_name)
75
+ async def _tool(**kwargs: Any) -> str:
76
+ argv = list(path[1:] if path and path[0] == app.name else path)
77
+ for key, value in kwargs.items():
78
+ flag = f"--{key.replace('_', '-')}"
79
+ if value is True:
80
+ argv.append(flag)
81
+ elif value not in (False, None):
82
+ argv.extend([flag, str(value)])
83
+ result = app.run(argv, tty=False)
84
+ return json.dumps(
85
+ {
86
+ "ok": result.envelope.ok,
87
+ "data": result.envelope.data,
88
+ "error": result.envelope.error,
89
+ },
90
+ default=str,
91
+ )
92
+
93
+ server.run()
agentcli/_app.py ADDED
@@ -0,0 +1,432 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import os
6
+ import sys
7
+ import time
8
+ from collections.abc import Callable, Iterable
9
+ from contextlib import AbstractContextManager
10
+ from typing import Any
11
+
12
+ from ._agents import iter_commands, render_llms_full, render_llms_index, start_mcp
13
+ from ._context import Context, detect_agent_mode
14
+ from ._errors import normalize_exception, unknown_name_error
15
+ from ._help import render_app_help, render_command_help
16
+ from ._output import make_envelope, render_envelope, render_stream_item
17
+ from ._parser import normalize_format, parse_values, strip_builtin_flags
18
+ from ._schema import build_command_schema, command_name_for
19
+ from ._types import CommandSchema, ExecutionResult, MISSING, OutputFormat, Result
20
+ from ._wizard import prompt_for_parameter, wizard_schema
21
+
22
+
23
+ Middleware = Callable[[Context, Callable[[], Any]], Any]
24
+
25
+
26
+ class App:
27
+ def __init__(
28
+ self,
29
+ name: str,
30
+ *,
31
+ version: str | None = None,
32
+ description: str | None = None,
33
+ help_header: str | None = None,
34
+ help_footer: str | None = None,
35
+ autocorrect_threshold: int = 2,
36
+ ) -> None:
37
+ self.name = name
38
+ self.version = version
39
+ self.description = description
40
+ self.help_header = help_header
41
+ self.help_footer = help_footer
42
+ self.autocorrect_threshold = autocorrect_threshold
43
+ self.commands: dict[str, CommandSchema] = {}
44
+ self.subapps: dict[str, App] = {}
45
+ self.middleware: list[Middleware] = []
46
+ self._default_command: CommandSchema | None = None
47
+
48
+ def command(
49
+ self,
50
+ func: Callable[..., Any] | None = None,
51
+ *,
52
+ name: str | None = None,
53
+ output: type[Any] | None = None,
54
+ ):
55
+ def decorator(callback: Callable[..., Any]) -> Callable[..., Any]:
56
+ command_name = name or command_name_for(callback.__name__)
57
+ self.commands[command_name] = build_command_schema(
58
+ callback,
59
+ name=command_name,
60
+ full_path=(self.name, command_name),
61
+ output_type=output,
62
+ )
63
+ return callback
64
+
65
+ return decorator if func is None else decorator(func)
66
+
67
+ def use(self, middleware: Middleware) -> Middleware:
68
+ self.middleware.append(middleware)
69
+ return middleware
70
+
71
+ def mount(self, sub_app: App, name: str | None = None) -> None:
72
+ self.subapps[name or sub_app.name] = sub_app
73
+
74
+ def default(
75
+ self, func: Callable[..., Any] | None = None, *, output: type[Any] | None = None
76
+ ):
77
+ """Register the default command — runs when no sub-command is given.
78
+
79
+ Can be used as a bare decorator (``@app.default``) or with options
80
+ (``@app.default(output=MyType)``).
81
+ """
82
+
83
+ def decorator(callback: Callable[..., Any]) -> Callable[..., Any]:
84
+ self._default_command = build_command_schema(
85
+ callback, name=self.name, full_path=(self.name,), output_type=output
86
+ )
87
+ return callback
88
+
89
+ return decorator if func is None else decorator(func)
90
+
91
+ def _set_default(
92
+ self, callback: Callable[..., Any], *, output: type[Any] | None = None
93
+ ) -> None:
94
+ self.default(callback, output=output)
95
+
96
+ def invoke(
97
+ self,
98
+ *,
99
+ argv: Iterable[str] | None = None,
100
+ env: dict[str, str] | None = None,
101
+ is_tty: bool | None = None,
102
+ stdin: Any = None,
103
+ stdout: Any = None,
104
+ stderr: Any = None,
105
+ ) -> ExecutionResult:
106
+ return self.run(
107
+ argv, env=env, tty=is_tty, stdin=stdin, stdout=stdout, stderr=stderr
108
+ )
109
+
110
+ def run(
111
+ self,
112
+ argv: Iterable[str] | None = None,
113
+ *,
114
+ env: dict[str, str] | None = None,
115
+ tty: bool | None = None,
116
+ stdin: Any = None,
117
+ stdout: Any = None,
118
+ stderr: Any = None,
119
+ ) -> ExecutionResult:
120
+ args = list(sys.argv[1:] if argv is None else argv)
121
+ env_map = {**os.environ, **(env or {})}
122
+ stdin = stdin or sys.stdin
123
+ stdout = stdout or sys.stdout
124
+ stderr = stderr or sys.stderr
125
+ is_tty = bool(tty) if tty is not None else _isatty(stdout)
126
+ builtins, remaining = strip_builtin_flags(args)
127
+ output_format = normalize_format(builtins.format, tty=is_tty)
128
+ started = time.perf_counter()
129
+ try:
130
+ app, command, remainder, middlewares = self._resolve(remaining)
131
+ if builtins.version:
132
+ text = self.version or ""
133
+ _write(stdout, text)
134
+ return _finish(
135
+ make_envelope(
136
+ ok=True, command=self.name, format=output_format, data=text
137
+ ),
138
+ stdout,
139
+ output_format,
140
+ )
141
+ if builtins.llms:
142
+ text = render_llms_index(self)
143
+ _write(stdout, text)
144
+ return _finish(
145
+ make_envelope(
146
+ ok=True, command=self.name, format=output_format, data=text
147
+ ),
148
+ stdout,
149
+ output_format,
150
+ )
151
+ if builtins.llms_full:
152
+ text = render_llms_full(self)
153
+ _write(stdout, text)
154
+ return _finish(
155
+ make_envelope(
156
+ ok=True, command=self.name, format=output_format, data=text
157
+ ),
158
+ stdout,
159
+ output_format,
160
+ )
161
+ if builtins.mcp:
162
+ start_mcp(self)
163
+ return _finish(
164
+ make_envelope(
165
+ ok=True, command=self.name, format=output_format, data=None
166
+ ),
167
+ stdout,
168
+ output_format,
169
+ )
170
+ if builtins.help or command is None:
171
+ text = (
172
+ render_command_help(command)
173
+ if command
174
+ else render_app_help(app, path=_app_path(self, app))
175
+ )
176
+ _write(stdout, text)
177
+ return _finish(
178
+ make_envelope(
179
+ ok=True,
180
+ command=" ".join(_app_path(self, app)),
181
+ format=output_format,
182
+ data=text,
183
+ ),
184
+ stdout,
185
+ output_format,
186
+ )
187
+ if builtins.wizard and not is_tty:
188
+ envelope = make_envelope(
189
+ ok=True,
190
+ command=command.command,
191
+ format=output_format,
192
+ data=wizard_schema(command),
193
+ )
194
+ _write(
195
+ stdout,
196
+ render_envelope(envelope, format=output_format, verbose=True),
197
+ )
198
+ return _finish(envelope, stdout, output_format)
199
+ values = parse_values(
200
+ remainder,
201
+ command,
202
+ env=env_map,
203
+ tty=is_tty,
204
+ prompt_value=lambda spec, current_value=None, is_tty=False, **_kwargs: (
205
+ prompt_for_parameter(spec, stdin=stdin, stdout=stdout)
206
+ if spec.prompt and is_tty
207
+ else current_value
208
+ ),
209
+ )
210
+ if builtins.wizard and is_tty:
211
+ for parameter in command.all_parameters:
212
+ if values.get(parameter.name, MISSING) is MISSING:
213
+ values[parameter.name] = prompt_for_parameter(
214
+ parameter, stdin=stdin, stdout=stdout
215
+ )
216
+ context = Context(
217
+ self.name,
218
+ command.path,
219
+ output_format,
220
+ detect_agent_mode(stdin=stdin, stdout=stdout, env=env_map),
221
+ args,
222
+ env_map,
223
+ {},
224
+ {},
225
+ {},
226
+ )
227
+ if command.context_parameter:
228
+ values[command.context_parameter] = context
229
+ envelope, streamed = asyncio.run(
230
+ self._execute(
231
+ command,
232
+ values,
233
+ context=context,
234
+ middlewares=middlewares,
235
+ stdout=stdout,
236
+ format=output_format,
237
+ )
238
+ )
239
+ if envelope.meta:
240
+ envelope.meta.duration_ms = (time.perf_counter() - started) * 1000
241
+ rendered = render_envelope(
242
+ envelope,
243
+ format=output_format,
244
+ verbose=builtins.verbose or not envelope.ok,
245
+ )
246
+ if not streamed or builtins.verbose:
247
+ _write(stdout, rendered)
248
+ return _finish(envelope, stdout, output_format, streamed)
249
+ except Exception as error: # noqa: BLE001
250
+ agent_error = normalize_exception(error)
251
+ envelope = make_envelope(
252
+ ok=False,
253
+ command=self.name,
254
+ format=output_format,
255
+ error=agent_error.to_error_info(),
256
+ duration_ms=(time.perf_counter() - started) * 1000,
257
+ )
258
+ target = stderr if stderr is not None else stdout
259
+ _write(
260
+ target, render_envelope(envelope, format=output_format, verbose=True)
261
+ )
262
+ return ExecutionResult(
263
+ agent_error.exit_code, envelope, _buffer(stdout, stderr), output_format
264
+ )
265
+
266
+ def __call__(self, argv: Iterable[str] | None = None) -> None:
267
+ result = self.run(argv)
268
+ if result.exit_code:
269
+ raise SystemExit(result.exit_code)
270
+
271
+ def test(self) -> AbstractContextManager[Any]:
272
+ from .testing import _TestClient
273
+
274
+ return _TestClient(self)
275
+
276
+ def _resolve(
277
+ self, argv: list[str]
278
+ ) -> tuple[App, CommandSchema | None, list[str], list[Middleware]]:
279
+ current = self
280
+ index = 0
281
+ middlewares = list(self.middleware)
282
+ while index < len(argv):
283
+ token = argv[index]
284
+ if token.startswith("-"):
285
+ break
286
+ if token in current.subapps:
287
+ current = current.subapps[token]
288
+ middlewares.extend(current.middleware)
289
+ index += 1
290
+ continue
291
+ if token in current.commands:
292
+ command = current.commands[token]
293
+ command.full_path = (*_app_path(self, current), token)
294
+ return current, command, argv[index + 1 :], middlewares
295
+ if current._default_command is not None:
296
+ break
297
+ raise unknown_name_error(
298
+ kind="command",
299
+ value=token,
300
+ choices=[*current.subapps.keys(), *current.commands.keys()],
301
+ )
302
+ if current._default_command is not None:
303
+ current._default_command.full_path = _app_path(self, current)
304
+ return current, current._default_command, argv[index:], middlewares
305
+ return current, None, argv[index:], middlewares
306
+
307
+ async def _execute(
308
+ self,
309
+ command: CommandSchema,
310
+ values: dict[str, Any],
311
+ *,
312
+ context: Context,
313
+ middlewares: list[Middleware],
314
+ stdout: Any,
315
+ format: OutputFormat,
316
+ ) -> tuple[Any, list[Any]]:
317
+ streamed: list[Any] = []
318
+ cta: list[str] | None = None
319
+
320
+ async def invoke_callback() -> Any:
321
+ nonlocal cta
322
+ outcome = command.callback(**values)
323
+ if inspect.isawaitable(outcome):
324
+ outcome = await outcome
325
+ if inspect.isasyncgen(outcome):
326
+ async for item in outcome:
327
+ streamed.append(item)
328
+ _write(
329
+ stdout,
330
+ render_stream_item(item, output_format=format, is_tty=False),
331
+ )
332
+ return None
333
+ if inspect.isgenerator(outcome):
334
+ for item in outcome:
335
+ streamed.append(item)
336
+ _write(
337
+ stdout,
338
+ render_stream_item(item, output_format=format, is_tty=False),
339
+ )
340
+ return None
341
+ if isinstance(outcome, Result):
342
+ cta = [
343
+ item.command if hasattr(item, "command") else str(item)
344
+ for item in outcome.cta
345
+ ]
346
+ return outcome.data
347
+ return outcome
348
+
349
+ last_result: Any = None
350
+
351
+ async def dispatch(index: int) -> Any:
352
+ nonlocal last_result
353
+ if index == len(middlewares):
354
+ last_result = await invoke_callback()
355
+ return last_result
356
+ middleware = middlewares[index]
357
+
358
+ async def next_call() -> Any:
359
+ return await dispatch(index + 1)
360
+
361
+ result = middleware(context, next_call)
362
+ if inspect.isawaitable(result):
363
+ result = await result
364
+ if result is not None:
365
+ last_result = result
366
+ return last_result
367
+
368
+ data = await dispatch(0)
369
+ if streamed and data is None:
370
+ data = streamed
371
+ envelope = make_envelope(
372
+ ok=True,
373
+ command=command.command,
374
+ format=format,
375
+ data=data,
376
+ cta=cta,
377
+ streamed=bool(streamed),
378
+ )
379
+ return envelope, streamed
380
+
381
+
382
+ def run(
383
+ func: Callable[..., Any],
384
+ *,
385
+ name: str | None = None,
386
+ version: str | None = None,
387
+ argv: list[str] | None = None,
388
+ ) -> None:
389
+ app = App(
390
+ name or command_name_for(func.__name__),
391
+ version=version,
392
+ description=inspect.getdoc(func),
393
+ )
394
+ app._set_default(func)
395
+ app(argv)
396
+
397
+
398
+ def _app_path(root: App, target: App) -> tuple[str, ...]:
399
+ if root is target:
400
+ return (root.name,)
401
+ for path, _command in iter_commands(root):
402
+ if path[:-1] and path[-2] == target.name:
403
+ return path[:-1]
404
+ return (root.name, target.name)
405
+
406
+
407
+ def _isatty(stream: Any) -> bool:
408
+ isatty = getattr(stream, "isatty", None)
409
+ return bool(isatty()) if callable(isatty) else False
410
+
411
+
412
+ def _write(stream: Any, text: str) -> None:
413
+ if not text:
414
+ return
415
+ stream.write(text)
416
+ if not text.endswith("\n"):
417
+ stream.write("\n")
418
+
419
+
420
+ def _buffer(stdout: Any, stderr: Any | None = None) -> str:
421
+ for stream in (stdout, stderr):
422
+ if hasattr(stream, "getvalue"):
423
+ return stream.getvalue()
424
+ return ""
425
+
426
+
427
+ def _finish(
428
+ envelope, stdout: Any, format: OutputFormat, streamed: list[Any] | None = None
429
+ ) -> ExecutionResult:
430
+ return ExecutionResult(
431
+ 0 if envelope.ok else 1, envelope, _buffer(stdout), format, streamed or []
432
+ )
agentcli/_context.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Mapping
7
+
8
+ from ._types import OutputFormat
9
+
10
+
11
+ @dataclass
12
+ class Context:
13
+ app_name: str = ""
14
+ command_path: tuple[str, ...] = ()
15
+ format: OutputFormat = "toon"
16
+ agent: bool = False
17
+ argv: list[str] = field(default_factory=list)
18
+ env: Mapping[str, str] = field(default_factory=dict)
19
+ state: dict[str, Any] = field(default_factory=dict)
20
+ meta: dict[str, Any] = field(default_factory=dict)
21
+ parent_state: Mapping[str, Any] = field(default_factory=dict)
22
+
23
+ @property
24
+ def command(self) -> str:
25
+ return " ".join(self.command_path)
26
+
27
+
28
+ def detect_agent_mode(
29
+ *, stdin: Any = None, stdout: Any = None, env: Mapping[str, str] | None = None
30
+ ) -> bool:
31
+ environment = env or os.environ
32
+ if environment.get("AGENTCLI_MODE"):
33
+ return environment["AGENTCLI_MODE"].lower() == "agent"
34
+ return not _isatty(stdout or sys.stdout) or not _isatty(stdin or sys.stdin)
35
+
36
+
37
+ def _isatty(stream: Any) -> bool:
38
+ isatty = getattr(stream, "isatty", None)
39
+ return bool(isatty()) if callable(isatty) else False