|
|
@@ -0,0 +1,460 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+"""Staleness verifier for the claude-api-ops model + cache-minimum tables.
|
|
|
+
|
|
|
+Guards the two fast-moving fact tables in this skill against silent drift:
|
|
|
+ - the "Current Models" table in SKILL.md (ids, pricing, context, output)
|
|
|
+ - the per-model prompt-cache minimum table in references/caching-and-cost.md
|
|
|
+
|
|
|
+Two modes (protocol SKILL-RESOURCE-PROTOCOL.md §7):
|
|
|
+ --offline (default): parse both tables, assert internal consistency. No network.
|
|
|
+ Exit 4 (VALIDATION) on a malformed/contradictory row.
|
|
|
+ --live: curl the Anthropic Models API and compare its model-id set
|
|
|
+ against the documented ids. Advisory only.
|
|
|
+
|
|
|
+Live-mode scope limit: the Models API returns model IDs but NOT pricing, context,
|
|
|
+or output limits. --live therefore verifies model-ID existence/coverage ONLY:
|
|
|
+ - a documented id absent from the live list -> DRIFT (retired/typo)
|
|
|
+ - a live id newer than anything documented -> DRIFT (table lacks a new model)
|
|
|
+Pricing/context/output drift is out of scope for --live (the API can't confirm it);
|
|
|
+--offline guards their well-formedness, and the SKILL.md "Live Documentation" links
|
|
|
+remain the human cross-check for pricing.
|
|
|
+
|
|
|
+Usage: check-model-table.py [--offline | --live] [--json] [--skill-dir DIR] [-q]
|
|
|
+Input: reads SKILL.md and references/caching-and-cost.md (resolved relative to
|
|
|
+ this script, or --skill-dir)
|
|
|
+Output: stdout = data only (JSON envelope under --json, else a plain summary)
|
|
|
+Stderr: headers, progress, notes, errors
|
|
|
+Exit: 0 ok/consistent, 2 usage, 3 not-found, 4 validation (malformed/contradictory),
|
|
|
+ 5 missing-dep (curl, --live only), 7 unavailable (no key / API unreachable),
|
|
|
+ 10 drift (live id-set disagrees with the table)
|
|
|
+
|
|
|
+Examples:
|
|
|
+ check-model-table.py --offline
|
|
|
+ check-model-table.py --offline --json | python -m json.tool
|
|
|
+ ANTHROPIC_API_KEY=sk-... check-model-table.py --live
|
|
|
+ check-model-table.py --live # exits 7 (advisory) when the key is unset
|
|
|
+"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import argparse
|
|
|
+import json
|
|
|
+import os
|
|
|
+import re
|
|
|
+import subprocess
|
|
|
+import sys
|
|
|
+from pathlib import Path
|
|
|
+from typing import NoReturn
|
|
|
+
|
|
|
+# Windows consoles default to cp1252; force UTF-8 so em-dashes/§ in notes don't
|
|
|
+# raise UnicodeEncodeError or print mojibake (matches the repo's standard fix).
|
|
|
+for _stream in (sys.stdout, sys.stderr):
|
|
|
+ try:
|
|
|
+ _stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
|
|
+ except (AttributeError, ValueError):
|
|
|
+ pass
|
|
|
+
|
|
|
+EXIT_OK = 0
|
|
|
+EXIT_USAGE = 2
|
|
|
+EXIT_NOT_FOUND = 3
|
|
|
+EXIT_VALIDATION = 4
|
|
|
+EXIT_MISSING_DEP = 5
|
|
|
+EXIT_UNAVAILABLE = 7
|
|
|
+EXIT_DRIFT = 10
|
|
|
+
|
|
|
+SCHEMA = "claude-mods.claude-api-ops.model-table/v1"
|
|
|
+MODELS_API = "https://api.anthropic.com/v1/models?limit=1000"
|
|
|
+ANTHROPIC_VERSION = "2023-06-01"
|
|
|
+
|
|
|
+# A well-formed alias id: claude-<word>-<digit>... and NO date suffix.
|
|
|
+# Accepts claude-opus-4-8, claude-fable-5, claude-sonnet-4-6, claude-haiku-4-5.
|
|
|
+ID_RE = re.compile(r"^claude-[a-z]+-\d+(?:-\d+)?$")
|
|
|
+# A date suffix looks like an 8-digit run (e.g. -20251114).
|
|
|
+DATE_SUFFIX_RE = re.compile(r"-\d{8}$")
|
|
|
+
|
|
|
+
|
|
|
+def note(msg: str, quiet: bool) -> None:
|
|
|
+ if not quiet:
|
|
|
+ print(msg, file=sys.stderr)
|
|
|
+
|
|
|
+
|
|
|
+def fail_validation(message: str, details: dict, json_mode: bool) -> NoReturn:
|
|
|
+ if json_mode:
|
|
|
+ print(json.dumps({"error": {"code": "VALIDATION", "message": message,
|
|
|
+ "details": details}}))
|
|
|
+ print(f"ERROR: {message}", file=sys.stderr)
|
|
|
+ for k, v in details.items():
|
|
|
+ print(f" {k}: {v}", file=sys.stderr)
|
|
|
+ sys.exit(EXIT_VALIDATION)
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# Parsing
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+def split_row(line: str) -> list[str]:
|
|
|
+ """Split a markdown table row into trimmed cells (drops outer pipes)."""
|
|
|
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
|
+ return cells
|
|
|
+
|
|
|
+
|
|
|
+def is_separator(cells: list[str]) -> bool:
|
|
|
+ return all(re.fullmatch(r":?-{2,}:?", c or "") for c in cells) and bool(cells)
|
|
|
+
|
|
|
+
|
|
|
+def parse_model_table(text: str) -> tuple[list[dict], list[str]]:
|
|
|
+ """Parse the SKILL.md 'Current Models' table.
|
|
|
+
|
|
|
+ Columns: Model | ID | Context | Max Output | Input $/MTok | Output $/MTok
|
|
|
+ Returns one dict per data row.
|
|
|
+ """
|
|
|
+ lines = text.splitlines()
|
|
|
+ # Locate the header row that contains the ID column and a price column.
|
|
|
+ start = None
|
|
|
+ for i, line in enumerate(lines):
|
|
|
+ low = line.lower()
|
|
|
+ if line.lstrip().startswith("|") and "id" in low and "context" in low and "output" in low:
|
|
|
+ start = i
|
|
|
+ break
|
|
|
+ if start is None:
|
|
|
+ return [], []
|
|
|
+
|
|
|
+ header = split_row(lines[start])
|
|
|
+ rows: list[dict] = []
|
|
|
+ # Expect a separator row next, then data rows until a non-table line.
|
|
|
+ j = start + 1
|
|
|
+ if j < len(lines) and is_separator(split_row(lines[j])):
|
|
|
+ j += 1
|
|
|
+ while j < len(lines):
|
|
|
+ line = lines[j]
|
|
|
+ if not line.lstrip().startswith("|"):
|
|
|
+ break
|
|
|
+ cells = split_row(line)
|
|
|
+ if is_separator(cells):
|
|
|
+ j += 1
|
|
|
+ continue
|
|
|
+ if len(cells) >= 6:
|
|
|
+ rows.append({
|
|
|
+ "name": cells[0],
|
|
|
+ "id_cell": cells[1],
|
|
|
+ "context": cells[2],
|
|
|
+ "max_output": cells[3],
|
|
|
+ "input_price": cells[4],
|
|
|
+ "output_price": cells[5],
|
|
|
+ })
|
|
|
+ j += 1
|
|
|
+ return rows, header
|
|
|
+
|
|
|
+
|
|
|
+def parse_cache_min_table(text: str) -> list[dict]:
|
|
|
+ """Parse the caching-and-cost.md 'Minimum prefix tokens' table.
|
|
|
+
|
|
|
+ Columns: Model | Minimum prefix tokens. The Model cell holds friendly names
|
|
|
+ (possibly several comma-separated), not ids.
|
|
|
+ """
|
|
|
+ lines = text.splitlines()
|
|
|
+ start = None
|
|
|
+ for i, line in enumerate(lines):
|
|
|
+ low = line.lower()
|
|
|
+ if line.lstrip().startswith("|") and "model" in low and "minimum" in low and "prefix" in low:
|
|
|
+ start = i
|
|
|
+ break
|
|
|
+ if start is None:
|
|
|
+ return []
|
|
|
+ rows: list[dict] = []
|
|
|
+ j = start + 1
|
|
|
+ if j < len(lines) and is_separator(split_row(lines[j])):
|
|
|
+ j += 1
|
|
|
+ while j < len(lines):
|
|
|
+ line = lines[j]
|
|
|
+ if not line.lstrip().startswith("|"):
|
|
|
+ break
|
|
|
+ cells = split_row(line)
|
|
|
+ if is_separator(cells):
|
|
|
+ j += 1
|
|
|
+ continue
|
|
|
+ if len(cells) >= 2:
|
|
|
+ rows.append({"names": cells[0], "min_tokens": cells[1]})
|
|
|
+ j += 1
|
|
|
+ return rows
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# Offline validation
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+PRICE_RE = re.compile(r"^\$\d+(?:\.\d+)?$")
|
|
|
+SIZE_RE = re.compile(r"^\d+(?:\.\d+)?[KM]$")
|
|
|
+
|
|
|
+
|
|
|
+def clean_id(id_cell: str) -> str:
|
|
|
+ """Strip backtick code fences from an ID cell."""
|
|
|
+ return id_cell.strip().strip("`").strip()
|
|
|
+
|
|
|
+
|
|
|
+def validate_offline(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
|
|
|
+ skill_md = skill_dir / "SKILL.md"
|
|
|
+ cache_md = skill_dir / "references" / "caching-and-cost.md"
|
|
|
+ for p in (skill_md, cache_md):
|
|
|
+ if not p.is_file():
|
|
|
+ if json_mode:
|
|
|
+ print(json.dumps({"error": {"code": "NOT_FOUND",
|
|
|
+ "message": f"missing file: {p}",
|
|
|
+ "details": {}}}))
|
|
|
+ print(f"ERROR: required file not found: {p}", file=sys.stderr)
|
|
|
+ sys.exit(EXIT_NOT_FOUND)
|
|
|
+
|
|
|
+ note("=== offline model-table consistency check ===", quiet)
|
|
|
+
|
|
|
+ model_rows, _ = parse_model_table(skill_md.read_text(encoding="utf-8"))
|
|
|
+ if not model_rows:
|
|
|
+ fail_validation("could not locate a non-empty Current Models table in SKILL.md",
|
|
|
+ {"file": str(skill_md)}, json_mode)
|
|
|
+
|
|
|
+ documented_ids: list[str] = []
|
|
|
+ models_out: list[dict] = []
|
|
|
+ for row in model_rows:
|
|
|
+ mid = clean_id(row["id_cell"])
|
|
|
+ problems = []
|
|
|
+ if not ID_RE.match(mid):
|
|
|
+ problems.append("id does not match claude-[a-z]+-<digits>")
|
|
|
+ if DATE_SUFFIX_RE.search(mid):
|
|
|
+ problems.append("id carries a date suffix (should be a bare alias)")
|
|
|
+ if not PRICE_RE.match(row["input_price"]):
|
|
|
+ problems.append(f"input price not numeric: {row['input_price']!r}")
|
|
|
+ if not PRICE_RE.match(row["output_price"]):
|
|
|
+ problems.append(f"output price not numeric: {row['output_price']!r}")
|
|
|
+ if not SIZE_RE.match(row["context"]):
|
|
|
+ problems.append(f"context not a size (e.g. 1M/200K): {row['context']!r}")
|
|
|
+ if not SIZE_RE.match(row["max_output"]):
|
|
|
+ problems.append(f"max output not a size: {row['max_output']!r}")
|
|
|
+ if problems:
|
|
|
+ fail_validation(f"malformed model row: {row['name']!r}",
|
|
|
+ {"id": mid, "problems": "; ".join(problems)}, json_mode)
|
|
|
+ documented_ids.append(mid)
|
|
|
+ models_out.append({
|
|
|
+ "name": row["name"], "id": mid, "context": row["context"],
|
|
|
+ "max_output": row["max_output"],
|
|
|
+ "input_price": row["input_price"], "output_price": row["output_price"],
|
|
|
+ })
|
|
|
+
|
|
|
+ # No duplicate ids.
|
|
|
+ dupes = {x for x in documented_ids if documented_ids.count(x) > 1}
|
|
|
+ if dupes:
|
|
|
+ fail_validation("duplicate model ids in the table",
|
|
|
+ {"ids": ", ".join(sorted(dupes))}, json_mode)
|
|
|
+
|
|
|
+ # Cache-minimum table.
|
|
|
+ cache_rows = parse_cache_min_table(cache_md.read_text(encoding="utf-8"))
|
|
|
+ if not cache_rows:
|
|
|
+ fail_validation("could not locate the cache-minimum table in caching-and-cost.md",
|
|
|
+ {"file": str(cache_md)}, json_mode)
|
|
|
+ for crow in cache_rows:
|
|
|
+ if not re.fullmatch(r"\d+", crow["min_tokens"]):
|
|
|
+ fail_validation("cache-minimum value is not an integer",
|
|
|
+ {"row": crow["names"], "value": crow["min_tokens"]},
|
|
|
+ json_mode)
|
|
|
+
|
|
|
+ # Cross-file consistency: every model NAME (e.g. "Opus 4.8", "Fable 5",
|
|
|
+ # "Sonnet 4.6", "Haiku 4.5") in the model table must appear in the cache
|
|
|
+ # table's name set, so the two files agree on the model lineup.
|
|
|
+ cache_blob = " ".join(c["names"] for c in cache_rows).lower()
|
|
|
+ missing_in_cache: list[str] = []
|
|
|
+ for m in models_out:
|
|
|
+ # Derive the short family+version token, e.g. "Claude Opus 4.8" -> "opus 4.8".
|
|
|
+ short = re.sub(r"^claude\s+", "", m["name"], flags=re.I).strip().lower()
|
|
|
+ if short not in cache_blob:
|
|
|
+ missing_in_cache.append(m["name"])
|
|
|
+ if missing_in_cache:
|
|
|
+ fail_validation(
|
|
|
+ "model(s) in SKILL.md absent from the cache-minimum table — files contradict",
|
|
|
+ {"missing": ", ".join(missing_in_cache),
|
|
|
+ "hint": "every documented model needs a prompt-cache minimum row"},
|
|
|
+ json_mode)
|
|
|
+
|
|
|
+ note(f" {len(models_out)} model rows, all well-formed", quiet)
|
|
|
+ note(f" {len(cache_rows)} cache-minimum rows, all integer", quiet)
|
|
|
+ note(" cross-file model lineup consistent", quiet)
|
|
|
+ note("OK: tables internally consistent.", quiet)
|
|
|
+
|
|
|
+ return {
|
|
|
+ "mode": "offline",
|
|
|
+ "models": models_out,
|
|
|
+ "documented_ids": documented_ids,
|
|
|
+ "cache_min_rows": cache_rows,
|
|
|
+ "consistent": True,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# Live validation
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+def fetch_live_ids(quiet: bool) -> list[str] | None:
|
|
|
+ """Return the live model-id list, or None if unavailable (advisory)."""
|
|
|
+ key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
|
+ if not key:
|
|
|
+ note("NOTE: ANTHROPIC_API_KEY is unset - skipping live check (advisory).",
|
|
|
+ quiet)
|
|
|
+ return None
|
|
|
+ cmd = [
|
|
|
+ "curl", "-fsS", "--max-time", "20",
|
|
|
+ "-H", f"x-api-key: {key}",
|
|
|
+ "-H", f"anthropic-version: {ANTHROPIC_VERSION}",
|
|
|
+ MODELS_API,
|
|
|
+ ]
|
|
|
+ try:
|
|
|
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
|
+ except (subprocess.TimeoutExpired, OSError) as exc:
|
|
|
+ note(f"NOTE: Models API call failed ({exc}) — advisory, not a failure.",
|
|
|
+ quiet)
|
|
|
+ return None
|
|
|
+ if proc.returncode != 0:
|
|
|
+ note(f"NOTE: Models API unreachable (curl exit {proc.returncode}) — advisory.",
|
|
|
+ quiet)
|
|
|
+ if proc.stderr.strip():
|
|
|
+ note(f" {proc.stderr.strip().splitlines()[-1]}", quiet)
|
|
|
+ return None
|
|
|
+ try:
|
|
|
+ payload = json.loads(proc.stdout)
|
|
|
+ except json.JSONDecodeError:
|
|
|
+ note("NOTE: Models API returned non-JSON — advisory, not a failure.", quiet)
|
|
|
+ return None
|
|
|
+ data = payload.get("data")
|
|
|
+ if not isinstance(data, list):
|
|
|
+ note("NOTE: Models API JSON missing 'data' list — advisory.", quiet)
|
|
|
+ return None
|
|
|
+ return [m.get("id", "") for m in data if isinstance(m, dict) and m.get("id")]
|
|
|
+
|
|
|
+
|
|
|
+def validate_live(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
|
|
|
+ if not _have("curl"):
|
|
|
+ if json_mode:
|
|
|
+ print(json.dumps({"error": {"code": "PRECONDITION",
|
|
|
+ "message": "curl required for --live",
|
|
|
+ "details": {}}}))
|
|
|
+ print("ERROR: curl is required for --live", file=sys.stderr)
|
|
|
+ sys.exit(EXIT_MISSING_DEP)
|
|
|
+
|
|
|
+ # Reuse offline parse for the documented id set (also validates well-formedness).
|
|
|
+ note("=== live model-id coverage check ===", quiet)
|
|
|
+ skill_md = skill_dir / "SKILL.md"
|
|
|
+ if not skill_md.is_file():
|
|
|
+ print(f"ERROR: required file not found: {skill_md}", file=sys.stderr)
|
|
|
+ sys.exit(EXIT_NOT_FOUND)
|
|
|
+ parsed = parse_model_table(skill_md.read_text(encoding="utf-8"))
|
|
|
+ if not parsed or not parsed[0]:
|
|
|
+ fail_validation("could not parse the model table for live comparison",
|
|
|
+ {"file": str(skill_md)}, json_mode)
|
|
|
+ documented = [clean_id(r["id_cell"]) for r in parsed[0]]
|
|
|
+
|
|
|
+ live = fetch_live_ids(quiet)
|
|
|
+ if live is None:
|
|
|
+ # Advisory: not a failure. Exit 7.
|
|
|
+ if json_mode:
|
|
|
+ print(json.dumps({"data": {"mode": "live", "status": "unavailable",
|
|
|
+ "documented_ids": documented, "live_ids": None},
|
|
|
+ "meta": {"schema": SCHEMA, "status": "unavailable"}}))
|
|
|
+ sys.exit(EXIT_UNAVAILABLE)
|
|
|
+
|
|
|
+ live_set = set(live)
|
|
|
+ doc_set = set(documented)
|
|
|
+
|
|
|
+ # A documented id absent from the live list = drift (retired/typo).
|
|
|
+ missing = sorted(doc_set - live_set)
|
|
|
+ # A live id NEWER than anything documented = drift (table lacks a new model).
|
|
|
+ # Restrict "newer" to well-formed alias ids so we ignore date-suffixed and
|
|
|
+ # snapshot variants the docs intentionally don't list.
|
|
|
+ live_alias = {m for m in live_set if ID_RE.match(m) and not DATE_SUFFIX_RE.search(m)}
|
|
|
+ new_models = sorted(live_alias - doc_set)
|
|
|
+
|
|
|
+ drift = bool(missing or new_models)
|
|
|
+ result = {
|
|
|
+ "mode": "live",
|
|
|
+ "status": "drift" if drift else "ok",
|
|
|
+ "documented_ids": documented,
|
|
|
+ "live_ids": sorted(live_set),
|
|
|
+ "missing_from_live": missing,
|
|
|
+ "new_in_live": new_models,
|
|
|
+ }
|
|
|
+
|
|
|
+ if drift:
|
|
|
+ if missing:
|
|
|
+ note("DRIFT: documented id(s) absent from live Models API:", quiet)
|
|
|
+ for m in missing:
|
|
|
+ note(f" - {m}", quiet)
|
|
|
+ if new_models:
|
|
|
+ note("DRIFT: live Models API has alias id(s) the table lacks:", quiet)
|
|
|
+ for m in new_models:
|
|
|
+ note(f" + {m}", quiet)
|
|
|
+ if json_mode:
|
|
|
+ print(json.dumps({"data": result, "meta": {"schema": SCHEMA,
|
|
|
+ "status": "drift"}}))
|
|
|
+ else:
|
|
|
+ print("DRIFT: model-id table disagrees with the live Models API "
|
|
|
+ f"(missing={missing}, new={new_models})")
|
|
|
+ sys.exit(EXIT_DRIFT)
|
|
|
+
|
|
|
+ note("OK: every documented id exists live; no newer alias id missing from the table.",
|
|
|
+ quiet)
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _have(tool: str) -> bool:
|
|
|
+ from shutil import which
|
|
|
+ return which(tool) is not None
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# Main
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+def main(argv: list[str]) -> int:
|
|
|
+ parser = argparse.ArgumentParser(
|
|
|
+ prog="check-model-table.py", add_help=True,
|
|
|
+ description="Staleness verifier for the claude-api-ops model + cache tables.",
|
|
|
+ epilog=(
|
|
|
+ "EXAMPLES:\n"
|
|
|
+ " check-model-table.py --offline\n"
|
|
|
+ " check-model-table.py --offline --json | python -m json.tool\n"
|
|
|
+ " ANTHROPIC_API_KEY=sk-... check-model-table.py --live\n"
|
|
|
+ " check-model-table.py --live # exits 7 (advisory) when key unset\n"
|
|
|
+ ),
|
|
|
+ formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
+ )
|
|
|
+ mode = parser.add_mutually_exclusive_group()
|
|
|
+ mode.add_argument("--offline", action="store_true",
|
|
|
+ help="parse + assert internal consistency, no network (default)")
|
|
|
+ mode.add_argument("--live", action="store_true",
|
|
|
+ help="compare documented ids against the live Models API (advisory)")
|
|
|
+ parser.add_argument("--json", action="store_true",
|
|
|
+ help="emit the JSON envelope on stdout")
|
|
|
+ parser.add_argument("--skill-dir", default=None,
|
|
|
+ help="skill root (default: parent of this script's dir)")
|
|
|
+ parser.add_argument("-q", "--quiet", action="store_true",
|
|
|
+ help="suppress stderr progress/notes")
|
|
|
+ args = parser.parse_args(argv)
|
|
|
+
|
|
|
+ if args.skill_dir:
|
|
|
+ skill_dir = Path(args.skill_dir).resolve()
|
|
|
+ else:
|
|
|
+ skill_dir = Path(__file__).resolve().parent.parent
|
|
|
+ if not skill_dir.is_dir():
|
|
|
+ print(f"ERROR: skill dir not found: {skill_dir}", file=sys.stderr)
|
|
|
+ return EXIT_NOT_FOUND
|
|
|
+
|
|
|
+ if args.live:
|
|
|
+ result = validate_live(skill_dir, args.json, args.quiet)
|
|
|
+ else:
|
|
|
+ result = validate_offline(skill_dir, args.json, args.quiet)
|
|
|
+
|
|
|
+ if args.json:
|
|
|
+ print(json.dumps({"data": result,
|
|
|
+ "meta": {"schema": SCHEMA, "status": "ok"}}))
|
|
|
+ return EXIT_OK
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ try:
|
|
|
+ sys.exit(main(sys.argv[1:]))
|
|
|
+ except KeyboardInterrupt:
|
|
|
+ sys.exit(EXIT_USAGE)
|