Skip to content

CLI API

Command-line interface for markdown-validator.

Entry point: md-validate (defined in pyproject.toml).

Usage examples::

# Validate a single file
md-validate validate article.md --rules rules/tutorial.json

# Validate all .md files in a directory
md-validate validate docs/ --rules rules/tutorial.json --output results/

# Output JSON report
md-validate validate article.md --rules rules/tutorial.json --format json

# Verbose logging
md-validate --verbose validate article.md --rules rules/tutorial.json

cli(ctx: click.Context, verbose: bool, quiet: bool) -> None

Markdown Validator — rule-based document validation.

Parameters:

Name Type Description Default
ctx Context

Click context object.

required
verbose bool

Enable DEBUG-level output when True.

required
quiet bool

Suppress INFO/DEBUG output when True.

required
Source code in markdown_validator/cli/main.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@click.group()
@click.option("--verbose", "-v", is_flag=True, default=False, help="Enable debug logging.")
@click.option("--quiet", "-q", is_flag=True, default=False, help="Suppress all non-error output.")
@click.pass_context
def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None:
    """Markdown Validator — rule-based document validation.

    :param ctx: Click context object.
    :param verbose: Enable DEBUG-level output when True.
    :param quiet: Suppress INFO/DEBUG output when True.
    """
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose
    ctx.obj["quiet"] = quiet
    _configure_logging(verbose, quiet)

validate(ctx: click.Context, target: str, rules: str, output: str | None, output_format: str, workflows: bool) -> None

Validate TARGET (file or directory) against RULES.

TARGET may be a single .md file or a directory; when a directory is given, all .md files within it are validated recursively.

Parameters:

Name Type Description Default
ctx Context

Click context object.

required
target str

Path to the markdown file or directory.

required
rules str

Path to the rule-set JSON file.

required
output str | None

Optional output directory for persisting reports.

required
output_format str

Format for report output (text, json, csv).

required
workflows bool

Whether to execute workflow chains after rule evaluation.

required
Source code in markdown_validator/cli/main.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
@cli.command()
@click.argument("target", type=click.Path(exists=True))
@click.option(
    "--rules",
    "-r",
    required=True,
    type=click.Path(exists=True),
    help="Path to the rule-set JSON file.",
)
@click.option(
    "--output",
    "-o",
    default=None,
    type=click.Path(),
    help="Output directory for reports (optional).",
)
@click.option(
    "--format",
    "-f",
    "output_format",
    default="text",
    type=click.Choice(["text", "json", "csv"], case_sensitive=False),
    help="Output format (default: text).",
)
@click.option(
    "--workflows/--no-workflows",
    default=True,
    help="Run workflow chains defined in the rule set (default: enabled).",
)
@click.pass_context
def validate(
    ctx: click.Context,
    target: str,
    rules: str,
    output: str | None,
    output_format: str,
    workflows: bool,
) -> None:
    """Validate TARGET (file or directory) against RULES.

    TARGET may be a single ``.md`` file or a directory; when a directory is
    given, all ``.md`` files within it are validated recursively.

    :param ctx: Click context object.
    :param target: Path to the markdown file or directory.
    :param rules: Path to the rule-set JSON file.
    :param output: Optional output directory for persisting reports.
    :param output_format: Format for report output (``text``, ``json``, ``csv``).
    :param workflows: Whether to execute workflow chains after rule evaluation.
    """
    scanner = Scanner()
    target_path = Path(target)

    try:
        if target_path.is_dir():
            reports = scanner.validate_directory(target_path, rules)
        else:
            reports = [scanner.validate(target_path, rules)]
    except (FileNotFoundError, ValueError) as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(1)

    overall_passed = all(r.passed for r in reports)

    for report in reports:
        _render_report(report, output_format, output, rules, workflows)

    if not overall_passed:
        sys.exit(1)

Interactive REPL for developing and testing validation rules.

Provides a :class:ValidatorREPL class (cmd.Cmd subclass) that lets developers probe documents interactively without writing a full rule set.

Start the REPL::

python -m markdown_validator.cli.repl

or via the CLI::

md-validate repl

ValidatorREPL

Bases: Cmd

Interactive REPL for exploring and testing validation rules.

Attributes:

Name Type Description
intro str

Introductory message displayed on startup.

prompt str

Shell prompt string.

