[object Object]

← back to Cli Printing Press

feat(scripts): add verify-skill — static SKILL.md validator (#194)

297c3c14f2cd35cd359b503fddc2a8524421a5ce · 2026-04-12 19:02:33 -0700 · Trevin Chow

Files touched

Diff

commit 297c3c14f2cd35cd359b503fddc2a8524421a5ce
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun Apr 12 19:02:33 2026 -0700

    feat(scripts): add verify-skill — static SKILL.md validator (#194)
---
 scripts/verify-skill/.gitignore      |   1 +
 scripts/verify-skill/README.md       |  93 ++++++
 scripts/verify-skill/verify_skill.py | 546 +++++++++++++++++++++++++++++++++++
 3 files changed, 640 insertions(+)

diff --git a/scripts/verify-skill/.gitignore b/scripts/verify-skill/.gitignore
new file mode 100644
index 00000000..c18dd8d8
--- /dev/null
+++ b/scripts/verify-skill/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/scripts/verify-skill/README.md b/scripts/verify-skill/README.md
new file mode 100644
index 00000000..2081c3f9
--- /dev/null
+++ b/scripts/verify-skill/README.md
@@ -0,0 +1,93 @@
+# verify-skill
+
+Static verifier for `SKILL.md` files that ship alongside printed CLIs. Checks
+that every command, flag, and positional-arg signature referenced in a
+SKILL.md actually exists in the shipped CLI's `internal/cli/*.go` source.
+
+## Why
+
+SKILL.md files are authored prose. The authoring process (human or LLM)
+can confidently reference a flag like `--moneyness` or a command like
+`portfolio perf` that doesn't exist in the shipped code. Without
+verification, agents following the SKILL will hit "unknown flag" errors
+in production.
+
+This verifier was built after hand-authoring SKILLs for the 11 launch
+CLIs and discovering **23 invented / wrong-command / wrong-arg-count
+errors** across 7 of them. Three tiers of checks catch different error
+classes; the earlier tier is strictest and the later tiers cover what
+the earlier ones miss.
+
+## What it checks
+
+**1. flag-names** — every `--flag` token in SKILL.md is declared as
+`.Flags().XVar(&var, "name", ...)` somewhere in `internal/cli/*.go`. Catches
+pure inventions.
+
+**2. flag-commands** — every `--flag` used on a specific bash recipe is
+declared on that command's source file (or as a persistent root flag).
+Catches cases where a flag exists somewhere but I used it on the wrong
+command (e.g., `--interval` on `payouts` when it's actually on
+`commissions`).
+
+**3. positional-args** — each bash recipe's positional-arg count is
+compatible with the command's `Use:` field (`<required>` + `[optional]` +
+`variadic`) and its `Args:` validator (`cobra.ExactArgs(N)`,
+`MinimumNArgs(N)`, etc.). Catches wrong-arity invocations.
+
+## Usage
+
+```bash
+# Run all three checks against a CLI directory
+python3 verify_skill.py --dir /path/to/my-pp-cli
+
+# Run only the flag-command check
+python3 verify_skill.py --dir /path/to/my-pp-cli --only flag-commands
+
+# JSON output for CI
+python3 verify_skill.py --dir /path/to/my-pp-cli --json
+
+# Fail on findings that the script flags as likely false positives
+python3 verify_skill.py --dir /path/to/my-pp-cli --strict
+```
+
+Exit code `0` = clean (or only likely-false-positive findings), `1` =
+real issues found, `2` = usage error.
+
+## Known false positives
+
+The verifier uses pattern matching against Go source text — it doesn't
+build a full Cobra command tree. Two known FP patterns:
+
+**Parent command's first positional arg is a valid subcommand name.**
+Example: `hubspot-pp-cli associations companies <id> contacts` — `companies`
+is an object-type argument, not a subcommand. The parser reads `companies`
+as extending the command path and reports a bogus arity mismatch on a
+non-existent `associations.companies` command.
+
+**Shell command substitution inside a recipe.** Example:
+`tool --flag "$(tool other-subcommand ...)"`. The parser strips `$(...)`
+and replaces with a sentinel before tokenizing, but edge cases can slip
+through.
+
+Findings in these patterns are tagged `[likely false positive]` and
+don't trigger non-zero exit unless `--strict` is set.
+
+## When to run it
+
+**Per CLI, during SKILL authoring.** Any time you edit a SKILL.md,
+run the verifier against its CLI directory to catch invented flags
+immediately.
+
+**In CI for the library repo.** A GitHub Actions workflow can run this
+against every CLI directory on every PR, blocking merges that introduce
+unverified commands or flags. See `.github/workflows/verify-skills.yml`
+in `mvanhorn/printing-press-library` for the reference setup.
+
+## Future: Go port
+
+This is an interim Python implementation. The eventual home is a
+`printing-press verify-skill` subcommand in the CLI Printing Press,
+alongside `dogfood`, `verify`, and `scorecard` — integrated into
+`shipcheck` so SKILL validity is gated at publish time. See
+`docs/plans/` for the Go-port plan when it lands.
diff --git a/scripts/verify-skill/verify_skill.py b/scripts/verify-skill/verify_skill.py
new file mode 100755
index 00000000..6a393a1c
--- /dev/null
+++ b/scripts/verify-skill/verify_skill.py
@@ -0,0 +1,546 @@
+#!/usr/bin/env python3
+"""verify_skill.py — validate that SKILL.md matches the shipped CLI source.
+
+Three checks run in sequence:
+
+  1. flag-names — every `--flag` in SKILL.md is declared as a cobra flag
+     somewhere in internal/cli/*.go.
+  2. flag-commands — every `--flag` used on a specific command is declared
+     on that command (or as a persistent/root flag).
+  3. positional-args — positional args in bash recipes match the command's
+     `Use:` field signature (required + optional + variadic).
+
+The checks are pattern-matching heuristics against Go AST-adjacent text.
+False positives are possible for edge cases:
+  - Shell command substitution (`$(...)`) inside a recipe can be
+    misinterpreted as extending the outer command path.
+  - Commands where the first positional arg is a valid subcommand name
+    (e.g., `hubspot associations companies <id>` where `companies` is an
+    object type passed as arg, not a subcommand).
+
+Known false-positives are reported with a `[likely false positive]` tag.
+
+USAGE
+
+    python3 verify_skill.py --dir <cli-dir>
+    python3 verify_skill.py --dir <cli-dir> --json
+    python3 verify_skill.py --dir <cli-dir> --only flag-names
+    python3 verify_skill.py --dir <cli-dir> --strict  # treat known-FPs as failures
+
+Exit codes:
+    0 — all checks passed
+    1 — one or more checks found issues (excluding known false positives
+        unless --strict is set)
+    2 — usage error (missing --dir, SKILL.md not found, etc.)
+
+The CLI dir must contain both `SKILL.md` and `internal/cli/*.go`.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import shlex
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Iterable
+
+
+COMMON_FLAGS = {
+    "help", "version", "json", "csv", "plain", "quiet", "agent",
+    "select", "compact", "dry-run", "no-cache", "yes", "no-input",
+    "no-color", "human-friendly", "config", "base-url", "rate-limit",
+    "timeout", "data-source", "stdin", "limit", "format", "output",
+    "no-prompt", "days",
+}
+
+CODEBLOCK_BASH = re.compile(r"```bash\n(.*?)\n```", re.DOTALL)
+USE_RE = re.compile(r'Use:\s*"([^"]+)"')
+ARGS_RE = re.compile(
+    r'Args:\s*cobra\.(ExactArgs|MinimumNArgs|MaximumNArgs|RangeArgs|NoArgs|OnlyValidArgs|ExactValidArgs)\s*\(([^)]*)\)'
+)
+FLAG_DECL_RE = re.compile(
+    r'(Persistent)?Flags\(\)\.'
+    r'(StringVar|BoolVar|IntVar|Int64Var|Float64Var|DurationVar|'
+    r'StringSliceVar|StringArrayVar|UintVar|Uint64Var)P?\('
+    r'&[^,]+,\s*"([a-z][a-z0-9-]*)"'
+)
+FLAG_TOKEN_RE = re.compile(r"(?:^|\s)(--[a-z][a-z0-9-]*)")
+
+
+@dataclass
+class Finding:
+    check: str
+    severity: str  # "error" or "warning"
+    command: str
+    detail: str
+    evidence: str = ""
+    likely_false_positive: bool = False
+
+
+@dataclass
+class Report:
+    cli_dir: str
+    skill_path: str
+    findings: list[Finding] = field(default_factory=list)
+    checks_run: list[str] = field(default_factory=list)
+    recipes_checked: int = 0
+
+    def has_real_failures(self) -> bool:
+        return any(not f.likely_false_positive for f in self.findings)
+
+
+# ---------------------------------------------------------------------------
+# Source inspection
+# ---------------------------------------------------------------------------
+
+
+def parse_use(use_str: str) -> tuple[str, int, int, bool]:
+    """Return (name, required_count, optional_count, has_variadic)."""
+    tokens = use_str.split()
+    if not tokens:
+        return "", 0, 0, False
+    name = tokens[0]
+    required = optional = 0
+    variadic = False
+    for t in tokens[1:]:
+        if t.startswith("<") and t.endswith(">"):
+            required += 1
+        elif t.startswith("[") and t.endswith("]"):
+            if "..." in t:
+                variadic = True
+            else:
+                optional += 1
+        elif "..." in t:
+            variadic = True
+    return name, required, optional, variadic
+
+
+def find_command_source(cli_dir: Path, cmd_path: list[str]):
+    """Locate source file(s) whose cobra.Command matches this path.
+
+    Returns (go_files, use_str, args_info) where go_files is a list.
+    Why a list: CLIs sometimes declare the same Use in two files (historical
+    artifact or generator + transcendence both shipping a version of the
+    same command). Cobra only registers one at runtime, but we don't know
+    which without parsing root.go's AddCommand calls. Returning all matching
+    files lets callers union their flags when checking declarations.
+    """
+    if not cmd_path:
+        return [], None, None
+    leaf = cmd_path[-1]
+    src = cli_dir / "internal/cli"
+    if not src.exists():
+        return [], None, None
+
+    candidates = []
+    for go_file in src.glob("*.go"):
+        if go_file.name.endswith("_test.go"):
+            continue
+        try:
+            text = go_file.read_text()
+        except Exception:
+            continue
+        for m in USE_RE.finditer(text):
+            use_str = m.group(1)
+            name, req, opt, var_ = parse_use(use_str)
+            if name != leaf:
+                continue
+            end = m.end()
+            window = text[end : end + 500]
+            args_match = ARGS_RE.search(window)
+            args_info = (args_match.group(1), args_match.group(2)) if args_match else None
+            specificity = req + opt + (1 if var_ else 0)
+            candidates.append((specificity, go_file, use_str, args_info))
+
+    if not candidates:
+        return [], None, None
+
+    # Multi-token paths: prefer filename-match (e.g., contacts_search.go for
+    # cmd_path ["contacts", "search"]).
+    if len(cmd_path) >= 2:
+        expected_basename = "_".join(cmd_path).replace("-", "_") + ".go"
+        for spec, go_file, use_str, args_info in candidates:
+            if go_file.name == expected_basename:
+                return [go_file], use_str, args_info
+
+    # Single-token paths (or no filename match): take all files at the
+    # highest specificity tier. For flag verification, any one of them
+    # declaring the flag counts. For Use string, pick the representative.
+    candidates.sort(key=lambda c: -c[0])
+    top_spec = candidates[0][0]
+    top_files = [c[1] for c in candidates if c[0] == top_spec]
+    return top_files, candidates[0][2], candidates[0][3]
+
+
+def flag_declared_in(files: Iterable[Path], flag_name: str) -> bool:
+    for f in files:
+        try:
+            text = f.read_text()
+        except Exception:
+            continue
+        for m in FLAG_DECL_RE.finditer(text):
+            if m.group(3) == flag_name:
+                return True
+    return False
+
+
+def persistent_flag_declared(cli_dir: Path, flag_name: str) -> bool:
+    src = cli_dir / "internal/cli"
+    if not src.exists():
+        return False
+    for go_file in src.glob("*.go"):
+        try:
+            text = go_file.read_text()
+        except Exception:
+            continue
+        for m in FLAG_DECL_RE.finditer(text):
+            persistent, _, name = m.groups()
+            if name == flag_name and persistent == "Persistent":
+                return True
+    return False
+
+
+# ---------------------------------------------------------------------------
+# SKILL.md extraction
+# ---------------------------------------------------------------------------
+
+
+def extract_all_flags(skill: Path) -> set[str]:
+    """Return every `--flag-name` token (without `--`) used anywhere in SKILL.md."""
+    text = skill.read_text()
+    return {t.lstrip("-") for t in FLAG_TOKEN_RE.findall(text)}
+
+
+def extract_recipes(skill: Path, cli_binary: str, cli_dir: Path | None = None) -> list[tuple[list[str], list[str], list[str]]]:
+    """Return list of (cmd_path, positional_args, flags) tuples from bash blocks.
+
+    cmd_path: leading lowercase-hyphenated tokens (up to 3)
+    positional_args: non-flag tokens after cmd_path (shell-quoted strings preserved)
+    flags: --flag tokens (with their -- prefix)
+    """
+    text = skill.read_text()
+    blocks = CODEBLOCK_BASH.findall(text)
+    results = []
+    for block in blocks:
+        # Merge line continuations
+        merged = []
+        buf = []
+        for raw in block.splitlines():
+            stripped = raw.rstrip()
+            if stripped.endswith("\\"):
+                buf.append(stripped[:-1].strip())
+            else:
+                buf.append(stripped)
+                merged.append(" ".join(buf))
+                buf = []
+        if buf:
+            merged.append(" ".join(buf))
+
+        for line in merged:
+            line = line.strip()
+            if not line or line.startswith("#"):
+                continue
+            # Strip trailing comment
+            cmt = line.find(" #")
+            if cmt != -1:
+                line = line[:cmt].strip()
+            if not line.startswith(cli_binary + " "):
+                continue
+            # Strip shell command substitutions $(...) and backtick forms
+            # FIRST — their contents are separate commands. Do this before
+            # splitting on pipes so we don't mistakenly cut inside a $(...).
+            line = re.sub(r"\$\([^)]*\)", "__SUBST__", line)
+            line = re.sub(r"`[^`]*`", "__SUBST__", line)
+            # Stop at outer shell operators so we don't parse pipes/redirects
+            for op in [" | ", " && ", " || ", " > ", " >> ", " < "]:
+                if op in line:
+                    line = line.split(op)[0]
+                    break
+            after = line[len(cli_binary) + 1 :].strip()
+            try:
+                tokens = shlex.split(after, posix=True)
+            except ValueError:
+                tokens = after.split()
+            if not tokens:
+                continue
+            cmd_path: list[str] = [tokens[0].lower()]
+            i = 1
+            while i < len(tokens):
+                t = tokens[i]
+                if t.startswith("-"):
+                    break
+                if (
+                    t.startswith("<") or t.startswith("[")
+                    or t.startswith('"') or t.startswith("'")
+                    or t.startswith("$") or t.startswith("http")
+                    or "/" in t or "=" in t
+                    or re.match(r"^[A-Z]", t)
+                    or re.match(r"^\d", t)
+                ):
+                    break
+                if len(cmd_path) < 3 and re.match(r"^[a-z][a-z0-9-]*$", t):
+                    # Verify adding this token still maps to a valid command.
+                    # If the extended path has no source match (e.g. the
+                    # parent command's Use documents <positional> and this
+                    # token is just the arg), treat it as positional.
+                    if cli_dir is not None:
+                        trial = cmd_path + [t]
+                        files, _, _ = find_command_source(cli_dir, trial)
+                        if not files:
+                            break
+                    cmd_path.append(t)
+                    i += 1
+                    continue
+                break
+            positional: list[str] = []
+            flags: list[str] = []
+            while i < len(tokens):
+                t = tokens[i]
+                if t.startswith("--"):
+                    flags.append(t)
+                    # Skip value if present and not another flag
+                    if i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
+                        i += 2
+                        continue
+                elif t.startswith("-"):
+                    # Short flag, skip its value heuristically
+                    if i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
+                        i += 2
+                        continue
+                else:
+                    positional.append(t)
+                i += 1
+            results.append((cmd_path, positional, flags))
+    return results
+
+
+# ---------------------------------------------------------------------------
+# Checks
+# ---------------------------------------------------------------------------
+
+
+def check_flag_names(cli_dir: Path, skill: Path, report: Report) -> None:
+    all_files = list((cli_dir / "internal/cli").glob("*.go"))
+    flags = extract_all_flags(skill) - COMMON_FLAGS
+    for flag in sorted(flags):
+        if not flag_declared_in(all_files, flag):
+            report.findings.append(
+                Finding(
+                    check="flag-names",
+                    severity="error",
+                    command="(any)",
+                    detail=f"--{flag} is referenced in SKILL.md but not declared in any internal/cli/*.go",
+                )
+            )
+
+
+def check_flag_commands(cli_dir: Path, skill: Path, cli_binary: str, report: Report) -> None:
+    all_files = list((cli_dir / "internal/cli").glob("*.go"))
+    recipes = extract_recipes(skill, cli_binary, cli_dir)
+    for cmd_path, _positional, flags in recipes:
+        for raw_flag in flags:
+            flag = raw_flag.lstrip("-")
+            if flag in COMMON_FLAGS:
+                continue
+            cmd_files, _, _ = find_command_source(cli_dir, cmd_path)
+            if cmd_files and flag_declared_in(cmd_files, flag):
+                continue
+            if persistent_flag_declared(cli_dir, flag):
+                continue
+            path_str = " ".join(cmd_path)
+            if flag_declared_in(all_files, flag):
+                report.findings.append(
+                    Finding(
+                        check="flag-commands",
+                        severity="error",
+                        command=f"{cli_binary} {path_str}",
+                        detail=f"--{flag} is declared elsewhere but not on {path_str}",
+                    )
+                )
+            else:
+                report.findings.append(
+                    Finding(
+                        check="flag-commands",
+                        severity="error",
+                        command=f"{cli_binary} {path_str}",
+                        detail=f"--{flag} is not declared anywhere",
+                    )
+                )
+
+
+def check_positional_args(cli_dir: Path, skill: Path, cli_binary: str, report: Report) -> None:
+    recipes = extract_recipes(skill, cli_binary, cli_dir)
+    report.recipes_checked = len(recipes)
+    for cmd_path, positional, _flags in recipes:
+        _files, use_str, args_info = find_command_source(cli_dir, cmd_path)
+        if not use_str:
+            continue  # command not found — not our job to flag here
+        _, required, optional, variadic = parse_use(use_str)
+        min_ok = required
+        max_ok = float("inf") if variadic else required + optional
+        if args_info:
+            validator, arg = args_info
+            try:
+                n = int(arg) if arg else 0
+            except ValueError:
+                n = 0
+            if validator == "ExactArgs":
+                min_ok = max_ok = n
+            elif validator == "MinimumNArgs":
+                min_ok = n
+                max_ok = float("inf")
+            elif validator == "MaximumNArgs":
+                min_ok = 0
+                max_ok = n
+            elif validator == "NoArgs":
+                min_ok = max_ok = 0
+        actual = len(positional)
+        if min_ok <= actual <= max_ok:
+            continue
+
+        path_str = " ".join(cmd_path)
+        # Classify common false-positive patterns.
+        # FP-1: shell command-substitution residue inside an --arg value
+        # (parser may have kept `$(dub-pp-cli links stale ...)` contents).
+        # FP-2: parent command whose first positional arg happens to be a
+        # valid cobra subcommand name (e.g., `associations companies`).
+        fp = False
+        if any(p.startswith("$") for p in positional):
+            fp = True
+        # For single-token cmd_path where positional[0] is lowercase+alpha,
+        # the parser may have under-counted cmd_path.
+        if len(cmd_path) == 1 and positional and re.match(r"^[a-z][a-z0-9-]+$", positional[0]):
+            fp = True
+
+        max_display = "∞" if max_ok == float("inf") else int(max_ok)
+        report.findings.append(
+            Finding(
+                check="positional-args",
+                severity="error" if not fp else "warning",
+                command=f"{cli_binary} {path_str}",
+                detail=f'got {actual} positional args; Use: "{use_str}" expects {min_ok}–{max_display}',
+                evidence=" ".join(positional) or "(none)",
+                likely_false_positive=fp,
+            )
+        )
+
+
+# ---------------------------------------------------------------------------
+# Runner
+# ---------------------------------------------------------------------------
+
+
+def derive_cli_binary(cli_dir: Path) -> str:
+    """Derive the CLI binary name from .printing-press.json, go.mod, or dir name."""
+    manifest = cli_dir / ".printing-press.json"
+    if manifest.exists():
+        try:
+            data = json.loads(manifest.read_text())
+            if data.get("cli_name"):
+                return data["cli_name"]
+        except Exception:
+            pass
+    # Fallback — assume <dirname>-pp-cli
+    return cli_dir.name + "-pp-cli"
+
+
+def run_checks(cli_dir: Path, only: set[str] | None) -> Report:
+    skill = cli_dir / "SKILL.md"
+    if not skill.exists():
+        print(f"error: no SKILL.md in {cli_dir}", file=sys.stderr)
+        sys.exit(2)
+    if not (cli_dir / "internal/cli").exists():
+        print(f"error: no internal/cli/ in {cli_dir}", file=sys.stderr)
+        sys.exit(2)
+
+    cli_binary = derive_cli_binary(cli_dir)
+    report = Report(cli_dir=str(cli_dir), skill_path=str(skill))
+
+    checks = only or {"flag-names", "flag-commands", "positional-args"}
+    if "flag-names" in checks:
+        report.checks_run.append("flag-names")
+        check_flag_names(cli_dir, skill, report)
+    if "flag-commands" in checks:
+        report.checks_run.append("flag-commands")
+        check_flag_commands(cli_dir, skill, cli_binary, report)
+    if "positional-args" in checks:
+        report.checks_run.append("positional-args")
+        check_positional_args(cli_dir, skill, cli_binary, report)
+    return report
+
+
+def format_human(report: Report) -> str:
+    lines = [f"=== {Path(report.cli_dir).name} ==="]
+    errors = [f for f in report.findings if not f.likely_false_positive]
+    warnings = [f for f in report.findings if f.likely_false_positive]
+    if not report.findings:
+        lines.append(f"  ✓ All checks passed ({', '.join(report.checks_run)})")
+        return "\n".join(lines)
+    lines.append(f"  ✘ {len(errors)} error(s), {len(warnings)} likely false-positive(s)")
+    for f in errors:
+        lines.append(f"    [{f.check}] {f.command}: {f.detail}")
+        if f.evidence:
+            lines.append(f"      evidence: {f.evidence}")
+    for f in warnings:
+        lines.append(f"    [{f.check}] {f.command}: {f.detail}  [likely false positive]")
+        if f.evidence:
+            lines.append(f"      evidence: {f.evidence}")
+    return "\n".join(lines)
+
+
+def format_json(report: Report) -> str:
+    out = {
+        "cli_dir": report.cli_dir,
+        "skill_path": report.skill_path,
+        "checks_run": report.checks_run,
+        "recipes_checked": report.recipes_checked,
+        "findings": [
+            {
+                "check": f.check,
+                "severity": f.severity,
+                "command": f.command,
+                "detail": f.detail,
+                "evidence": f.evidence,
+                "likely_false_positive": f.likely_false_positive,
+            }
+            for f in report.findings
+        ],
+    }
+    return json.dumps(out, indent=2)
+
+
+def main():
+    p = argparse.ArgumentParser(
+        description="Verify SKILL.md matches shipped CLI source."
+    )
+    p.add_argument("--dir", required=True, help="CLI directory (contains SKILL.md + internal/cli/)")
+    p.add_argument(
+        "--only",
+        choices=["flag-names", "flag-commands", "positional-args"],
+        action="append",
+        help="Run only the named check(s). Pass multiple times to include multiple.",
+    )
+    p.add_argument("--json", action="store_true", help="Emit JSON output")
+    p.add_argument(
+        "--strict",
+        action="store_true",
+        help="Exit non-zero even for findings classified as likely false positives.",
+    )
+    args = p.parse_args()
+    only = set(args.only) if args.only else None
+    report = run_checks(Path(args.dir).resolve(), only)
+
+    if args.json:
+        print(format_json(report))
+    else:
+        print(format_human(report))
+
+    if args.strict:
+        sys.exit(1 if report.findings else 0)
+    sys.exit(1 if report.has_real_failures() else 0)
+
+
+if __name__ == "__main__":
+    main()

← 0ac65134 feat(cli): add travel and food-and-dining categories (#187)  ·  back to Cli Printing Press  ·  fix(cli): path-aware dogfood novel-feature matcher (#195) 126b00d0 →