AXM CLI — one command per tool, auto-generated and lazily dispatched.
Every axm.tools entry point becomes a CLI command (axm audit,
axm git_commit, …) with the same typed signature as the tool's
execute — so a tool's CLI and its MCP schema never drift. Explicit
axm.commands entry points keep priority over the auto-generated ones.
Dispatch is lazy: a CLI process lives for one invocation, so we resolve
the requested command from sys.argv and import only that one entry point.
axm --help (or no command) lists every name from entry-point metadata
without importing a single tool module.
Output is the tool's ToolResult.text (token-optimised), falling back to a
JSON rendering of data when there is no text. Non-scalar parameters
(list / dict / pydantic models) are passed as a single JSON string and
decoded before the call — the one convention that keeps tool-signature ==
CLI-signature without CLI-only flags.
Exit codes: 0 success, 1 tool error / cyclopts arg-parsing error,
2 bad usage raised by this wrapper (invalid JSON for a non-scalar
parameter, unknown command). Note cyclopts itself exits 1 on its own
parsing failures (unknown option, unused token), so 2 is reserved for the
wrapper's own guards, not for every argument error.
Build a cyclopts command callable from an AXMTool (or plain callable).
The returned function carries the tool's typed __signature__ (non-scalar
params reshaped to JSON strings) and its docstring, runs execute /
the callable, prints result.text, and exits non-zero on failure.
Parameters:
| Name |
Type |
Description |
Default |
tool_name
|
str
|
|
required
|
tool_obj
|
Any
|
The tool instance (or plain callable).
|
required
|
Returns:
| Type |
Description |
Any
|
A function suitable for cyclopts.App.command.
|
Source code in packages/axm/src/axm/cli.py
| Python |
|---|
| def build_command_for_tool(tool_name: str, tool_obj: Any) -> Any:
"""Build a cyclopts command callable from an AXMTool (or plain callable).
The returned function carries the tool's typed ``__signature__`` (non-scalar
params reshaped to JSON strings) and its docstring, runs ``execute`` /
the callable, prints ``result.text``, and exits non-zero on failure.
Args:
tool_name: The command name.
tool_obj: The tool instance (or plain callable).
Returns:
A function suitable for ``cyclopts.App.command``.
"""
exec_fn = _exec_callable(tool_obj)
params = public_params(exec_fn)
json_params = _nonscalar_names(params)
cli_params = [cli_param(p) for p in params]
ordered_names = [p.name for p in cli_params]
def _command(*args: Any, **kwargs: Any) -> None:
# cyclopts binds tokens per ``__signature__``; positional tokens (the
# ``axm audit .`` ergonomic form) arrive in *args and are mapped back to
# their param names here so ``execute(**kwargs)`` stays keyword-only.
for name, value in zip(ordered_names, args, strict=False):
kwargs[name] = value
for key in json_params & kwargs.keys():
value = kwargs[key]
if isinstance(value, str):
try:
kwargs[key] = json.loads(value)
except json.JSONDecodeError as exc:
sys.stderr.write(f"{key}: invalid JSON: {exc}\n")
raise SystemExit(2) from exc
try:
result = exec_fn(**kwargs)
except Exception as exc: # surface any tool error on stderr
sys.stderr.write(f"{exc}\n")
raise SystemExit(1) from exc
_emit(result)
if getattr(result, "success", True) is False:
raise SystemExit(1)
_command.__name__ = tool_name
_command.__doc__ = exec_fn.__doc__ or f"Run the {tool_name} tool."
_command.__signature__ = inspect.Signature(cli_params) # type: ignore[attr-defined]
# Mirror the resolved annotations onto __annotations__ as real types (not
# strings): cyclopts calls get_type_hints() on the command, which reads
# __annotations__ and would otherwise try to eval our closure's stringised
# ``**kwargs``/forward refs against this module's globals.
_command.__annotations__ = {
p.name: p.annotation
for p in cli_params
if p.annotation is not inspect.Parameter.empty
}
_command.__annotations__["return"] = None
return _command
|
cli_param(p)
Map a tool param to its CLI form.
Tool execute params are keyword-only by convention; the CLI relaxes them
to POSITIONAL_OR_KEYWORD so the documented ergonomic form axm audit .
works alongside axm audit --path . (both still bind by name into
execute(**kwargs)). Non-scalar params additionally become a JSON string
(str when required, str | None when optional so cyclopts accepts the
None default without a strict string validation error).
Source code in packages/axm/src/axm/cli.py
| Python |
|---|
| def cli_param(p: inspect.Parameter) -> inspect.Parameter:
"""Map a tool param to its CLI form.
Tool ``execute`` params are keyword-only by convention; the CLI relaxes them
to ``POSITIONAL_OR_KEYWORD`` so the documented ergonomic form ``axm audit .``
works alongside ``axm audit --path .`` (both still bind by name into
``execute(**kwargs)``). Non-scalar params additionally become a JSON string
(``str`` when required, ``str | None`` when optional so cyclopts accepts the
``None`` default without a strict string validation error).
"""
kind = (
inspect.Parameter.POSITIONAL_OR_KEYWORD
if p.kind is inspect.Parameter.KEYWORD_ONLY
else p.kind
)
if not is_nonscalar(p.annotation):
return p.replace(kind=kind)
if p.default is inspect.Parameter.empty:
return p.replace(kind=kind, annotation=str)
return p.replace(kind=kind, annotation=str | None, default=p.default)
|
create_app()
Create an app with every command registered (eager).
This loads all entry points — convenient for tests and introspection, but
NOT the path used by main (which dispatches lazily). Explicit
axm.commands win over auto-generated tool commands of the same name.
Returns:
| Type |
Description |
App
|
A fully-populated cyclopts App.
|
Source code in packages/axm/src/axm/cli.py
| Python |
|---|
| def create_app() -> cyclopts.App:
"""Create an app with *every* command registered (eager).
This loads all entry points — convenient for tests and introspection, but
NOT the path used by ``main`` (which dispatches lazily). Explicit
``axm.commands`` win over auto-generated tool commands of the same name.
Returns:
A fully-populated cyclopts App.
"""
app = _new_app()
commands = _entry_points(_COMMANDS_GROUP)
tools = _entry_points(_TOOLS_GROUP)
mounted = {
name for name in commands if _try_mount_command(app, name, commands[name])
}
for name, ep in tools.items():
if name in mounted:
continue # an explicit command already claims this name
try:
app.command(build_command_for_tool(name, _load(ep)), name=name)
except Exception: # noqa: BLE001
logger.warning("Failed to auto-register tool '%s'", name, exc_info=True)
return app
|
is_nonscalar(annotation)
Whether annotation should be passed as JSON (not a plain scalar).
Unwraps Optional / X | None and inspects the non-None member:
container types (list/dict/tuple/set) and arbitrary classes
that are not str/int/float/bool count as non-scalar.
Source code in packages/axm/src/axm/cli.py
| Python |
|---|
| def is_nonscalar(annotation: Any) -> bool:
"""Whether *annotation* should be passed as JSON (not a plain scalar).
Unwraps ``Optional`` / ``X | None`` and inspects the non-``None`` member:
container types (``list``/``dict``/``tuple``/``set``) and arbitrary classes
that are not ``str``/``int``/``float``/``bool`` count as non-scalar.
"""
if annotation is inspect.Parameter.empty:
return False
origin = typing.get_origin(annotation)
if origin is Annotated:
return is_nonscalar(typing.get_args(annotation)[0])
if origin in (Union, types.UnionType):
members = [a for a in typing.get_args(annotation) if a is not type(None)]
return any(is_nonscalar(m) for m in members)
if origin in (list, dict, tuple, set):
return True
return isinstance(annotation, type) and not issubclass(annotation, _SCALARS)
|
main()
CLI entry point with lazy, dispatch-first command resolution.
Resolves the command from sys.argv and imports only that entry point.
axm / axm --help / an unknown command print the catalog (built from
metadata, no tool import).
Source code in packages/axm/src/axm/cli.py
| Python |
|---|
| def main() -> None:
"""CLI entry point with lazy, dispatch-first command resolution.
Resolves the command from ``sys.argv`` and imports only that entry point.
``axm`` / ``axm --help`` / an unknown command print the catalog (built from
metadata, no tool import).
"""
argv = sys.argv[1:]
# Version handled here (not via cyclopts version_flags) to keep the
# lazy, dispatch-first resolution and catalog behaviour intact.
if argv and argv[0] in ("--version", "-V"):
from axm import __version__
sys.stdout.write(f"{__version__}\n")
return
commands = _entry_points(_COMMANDS_GROUP)
tools = _entry_points(_TOOLS_GROUP)
cmd = _resolve_command(argv)
if cmd is None or (cmd not in commands and cmd not in tools):
if cmd is None or cmd in ("-h", "--help"):
_print_catalog(commands, tools)
return
# Unknown command: show catalog on stderr, exit 2 (bad usage).
sys.stderr.write(f"Unknown command: {cmd}\n\n")
_print_catalog(commands, tools)
raise SystemExit(2)
app = _build_single_app(cmd, commands, tools)
app(argv)
|
public_params(fn)
Typed params of fn with annotations resolved to real types.
Drops self, **kwargs and a dispatch kwargs: object catch-all
(AXM tools accept the latter for forward-compat). Annotations are resolved
via get_type_hints so cyclopts never has to evaluate string forward
references (which may name symbols absent from this process).
Source code in packages/axm/src/axm/cli.py
| Python |
|---|
| def public_params(fn: Any) -> list[inspect.Parameter]:
"""Typed params of *fn* with annotations resolved to real types.
Drops ``self``, ``**kwargs`` and a dispatch ``kwargs: object`` catch-all
(AXM tools accept the latter for forward-compat). Annotations are resolved
via ``get_type_hints`` so cyclopts never has to evaluate string forward
references (which may name symbols absent from this process).
"""
sig = inspect.signature(fn)
hints = _resolve_hints(fn)
params: list[inspect.Parameter] = []
for p in sig.parameters.values():
if p.name in ("self", "kwargs") or p.kind is inspect.Parameter.VAR_KEYWORD:
continue
resolved = hints.get(p.name, p.annotation)
# If a hint is still an unresolved string, fall back to Any (str-like).
if isinstance(resolved, str):
resolved = inspect.Parameter.empty
params.append(p.replace(annotation=resolved))
return params
|