markitect-tool/src/markitect_tool/cli/main.py

1340 lines
43 KiB
Python
Raw Normal View History

"""`mkt` command entry point."""
from __future__ import annotations
import json
from pathlib import Path
import click
import yaml
from markitect_tool.cache import (
build_cache,
cache_path_for,
detect_changes,
fingerprint_file,
load_cache,
save_cache,
)
2026-05-04 02:43:32 +02:00
from markitect_tool.backend import (
BackendRegistryError,
load_backend_registry,
snapshot_identity_for_file,
)
from markitect_tool.content_class import (
ContentClassResolutionError,
load_content_class_file,
)
from markitect_tool.core import parse_markdown_file
from markitect_tool.contract import (
ContractLoaderError,
check_markdown_file,
collect_metrics,
load_contract_file,
validate_contract,
)
from markitect_tool.explode import (
ExplodeError,
explode_markdown_file,
implode_markdown_directory,
)
from markitect_tool.generation import (
GenerationPlanError,
generate_stub_from_contract,
load_data_file,
load_generation_plan_file,
run_generation_plan,
)
from markitect_tool.literate import tangle_markdown, weave_markdown, write_tangle_files
2026-05-04 00:23:04 +02:00
from markitect_tool.ops import IncludeError, compose_files, resolve_includes, transform_markdown
from markitect_tool.processor import ProcessorContext, run_fenced_processors
from markitect_tool.query import InvalidQueryError, extract_document, query_document
from markitect_tool.reference import (
ReferenceContext,
ReferenceResolutionError,
load_namespaces,
resolve_reference,
)
from markitect_tool.schema import load_schema_file, validate_markdown_file, validate_schema
from markitect_tool.template import (
MissingTemplateVariable,
TemplateError,
analyze_template,
render_template,
)
@click.group()
@click.version_option()
def main() -> None:
"""Markdown-native toolkit for structured knowledge artifacts."""
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "tree"], case_sensitive=False),
default="json",
show_default=True,
)
def parse(file: Path, output_format: str) -> None:
"""Parse a Markdown file into a structured representation."""
document = parse_markdown_file(file)
data = document.to_dict()
if output_format == "yaml":
click.echo(yaml.safe_dump(data, sort_keys=False))
elif output_format == "tree":
for heading in document.headings:
click.echo(f"{'#' * heading.level} {heading.text}")
else:
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def metrics(file: Path, output_format: str) -> None:
"""Report practical size and complexity metrics for a Markdown file."""
document = parse_markdown_file(file)
data = collect_metrics(document).to_dict() | {"document_path": str(file)}
_emit_metrics(data, output_format)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("selector")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="json",
show_default=True,
)
def query(file: Path, selector: str, output_format: str) -> None:
"""Query structured Markdown content with a small selector."""
document = parse_markdown_file(file)
try:
matches = query_document(document, selector)
except InvalidQueryError as exc:
raise click.ClickException(str(exc)) from exc
data = {
"selector": selector,
"document_path": str(file),
"count": len(matches),
"matches": [match.to_dict() for match in matches],
}
_emit_query(data, output_format)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("selector")
@click.option(
"--format",
"output_format",
type=click.Choice(["text", "json", "yaml"], case_sensitive=False),
default="text",
show_default=True,
)
def extract(file: Path, selector: str, output_format: str) -> None:
"""Extract text or Markdown content from structured Markdown."""
document = parse_markdown_file(file)
try:
items = extract_document(document, selector)
except InvalidQueryError as exc:
raise click.ClickException(str(exc)) from exc
data = {
"selector": selector,
"document_path": str(file),
"count": len(items),
"items": items,
}
_emit_extract(data, output_format)
2026-05-04 00:23:04 +02:00
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--strip-frontmatter", is_flag=True, help="Remove YAML frontmatter.")
@click.option(
"--set",
"set_values",
multiple=True,
metavar="KEY=VALUE",
help="Set a frontmatter value. Dot paths create nested mappings.",
)
@click.option(
"--heading-delta",
type=int,
default=0,
show_default=True,
help="Shift ATX heading levels, clamped to 1..6.",
)
@click.option("--extract", "extract_selector", help="Replace content with selector output.")
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write transformed Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def transform(
file: Path,
strip_frontmatter: bool,
set_values: tuple[str, ...],
heading_delta: int,
extract_selector: str | None,
output: Path | None,
output_format: str,
) -> None:
"""Apply deterministic transforms to a Markdown file."""
try:
frontmatter_updates = _parse_key_value_options(set_values)
result = transform_markdown(
file.read_text(encoding="utf-8"),
strip_frontmatter=strip_frontmatter,
set_frontmatter=frontmatter_updates,
heading_delta=heading_delta,
extract_selector=extract_selector,
source_path=str(file),
)
except (InvalidQueryError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
_emit_markdown_result(result.to_dict(), output_format, output)
@main.command()
@click.argument(
"files",
nargs=-1,
required=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option("--title", help="Add a top-level title before composed files.")
@click.option(
"--heading-delta",
type=int,
default=0,
show_default=True,
help="Shift heading levels in each input before composing.",
)
@click.option(
"--include-frontmatter",
is_flag=True,
help="Keep each input file's frontmatter in the composed body.",
)
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write composed Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def compose(
files: tuple[Path, ...],
title: str | None,
heading_delta: int,
include_frontmatter: bool,
output: Path | None,
output_format: str,
) -> None:
"""Compose multiple Markdown files into one document."""
result = compose_files(
list(files),
title=title,
heading_delta=heading_delta,
include_frontmatter=include_frontmatter,
)
_emit_markdown_result(result.to_dict(), output_format, output)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--base-dir",
type=click.Path(exists=True, file_okay=False, path_type=Path),
help="Directory includes must stay within. Defaults to the input file directory.",
)
@click.option(
"--max-depth",
type=int,
default=10,
show_default=True,
help="Maximum recursive include depth.",
)
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write resolved Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def include(
file: Path,
base_dir: Path | None,
max_depth: int,
output: Path | None,
output_format: str,
) -> None:
"""Resolve Markdown include markers in a document."""
try:
result = resolve_includes(
file.read_text(encoding="utf-8"),
base_dir=base_dir or file.parent,
current_path=file,
max_depth=max_depth,
)
except IncludeError as exc:
raise click.ClickException(str(exc)) from exc
_emit_markdown_result(result.to_dict(), output_format, output)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--output-dir",
required=True,
type=click.Path(file_okay=False, path_type=Path),
help="Directory to write exploded Markdown files and manifest into.",
)
@click.option(
"--variant",
type=click.Choice(["flat", "hierarchical"], case_sensitive=False),
default="flat",
show_default=True,
)
@click.option("--force", is_flag=True, help="Allow writing into a non-empty output directory.")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def explode(
file: Path,
output_dir: Path,
variant: str,
force: bool,
output_format: str,
) -> None:
"""Explode a Markdown file into reversible section files."""
try:
result = explode_markdown_file(file, output_dir, variant=variant, overwrite=force)
except ExplodeError as exc:
raise click.ClickException(str(exc)) from exc
_emit_explode_result(result.to_dict(), output_format)
@main.command()
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option(
"--manifest",
"manifest_path",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Manifest path. Defaults to markitect-explode.yaml in the input directory.",
)
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write imploded Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def implode(
directory: Path,
manifest_path: Path | None,
output: Path | None,
output_format: str,
) -> None:
"""Implode a Markdown directory created by `mkt explode`."""
try:
result = implode_markdown_directory(directory, manifest_path=manifest_path)
except ExplodeError as exc:
raise click.ClickException(str(exc)) from exc
_emit_markdown_result(result.to_dict(), output_format, output)
@main.group("ref")
def ref_group() -> None:
"""Resolve namespaced Markdown content references."""
@ref_group.command("resolve")
@click.argument("context_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("reference")
@click.option(
"--root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path("."),
show_default=True,
help="Root that relative paths and namespaces must stay within.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def ref_resolve(context_file: Path, reference: str, root: Path, output_format: str) -> None:
"""Resolve a content reference using a Markdown document as context."""
context_document = parse_markdown_file(context_file)
context = ReferenceContext.from_document(
context_document,
root=root,
current_path=context_file,
)
try:
resolution = resolve_reference(reference, context=context)
except ReferenceResolutionError as exc:
raise click.ClickException(str(exc)) from exc
_emit_reference_result(resolution.to_dict(), output_format)
@main.command("process")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path("."),
show_default=True,
help="Root used for relative processor references.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def process(file: Path, root: Path, output_format: str) -> None:
"""Run deterministic fenced-block processors in a Markdown file."""
document = parse_markdown_file(file)
context = ProcessorContext(
root=root,
current_path=file,
namespaces=load_namespaces(document.frontmatter),
)
result = run_fenced_processors(
file.read_text(encoding="utf-8"),
context=context,
source_path=file,
)
_emit_processor_run(result.to_dict(), output_format)
raise click.exceptions.Exit(0 if result.valid else 1)
2026-05-04 02:43:32 +02:00
@main.group()
def backend() -> None:
"""Inspect optional backend manifests and snapshot identities."""
@backend.command("list")
@click.option(
"--path",
"paths",
multiple=True,
type=click.Path(path_type=Path),
help="Backend manifest file or directory. Defaults to .markitect/backends and .markitect/backend.yaml.",
)
@click.option("--capability", help="Only show backends that declare this capability.")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def backend_list(paths: tuple[Path, ...], capability: str | None, output_format: str) -> None:
"""List registered optional backend manifests."""
try:
registry = load_backend_registry(list(paths) or None)
except BackendRegistryError as exc:
raise click.ClickException(str(exc)) from exc
manifests = (
registry.find_by_capability(capability.replace("-", "_").lower())
if capability
else registry.list()
)
data = {
"count": len(manifests),
"backends": [manifest.to_dict() for manifest in manifests],
}
_emit_backend_list(data, output_format)
@backend.command("inspect")
@click.argument("backend_id")
@click.option(
"--path",
"paths",
multiple=True,
type=click.Path(path_type=Path),
help="Backend manifest file or directory. Defaults to .markitect/backends and .markitect/backend.yaml.",
)
@click.option(
"--require",
"required_capabilities",
multiple=True,
help="Required capability to check. May be repeated.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def backend_inspect(
backend_id: str,
paths: tuple[Path, ...],
required_capabilities: tuple[str, ...],
output_format: str,
) -> None:
"""Inspect one backend manifest and optional compatibility check."""
try:
registry = load_backend_registry(list(paths) or None)
manifest = registry.get(backend_id)
except BackendRegistryError as exc:
raise click.ClickException(str(exc)) from exc
data = manifest.to_dict()
if required_capabilities:
data["capability_check"] = manifest.check(list(required_capabilities)).to_dict()
_emit_backend_manifest(data, output_format)
@backend.command("snapshot-id")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--parse-option",
"parse_options",
multiple=True,
metavar="KEY=VALUE",
help="Parse option included in the snapshot identity hash.",
)
@click.option("--contract-hash", help="Optional contract hash included in the snapshot identity.")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def backend_snapshot_id(
file: Path,
parse_options: tuple[str, ...],
contract_hash: str | None,
output_format: str,
) -> None:
"""Compute a read-only content-addressed snapshot identity for a file."""
try:
identity = snapshot_identity_for_file(
file,
parse_options=_parse_key_value_options(parse_options),
contract_hash=contract_hash,
)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
data = identity.to_dict() | {"snapshot_id": identity.snapshot_id}
_emit_snapshot_identity(data, output_format)
@main.group("class")
def class_group() -> None:
"""Resolve deterministic content classes."""
@class_group.command("resolve")
@click.argument("class_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("class_name")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def class_resolve(class_file: Path, class_name: str, output_format: str) -> None:
"""Resolve content class inheritance and merged slots."""
try:
registry = load_content_class_file(class_file)
result = registry.compose(class_name)
except ContentClassResolutionError as exc:
raise click.ClickException(str(exc)) from exc
_emit_content_class_result(result.to_dict(), output_format)
raise click.exceptions.Exit(0 if result.valid else 1)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--output-dir",
type=click.Path(file_okay=False, path_type=Path),
help="Write tangled files under this directory. Omit for dry JSON/YAML/text output.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def tangle(file: Path, output_dir: Path | None, output_format: str) -> None:
"""Tangle named Markdown code chunks into target files."""
result = tangle_markdown(file.read_text(encoding="utf-8"), source_path=file)
data = result.to_dict()
if output_dir and result.valid:
data["written_files"] = write_tangle_files(result, output_dir)
_emit_tangle_result(data, output_format)
raise click.exceptions.Exit(0 if result.valid else 1)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write woven Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def weave(file: Path, output: Path | None, output_format: str) -> None:
"""Weave Markdown documentation with a deterministic chunk index."""
result = weave_markdown(file.read_text(encoding="utf-8"), source_path=file)
_emit_markdown_result(result.to_dict(), output_format, output)
@main.group()
def cache() -> None:
"""Fingerprint Markdown files and detect changed inputs."""
@cache.command("fingerprint")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path("."),
show_default=True,
help="Root used for relative cache paths.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="json",
show_default=True,
)
def cache_fingerprint(file: Path, root: Path, output_format: str) -> None:
"""Fingerprint one Markdown file."""
entry = fingerprint_file(file, root=root)
_emit_cache_data(entry.to_dict(), output_format)
@cache.command("build")
@click.argument("paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
@click.option(
"--root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path("."),
show_default=True,
help="Root used for relative cache paths.",
)
@click.option(
"--cache-path",
type=click.Path(dir_okay=False, path_type=Path),
help="Cache manifest path. Defaults to .markitect/cache/manifest.json under root.",
)
@click.option("--no-recursive", is_flag=True, help="Do not recurse into directories.")
@click.option("--dry-run", is_flag=True, help="Report manifest without writing it.")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def cache_build(
paths: tuple[Path, ...],
root: Path,
cache_path: Path | None,
no_recursive: bool,
dry_run: bool,
output_format: str,
) -> None:
"""Build or refresh a lightweight Markdown cache manifest."""
manifest = build_cache(list(paths), root=root, recursive=not no_recursive)
manifest_path = cache_path_for(root, cache_path)
if not dry_run:
save_cache(manifest, manifest_path)
data = manifest.to_dict() | {
"cache_path": str(manifest_path),
"written": not dry_run,
"count": len(manifest.entries),
}
_emit_cache_data(data, output_format)
@cache.command("status")
@click.argument("paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
@click.option(
"--root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path("."),
show_default=True,
help="Root used for relative cache paths.",
)
@click.option(
"--cache-path",
type=click.Path(dir_okay=False, path_type=Path),
help="Cache manifest path. Defaults to .markitect/cache/manifest.json under root.",
)
@click.option("--no-recursive", is_flag=True, help="Do not recurse into directories.")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def cache_status(
paths: tuple[Path, ...],
root: Path,
cache_path: Path | None,
no_recursive: bool,
output_format: str,
) -> None:
"""Report changed, new, unchanged, and deleted Markdown files."""
manifest_path = cache_path_for(root, cache_path)
manifest = load_cache(manifest_path)
status = detect_changes(manifest, list(paths), root=root, recursive=not no_recursive)
data = status.to_dict() | {"cache_path": str(manifest_path)}
_emit_cache_data(data, output_format)
raise click.exceptions.Exit(1 if status.dirty else 0)
@main.group()
def template() -> None:
"""Render and inspect deterministic Markdown templates."""
@template.command("inspect")
@click.argument("template_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def template_inspect(template_file: Path, output_format: str) -> None:
"""Inspect variables required by a template."""
data = analyze_template(template_file.read_text(encoding="utf-8")).to_dict() | {
"template_path": str(template_file)
}
_emit_template_analysis(data, output_format)
raise click.exceptions.Exit(0 if data["valid"] else 1)
@template.command("render")
@click.argument("template_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--data",
"data_file",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="JSON, YAML, or CSV data file. CSV must contain one record for render.",
)
@click.option(
"--set",
"set_values",
multiple=True,
metavar="KEY=VALUE",
help="Set a template data value. Dot paths create nested mappings.",
)
@click.option("--lenient", is_flag=True, help="Keep unresolved placeholders instead of failing.")
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write rendered Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def template_render(
template_file: Path,
data_file: Path | None,
set_values: tuple[str, ...],
lenient: bool,
output: Path | None,
output_format: str,
) -> None:
"""Render a Markdown template with structured data."""
try:
data = _load_template_data(data_file)
data = _deep_merge_cli(data, _parse_key_value_options(set_values))
result = render_template(
template_file.read_text(encoding="utf-8"),
data,
strict=not lenient,
)
except (MissingTemplateVariable, TemplateError, ValueError, TypeError) as exc:
raise click.ClickException(str(exc)) from exc
_emit_markdown_result(result.to_dict(), output_format, output)
@main.group()
def generate() -> None:
"""Generate Markdown from contracts, rules, or external hooks."""
@generate.command("stub")
@click.option(
"--contract",
"contract_file",
required=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Markdown document contract to generate from.",
)
@click.option(
"--data",
"data_file",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Optional JSON/YAML data for frontmatter values.",
)
@click.option(
"--set",
"set_values",
multiple=True,
metavar="KEY=VALUE",
help="Set generation data. Dot paths create nested mappings.",
)
@click.option("--include-optional", is_flag=True, help="Include optional contract sections.")
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write generated Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def generate_stub(
contract_file: Path,
data_file: Path | None,
set_values: tuple[str, ...],
include_optional: bool,
output: Path | None,
output_format: str,
) -> None:
"""Generate a Markdown stub from a document contract."""
try:
data = _load_template_data(data_file)
data = _deep_merge_cli(data, _parse_key_value_options(set_values))
result = generate_stub_from_contract(
load_contract_file(contract_file),
data=data,
include_optional=include_optional,
)
except (ContractLoaderError, ValueError, TypeError) as exc:
raise click.ClickException(str(exc)) from exc
_emit_markdown_result(result.to_dict(), output_format, output)
@generate.command("rules")
@click.argument("rules_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--output-dir",
type=click.Path(file_okay=False, path_type=Path),
help="Directory used for relative output paths in the plan.",
)
@click.option("--dry-run", is_flag=True, help="Render without writing output files.")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml"], case_sensitive=False),
default="json",
show_default=True,
)
def generate_rules(
rules_file: Path,
output_dir: Path | None,
dry_run: bool,
output_format: str,
) -> None:
"""Run a Markdown/YAML generation plan."""
try:
plan = load_generation_plan_file(rules_file)
result = run_generation_plan(
plan,
base_dir=rules_file.parent,
output_dir=output_dir,
dry_run=dry_run,
)
except (GenerationPlanError, TemplateError, MissingTemplateVariable) as exc:
raise click.ClickException(str(exc)) from exc
_emit_jsonish(result.to_dict(), output_format)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--schema",
"schema_file",
required=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def validate(file: Path, schema_file: Path, output_format: str) -> None:
"""Validate a Markdown file against a Markdown schema file."""
result = validate_markdown_file(file, schema_file)
_emit_result(result.to_dict(), output_format)
raise click.exceptions.Exit(0 if result.valid else 1)
@main.group()
def schema() -> None:
"""Work with Markdown schema files."""
@schema.command("validate")
@click.argument("schema_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def schema_validate(schema_file: Path, output_format: str) -> None:
"""Validate that a Markdown schema contains a well-formed JSON Schema."""
loaded = load_schema_file(schema_file)
result = validate_schema(loaded.schema)
data = result.to_dict() | {"schema_path": str(schema_file)}
_emit_result(data, output_format)
raise click.exceptions.Exit(0 if result.valid else 1)
@main.group()
def contract() -> None:
"""Work with Markdown document contracts."""
@contract.command("validate")
@click.argument("contract_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def contract_validate(contract_file: Path, output_format: str) -> None:
"""Validate that a Markdown contract file is well formed."""
result = validate_contract(load_contract_file(contract_file))
_emit_diagnostic_result(result.to_dict(), output_format)
raise click.exceptions.Exit(0 if result.valid else 1)
@contract.command("check")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--contract",
"contract_file",
required=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def contract_check(file: Path, contract_file: Path, output_format: str) -> None:
"""Check a Markdown file against a Markdown document contract."""
try:
result = check_markdown_file(file, contract_file)
except ContractLoaderError as exc:
raise click.ClickException(str(exc)) from exc
_emit_diagnostic_result(result.to_dict(), output_format)
raise click.exceptions.Exit(0 if result.valid else 1)
def _emit_result(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))
else:
if data.get("valid"):
click.echo("valid")
else:
click.echo("invalid")
for violation in data.get("violations", []):
click.echo(f"- {violation['path']}: {violation['message']}")
def _emit_diagnostic_result(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))
else:
click.echo("valid" if data.get("valid") else "invalid")
for diagnostic in data.get("diagnostics", []):
click.echo(
f"- [{diagnostic['severity']}] {diagnostic['code']}: "
f"{diagnostic['message']}"
)
if diagnostic.get("source"):
source = diagnostic["source"]
suffix = f":{source['line']}" if source.get("line") else ""
click.echo(f" source: {source.get('path', '<document>')}{suffix}")
if diagnostic.get("guidance"):
click.echo(f" guidance: {diagnostic['guidance']}")
def _emit_metrics(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))
else:
doc = data["document"]
click.echo("document")
for metric, value in doc.items():
click.echo(f"- {metric}: {value}")
sections = data.get("sections", [])
if sections:
click.echo("sections")
for section in sections:
click.echo(
f"- {section['heading']}: words={section['words']}, "
f"paragraphs={section['paragraphs']}, line={section['line']}"
)
def _emit_query(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))
else:
click.echo(f"{data['count']} match(es)")
for match in data["matches"]:
location = f":{match['line']}" if match.get("line") else ""
click.echo(f"- {match['kind']} {match['path']}{location}")
if match.get("text"):
click.echo(f" {match['text'].splitlines()[0]}")
def _emit_extract(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))
else:
click.echo("\n\n".join(data["items"]))
2026-05-04 00:23:04 +02:00
def _emit_markdown_result(data: dict, output_format: str, output: Path | None) -> None:
if output_format == "json":
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
return
if output_format == "yaml":
click.echo(yaml.safe_dump(data, sort_keys=False))
return
markdown = data["markdown"]
if output:
output.write_text(markdown, encoding="utf-8")
else:
click.echo(markdown, nl=False)
def _emit_cache_data(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))
else:
if "dirty" in data:
click.echo("dirty" if data["dirty"] else "clean")
for key in ["new", "changed", "deleted", "unchanged"]:
values = data.get(key, [])
if values:
click.echo(f"{key}: {len(values)}")
for value in values:
click.echo(f"- {value}")
else:
click.echo(f"cache_path: {data.get('cache_path', '<none>')}")
click.echo(f"count: {data.get('count', len(data.get('entries', [])))}")
if data.get("written") is not None:
click.echo(f"written: {data['written']}")
def _emit_reference_result(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))
else:
click.echo(f"{data['count']} unit(s)")
click.echo(f"target: {data['target_path']}")
for unit in data["units"]:
span = unit.get("span", {})
line = f":{span['line_start']}" if span.get("line_start") else ""
click.echo(f"- {unit['kind']} {unit['unit_id']} {unit['source_path']}{line}")
if unit.get("name"):
click.echo(f" {unit['name']}")
def _emit_explode_result(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))
else:
manifest = data["manifest"]
click.echo(f"manifest: {data['manifest_path']}")
click.echo(f"variant: {manifest['variant']}")
click.echo(f"entries: {len(manifest['entries'])}")
for entry in manifest["entries"]:
click.echo(f"- {entry['kind']} {entry['file']}")
def _emit_processor_run(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))
else:
click.echo("valid" if data["valid"] else "invalid")
click.echo(f"processors: {data['count']}")
for block, result in zip(data["blocks"], data["results"], strict=False):
line = f":{block['line_start']}" if block.get("line_start") else ""
click.echo(f"- {block['processor']} {block['unit_id']}{line}")
if result.get("content"):
click.echo(f" content: {result['content'].splitlines()[0]}")
for diagnostic in result.get("diagnostics", []):
click.echo(f" [{diagnostic['severity']}] {diagnostic['code']}: {diagnostic['message']}")
2026-05-04 02:43:32 +02:00
def _emit_backend_list(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))
else:
click.echo(f"backends: {data['count']}")
for backend_data in data["backends"]:
capabilities = ", ".join(backend_data.get("capabilities", []))
click.echo(f"- {backend_data['id']} [{capabilities}]")
def _emit_backend_manifest(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))
else:
click.echo(data["id"])
if data.get("name"):
click.echo(f"name: {data['name']}")
click.echo(f"kind: {data.get('kind', 'cache-backend')}")
click.echo("capabilities: " + ", ".join(data.get("capabilities", [])))
if data.get("storage"):
click.echo(f"storage: {data['storage']}")
if data.get("policy"):
click.echo(f"policy: {data['policy']}")
if data.get("capability_check"):
check = data["capability_check"]
click.echo("compatible" if check["compatible"] else "incompatible")
if check.get("missing"):
click.echo("missing: " + ", ".join(check["missing"]))
def _emit_snapshot_identity(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))
else:
click.echo(data["snapshot_id"])
click.echo(f"content_hash: {data['content_hash']}")
click.echo(f"parser: {data['parser']} {data['parser_version']}")
def _emit_content_class_result(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))
else:
click.echo("valid" if data["valid"] else "invalid")
click.echo("linearization: " + " -> ".join(data["linearization"]))
for slot, value in data.get("slots", {}).items():
click.echo(f"- {slot}: {value}")
for diagnostic in data.get("diagnostics", []):
click.echo(f"! [{diagnostic['severity']}] {diagnostic['code']}: {diagnostic['message']}")
def _emit_tangle_result(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))
else:
click.echo("valid" if data["valid"] else "invalid")
click.echo(f"files: {len(data['files'])}")
for file in data["files"]:
click.echo(f"- {file['path']}: {', '.join(file['chunk_ids'])}")
for diagnostic in data.get("diagnostics", []):
click.echo(f"! [{diagnostic['severity']}] {diagnostic['code']}: {diagnostic['message']}")
for written in data.get("written_files", []):
click.echo(f"written: {written}")
def _emit_jsonish(data: dict, output_format: str) -> None:
if output_format == "yaml":
click.echo(yaml.safe_dump(data, sort_keys=False))
else:
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
def _emit_template_analysis(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))
else:
click.echo("valid" if data["valid"] else "invalid")
click.echo(f"variables: {data['unique_variables']}")
for variable in data["variables"]:
click.echo(f"- {variable}")
for error in data["syntax_errors"]:
click.echo(f"! {error}")
2026-05-04 00:23:04 +02:00
def _parse_key_value_options(items: tuple[str, ...]) -> dict[str, object]:
values: dict[str, object] = {}
for item in items:
if "=" not in item:
raise ValueError(f"Expected KEY=VALUE, got `{item}`")
key, raw_value = item.split("=", 1)
key = key.strip()
if not key:
raise ValueError(f"Expected non-empty key in `{item}`")
_set_path(values, key.split("."), yaml.safe_load(raw_value))
return values
def _set_path(mapping: dict[str, object], path: list[str], value: object) -> None:
current = mapping
for part in path[:-1]:
next_value = current.setdefault(part, {})
if not isinstance(next_value, dict):
raise ValueError(f"Cannot set nested frontmatter path through scalar `{part}`")
current = next_value
current[path[-1]] = value
def _load_template_data(data_file: Path | None) -> dict[str, object]:
if data_file is None:
return {}
data = load_data_file(data_file)
if isinstance(data, list):
if len(data) != 1:
raise ValueError("Template render expects exactly one CSV record")
data = data[0]
if not isinstance(data, dict):
raise ValueError("Template data must be a mapping")
return data
def _deep_merge_cli(left: dict[str, object], right: dict[str, object]) -> dict[str, object]:
merged = dict(left)
for key, value in right.items():
if isinstance(merged.get(key), dict) and isinstance(value, dict):
merged[key] = _deep_merge_cli(merged[key], value)
else:
merged[key] = value
return merged
if __name__ == "__main__":
main()