Source code in markdown_validator/cli/repl.py
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
class ValidatorREPL(cmd.Cmd):
    """Interactive REPL for exploring and testing validation rules.

    :cvar intro: Introductory message displayed on startup.
    :cvar prompt: Shell prompt string.
    """

    intro: str = _INTRO
    prompt: str = _PROMPT

    def __init__(self) -> None:
        super().__init__()
        self._doc: ParsedDocument | None = None

    # ------------------------------------------------------------------
    # Document loading
    # ------------------------------------------------------------------

    def do_load(self, line: str) -> None:
        """Load a Markdown file for subsequent queries.

        Usage: load <path-to-markdown-file>
        """
        parts = shlex.split(line)
        if not parts:
            print("Usage: load <path-to-markdown-file>")
            return
        path = parts[0]
        try:
            self._doc = parse_document(path)
            print(f"Loaded: {self._doc.filepath}")
            print(f"  Metadata keys : {list(self._doc.metadata.keys())}")
        except (FileNotFoundError, ValueError) as exc:
            print(f"Error: {exc}")

    def do_dump(self, line: str) -> None:
        """Dump document content.  Options: metadata | html | raw

        Usage: dump metadata
               dump html
        """
        if self._doc is None:
            print("No document loaded. Use: load <path>")
            return
        arg = line.strip().lower()
        if arg == "metadata":
            print(json.dumps(self._doc.metadata, indent=2))
        elif arg == "html":
            print(self._doc.html)
        else:
            print("Usage: dump metadata | html")

    # ------------------------------------------------------------------
    # XPath queries
    # ------------------------------------------------------------------

    def do_query(self, line: str) -> None:
        """Run an XPath query against the loaded document.

        Usage: query <xpath> [flag]

        Flags: text (default), count, dom
        Example: query /html/body/h1 text
                 query /html/body/h2 count
        """
        if self._doc is None:
            print("No document loaded. Use: load <path>")
            return
        parts = shlex.split(line)
        if not parts:
            print("Usage: query <xpath> [flag]")
            return
        xpath = parts[0]
        flag = parts[1] if len(parts) > 1 else "text"
        result = _xpath_query(self._doc.html, xpath, flag)
        print(f"Result: {result}")

    # ------------------------------------------------------------------
    # Rule evaluation
    # ------------------------------------------------------------------

    def do_eval(self, line: str) -> None:
        """Evaluate a rule JSON payload against the loaded document.

        Usage: eval <json-rule-object>

        Example:
            eval {"name":"check-h1","id":1,"type":"body","query":"/html/body/h1","flag":"count","operation":"==","value":"1"}
        """
        if self._doc is None:
            print("No document loaded. Use: load <path>")
            return
        try:
            data = json.loads(line)
            rule = RuleModel.model_validate(data)
        except (json.JSONDecodeError, ValueError) as exc:
            print(f"Invalid rule JSON: {exc}")
            return
        result = evaluate_rule(rule, self._doc)
        status = "PASS" if result.passed else "FAIL"
        print(f"{status}: rule id={result.rule_id} name={result.rule_name!r}")
        if not result.passed and result.mitigation:
            print(f"  → {result.mitigation}")

    def do_get(self, line: str) -> None:
        """Get a metadata value by key.

        Usage: get <metadata-key>
        Example: get ms.topic
        """
        if self._doc is None:
            print("No document loaded. Use: load <path>")
            return
        key = line.strip()
        value = self._doc.metadata.get(key)
        if value is None:
            print(f"Key {key!r} not found in metadata.")
        else:
            print(f"{key}: {value}")

    # ------------------------------------------------------------------
    # Exit commands
    # ------------------------------------------------------------------

    def do_quit(self, _line: str) -> bool:
        """Exit the REPL."""
        return True

    def do_exit(self, line: str) -> bool:
        """Exit the REPL."""
        return self.do_quit(line)

    def do_EOF(self, line: str) -> bool:  # noqa: N802  (cmd.Cmd convention requires uppercase)
        """Exit on Ctrl-D."""
        print()
        return self.do_quit(line)

    # ------------------------------------------------------------------
    # Help shortcut
    # ------------------------------------------------------------------

    def do_help(self, arg: str) -> None:
        """Show help."""
        super().do_help(arg)

do_EOF(line: str) -> bool

Exit on Ctrl-D.

Source code in markdown_validator/cli/repl.py
168
169
170
171
def do_EOF(self, line: str) -> bool:  # noqa: N802  (cmd.Cmd convention requires uppercase)
    """Exit on Ctrl-D."""
    print()
    return self.do_quit(line)

do_dump(line: str) -> None

Dump document content. Options: metadata | html | raw

Usage: dump metadata dump html

