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, 2 bad args (cyclopts).
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)
def _command(**kwargs: Any) -> None:
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)
cli_params = [cli_param(p) for p in params]
_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.
Non-scalar params 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.
Non-scalar params become a JSON string (``str`` when required, ``str | None``
when optional so cyclopts accepts the ``None`` default without a strict
string validation error).
"""
if not is_nonscalar(p.annotation):
return p
if p.default is inspect.Parameter.empty:
return p.replace(annotation=str)
return p.replace(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)
for name, ep in commands.items():
try:
app.command(_load(ep), name=name)
except Exception: # noqa: BLE001 — a broken package must not sink the CLI
logger.warning("Failed to load command '%s'", name, exc_info=True)
for name, ep in tools.items():
if name in commands:
continue
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 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:]
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
|