#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import ssl import shutil import time import urllib.request from dataclasses import dataclass from pathlib import Path from typing import Any, cast from urllib.error import HTTPError DESTRUCTIVE_VERBS = {"delete", "cancel", "reset", "disable", "detach"} SENSITIVE_VERBS = { "attach", "attach-domains", "detach-domains", "edit", "enable", "logout", "pause", "restore", "resume", "set", "update", "upgrade", "use", } FAMILY_TITLES = { "ticloud": "Top-Level Commands", "ticloud_auth": "Authentication", "ticloud_completion": "Shell Completion", "ticloud_config": "Configuration", "ticloud_project": "Projects", "ticloud_serverless": "Serverless Core", "ticloud_serverless_audit-log": "Serverless Audit Log", "ticloud_serverless_audit-log_config": "Serverless Audit Log Config", "ticloud_serverless_audit-log_filter-rule": "Serverless Audit Log Filter Rules", "ticloud_serverless_authorized-network": "Serverless Authorized Network", "ticloud_serverless_backup": "Serverless Backup", "ticloud_serverless_branch": "Serverless Branch", "ticloud_serverless_changefeed": "Serverless Changefeed", "ticloud_serverless_export": "Serverless Export", "ticloud_serverless_import": "Serverless Import", "ticloud_serverless_migration": "Serverless Migration", "ticloud_serverless_private-link-connection": "Serverless Private Link Connection", "ticloud_serverless_sql-user": "Serverless SQL User", } @dataclass class CommandDoc: stem: str command: str summary: str usage: str options: list[str] examples: str parent: str | None children: list[str] supports_human_output: bool uses_force: bool risk_level: str class RateLimitError(RuntimeError): pass def _auth_headers() -> dict[str, str]: headers = {"User-Agent": "tidbcloud-cli-skill-generator"} token = os.getenv("GITHUB_TOKEN") if token: headers["Authorization"] = f"Bearer {token}" return headers def _retry_after_seconds(error: HTTPError) -> int | None: retry_after = error.headers.get("Retry-After") if retry_after: try: return max(1, int(retry_after)) except ValueError: pass reset = error.headers.get("X-RateLimit-Reset") if reset: try: return max(1, int(reset) - int(time.time())) except ValueError: pass return None def _raise_http_error(url: str, error: HTTPError) -> None: if error.code == 403: wait_s = _retry_after_seconds(error) remaining = error.headers.get("X-RateLimit-Remaining") parts = [f"GitHub returned 403 for {url}."] if remaining is not None: parts.append(f"remaining={remaining}") if wait_s is not None: parts.append(f"retry_after~={wait_s}s") parts.append( "Set GITHUB_TOKEN or use --docs-dir to refresh from a local checkout." ) raise RateLimitError(" ".join(parts)) from error raise RuntimeError(f"Request failed for {url}: HTTP {error.code}") from error def fetch_url( url: str, *, accept: str | None = None, extra_headers: dict[str, str] | None = None ) -> tuple[bytes, dict[str, str], int]: headers = _auth_headers() if accept: headers["Accept"] = accept if extra_headers: headers.update(extra_headers) req = urllib.request.Request(url, headers=headers) resp: Any | None = None try: resp = urllib.request.urlopen(req) except HTTPError as error: if error.code == 304: return b"", dict(error.headers.items()), 304 _raise_http_error(url, error) except Exception: try: resp = urllib.request.urlopen(req, context=ssl._create_unverified_context()) except HTTPError as error: if error.code == 304: return b"", dict(error.headers.items()), 304 _raise_http_error(url, error) except Exception as error: raise RuntimeError(f"Request failed for {url}: {error}") from error if resp is None: raise RuntimeError(f"Request failed for {url}: no response returned") with resp: return resp.read(), dict(resp.headers.items()), resp.getcode() def load_cache(cache_file: Path) -> dict[str, Any]: if not cache_file.exists(): return {"listing": {}, "docs": {}} return cast(dict[str, Any], json.loads(cache_file.read_text(encoding="utf-8"))) def save_cache(cache_file: Path, cache: dict[str, Any]) -> None: cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text( json.dumps(cache, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) def fetch_json(url: str) -> Any: body, _, _ = fetch_url(url, accept="application/vnd.github+json") return json.loads(body.decode("utf-8")) def fetch_text(url: str) -> str: body, _, _ = fetch_url(url) return body.decode("utf-8") def extract_first_paragraph(lines: list[str]) -> str: collected: list[str] = [] for line in lines[1:]: stripped = line.strip() if not stripped: if collected: break continue if stripped.startswith("### ") or stripped.startswith("```"): break collected.append(stripped) return " ".join(collected) def extract_section(lines: list[str], heading: str) -> str: start = None for idx, line in enumerate(lines): if line.strip() == heading: start = idx + 1 break if start is None: return "" end = len(lines) for idx in range(start, len(lines)): if lines[idx].startswith("### "): end = idx break return "\n".join(lines[start:end]).strip() def extract_code_block(section: str) -> str: marker = "```" start = section.find(marker) if start == -1: return "" start += len(marker) if start < len(section) and section[start] == "\n": start += 1 end = section.find(marker, start) if end == -1: return "" return section[start:end].strip() def parse_options(section: str) -> list[str]: block = extract_code_block(section) return [line.rstrip() for line in block.splitlines() if line.strip()] def infer_risk(command: str, options: list[str]) -> str: action = command.split()[-1] if any("--force" in line for line in options): if action in DESTRUCTIVE_VERBS: return "dangerous" return "sensitive" if action in DESTRUCTIVE_VERBS: return "dangerous" if action in SENSITIVE_VERBS: return "sensitive" return "normal" def parse_doc(stem: str, text: str) -> CommandDoc: lines = text.splitlines() command = lines[0].removeprefix("## ").strip() summary = extract_first_paragraph(lines) usage = extract_code_block("\n".join(lines[1:])) options = parse_options(extract_section(lines, "### Options")) examples = extract_code_block(extract_section(lines, "### Examples")) parent = "_".join(stem.split("_")[:-1]) or None supports_human_output = any( "human" in line and "--output" in line for line in options ) uses_force = any("--force" in line for line in options) risk_level = infer_risk(command, options) return CommandDoc( stem=stem, command=command, summary=summary, usage=usage, options=options, examples=examples, parent=parent, children=[], supports_human_output=supports_human_output, uses_force=uses_force, risk_level=risk_level, ) def load_remote_docs( owner: str, repo: str, ref: str, docs_path: str, cache_dir: Path ) -> tuple[dict[str, CommandDoc], str, str]: api_url = ( f"https://api.github.com/repos/{owner}/{repo}/contents/{docs_path}?ref={ref}" ) tree_url = f"https://github.com/{owner}/{repo}/tree/{ref}/{docs_path}" raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{docs_path}" cache_file = cache_dir / "metadata.json" docs_cache_dir = cache_dir / "docs" cache = load_cache(cache_file) listing_headers: dict[str, str] = {} cached_listing = cache.get("listing", {}) if cached_listing.get("etag"): listing_headers["If-None-Match"] = str(cached_listing["etag"]) body, headers, status = fetch_url( api_url, accept="application/vnd.github+json", extra_headers=listing_headers or None, ) if status == 304: listing = cast(list[dict[str, Any]], cached_listing.get("items", [])) if not listing: raise RuntimeError("GitHub returned 304 but no cached listing is available") else: listing = cast(list[dict[str, Any]], json.loads(body.decode("utf-8"))) cache["listing"] = { "etag": headers.get("ETag"), "fetched_at": int(time.time()), "items": listing, } docs: dict[str, CommandDoc] = {} doc_cache = cast(dict[str, Any], cache.setdefault("docs", {})) for item in listing: if item.get("type") != "file": continue name = item.get("name", "") if not name.startswith("ticloud") or not name.endswith(".md"): continue stem = name[:-3] sha = item.get("sha") cache_entry = cast(dict[str, Any], doc_cache.get(name, {})) cache_path = docs_cache_dir / name if sha and cache_entry.get("sha") == sha and cache_path.exists(): text = cache_path.read_text(encoding="utf-8") else: text = fetch_text(item["download_url"]) write_text(cache_path, text) doc_cache[name] = { "sha": sha, "download_url": item.get("download_url"), "fetched_at": int(time.time()), } docs[stem] = parse_doc(stem, text) keep = {item.get("name") for item in listing if item.get("type") == "file"} for name in list(doc_cache): if name not in keep: doc_cache.pop(name, None) cache_path = docs_cache_dir / name if cache_path.exists(): cache_path.unlink() save_cache(cache_file, cache) return docs, tree_url, raw_base def load_local_docs(docs_dir: Path) -> dict[str, CommandDoc]: docs: dict[str, CommandDoc] = {} for path in sorted(docs_dir.glob("ticloud*.md")): docs[path.stem] = parse_doc(path.stem, path.read_text(encoding="utf-8")) return docs def command_ref(stem: str) -> str: return f"generated/commands/{stem}.md" def family_ref(stem: str) -> str: return f"generated/families/{stem}.md" def render_risk(doc: CommandDoc) -> str: if doc.risk_level == "dangerous": return "Dangerous: require explicit user confirmation before execution." if doc.risk_level == "sensitive": return "Sensitive: explain impact before execution; confirm when the change is disruptive." return "Normal: no additional confirmation rule beyond the skill defaults." def write_text(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text.rstrip() + "\n", encoding="utf-8") def render_command_page( doc: CommandDoc, docs: dict[str, CommandDoc], tree_url: str, raw_base: str ) -> str: lines = [ f"# `{doc.command}`", "", doc.summary or "No summary extracted from upstream docs.", "", f"- Upstream tree: `{tree_url}/{doc.stem}.md`", f"- Upstream raw: `{raw_base}/{doc.stem}.md`", f"- Risk: {render_risk(doc)}", f"- Human-readable output: {'Supported' if doc.supports_human_output else 'Not documented'}", f"- `--force` flag: {'Present' if doc.uses_force else 'Not documented'}", ] if doc.parent and doc.parent in docs: lines.append( f"- Parent command: [`{docs[doc.parent].command}`]({doc.parent}.md)" ) if doc.children: lines.append("- Direct subcommands:") for child in doc.children: child_doc = docs[child] lines.append( f" - [`{child_doc.command}`]({child_doc.stem}.md) - {child_doc.summary}" ) if doc.usage: lines.extend(["", "## Usage", "", "```bash", doc.usage, "```"]) if doc.options: lines.extend(["", "## Key Options", ""]) for option in doc.options: lines.append(f"- `{option}`") if doc.examples: lines.extend(["", "## Examples", "", "```bash", doc.examples, "```"]) return "\n".join(lines) def render_family_page(stem: str, docs: dict[str, CommandDoc]) -> str: doc = docs[stem] title = FAMILY_TITLES.get(stem, doc.command) lines = [ f"# {title}", "", f"Base command: `{doc.command}`", "", doc.summary or "No summary extracted from upstream docs.", ] if doc.children: lines.extend(["", "## Direct Subcommands", ""]) for child_stem in doc.children: child = docs[child_stem] flags = [] if child.risk_level == "dangerous": flags.append("dangerous") elif child.risk_level == "sensitive": flags.append("sensitive") if child.supports_human_output: flags.append("human-output") suffix = f" ({', '.join(flags)})" if flags else "" lines.append( f"- [`{child.command}`](../commands/{child.stem}.md) - {child.summary}{suffix}" ) dangerous = [docs[s] for s in doc.children if docs[s].risk_level == "dangerous"] if dangerous: lines.extend(["", "## Confirmation Required", ""]) for child in dangerous: lines.append(f"- `{child.command}`") return "\n".join(lines) def build_tree(root: str, docs: dict[str, CommandDoc], indent: int = 0) -> list[str]: doc = docs[root] flags = [] if doc.risk_level == "dangerous": flags.append("dangerous") elif doc.risk_level == "sensitive": flags.append("sensitive") if doc.supports_human_output: flags.append("human-output") suffix = f" ({', '.join(flags)})" if flags else "" lines = [ f"{' ' * indent}- [`{doc.command}`]({command_ref(doc.stem)}) - {doc.summary}{suffix}" ] for child in doc.children: lines.extend(build_tree(child, docs, indent + 1)) return lines def render_index(docs: dict[str, CommandDoc], tree_url: str, script_path: str) -> str: top = docs["ticloud"].children dangerous = sorted( (d for d in docs.values() if d.risk_level == "dangerous"), key=lambda d: d.command, ) human = sorted( (d for d in docs.values() if d.supports_human_output), key=lambda d: d.command ) lines = [ "# TiDB Cloud CLI Command Index", "", f"This file is generated from the upstream docs at `{tree_url}`.", f"Do not edit generated references by hand; run `{script_path}` instead.", "", "## How to Use These References", "", "- Start with interactive commands whenever the CLI supports them.", "- Keep non-interactive output human-friendly; prefer `-o human` where the docs support it.", "- Require explicit confirmation before dangerous operations.", "- Regenerate after upstream docs change.", "", "## Top-Level Families", "", ] for stem in top: title = FAMILY_TITLES.get(stem, docs[stem].command) target = family_ref(stem) if docs[stem].children else command_ref(stem) lines.append(f"- [{title}]({target}) - `{docs[stem].command}`") lines.extend(["", "## Full Command Tree", ""]) for stem in top: lines.extend(build_tree(stem, docs)) lines.extend(["", "## Dangerous Commands", ""]) for doc in dangerous: lines.append(f"- [`{doc.command}`]({command_ref(doc.stem)})") lines.extend(["", "## Commands With Documented Human Output", ""]) for doc in human: lines.append(f"- [`{doc.command}`]({command_ref(doc.stem)})") return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser( description="Generate skill references from upstream TiDB Cloud CLI docs" ) parser.add_argument("--skill-dir", type=Path, required=True) parser.add_argument("--repo-owner", default="tidbcloud") parser.add_argument("--repo-name", default="tidbcloud-cli") parser.add_argument("--ref", default="main") parser.add_argument("--docs-path", default="docs/generate_doc") parser.add_argument("--docs-dir", type=Path) args = parser.parse_args() tree_url = f"https://github.com/{args.repo_owner}/{args.repo_name}/tree/{args.ref}/{args.docs_path}" raw_base = f"https://raw.githubusercontent.com/{args.repo_owner}/{args.repo_name}/{args.ref}/{args.docs_path}" if args.docs_dir: docs = load_local_docs(args.docs_dir) else: docs, tree_url, raw_base = load_remote_docs( args.repo_owner, args.repo_name, args.ref, args.docs_path, args.skill_dir / "references" / ".cache", ) for stem, doc in docs.items(): if doc.parent and doc.parent in docs: docs[doc.parent].children.append(stem) for doc in docs.values(): doc.children.sort(key=lambda s: docs[s].command) refs = args.skill_dir / "references" generated = refs / "generated" commands = generated / "commands" families = generated / "families" if generated.exists(): shutil.rmtree(generated) commands.mkdir(parents=True, exist_ok=True) families.mkdir(parents=True, exist_ok=True) write_text( refs / "command-index.md", render_index(docs, tree_url, "scripts/generate_references.py"), ) for stem, doc in docs.items(): write_text( commands / f"{stem}.md", render_command_page(doc, docs, tree_url, raw_base) ) if doc.children: write_text(families / f"{stem}.md", render_family_page(stem, docs)) return 0 if __name__ == "__main__": raise SystemExit(main())