Files
Reza Rezvani 530ecab247 fix(code-to-prd): pass skill-tester validation — README, assets, frontmatter, imports
- Add README.md with quick start, framework table, output structure
- Add assets/sample-analysis.json for script testing
- Expand SKILL.md frontmatter with version, author, category, tier, dependencies
- Add Features, Usage, Examples sections to SKILL.md
- Remove __future__ imports, fix str|None → Optional[str] for Python 3.9 compat
- Validation: 65→85.7, quality: 51→62.1, scripts: 2/2 PASS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:43:22 +01:00

733 lines
26 KiB
Python
Executable File

#!/usr/bin/env python3
"""Analyze any codebase (frontend, backend, or fullstack) and extract routes, APIs, models, and structure.
Supports: React, Vue, Angular, Svelte, Next.js, Nuxt, NestJS, Express, Django, FastAPI, Flask.
Stdlib only — no third-party dependencies. Outputs JSON for downstream PRD generation.
Usage:
python3 codebase_analyzer.py /path/to/project
python3 codebase_analyzer.py /path/to/project --output prd-analysis.json
python3 codebase_analyzer.py /path/to/project --format markdown
"""
import argparse
import json
import os
import re
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
IGNORED_DIRS = {
".git", "node_modules", ".next", "dist", "build", "coverage",
"venv", ".venv", "__pycache__", ".nuxt", ".output", ".cache",
".turbo", ".vercel", "out", "storybook-static",
".tox", ".mypy_cache", ".pytest_cache", "htmlcov", "staticfiles",
"media", "migrations", "egg-info",
}
FRAMEWORK_SIGNALS = {
"react": ["react", "react-dom"],
"next": ["next"],
"vue": ["vue"],
"nuxt": ["nuxt"],
"angular": ["@angular/core"],
"svelte": ["svelte"],
"sveltekit": ["@sveltejs/kit"],
"solid": ["solid-js"],
"astro": ["astro"],
"remix": ["@remix-run/react"],
"nestjs": ["@nestjs/core"],
"express": ["express"],
"fastify": ["fastify"],
}
# Python backend frameworks detected via project files (no package.json)
PYTHON_FRAMEWORK_FILES = {
"django": ["manage.py", "settings.py"],
"fastapi": ["main.py"], # confirmed via imports
"flask": ["app.py"], # confirmed via imports
}
ROUTE_FILE_PATTERNS = [
"**/router.{ts,tsx,js,jsx}",
"**/routes.{ts,tsx,js,jsx}",
"**/routing.{ts,tsx,js,jsx}",
"**/app-routing*.{ts,tsx,js,jsx}",
]
ROUTE_DIR_PATTERNS = [
"pages", "views", "routes", "app",
"src/pages", "src/views", "src/routes", "src/app",
]
API_DIR_PATTERNS = [
"api", "services", "requests", "endpoints", "client",
"src/api", "src/services", "src/requests",
]
STATE_DIR_PATTERNS = [
"store", "stores", "models", "context", "state",
"src/store", "src/stores", "src/models", "src/context",
]
I18N_DIR_PATTERNS = [
"locales", "i18n", "lang", "translations", "messages",
"src/locales", "src/i18n", "src/lang",
]
# Backend-specific directory patterns
CONTROLLER_DIR_PATTERNS = [
"controllers", "src/controllers", "src/modules",
]
MODEL_DIR_PATTERNS = [
"models", "entities", "src/entities", "src/models",
]
DTO_DIR_PATTERNS = [
"dto", "dtos", "src/dto", "serializers",
]
MOCK_SIGNALS = [
r"setTimeout\s*\(.*\breturn\b",
r"Promise\.resolve\s*\(",
r"\.mock\.",
r"__mocks__",
r"mockData",
r"mock[A-Z]",
r"faker\.",
r"fixtures?/",
]
REAL_API_SIGNALS = [
r"\baxios\b",
r"\bfetch\s*\(",
r"httpGet|httpPost|httpPut|httpDelete|httpPatch",
r"\.get\s*\(\s*['\"`/]",
r"\.post\s*\(\s*['\"`/]",
r"\.put\s*\(\s*['\"`/]",
r"\.delete\s*\(\s*['\"`/]",
r"\.patch\s*\(\s*['\"`/]",
r"useSWR|useQuery|useMutation",
r"\$http\.",
r"this\.http\.",
]
ROUTE_PATTERNS = [
# React Router
r'<Route\s+[^>]*path\s*=\s*["\']([^"\']+)["\']',
r'path\s*:\s*["\']([^"\']+)["\']',
# Vue Router
r'path\s*:\s*["\']([^"\']+)["\']',
# Angular
r'path\s*:\s*["\']([^"\']+)["\']',
]
API_PATH_PATTERNS = [
r'["\'](?:GET|POST|PUT|DELETE|PATCH)["\'].*?["\'](/[a-zA-Z0-9/_\-:{}]+)["\']',
r'(?:get|post|put|delete|patch)\s*\(\s*["\'](/[a-zA-Z0-9/_\-:{}]+)["\']',
r'(?:url|path|endpoint|baseURL)\s*[:=]\s*["\'](/[a-zA-Z0-9/_\-:{}]+)["\']',
r'fetch\s*\(\s*[`"\'](?:https?://[^/]+)?(/[a-zA-Z0-9/_\-:{}]+)',
]
COMPONENT_EXTENSIONS = {".tsx", ".jsx", ".vue", ".svelte", ".astro"}
CODE_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".astro", ".py"}
# NestJS decorator patterns
NEST_ROUTE_PATTERNS = [
r"@(?:Get|Post|Put|Delete|Patch|Head|Options|All)\s*\(\s*['\"]([^'\"]*)['\"]",
r"@Controller\s*\(\s*['\"]([^'\"]*)['\"]",
]
# Django URL patterns
DJANGO_ROUTE_PATTERNS = [
r"path\s*\(\s*['\"]([^'\"]+)['\"]",
r"url\s*\(\s*r?['\"]([^'\"]+)['\"]",
r"register\s*\(\s*r?['\"]([^'\"]+)['\"]",
]
# Django/Python model patterns
PYTHON_MODEL_PATTERNS = [
r"class\s+(\w+)\s*\(.*?models\.Model\)",
r"class\s+(\w+)\s*\(.*?BaseModel\)", # Pydantic
]
# NestJS entity/DTO patterns
NEST_MODEL_PATTERNS = [
r"@Entity\s*\(.*?\)\s*(?:export\s+)?class\s+(\w+)",
r"class\s+(\w+(?:Dto|DTO|Entity|Schema))\b",
]
def detect_framework(project_root: Path) -> Dict[str, Any]:
"""Detect framework from package.json (Node.js) or project files (Python)."""
detected = []
all_deps = {}
pkg_name = ""
pkg_version = ""
# Node.js detection via package.json
pkg_path = project_root / "package.json"
if pkg_path.exists():
try:
with open(pkg_path) as f:
pkg = json.load(f)
pkg_name = pkg.get("name", "")
pkg_version = pkg.get("version", "")
for key in ("dependencies", "devDependencies", "peerDependencies"):
all_deps.update(pkg.get(key, {}))
for framework, signals in FRAMEWORK_SIGNALS.items():
if any(s in all_deps for s in signals):
detected.append(framework)
except (json.JSONDecodeError, IOError):
pass
# Python backend detection via project files and imports
if (project_root / "manage.py").exists():
detected.append("django")
if (project_root / "requirements.txt").exists() or (project_root / "pyproject.toml").exists():
for req_file in ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"]:
req_path = project_root / req_file
if req_path.exists():
try:
content = req_path.read_text(errors="replace").lower()
if "django" in content and "django" not in detected:
detected.append("django")
if "fastapi" in content:
detected.append("fastapi")
if "flask" in content and "flask" not in detected:
detected.append("flask")
except IOError:
pass
# Prefer specific over generic
priority = [
"sveltekit", "next", "nuxt", "remix", "astro", # fullstack JS
"nestjs", "express", "fastify", # backend JS
"django", "fastapi", "flask", # backend Python
"angular", "svelte", "vue", "react", "solid", # frontend JS
]
framework = "unknown"
for fw in priority:
if fw in detected:
framework = fw
break
return {
"framework": framework,
"name": pkg_name or project_root.name,
"version": pkg_version,
"detected_frameworks": detected,
"dependency_count": len(all_deps),
"key_deps": {k: v for k, v in all_deps.items()
if any(s in k for s in ["router", "redux", "vuex", "pinia", "zustand",
"mobx", "recoil", "jotai", "tanstack", "swr",
"axios", "tailwind", "material", "ant",
"chakra", "shadcn", "i18n", "intl",
"typeorm", "prisma", "sequelize", "mongoose",
"passport", "jwt", "class-validator"])},
}
def find_dirs(root: Path, patterns: List[str]) -> List[Path]:
"""Find directories matching common patterns."""
found = []
for pattern in patterns:
candidate = root / pattern
if candidate.is_dir():
found.append(candidate)
return found
def walk_files(root: Path, extensions: Set[str] = CODE_EXTENSIONS) -> List[Path]:
"""Walk project tree, skip ignored dirs, return files matching extensions."""
results = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRS]
for fname in filenames:
if Path(fname).suffix in extensions:
results.append(Path(dirpath) / fname)
return results
def extract_routes_from_file(filepath: Path) -> List[Dict[str, str]]:
"""Extract route definitions from a file."""
routes = []
try:
content = filepath.read_text(errors="replace")
except IOError:
return routes
for pattern in ROUTE_PATTERNS:
for match in re.finditer(pattern, content):
path = match.group(1)
if path and not path.startswith("http") and len(path) < 200:
routes.append({
"path": path,
"source": str(filepath),
"line": content[:match.start()].count("\n") + 1,
})
return routes
def extract_routes_from_filesystem(pages_dir: Path, root: Path) -> List[Dict[str, str]]:
"""Infer routes from file-system routing (Next.js, Nuxt, SvelteKit)."""
routes = []
for filepath in sorted(pages_dir.rglob("*")):
if filepath.is_file() and filepath.suffix in CODE_EXTENSIONS:
rel = filepath.relative_to(pages_dir)
route = "/" + str(rel.with_suffix("")).replace("\\", "/")
# Normalize index routes
route = re.sub(r"/index$", "", route) or "/"
# Convert [param] to :param
route = re.sub(r"\[\.\.\.(\w+)\]", r"*\1", route)
route = re.sub(r"\[(\w+)\]", r":\1", route)
routes.append({
"path": route,
"source": str(filepath),
"filesystem": True,
})
return routes
def extract_apis_from_file(filepath: Path) -> List[Dict[str, Any]]:
"""Extract API calls from a file."""
apis = []
try:
content = filepath.read_text(errors="replace")
except IOError:
return apis
is_mock = any(re.search(p, content) for p in MOCK_SIGNALS)
is_real = any(re.search(p, content) for p in REAL_API_SIGNALS)
for pattern in API_PATH_PATTERNS:
for match in re.finditer(pattern, content):
path = match.group(1) if match.lastindex else match.group(0)
if path and len(path) < 200:
# Try to detect HTTP method
context = content[max(0, match.start() - 100):match.end()]
method = "UNKNOWN"
for m in ["GET", "POST", "PUT", "DELETE", "PATCH"]:
if m.lower() in context.lower():
method = m
break
apis.append({
"path": path,
"method": method,
"source": str(filepath),
"line": content[:match.start()].count("\n") + 1,
"integrated": is_real and not is_mock,
"mock_detected": is_mock,
})
return apis
def extract_enums(filepath: Path) -> List[Dict[str, Any]]:
"""Extract enum/constant definitions."""
enums = []
try:
content = filepath.read_text(errors="replace")
except IOError:
return enums
# TypeScript enums
for match in re.finditer(r"enum\s+(\w+)\s*\{([^}]+)\}", content):
name = match.group(1)
body = match.group(2)
values = re.findall(r"(\w+)\s*=\s*['\"]?([^,'\"\n]+)", body)
enums.append({
"name": name,
"type": "enum",
"values": {k.strip(): v.strip().rstrip(",") for k, v in values},
"source": str(filepath),
})
# Object constant maps (const STATUS_MAP = { ... })
for match in re.finditer(
r"(?:const|export\s+const)\s+(\w*(?:MAP|STATUS|TYPE|ENUM|OPTION|ROLE|STATE)\w*)\s*[:=]\s*\{([^}]+)\}",
content, re.IGNORECASE
):
name = match.group(1)
body = match.group(2)
values = re.findall(r"['\"]?(\w+)['\"]?\s*:\s*['\"]([^'\"]+)['\"]", body)
if values:
enums.append({
"name": name,
"type": "constant_map",
"values": dict(values),
"source": str(filepath),
})
return enums
def extract_backend_routes(filepath: Path, framework: str) -> List[Dict[str, str]]:
"""Extract route definitions from NestJS controllers or Django url configs."""
routes = []
try:
content = filepath.read_text(errors="replace")
except IOError:
return routes
patterns = []
if framework in ("nestjs", "express", "fastify"):
patterns = NEST_ROUTE_PATTERNS
elif framework == "django":
patterns = DJANGO_ROUTE_PATTERNS
# For NestJS, also grab the controller prefix
controller_prefix = ""
if framework == "nestjs":
m = re.search(r"@Controller\s*\(\s*['\"]([^'\"]*)['\"]", content)
if m:
controller_prefix = "/" + m.group(1).strip("/")
for pattern in patterns:
for match in re.finditer(pattern, content):
path = match.group(1)
if not path or path.startswith("http") or len(path) > 200:
continue
# For NestJS method decorators, prepend controller prefix
if framework == "nestjs" and not path.startswith("/"):
full_path = f"{controller_prefix}/{path}".replace("//", "/")
else:
full_path = path if path.startswith("/") else f"/{path}"
# Detect HTTP method from decorator name
method = "UNKNOWN"
ctx = content[max(0, match.start() - 30):match.start()]
for m_name in ["Get", "Post", "Put", "Delete", "Patch"]:
if f"@{m_name}" in ctx or f"@{m_name.lower()}" in ctx:
method = m_name.upper()
break
routes.append({
"path": full_path,
"method": method,
"source": str(filepath),
"line": content[:match.start()].count("\n") + 1,
"type": "backend",
})
return routes
def extract_models(filepath: Path, framework: str) -> List[Dict[str, Any]]:
"""Extract model/entity definitions from backend code."""
models = []
try:
content = filepath.read_text(errors="replace")
except IOError:
return models
patterns = PYTHON_MODEL_PATTERNS if framework in ("django", "fastapi", "flask") else NEST_MODEL_PATTERNS
for pattern in patterns:
for match in re.finditer(pattern, content):
name = match.group(1)
# Try to extract fields
fields = []
# For Django models: field_name = models.FieldType(...)
if framework == "django":
block_start = match.end()
block = content[block_start:block_start + 2000]
for fm in re.finditer(
r"(\w+)\s*=\s*models\.(\w+)\s*\(([^)]*)\)", block
):
fields.append({
"name": fm.group(1),
"type": fm.group(2),
"args": fm.group(3).strip()[:100],
})
models.append({
"name": name,
"source": str(filepath),
"framework": framework,
"fields": fields,
})
return models
def count_components(files: List[Path]) -> Dict[str, int]:
"""Count components by type."""
counts: Dict[str, int] = defaultdict(int)
for f in files:
if f.suffix in COMPONENT_EXTENSIONS:
counts["components"] += 1
elif f.suffix in {".ts", ".js"}:
counts["modules"] += 1
return dict(counts)
def analyze_project(project_root: Path) -> Dict[str, Any]:
"""Run full analysis on a frontend project."""
root = Path(project_root).resolve()
if not root.is_dir():
return {"error": f"Not a directory: {root}"}
# 1. Framework detection
framework_info = detect_framework(root)
# 2. File inventory
all_files = walk_files(root)
component_counts = count_components(all_files)
# 3. Directory structure
route_dirs = find_dirs(root, ROUTE_DIR_PATTERNS)
api_dirs = find_dirs(root, API_DIR_PATTERNS)
state_dirs = find_dirs(root, STATE_DIR_PATTERNS)
i18n_dirs = find_dirs(root, I18N_DIR_PATTERNS)
# 4. Routes (frontend + backend)
routes = []
fw = framework_info["framework"]
# Frontend: config-based routes
for f in all_files:
if any(p in f.name.lower() for p in ["router", "routes", "routing"]):
routes.extend(extract_routes_from_file(f))
# Frontend: file-system routes (Next.js, Nuxt, SvelteKit)
if fw in ("next", "nuxt", "sveltekit", "remix", "astro"):
for d in route_dirs:
routes.extend(extract_routes_from_filesystem(d, root))
# Backend: NestJS controllers, Django urls
if fw in ("nestjs", "express", "fastify", "django"):
for f in all_files:
if fw == "django" and "urls.py" in f.name:
routes.extend(extract_backend_routes(f, fw))
elif fw in ("nestjs", "express", "fastify") and ".controller." in f.name:
routes.extend(extract_backend_routes(f, fw))
# Deduplicate routes by path (+ method for backend)
seen_paths: Set[str] = set()
unique_routes = []
for r in routes:
key = r["path"] if r.get("type") != "backend" else f"{r.get('method', '')}:{r['path']}"
if key not in seen_paths:
seen_paths.add(key)
unique_routes.append(r)
routes = sorted(unique_routes, key=lambda r: r["path"])
# 5. API calls
apis = []
for f in all_files:
apis.extend(extract_apis_from_file(f))
# Deduplicate APIs by path+method
seen_apis: Set[Tuple[str, str]] = set()
unique_apis = []
for a in apis:
key = (a["path"], a["method"])
if key not in seen_apis:
seen_apis.add(key)
unique_apis.append(a)
apis = sorted(unique_apis, key=lambda a: a["path"])
# 6. Enums
enums = []
for f in all_files:
enums.extend(extract_enums(f))
# 7. Models/entities (backend)
models = []
if fw in ("django", "fastapi", "flask", "nestjs"):
for f in all_files:
if fw == "django" and "models.py" in f.name:
models.extend(extract_models(f, fw))
elif fw == "nestjs" and (".entity." in f.name or ".dto." in f.name):
models.extend(extract_models(f, fw))
# Deduplicate models by name
seen_models: Set[str] = set()
unique_models = []
for m in models:
if m["name"] not in seen_models:
seen_models.add(m["name"])
unique_models.append(m)
models = sorted(unique_models, key=lambda m: m["name"])
# Backend-specific directories
controller_dirs = find_dirs(root, CONTROLLER_DIR_PATTERNS)
model_dirs = find_dirs(root, MODEL_DIR_PATTERNS)
dto_dirs = find_dirs(root, DTO_DIR_PATTERNS)
# 8. Summary
mock_count = sum(1 for a in apis if a.get("mock_detected"))
real_count = sum(1 for a in apis if a.get("integrated"))
backend_routes = [r for r in routes if r.get("type") == "backend"]
frontend_routes = [r for r in routes if r.get("type") != "backend"]
analysis = {
"project": {
"root": str(root),
"name": framework_info.get("name", root.name),
"framework": framework_info["framework"],
"detected_frameworks": framework_info.get("detected_frameworks", []),
"key_dependencies": framework_info.get("key_deps", {}),
"stack_type": "backend" if fw in ("django", "fastapi", "flask", "nestjs", "express", "fastify") and not frontend_routes else
"fullstack" if backend_routes and frontend_routes else "frontend",
},
"structure": {
"total_files": len(all_files),
"components": component_counts,
"route_dirs": [str(d) for d in route_dirs],
"api_dirs": [str(d) for d in api_dirs],
"state_dirs": [str(d) for d in state_dirs],
"i18n_dirs": [str(d) for d in i18n_dirs],
"controller_dirs": [str(d) for d in controller_dirs],
"model_dirs": [str(d) for d in model_dirs],
"dto_dirs": [str(d) for d in dto_dirs],
},
"routes": {
"count": len(routes),
"frontend_pages": frontend_routes,
"backend_endpoints": backend_routes,
"pages": routes, # backward compat
},
"apis": {
"total": len(apis),
"integrated": real_count,
"mock": mock_count,
"endpoints": apis,
},
"enums": {
"count": len(enums),
"definitions": enums,
},
"models": {
"count": len(models),
"definitions": models,
},
"summary": {
"pages": len(frontend_routes),
"backend_endpoints": len(backend_routes),
"api_endpoints": len(apis),
"api_integrated": real_count,
"api_mock": mock_count,
"enums": len(enums),
"models": len(models),
"has_i18n": len(i18n_dirs) > 0,
"has_state_management": len(state_dirs) > 0,
"stack_type": "backend" if fw in ("django", "fastapi", "flask", "nestjs", "express", "fastify") and not frontend_routes else
"fullstack" if backend_routes and frontend_routes else "frontend",
},
}
return analysis
def format_markdown(analysis: Dict[str, Any]) -> str:
"""Format analysis as markdown summary."""
lines = []
proj = analysis["project"]
summary = analysis["summary"]
stack = summary.get("stack_type", "frontend")
lines.append(f"# Codebase Analysis: {proj['name'] or 'Project'}")
lines.append("")
lines.append(f"**Framework:** {proj['framework']}")
lines.append(f"**Stack type:** {stack}")
lines.append(f"**Total files:** {analysis['structure']['total_files']}")
if summary.get("pages"):
lines.append(f"**Frontend pages:** {summary['pages']}")
if summary.get("backend_endpoints"):
lines.append(f"**Backend endpoints:** {summary['backend_endpoints']}")
lines.append(f"**API calls detected:** {summary['api_endpoints']} "
f"({summary['api_integrated']} integrated, {summary['api_mock']} mock)")
lines.append(f"**Enums:** {summary['enums']}")
if summary.get("models"):
lines.append(f"**Models/entities:** {summary['models']}")
lines.append(f"**i18n:** {'Yes' if summary['has_i18n'] else 'No'}")
lines.append(f"**State management:** {'Yes' if summary['has_state_management'] else 'No'}")
lines.append("")
if analysis["routes"]["pages"]:
lines.append("## Pages / Routes")
lines.append("")
lines.append("| # | Route | Source |")
lines.append("|---|-------|--------|")
for i, r in enumerate(analysis["routes"]["pages"], 1):
src = r.get("source", "").split("/")[-1]
fs = " (fs)" if r.get("filesystem") else ""
lines.append(f"| {i} | `{r['path']}` | {src}{fs} |")
lines.append("")
if analysis["apis"]["endpoints"]:
lines.append("## API Endpoints")
lines.append("")
lines.append("| Method | Path | Integrated | Source |")
lines.append("|--------|------|-----------|--------|")
for a in analysis["apis"]["endpoints"]:
src = a.get("source", "").split("/")[-1]
status = "" if a.get("integrated") else "⚠️ Mock"
lines.append(f"| {a['method']} | `{a['path']}` | {status} | {src} |")
lines.append("")
if analysis["enums"]["definitions"]:
lines.append("## Enums & Constants")
lines.append("")
for e in analysis["enums"]["definitions"]:
lines.append(f"### {e['name']} ({e['type']})")
if e["values"]:
lines.append("| Key | Value |")
lines.append("|-----|-------|")
for k, v in e["values"].items():
lines.append(f"| {k} | {v} |")
lines.append("")
if analysis.get("models", {}).get("definitions"):
lines.append("## Models / Entities")
lines.append("")
for m in analysis["models"]["definitions"]:
lines.append(f"### {m['name']} ({m.get('framework', '')})")
if m.get("fields"):
lines.append("| Field | Type | Args |")
lines.append("|-------|------|------|")
for fld in m["fields"]:
lines.append(f"| {fld['name']} | {fld['type']} | {fld.get('args', '')} |")
lines.append("")
if proj.get("key_dependencies"):
lines.append("## Key Dependencies")
lines.append("")
for dep, ver in sorted(proj["key_dependencies"].items()):
lines.append(f"- `{dep}`: {ver}")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Analyze any codebase (frontend, backend, fullstack) for PRD generation"
)
parser.add_argument("project", help="Path to project root")
parser.add_argument("-o", "--output", help="Output file (default: stdout)")
parser.add_argument(
"-f", "--format",
choices=["json", "markdown"],
default="json",
help="Output format (default: json)",
)
args = parser.parse_args()
analysis = analyze_project(Path(args.project))
if args.format == "markdown":
output = format_markdown(analysis)
else:
output = json.dumps(analysis, indent=2, ensure_ascii=False)
if args.output:
Path(args.output).write_text(output)
print(f"Written to {args.output}")
else:
print(output)
if __name__ == "__main__":
main()