Source code in markdown_validator/cli/repl.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def do_dump(self, line: str) -> None:
    """Dump document content.  Options: metadata | html | raw

    Usage: dump metadata
           dump html
    """
    if self._doc is None:
        print("No document loaded. Use: load <path>")
        return
    arg = line.strip().lower()
    if arg == "metadata":
        print(json.dumps(self._doc.metadata, indent=2))
    elif arg == "html":
        print(self._doc.html)
    else:
        print("Usage: dump metadata | html")

do_eval(line: str) -> None

Evaluate a rule JSON payload against the loaded document.

Usage: eval

Example: eval {"name":"check-h1","id":1,"type":"body","query":"/html/body/h1","flag":"count","operation":"==","value":"1"}

Source code in markdown_validator/cli/repl.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def do_eval(self, line: str) -> None:
    """Evaluate a rule JSON payload against the loaded document.

    Usage: eval <json-rule-object>

    Example:
        eval {"name":"check-h1","id":1,"type":"body","query":"/html/body/h1","flag":"count","operation":"==","value":"1"}
    """
    if self._doc is None:
        print("No document loaded. Use: load <path>")
        return
    try:
        data = json.loads(line)
        rule = RuleModel.model_validate(data)
    except (json.JSONDecodeError, ValueError) as exc:
        print(f"Invalid rule JSON: {exc}")
        return
    result = evaluate_rule(rule, self._doc)
    status = "PASS" if result.passed else "FAIL"
    print(f"{status}: rule id={result.rule_id} name={result.rule_name!r}")
    if not result.passed and result.mitigation:
        print(f"  → {result.mitigation}")

do_exit(line: str) -> bool

Exit the REPL.

Source code in markdown_validator/cli/repl.py
164
165
166
def do_exit(self, line: str) -> bool:
    """Exit the REPL."""
    return self.do_quit(line)

do_get(line: str) -> None

Get a metadata value by key.

Usage: get Example: get ms.topic

Source code in markdown_validator/cli/repl.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def do_get(self, line: str) -> None:
    """Get a metadata value by key.

    Usage: get <metadata-key>
    Example: get ms.topic
    """
    if self._doc is None:
        print("No document loaded. Use: load <path>")
        return
    key = line.strip()
    value = self._doc.metadata.get(key)
    if value is None:
        print(f"Key {key!r} not found in metadata.")
    else:
        print(f"{key}: {value}")

do_help(arg: str) -> None

Show help.

Source code in markdown_validator/cli/repl.py
177
178
179
def do_help(self, arg: str) -> None:
    """Show help."""
    super().do_help(arg)

do_load(line: str) -> None

Load a Markdown file for subsequent queries.

Usage: load

Source code in markdown_validator/cli/repl.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def do_load(self, line: str) -> None:
    """Load a Markdown file for subsequent queries.

    Usage: load <path-to-markdown-file>
    """
    parts = shlex.split(line)
    if not parts:
        print("Usage: load <path-to-markdown-file>")
        return
    path = parts[0]
    try:
        self._doc = parse_document(path)
        print(f"Loaded: {self._doc.filepath}")
        print(f"  Metadata keys : {list(self._doc.metadata.keys())}")
    except (FileNotFoundError, ValueError) as exc:
        print(f"Error: {exc}")

do_query(line: str) -> None

Run an XPath query against the loaded document.

Usage: query [flag]

Flags: text (default), count, dom Example: query /html/body/h1 text query /html/body/h2 count

Source code in markdown_validator/cli/repl.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def do_query(self, line: str) -> None:
    """Run an XPath query against the loaded document.

    Usage: query <xpath> [flag]

    Flags: text (default), count, dom
    Example: query /html/body/h1 text
             query /html/body/h2 count
    """
    if self._doc is None:
        print("No document loaded. Use: load <path>")
        return
    parts = shlex.split(line)
    if not parts:
        print("Usage: query <xpath> [flag]")
        return
    xpath = parts[0]
    flag = parts[1] if len(parts) > 1 else "text"
    result = _xpath_query(self._doc.html, xpath, flag)
    print(f"Result: {result}")

do_quit(_line: str) -> bool

Exit the REPL.

Source code in markdown_validator/cli/repl.py
160
161
162
def do_quit(self, _line: str) -> bool:
    """Exit the REPL."""
    return True

main() -> None

Start the interactive REPL.

Source code in markdown_validator/cli/repl.py
182
183
184
185
186
187
188
189
def main() -> None:
    """Start the interactive REPL."""
    logging.basicConfig(format="%(levelname)s %(message)s", level=logging.WARNING)
    try:
        ValidatorREPL().cmdloop()
    except KeyboardInterrupt:
        print("\nInterrupted.")
        sys.exit(0)