CLI and API polish

This commit is contained in:
2026-05-04 21:56:19 +02:00
parent 6744cc66e9
commit 539bb9b754
15 changed files with 2401 additions and 2 deletions

View File

@@ -3,10 +3,12 @@
from __future__ import annotations
import json
import inspect
from pathlib import Path
import click
import yaml
from click.shell_completion import get_completion_class
from markitect_tool.cache import (
build_cache,
@@ -43,7 +45,12 @@ from markitect_tool.document_function import (
render_document_functions,
validate_document_functions,
)
from markitect_tool.extension import ProcessingContext
from markitect_tool.cli.extensions import collect_cli_command_specs
from markitect_tool.extension import (
ExtensionRegistryError,
ProcessingContext,
builtin_extension_registry,
)
from markitect_tool.explode import (
ExplodeError,
explode_markdown_file,
@@ -107,6 +114,118 @@ def main() -> None:
"""Markdown-native toolkit for structured knowledge artifacts."""
@main.command("completion")
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False))
@click.option(
"--program-name",
default="mkt",
show_default=True,
help="Installed executable name used by the shell completion script.",
)
@click.option(
"--instructions",
is_flag=True,
help="Print installation instructions instead of the completion script.",
)
def completion(shell: str, program_name: str, instructions: bool) -> None:
"""Generate shell completion for Bash, Zsh, or Fish."""
normalized_shell = shell.lower()
if instructions:
click.echo(_completion_instructions(normalized_shell, program_name))
return
completion_class = get_completion_class(normalized_shell)
if completion_class is None:
raise click.ClickException(f"Unsupported completion shell `{shell}`")
complete = completion_class(main, {}, program_name, "_MKT_COMPLETE")
click.echo(complete.source())
@main.group("extension")
def extension_group() -> None:
"""Inspect built-in extension descriptors and CLI affordances."""
@extension_group.command("list")
@click.option("--kind", help="Only show extensions of this kind.")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def extension_list(kind: str | None, output_format: str) -> None:
"""List built-in extension descriptors."""
registry = builtin_extension_registry()
extensions = [descriptor.to_dict() for descriptor in registry.list(kind=kind)]
_emit_extension_catalog({"count": len(extensions), "extensions": extensions}, output_format)
@extension_group.command("inspect")
@click.argument("extension_id")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def extension_inspect(extension_id: str, output_format: str) -> None:
"""Inspect one built-in extension descriptor."""
try:
descriptor = builtin_extension_registry().get(extension_id)
except ExtensionRegistryError as exc:
raise click.ClickException(str(exc)) from exc
_emit_extension_catalog({"extension": descriptor.to_dict()}, output_format)
@extension_group.command("commands")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def extension_commands(output_format: str) -> None:
"""List CLI commands declared by built-in extension descriptors."""
specs = [spec.to_dict() for spec in collect_cli_command_specs(builtin_extension_registry())]
_emit_extension_catalog({"count": len(specs), "commands": specs}, output_format)
@main.group("docs")
def docs_group() -> None:
"""Generate CLI and API reference documentation."""
@docs_group.command("cli")
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write generated Markdown reference to this file.",
)
def docs_cli(output: Path | None) -> None:
"""Generate a Markdown command reference from the Click command tree."""
_emit_or_write_text(_generate_cli_reference(), output)
@docs_group.command("api")
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write generated Markdown reference to this file.",
)
def docs_api(output: Path | None) -> None:
"""Generate a compact Markdown API reference from public exports."""
_emit_or_write_text(_generate_api_reference(), output)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
@@ -2743,6 +2862,36 @@ def _emit_tangle_result(data: dict, output_format: str) -> None:
click.echo(f"written: {written}")
def _emit_extension_catalog(data: dict, output_format: str) -> None:
if output_format == "json":
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
elif output_format == "yaml":
click.echo(yaml.safe_dump(data, sort_keys=False))
elif "extension" in data:
extension = data["extension"]
click.echo(f"{extension['id']} ({extension['kind']})")
if extension.get("summary"):
click.echo(extension["summary"])
commands = extension.get("cli", {}).get("commands", [])
if commands:
click.echo("commands:")
for command in commands:
click.echo(f"- {command}")
docs = extension.get("docs", [])
if docs:
click.echo("docs:")
for doc in docs:
click.echo(f"- {doc}")
elif "commands" in data:
click.echo(f"command specs: {data['count']}")
for spec in data["commands"]:
click.echo(f"- {spec['command']} [{spec['extension_id']}]")
else:
click.echo(f"extensions: {data['count']}")
for extension in data["extensions"]:
click.echo(f"- {extension['id']} ({extension['kind']})")
def _emit_jsonish(data: dict, output_format: str) -> None:
if output_format == "yaml":
click.echo(yaml.safe_dump(data, sort_keys=False))
@@ -2764,6 +2913,115 @@ def _emit_template_analysis(data: dict, output_format: str) -> None:
click.echo(f"! {error}")
def _emit_or_write_text(text: str, output: Path | None) -> None:
if output:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(text, encoding="utf-8")
else:
click.echo(text)
def _completion_instructions(shell: str, program_name: str) -> str:
command = f"_MKT_COMPLETE={shell}_source {program_name}"
if shell == "fish":
target = f"~/.config/fish/completions/{program_name}.fish"
return (
f"Generate completion script:\n{command} > {target}\n\n"
f"Or load it for the current shell:\n{command} | source"
)
target = f"~/.{program_name}-complete.{shell}"
rc_file = "~/.bashrc" if shell == "bash" else "~/.zshrc"
return (
f"Generate completion script:\n{command} > {target}\n\n"
f"Then add this to {rc_file}:\n. {target}"
)
def _generate_cli_reference() -> str:
lines = [
"# Markitect CLI Reference",
"",
"Generated from the Click command tree.",
"",
]
_append_command_reference(lines, main, "mkt")
return "\n".join(lines).rstrip() + "\n"
def _append_command_reference(lines: list[str], command: click.Command, path: str) -> None:
lines.extend([f"## `{path}`", ""])
if command.help:
lines.extend([command.help, ""])
with click.Context(command, info_name=path.split()[-1]) as ctx:
usage = command.get_usage(ctx).replace("Usage: ", "", 1)
lines.extend(["```text", usage, "```", ""])
params = [
param
for param in command.params
if not getattr(param, "hidden", False) and getattr(param, "name", None)
]
if params:
lines.extend(["Parameters:", ""])
for param in params:
if isinstance(param, click.Option):
names = ", ".join(param.opts + param.secondary_opts)
else:
names = param.human_readable_name
help_text = getattr(param, "help", None) or ""
if param.required:
help_text = (help_text + " Required.").strip()
lines.append(f"- `{names}` - {help_text}".rstrip())
lines.append("")
if isinstance(command, click.Group):
for name, subcommand in sorted(command.commands.items()):
_append_command_reference(lines, subcommand, f"{path} {name}")
def _generate_api_reference() -> str:
import markitect_tool as api
lines = [
"# Markitect API Reference",
"",
"Generated from `markitect_tool.__all__`.",
"",
]
grouped: dict[str, list[str]] = {}
for name in sorted(api.__all__):
obj = getattr(api, name, None)
module = getattr(obj, "__module__", "markitect_tool")
grouped.setdefault(module, []).append(name)
for module in sorted(grouped):
lines.extend([f"## `{module}`", ""])
for name in grouped[module]:
obj = getattr(api, name, None)
lines.append(_api_reference_line(name, obj))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def _api_reference_line(name: str, obj: object) -> str:
kind = "object"
if inspect.isclass(obj):
kind = "class"
elif inspect.isfunction(obj):
kind = "function"
elif inspect.isbuiltin(obj):
kind = "builtin"
signature = ""
if callable(obj):
try:
signature = str(inspect.signature(obj))
except (TypeError, ValueError):
signature = ""
summary = ""
doc = inspect.getdoc(obj)
if doc:
summary = doc.splitlines()[0]
display = f"`{name}{signature}`" if signature else f"`{name}`"
return f"- {display} - {kind}. {summary}".rstrip()
def _parse_key_value_options(items: tuple[str, ...]) -> dict[str, object]:
values: dict[str, object] = {}
for item in items: