@topy-ai/maggie 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/maggie.js +152 -0
- package/bundled-references/ai-native-blog-contract.md +310 -0
- package/bundled-references/blog-data-contract.md +146 -0
- package/bundled-references/blog-implementation.md +46 -0
- package/bundled-references/blog-operations-contract.md +68 -0
- package/bundled-references/browser-inspection.md +39 -0
- package/bundled-references/provider-adapter-contract.md +68 -0
- package/bundled-references/seo-technical-contract.md +75 -0
- package/bundled-skills/README.md +16 -0
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +243 -0
- package/bundled-skills/maggie-clone/SKILL.md +213 -0
- package/bundled-skills/maggie-deployment/SKILL.md +61 -0
- package/bundled-skills/maggie-deployment/agents/openai.yaml +4 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +76 -0
- package/bundled-skills/maggie-deployment/references/provider-contract.md +32 -0
- package/bundled-skills/maggie-project-context/SKILL.md +38 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +53 -0
- package/bundled-skills/maggie-social-share/SKILL.md +48 -0
- package/bundled-tools/clis/maggie.py +748 -0
- package/bundled-tools/clis/maggie_clone.py +82 -0
- package/bundled-tools/clis/site_audit.py +99 -0
- package/bundled-tools/integrations/analytics.md +34 -0
- package/bundled-tools/integrations/maggie-api-pull.md +72 -0
- package/bundled-tools/integrations/maggie-project-context.md +62 -0
- package/bundled-tools/integrations/maggie-seo-audit.md +16 -0
- package/bundled-tools/integrations/maggie-skills-api.md +76 -0
- package/bundled-tools/integrations/maggie-social-share.md +23 -0
- package/bundled-tools/integrations/maggie-visibility.md +22 -0
- package/package.json +29 -0
- package/references/ai-native-blog-contract.md +310 -0
- package/references/blog-data-contract.md +146 -0
- package/references/blog-implementation.md +46 -0
- package/references/blog-operations-contract.md +68 -0
- package/references/browser-inspection.md +39 -0
- package/references/provider-adapter-contract.md +68 -0
- package/references/seo-technical-contract.md +75 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Maggie's dependency-free project analysis and bootstrap gate CLI."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.error import HTTPError, URLError
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
from urllib.request import Request, urlopen
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
STATE_DIR = ".maggie"
|
|
20
|
+
STATE_FILE = "bootstrap-state.json"
|
|
21
|
+
IGNORED_DIRS = {".git", ".next", "node_modules", "dist", "build", "coverage", "generated"}
|
|
22
|
+
TEXT_SUFFIXES = {".css", ".html", ".js", ".jsx", ".md", ".mdx", ".svelte", ".ts", ".tsx", ".vue", ".astro", ".njk", ".liquid", ".json", ".yaml", ".yml"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def utc_now() -> str:
|
|
26
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def files_for(root: Path):
|
|
30
|
+
for path in root.rglob("*"):
|
|
31
|
+
if not path.is_file() or any(part in IGNORED_DIRS for part in path.parts):
|
|
32
|
+
continue
|
|
33
|
+
if path.name.startswith(".") and path.name not in {".env.example"}:
|
|
34
|
+
continue
|
|
35
|
+
yield path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_text(path: Path) -> str:
|
|
39
|
+
try:
|
|
40
|
+
return path.read_text(encoding="utf-8", errors="ignore")
|
|
41
|
+
except OSError:
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def evidence(path: Path, root: Path) -> str:
|
|
46
|
+
try:
|
|
47
|
+
return str(path.relative_to(root))
|
|
48
|
+
except ValueError:
|
|
49
|
+
return str(path)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def package_info(root: Path) -> tuple[dict, Path | None]:
|
|
53
|
+
candidates = [root / "package.json"] + sorted(path for path in root.glob("*/package.json") if path.parent.name not in IGNORED_DIRS)
|
|
54
|
+
parsed: list[tuple[int, dict, Path]] = []
|
|
55
|
+
for package in candidates:
|
|
56
|
+
if not package.exists():
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
value = json.loads(package.read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, json.JSONDecodeError):
|
|
61
|
+
continue
|
|
62
|
+
deps = dependency_names(value)
|
|
63
|
+
scripts = value.get("scripts") or {}
|
|
64
|
+
score = (10 if deps & {"astro", "next", "@sveltejs/kit", "nuxt", "@11ty/eleventy"} else 0) + (3 if "build" in scripts else 0) + (2 if "dev" in scripts else 0)
|
|
65
|
+
parsed.append((score, value, package))
|
|
66
|
+
if not parsed:
|
|
67
|
+
return {}, None
|
|
68
|
+
_, value, package = max(parsed, key=lambda item: (item[0], -len(item[2].parts)))
|
|
69
|
+
return value, package
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dependency_names(package: dict) -> set[str]:
|
|
73
|
+
return set((package.get("dependencies") or {})) | set((package.get("devDependencies") or {}))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def detect(root: Path) -> dict:
|
|
77
|
+
package, package_path = package_info(root)
|
|
78
|
+
deps = dependency_names(package)
|
|
79
|
+
scripts = package.get("scripts") or {}
|
|
80
|
+
all_files = list(files_for(root))
|
|
81
|
+
names = {path.name for path in all_files}
|
|
82
|
+
rel_paths = [evidence(path, root) for path in all_files]
|
|
83
|
+
|
|
84
|
+
def found(value: str, source: str) -> dict:
|
|
85
|
+
return {"status": "Detected", "value": value, "evidence": [source]}
|
|
86
|
+
|
|
87
|
+
def missing(value: str = "not found") -> dict:
|
|
88
|
+
return {"status": "Missing", "value": value, "evidence": []}
|
|
89
|
+
|
|
90
|
+
if "astro" in deps or any(path.endswith(".astro") for path in rel_paths):
|
|
91
|
+
framework = found("astro", "package.json" if "astro" in deps else next(path for path in rel_paths if path.endswith(".astro")))
|
|
92
|
+
elif "next" in deps or any(path.startswith("app/") or path.startswith("pages/") for path in rel_paths):
|
|
93
|
+
framework = found("next", "package.json" if "next" in deps else "app/ or pages/")
|
|
94
|
+
elif "@sveltejs/kit" in deps:
|
|
95
|
+
framework = found("sveltekit", "package.json")
|
|
96
|
+
elif "nuxt" in deps:
|
|
97
|
+
framework = found("nuxt", "package.json")
|
|
98
|
+
elif "@11ty/eleventy" in deps or any(name == ".eleventy.js" for name in names):
|
|
99
|
+
framework = found("eleventy", "package.json" if "@11ty/eleventy" in deps else ".eleventy.js")
|
|
100
|
+
else:
|
|
101
|
+
framework = {"status": "Unknown", "value": "no supported framework detected", "evidence": []}
|
|
102
|
+
|
|
103
|
+
ts_files = [path for path in rel_paths if Path(path).suffix in {".ts", ".tsx"}]
|
|
104
|
+
js_files = [path for path in rel_paths if Path(path).suffix in {".js", ".jsx"}]
|
|
105
|
+
if ts_files:
|
|
106
|
+
language = found("typescript", ts_files[0])
|
|
107
|
+
elif js_files:
|
|
108
|
+
language = found("javascript", js_files[0])
|
|
109
|
+
else:
|
|
110
|
+
language = {"status": "Unknown", "value": "no TypeScript or JavaScript source detected", "evidence": []}
|
|
111
|
+
|
|
112
|
+
lockfiles = {"pnpm-lock.yaml": "pnpm", "yarn.lock": "yarn", "bun.lockb": "bun", "package-lock.json": "npm"}
|
|
113
|
+
package_manager = next((found(manager, lockfile) for lockfile, manager in lockfiles.items() if lockfile in names), {"status": "Unknown", "value": "not detected", "evidence": []})
|
|
114
|
+
|
|
115
|
+
ui_candidates = {"tailwindcss": "tailwind", "@mui/material": "mui", "@chakra-ui/react": "chakra", "styled-components": "styled-components", "@radix-ui/react": "radix"}
|
|
116
|
+
ui = next((found(value, "package.json") for dep, value in ui_candidates.items() if dep in deps), None)
|
|
117
|
+
if not ui:
|
|
118
|
+
css_evidence = next((path for path in rel_paths if Path(path).suffix == ".css"), None)
|
|
119
|
+
ui = found("plain-css-or-custom", css_evidence) if css_evidence else {"status": "Unknown", "value": "no UI system detected", "evidence": []}
|
|
120
|
+
|
|
121
|
+
icon_candidates = {"@heroicons/react": "heroicons", "lucide-react": "lucide", "@fortawesome/fontawesome": "fontawesome", "react-icons": "react-icons"}
|
|
122
|
+
icons = next((found(value, "package.json") for dep, value in icon_candidates.items() if dep in deps), {"status": "Unknown", "value": "no icon set detected", "evidence": []})
|
|
123
|
+
|
|
124
|
+
font_path = next((path for path in rel_paths if Path(path).suffix in {".woff", ".woff2", ".ttf", ".otf"}), None)
|
|
125
|
+
font_css = next((path for path in rel_paths if Path(path).suffix == ".css" and "@font-face" in read_text(root / path)), None)
|
|
126
|
+
typography = found("local-font", font_path or font_css) if font_path or font_css else {"status": "Unknown", "value": "no custom font detected", "evidence": []}
|
|
127
|
+
|
|
128
|
+
db_candidates = {"prisma": "prisma", "drizzle-orm": "drizzle", "@libsql/client": "sqlite/libsql", "better-sqlite3": "sqlite", "pg": "postgresql", "mysql2": "mysql"}
|
|
129
|
+
database = next((found(value, "package.json") for dep, value in db_candidates.items() if dep in deps), None)
|
|
130
|
+
db_path = next((path for path in rel_paths if any(token in path.lower() for token in {"prisma", "drizzle", "migration", "schema.sql", "database"})), None)
|
|
131
|
+
if not database and db_path:
|
|
132
|
+
database = found("database-files-present", db_path)
|
|
133
|
+
if not database:
|
|
134
|
+
database = {"status": "Unknown", "value": "database engine and schema not detected", "evidence": []}
|
|
135
|
+
|
|
136
|
+
content_candidates = [path for path in rel_paths if Path(path).suffix in {".md", ".mdx", ".json"} and any(token in path.lower() for token in {"post", "blog", "content", "article"})]
|
|
137
|
+
if content_candidates:
|
|
138
|
+
content_source = found("local-content", content_candidates[0])
|
|
139
|
+
elif any(token in deps for token in {"contentlayer", "@content-collections/core"}):
|
|
140
|
+
content_source = found("content-library", "package.json")
|
|
141
|
+
else:
|
|
142
|
+
content_source = {"status": "Unknown", "value": "content source not detected", "evidence": []}
|
|
143
|
+
|
|
144
|
+
texts = "\n".join(read_text(path) for path in all_files if path.suffix in TEXT_SUFFIXES)
|
|
145
|
+
cjk = len(re.findall(r"[\u3400-\u9fff]", texts))
|
|
146
|
+
latin = len(re.findall(r"[A-Za-z]", texts))
|
|
147
|
+
if cjk > max(50, latin // 5):
|
|
148
|
+
content_language = {"status": "Likely", "value": "zh-or-multilingual", "evidence": ["text scan; confirm Traditional vs Simplified Chinese"]}
|
|
149
|
+
elif latin > 50:
|
|
150
|
+
content_language = {"status": "Likely", "value": "en-or-multilingual", "evidence": ["text scan; confirm locale"]}
|
|
151
|
+
else:
|
|
152
|
+
content_language = {"status": "Unknown", "value": "content language not detected", "evidence": []}
|
|
153
|
+
|
|
154
|
+
route_candidates = [path for path in rel_paths if any(token in path.lower() for token in {"sitemap", "robots", "post"})]
|
|
155
|
+
seo = {"status": "Detected" if route_candidates else "Missing", "value": "SEO/content route files found" if route_candidates else "blog/SEO route files not found", "evidence": route_candidates[:8]}
|
|
156
|
+
|
|
157
|
+
deployment_candidates = {"vercel.json": "vercel", "netlify.toml": "netlify", "fly.toml": "fly.io", "docker-compose.yml": "docker", "Dockerfile": "docker"}
|
|
158
|
+
deployment = next((found(value, filename) for filename, value in deployment_candidates.items() if filename in names), {"status": "Unknown", "value": "deployment platform not detected; confirm before changes", "evidence": []})
|
|
159
|
+
cms_candidates = {"wordpress": "wordpress", "@sanity/client": "sanity", "contentful": "contentful", "@strapi/strapi": "strapi", "payload": "payload"}
|
|
160
|
+
existing_cms = next((found(value, "package.json") for dep, value in cms_candidates.items() if dep in deps), {"status": "Unknown", "value": "no CMS detected", "evidence": []})
|
|
161
|
+
env_file = root / ".env.example"
|
|
162
|
+
environment = sorted(set(re.findall(r"^([A-Z][A-Z0-9_]+)=", read_text(env_file), re.MULTILINE)))
|
|
163
|
+
implementation_paths = [path for path in rel_paths if not any(part.lower() in {"docs", "documentation", "references"} for part in Path(path).parts) and Path(path).name.lower() not in {"readme.md", "changelog.md"}]
|
|
164
|
+
existing_routes = [path for path in implementation_paths if any(token in path.lower() for token in {"route", "page", "sitemap", "robots"})][:100]
|
|
165
|
+
route_inventory = {
|
|
166
|
+
"public": sorted(path for path in existing_routes if not any(token in path.lower() for token in {"api/", "ops", "admin", "dashboard"})),
|
|
167
|
+
"private_or_api": sorted(path for path in existing_routes if any(token in path.lower() for token in {"api/", "ops", "admin", "dashboard"})),
|
|
168
|
+
"evidence": existing_routes,
|
|
169
|
+
}
|
|
170
|
+
command_names = ("dev", "build", "test", "lint", "typecheck", "deploy")
|
|
171
|
+
validation_commands = {name: {"status": "Detected", "value": scripts[name], "evidence": ["package.json#scripts"]} if name in scripts else {"status": "Missing", "value": "not found", "evidence": []} for name in command_names}
|
|
172
|
+
runtime_candidates = {"@astrojs/node": "node-server", "@astrojs/cloudflare": "cloudflare", "@opennextjs/cloudflare": "cloudflare", "wrangler": "cloudflare", "vercel": "vercel", "netlify-cli": "netlify"}
|
|
173
|
+
runtime = next((found(value, "package.json") for dep, value in runtime_candidates.items() if dep in deps), {"status": "Unknown", "value": "runtime/adapter not detected", "evidence": []})
|
|
174
|
+
asset_contract = {
|
|
175
|
+
"redirects": found("present", "redirects file") if any(name in names for name in {"_redirects", "redirects.json", "redirects.csv"}) else missing("no redirects manifest detected"),
|
|
176
|
+
"security_headers": found("present", name) if (name := next((name for name in {"_headers", "headers.json", "vercel.json", "netlify.toml"} if name in names), None)) else missing("no deployment/header policy detected"),
|
|
177
|
+
"favicon_or_brand": found("present", path) if (path := next((path for path in implementation_paths if any(token in path.lower() for token in {"favicon", "logo", "brand"}) and Path(path).suffix in {".svg", ".png", ".ico", ".webp"}), None)) else missing("no favicon or brand asset detected"),
|
|
178
|
+
"social_image": found("present", path) if (path := next((path for path in implementation_paths if any(token in path.lower() for token in {"og", "social", "twitter"}) and Path(path).suffix in {".jpg", ".jpeg", ".png", ".webp"}), None)) else missing("no social image asset detected"),
|
|
179
|
+
}
|
|
180
|
+
content_model = {
|
|
181
|
+
"frontmatter": found("detected", path) if (path := next((path for path in implementation_paths if Path(path).suffix in {".md", ".mdx", ".astro"} and ("frontmatter" in read_text(root / path).lower() or re.search(r"^---\\s*$", read_text(root / path), re.MULTILINE))), None)) else missing("frontmatter contract not detected"),
|
|
182
|
+
"slug_field": found("detected", path) if (path := next((path for path in implementation_paths if "slug" in read_text(root / path).lower() and Path(path).suffix in TEXT_SUFFIXES), None)) else missing("slug field not detected"),
|
|
183
|
+
"last_modified": found("detected", path) if (path := next((path for path in implementation_paths if any(token in read_text(root / path).lower() for token in {"lastmod", "dateupdated", "datemodified", "updated_at"})), None)) else missing("last-modified contract not detected"),
|
|
184
|
+
}
|
|
185
|
+
risks = []
|
|
186
|
+
if framework["status"] in {"Unknown", "Missing"}: risks.append("framework must be confirmed before generator changes")
|
|
187
|
+
if database["status"] != "Detected": risks.append("database/schema is not detected; do not invent a persistence layer")
|
|
188
|
+
if existing_cms["status"] == "Detected": risks.append("existing CMS requires an explicit migration/source-of-truth decision")
|
|
189
|
+
if validation_commands["build"]["status"] != "Detected": risks.append("build command is not detected; establish a reproducible build gate before editing")
|
|
190
|
+
if not route_inventory["public"]: risks.append("public route inventory is empty; inspect the running app before generating blog routes")
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"schema_version": "1.1",
|
|
194
|
+
"analyzed_at": utc_now(),
|
|
195
|
+
"project_root": str(root),
|
|
196
|
+
"framework": framework,
|
|
197
|
+
"programming_language": language,
|
|
198
|
+
"package_manager": package_manager,
|
|
199
|
+
"content_language": content_language,
|
|
200
|
+
"ui_system": ui,
|
|
201
|
+
"icon_set": icons,
|
|
202
|
+
"typography": typography,
|
|
203
|
+
"database": database,
|
|
204
|
+
"content_source": content_source,
|
|
205
|
+
"seo_routes": seo,
|
|
206
|
+
"deployment": deployment,
|
|
207
|
+
"existing_cms": existing_cms,
|
|
208
|
+
"environment_variables": environment,
|
|
209
|
+
"existing_routes": existing_routes,
|
|
210
|
+
"route_inventory": route_inventory,
|
|
211
|
+
"runtime": runtime,
|
|
212
|
+
"validation_commands": validation_commands,
|
|
213
|
+
"asset_contract": asset_contract,
|
|
214
|
+
"content_model": content_model,
|
|
215
|
+
"recommended_architecture": {"status": "Needs confirmation", "value": "preserve detected framework/UI/database; add typed server-side blog adapter and explicit Ops boundary", "evidence": []},
|
|
216
|
+
"risks": risks,
|
|
217
|
+
"required_confirmations": ["framework", "language", "ui_system", "icon_set", "font", "database", "content_source", "deployment", "seo_routes"],
|
|
218
|
+
"planned_changes": ["typed post contract", "public blog routes", "sitemap/robots", "server-only integrations", "authenticated Ops routes"],
|
|
219
|
+
"package_json": evidence(package_path, root) if package_path else None,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def state_path(root: Path) -> Path:
|
|
224
|
+
return root / STATE_DIR / STATE_FILE
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def load_state(root: Path) -> dict | None:
|
|
228
|
+
path = state_path(root)
|
|
229
|
+
if not path.exists():
|
|
230
|
+
return None
|
|
231
|
+
try:
|
|
232
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
233
|
+
except (OSError, json.JSONDecodeError):
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def require_bootstrap(root: Path) -> dict:
|
|
238
|
+
state = load_state(root)
|
|
239
|
+
if not state or state.get("status") != "completed":
|
|
240
|
+
print("BOOTSTRAP_REQUIRED: run `maggie bootstrap complete` after user confirmation.", file=sys.stderr)
|
|
241
|
+
raise SystemExit(2)
|
|
242
|
+
return state
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ApiClient:
|
|
246
|
+
"""Small API client with bounded retries and no secret-bearing output."""
|
|
247
|
+
|
|
248
|
+
def __init__(self, base_url: str, api_key: str, timeout: int = 30) -> None:
|
|
249
|
+
self.base_url = base_url.rstrip("/")
|
|
250
|
+
self.api_key = api_key
|
|
251
|
+
self.timeout = timeout
|
|
252
|
+
|
|
253
|
+
def request(self, method: str, path: str, body: dict | None = None, idempotency_key: str | None = None) -> object:
|
|
254
|
+
payload = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
255
|
+
headers = {
|
|
256
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
257
|
+
"Accept": "application/json",
|
|
258
|
+
"User-Agent": "Maggie-CLI/0.1",
|
|
259
|
+
}
|
|
260
|
+
if payload is not None:
|
|
261
|
+
headers["Content-Type"] = "application/json"
|
|
262
|
+
if idempotency_key:
|
|
263
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
264
|
+
for attempt in range(3):
|
|
265
|
+
try:
|
|
266
|
+
request = Request(self.base_url + path, data=payload, headers=headers, method=method)
|
|
267
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
268
|
+
raw = response.read(5_000_000).decode("utf-8", "replace")
|
|
269
|
+
return json.loads(raw) if raw else {}
|
|
270
|
+
except HTTPError as error:
|
|
271
|
+
detail = error.read(2_000).decode("utf-8", "replace")
|
|
272
|
+
if error.code in {408, 429, 500, 502, 503, 504} and attempt < 2:
|
|
273
|
+
time.sleep(2**attempt)
|
|
274
|
+
continue
|
|
275
|
+
raise RuntimeError(f"Maggie API request failed ({error.code}): {detail}") from error
|
|
276
|
+
except URLError as error:
|
|
277
|
+
if attempt < 2:
|
|
278
|
+
time.sleep(2**attempt)
|
|
279
|
+
continue
|
|
280
|
+
raise RuntimeError(f"Maggie API request failed: {error.reason}") from error
|
|
281
|
+
raise RuntimeError("Maggie API request failed after retries")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def api_client(args: argparse.Namespace) -> ApiClient:
|
|
285
|
+
api_key = os.getenv("AI_CMO_API_KEY")
|
|
286
|
+
if not api_key:
|
|
287
|
+
raise RuntimeError("set AI_CMO_API_KEY; the CLI never accepts API keys as positional arguments")
|
|
288
|
+
return ApiClient(getattr(args, "base_url", None) or os.getenv("AI_CMO_BASE_URL", "https://api.cmo.so"), api_key, getattr(args, "timeout", 30))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def load_body(path: str) -> dict:
|
|
292
|
+
try:
|
|
293
|
+
body = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
294
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
295
|
+
raise RuntimeError(f"invalid JSON body file: {path}") from error
|
|
296
|
+
if not isinstance(body, dict):
|
|
297
|
+
raise RuntimeError("API request body must be a JSON object")
|
|
298
|
+
return body
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def print_api(value: object) -> None:
|
|
302
|
+
print(json.dumps(value, indent=2, ensure_ascii=False))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def emit_api(value: object, args: argparse.Namespace) -> None:
|
|
306
|
+
serialized = json.dumps(value, indent=2, ensure_ascii=False) + "\n"
|
|
307
|
+
output = getattr(args, "output", None)
|
|
308
|
+
if output:
|
|
309
|
+
destination = Path(output)
|
|
310
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
temporary = destination.with_name(destination.name + ".tmp")
|
|
312
|
+
with temporary.open("w", encoding="utf-8") as handle:
|
|
313
|
+
handle.write(serialized)
|
|
314
|
+
handle.flush()
|
|
315
|
+
os.fsync(handle.fileno())
|
|
316
|
+
temporary.replace(destination)
|
|
317
|
+
print(f"Saved API response to {destination}")
|
|
318
|
+
else:
|
|
319
|
+
print(serialized, end="")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def require_execute(args: argparse.Namespace, root: Path | None = None) -> None:
|
|
323
|
+
if not args.execute:
|
|
324
|
+
print("DRY_RUN: no external request sent; add --execute to continue.")
|
|
325
|
+
return
|
|
326
|
+
if root is not None:
|
|
327
|
+
require_bootstrap(root)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def command_api_get(args: argparse.Namespace) -> int:
|
|
331
|
+
path = args.path
|
|
332
|
+
if args.charges_quota and not getattr(args, "execute", False):
|
|
333
|
+
print("DRY_RUN: this delivery request can consume quota; add --execute to continue.")
|
|
334
|
+
return 0
|
|
335
|
+
if args.charges_quota and getattr(args, "execute", False) and not getattr(args, "output", None):
|
|
336
|
+
print("OUTPUT_REQUIRED: quota-consuming delivery requires --output so the response is persisted.", file=sys.stderr)
|
|
337
|
+
return 2
|
|
338
|
+
if args.charges_quota and getattr(args, "requires_bootstrap", False):
|
|
339
|
+
require_bootstrap(Path(args.project).resolve())
|
|
340
|
+
client = api_client(args)
|
|
341
|
+
emit_api(client.request("GET", path), args)
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def command_api_post(args: argparse.Namespace) -> int:
|
|
346
|
+
root = Path(args.project).resolve() if getattr(args, "project", None) else None
|
|
347
|
+
if not args.execute:
|
|
348
|
+
print("DRY_RUN: no external request sent; add --execute to continue.")
|
|
349
|
+
return 0
|
|
350
|
+
if getattr(args, "requires_bootstrap", False):
|
|
351
|
+
require_bootstrap(root)
|
|
352
|
+
body = load_body(args.body_file) if getattr(args, "body_file", None) else None
|
|
353
|
+
if getattr(args, "sitemap_url", None):
|
|
354
|
+
body = {"sitemap_url": args.sitemap_url}
|
|
355
|
+
key = getattr(args, "idempotency_key", None) or f"maggie-{args.path.strip('/').replace('/', '-')}-{int(time.time())}"
|
|
356
|
+
emit_api(api_client(args).request("POST", args.path, body, key), args)
|
|
357
|
+
return 0
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def command_rewrite_history(args: argparse.Namespace) -> int:
|
|
361
|
+
args.path = "/rewrite/history/" + quote(args.content_id, safe="")
|
|
362
|
+
args.charges_quota = False
|
|
363
|
+
return command_api_get(args)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def command_analyze(args: argparse.Namespace) -> int:
|
|
367
|
+
root = Path(args.project).resolve()
|
|
368
|
+
result = detect(root)
|
|
369
|
+
if args.save:
|
|
370
|
+
output = root / STATE_DIR / "analysis.json"
|
|
371
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
373
|
+
print(json.dumps(result, indent=2, ensure_ascii=False) if args.json else format_analysis(result))
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def format_analysis(result: dict) -> str:
|
|
378
|
+
lines = ["Maggie codebase analysis", f"Project: {result['project_root']}"]
|
|
379
|
+
for key in ("framework", "programming_language", "package_manager", "content_language", "ui_system", "icon_set", "typography", "database", "content_source", "existing_cms", "deployment", "runtime", "seo_routes"):
|
|
380
|
+
item = result[key]
|
|
381
|
+
lines.append(f"- {key}: [{item['status']}] {item['value']}" + (f" ({', '.join(item['evidence'])})" if item["evidence"] else ""))
|
|
382
|
+
commands = result.get("validation_commands", {})
|
|
383
|
+
if commands:
|
|
384
|
+
lines.append("- validation_commands: " + ", ".join(f"{name}={item['value']}" for name, item in commands.items() if item["status"] == "Detected"))
|
|
385
|
+
inventory = result.get("route_inventory", {})
|
|
386
|
+
lines.append(f"- route_inventory: {len(inventory.get('public', []))} public, {len(inventory.get('private_or_api', []))} private/API")
|
|
387
|
+
return "\n".join(lines)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def command_status(args: argparse.Namespace) -> int:
|
|
391
|
+
root = Path(args.project).resolve()
|
|
392
|
+
state = load_state(root)
|
|
393
|
+
if not state:
|
|
394
|
+
print(json.dumps({"status": "not_started", "project_root": str(root)}, indent=2))
|
|
395
|
+
return 1
|
|
396
|
+
print(json.dumps(state, indent=2, ensure_ascii=False))
|
|
397
|
+
return 0 if state.get("status") == "completed" else 1
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def command_complete(args: argparse.Namespace) -> int:
|
|
401
|
+
root = Path(args.project).resolve()
|
|
402
|
+
required = {"foundation", "experience", "data", "publishing"}
|
|
403
|
+
confirmed = set(args.confirm or [])
|
|
404
|
+
missing = required - confirmed
|
|
405
|
+
if missing:
|
|
406
|
+
print("Confirmation required for: " + ", ".join(sorted(missing)), file=sys.stderr)
|
|
407
|
+
return 2
|
|
408
|
+
state = {
|
|
409
|
+
"schema_version": "1.0",
|
|
410
|
+
"status": "completed",
|
|
411
|
+
"completed_at": utc_now(),
|
|
412
|
+
"confirmed": {name: True for name in sorted(required)},
|
|
413
|
+
"decisions": {
|
|
414
|
+
"framework": args.framework,
|
|
415
|
+
"programming_language": args.language,
|
|
416
|
+
"content_language": args.content_language,
|
|
417
|
+
"ui_system": args.ui_system,
|
|
418
|
+
"icon_set": args.icon_set,
|
|
419
|
+
"font": args.font,
|
|
420
|
+
"database": args.database,
|
|
421
|
+
"content_source": args.content_source,
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
path = state_path(root)
|
|
425
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
426
|
+
path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
427
|
+
contracts = {
|
|
428
|
+
"project.json": {"schema_version": "1.0", "framework": args.framework, "language": args.language, "database": args.database, "content_source": args.content_source},
|
|
429
|
+
"decisions.json": state["decisions"],
|
|
430
|
+
"schema.json": {"post_required": ["id", "slug", "title", "content", "status", "canonicalUrl", "publishedAt", "updatedAt", "author"], "public_status": "published"},
|
|
431
|
+
"routes.json": {"public": ["/about", "/blog", "/blog/[slug]", "/topics", "/authors/[slug]", "/sitemap.xml", "/robots.txt"], "ops": ["/ops", "/ops/posts", "/ops/operations", "/ops/wordpress", "/api/ops/bulk", "/api/ops/agency", "/api/ops/entities", "/api/ops/migrations/[id]/resume", "/api/ops/wordpress/migration-plan", "/api/ops/pull/project-context", "/api/ops/pull/sync-updates", "/api/ops/sitemap/matching-history", "/api/ops/content-tracking/report-state", "/api/ops/content-tracking/report-state/batch", "/api/ops/playbooks/[id]/run"]},
|
|
432
|
+
"integrations.json": {"ai_cmo": "server-only", "gsc": "server-only", "ga4": "consent-aware"},
|
|
433
|
+
"migration.json": {"supported": ["wordpress-rest", "wxr", "csv", "json", "sitemap", "media-archive"], "idempotency": "source-id-and-checksum", "default_conflict": "manual", "sync_modes": ["all", "new", "modified"], "deleted_policy": "archive"},
|
|
434
|
+
}
|
|
435
|
+
for filename, content in contracts.items():
|
|
436
|
+
(path.parent / filename).write_text(json.dumps(content, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
437
|
+
print(f"Bootstrap completed: {path}")
|
|
438
|
+
return 0
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def command_generate(args: argparse.Namespace) -> int:
|
|
442
|
+
root = Path(args.project).resolve()
|
|
443
|
+
require_bootstrap(root)
|
|
444
|
+
if args.target == "contract":
|
|
445
|
+
print(f"Bootstrap contract already generated at {root / STATE_DIR}")
|
|
446
|
+
return 0
|
|
447
|
+
if args.target == "fixture":
|
|
448
|
+
output = root / STATE_DIR / "fixtures" / "api-pull.json"; content = [{"id": "fixture-1", "slug": "fixture-post", "title": "Fixture post", "content": "<p>Fixture content.</p>", "status": "published"}]
|
|
449
|
+
else:
|
|
450
|
+
output = root / STATE_DIR / "generated" / f"{args.target}.json"
|
|
451
|
+
content = {"type": args.target, "schema": "Maggie blog contract", "required": {"post": ["id", "slug", "title", "content", "status", "canonicalUrl", "publishedAt", "updatedAt", "author"], "topic": ["id", "slug", "title", "description"], "author": ["id", "slug", "name"], "migration": ["source", "sourceId", "mode", "checksum"], "seo": ["title", "description", "canonical", "robots", "jsonLd"], "ops-page": ["route", "auth", "api"]}[args.target]}
|
|
452
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
453
|
+
output.write_text(json.dumps(content, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
454
|
+
if args.target == "post":
|
|
455
|
+
generated = output.parent
|
|
456
|
+
(generated / "post.frontmatter.md").write_text("---\nid: stable-source-id\nslug: stable-public-slug\ntitle: Post title\nstatus: draft\ncanonicalUrl: https://example.com/blog/stable-public-slug\npublishedAt: null\nupdatedAt: null\nauthor: imported\n---\n\n# Post title\n\nContent goes here.\n", encoding="utf-8")
|
|
457
|
+
(generated / "post.types.ts").write_text("export type MaggiePost = { id: string; slug: string; title: string; content: string; status: 'draft' | 'review' | 'approved' | 'scheduled' | 'published' | 'archived'; canonicalUrl: string; publishedAt?: string; updatedAt?: string; author: { id: string; name: string } };\n", encoding="utf-8")
|
|
458
|
+
(generated / "post.fixture.json").write_text(json.dumps({"id":"fixture-post","slug":"fixture-post","title":"Fixture post","content":"<p>Fixture content.</p>","status":"draft","canonicalUrl":"https://example.com/blog/fixture-post","author":{"id":"author-imported","name":"Imported author"}}, indent=2) + "\n", encoding="utf-8")
|
|
459
|
+
(generated / "post.api.md").write_text("# Post API contract\n\nWrites must validate the fixed MaggiePost shape, create a revision, and require an explicit approval transition before publishing.\n", encoding="utf-8")
|
|
460
|
+
if args.target == "seo":
|
|
461
|
+
(output.parent / "seo.metadata.ts").write_text("export const robots = 'index,follow';\nexport const schemaType = 'Article';\nexport const jsonLdFields = ['@context','@type','headline','description','url','datePublished','dateModified','author'];\n", encoding="utf-8")
|
|
462
|
+
if args.target == "migration":
|
|
463
|
+
(output.parent / "migration.sql").write_text("-- Apply through the selected adapter; never bypass source_id/checksum idempotency.\nALTER TABLE posts ADD COLUMN source_id TEXT;\n", encoding="utf-8")
|
|
464
|
+
(output.parent / "migration.api.md").write_text("# Migration API contract\n\nPreview before apply. Every record requires `sourceId`, a normalized slug, a checksum, and a rollback identifier.\n", encoding="utf-8")
|
|
465
|
+
if args.target in {"topic", "author"}:
|
|
466
|
+
(output.parent / f"{args.target}.types.ts").write_text(f"export type Maggie{args.target.title()} = {{ id: string; slug: string; title: string; }};\n" if args.target == "topic" else "export type MaggieAuthor = { id: string; slug: string; name: string; credentials?: string[] };\n", encoding="utf-8")
|
|
467
|
+
if args.target == "ops-page":
|
|
468
|
+
(output.parent / "ops-page.api.md").write_text("# Ops page contract\n\nThe page is authenticated, calls a server-side `/api/ops/*` route, and must not expose provider credentials.\n", encoding="utf-8")
|
|
469
|
+
print(f"Generated contract artifact: {output}")
|
|
470
|
+
return 0
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def command_doctor(args: argparse.Namespace) -> int:
|
|
474
|
+
root = Path(args.project).resolve()
|
|
475
|
+
if getattr(args, "ci", False): args.strict = True
|
|
476
|
+
if args.require_bootstrap:
|
|
477
|
+
require_bootstrap(root)
|
|
478
|
+
files = list(files_for(root))
|
|
479
|
+
source_files = [
|
|
480
|
+
path for path in files
|
|
481
|
+
if path.suffix in {".astro", ".css", ".html", ".js", ".jsx", ".md", ".mdx", ".njk", ".svelte", ".ts", ".tsx", ".vue"}
|
|
482
|
+
and path.name.lower() not in {"readme.md", "changelog.md"}
|
|
483
|
+
and not any(part.lower() in {"docs", "documentation", "references"} for part in path.parts)
|
|
484
|
+
]
|
|
485
|
+
text = "\n".join(read_text(path) for path in source_files)
|
|
486
|
+
paths = [evidence(path, root).lower() for path in source_files]
|
|
487
|
+
|
|
488
|
+
checks = {
|
|
489
|
+
"posts_route": any("post" in path for path in paths),
|
|
490
|
+
"sitemap_route": any("sitemap" in path for path in paths) or "sitemap" in text.lower(),
|
|
491
|
+
"robots_route": any("robots" in path for path in paths) or "robots" in text.lower(),
|
|
492
|
+
"post_identity_fields": all(token in text for token in ("id", "slug", "title")),
|
|
493
|
+
"publication_guard": all(token in text for token in ("status", "published", "publishedAt")),
|
|
494
|
+
"source_identity_or_sync": any(token in text for token in ("sourceId", "content_id", "sync_state", "syncState")),
|
|
495
|
+
"canonical_metadata": "canonical" in text.lower(),
|
|
496
|
+
"absolute_canonical": "https://" in text and "canonical" in text.lower(),
|
|
497
|
+
"meta_description": "description" in text.lower(),
|
|
498
|
+
"open_graph_metadata": "og:title" in text.lower() or "property=\"og:" in text.lower(),
|
|
499
|
+
"open_graph_url": "og:url" in text.lower(),
|
|
500
|
+
"twitter_card": "twitter:card" in text.lower(),
|
|
501
|
+
"article_jsonld": "application/ld+json" in text.lower() and ("article" in text.lower() or "blogposting" in text.lower()),
|
|
502
|
+
"jsonld_dates": "datePublished" in text and ("dateModified" in text or "updatedAt" in text),
|
|
503
|
+
"published_only_guard": "published" in text.lower() and any(token in text.lower() for token in {"sitemap", "post"}),
|
|
504
|
+
"no_public_api_key": not bool(re.search(r"(?:PUBLIC|NEXT_PUBLIC)[A-Z0-9_]*(?:API|KEY|TOKEN)", text, re.IGNORECASE)),
|
|
505
|
+
"no_placeholder_metadata": not any(token in text.lower() for token in {"your-domain.com", "example.com", "lorem ipsum", "replace-me"}),
|
|
506
|
+
}
|
|
507
|
+
if args.strict:
|
|
508
|
+
checks.update({
|
|
509
|
+
"topic_surface": any("topic" in path for path in paths),
|
|
510
|
+
"topic_description": "topic" in text.lower() and "description" in text.lower(),
|
|
511
|
+
"faq_surface": "faq" in text.lower(),
|
|
512
|
+
"cta_surface": "cta" in text.lower(),
|
|
513
|
+
"pagination_surface": "pagination" in text.lower() or "page_size" in text.lower(),
|
|
514
|
+
"eeat_authorship": "author" in text.lower() and ("credential" in text.lower() or "organisation" in text.lower() or "organization" in text.lower()),
|
|
515
|
+
"modified_date": "dateModified" in text or "updatedAt" in text,
|
|
516
|
+
"ops_surface": any("ops" in path for path in paths),
|
|
517
|
+
"ops_authentication": "auth" in text.lower() or "middleware" in text.lower(),
|
|
518
|
+
"integration_config": "AI_CMO_API_KEY" in text or "measurement_id" in text.lower() or "verification" in text.lower(),
|
|
519
|
+
"bootstrap_contract": all((root / STATE_DIR / filename).exists() for filename in ("project.json", "decisions.json", "schema.json", "routes.json", "integrations.json", "migration.json")),
|
|
520
|
+
"scheduled_publication_state": "scheduled" in text.lower() and "published" in text.lower(),
|
|
521
|
+
"revision_or_audit_trail": "revision" in text.lower() or "audit" in text.lower(),
|
|
522
|
+
"migration_checksum": "checksum" in text.lower() or "source_id" in text.lower(),
|
|
523
|
+
"private_ops_noindex": "noindex" in text.lower() and "ops" in text.lower(),
|
|
524
|
+
"consent_aware_analytics": "consent" in text.lower() and ("GA4" in text or "analytics" in text.lower()),
|
|
525
|
+
"migration_formats": all(format_name in text.lower() for format_name in ("csv", "json", "sitemap", "media-archive")),
|
|
526
|
+
})
|
|
527
|
+
safe_fixes: list[str] = []
|
|
528
|
+
safe_skips: list[str] = []
|
|
529
|
+
if getattr(args, "fix_safe", False):
|
|
530
|
+
package, _ = package_info(root)
|
|
531
|
+
is_astro = "astro" in dependency_names(package) or any(path.suffix == ".astro" for path in files)
|
|
532
|
+
pages_dir = root / "src" / "pages"
|
|
533
|
+
if is_astro and not checks["sitemap_route"]:
|
|
534
|
+
target = pages_dir / "sitemap.xml.ts"
|
|
535
|
+
if not target.exists():
|
|
536
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
537
|
+
target.write_text("import type { APIRoute } from 'astro';\n\nexport const GET: APIRoute = () => new Response('<?xml version=\"1.0\" encoding=\"UTF-8\"?><urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"></urlset>', { headers: { 'content-type': 'application/xml; charset=utf-8' } });\n", encoding="utf-8")
|
|
538
|
+
safe_fixes.append("created empty public sitemap route")
|
|
539
|
+
elif not checks["sitemap_route"]:
|
|
540
|
+
safe_skips.append("sitemap route requires framework confirmation")
|
|
541
|
+
if is_astro and not checks["robots_route"]:
|
|
542
|
+
target = pages_dir / "robots.txt.ts"
|
|
543
|
+
if not target.exists():
|
|
544
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
545
|
+
target.write_text("import type { APIRoute } from 'astro';\n\nexport const GET: APIRoute = () => new Response('User-agent: *\\nAllow: /\\nDisallow: /ops\\nDisallow: /api\\n', { headers: { 'content-type': 'text/plain; charset=utf-8' } });\n", encoding="utf-8")
|
|
546
|
+
safe_fixes.append("created robots route with private-path disallow rules")
|
|
547
|
+
elif not checks["robots_route"]:
|
|
548
|
+
safe_skips.append("robots route requires framework confirmation")
|
|
549
|
+
for check, reason in (("canonical_metadata", "canonical metadata needs a site-specific URL"), ("meta_description", "metadata content needs editorial input"), ("article_jsonld", "JSON-LD shape needs content-specific fields")):
|
|
550
|
+
if not checks[check]: safe_skips.append(reason)
|
|
551
|
+
result = {"project_root": str(root), "checks": {key: {"ok": value} for key, value in checks.items()}, "passed": all(checks.values())}
|
|
552
|
+
if getattr(args, "fix_safe", False): result["safe_fixes"] = {"applied": safe_fixes, "skipped_requires_confirmation": safe_skips}
|
|
553
|
+
print(json.dumps(result, indent=2))
|
|
554
|
+
return 0 if result["passed"] else 1
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def command_contract_check(args: argparse.Namespace) -> int:
|
|
558
|
+
root = Path(args.project).resolve()
|
|
559
|
+
if args.kind == "seo":
|
|
560
|
+
args.require_bootstrap = False; args.strict = True; args.ci = False; args.fix_safe = False
|
|
561
|
+
return command_doctor(args)
|
|
562
|
+
if args.kind == "routes":
|
|
563
|
+
manifest = root / STATE_DIR / "routes.json"
|
|
564
|
+
if manifest.exists(): print(manifest.read_text(encoding="utf-8"), end="")
|
|
565
|
+
else: print(json.dumps({"routes": sorted(path for path in (evidence(item, root) for item in files_for(root)) if "route" in path.lower() or "sitemap" in path.lower())}, indent=2))
|
|
566
|
+
return 0
|
|
567
|
+
if args.kind == "env":
|
|
568
|
+
package, _ = package_info(root); example = root / ".env.example"; required = ["PUBLIC_SITE_URL", "AI_CMO_API_KEY", "BLOG_DB_PATH"]
|
|
569
|
+
found = set(re.findall(r"^([A-Z][A-Z0-9_]+)=", read_text(example), re.MULTILINE))
|
|
570
|
+
result = {"package": bool(package), "env_example": example.exists(), "required_variables_documented": {name: name in found for name in required}}
|
|
571
|
+
print(json.dumps(result, indent=2)); return 0 if result["env_example"] else 1
|
|
572
|
+
state = load_state(root); contract = root / STATE_DIR / ("schema.json" if args.kind == "schema" else "migration.json")
|
|
573
|
+
result = {"bootstrap": bool(state and state.get("status") == "completed"), "contract": str(contract), "contract_exists": contract.exists()}
|
|
574
|
+
print(json.dumps(result, indent=2)); return 0 if result["bootstrap"] and result["contract_exists"] else 1
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def command_fixture(args: argparse.Namespace) -> int:
|
|
578
|
+
root = Path(args.project).resolve(); require_bootstrap(root)
|
|
579
|
+
output = root / STATE_DIR / "fixtures"; output.mkdir(parents=True, exist_ok=True)
|
|
580
|
+
payloads = {
|
|
581
|
+
"wordpress": {"posts": [{"id": 1, "slug": "fixture-wordpress-post", "title": "Fixture WordPress post", "status": "publish"}], "categories": [{"id": 1, "name": "Guides", "slug": "guides"}], "tags": [{"id": 1, "name": "SEO", "slug": "seo"}], "media": []},
|
|
582
|
+
"api-pull": [{"id": "api-1", "slug": "fixture-api-post", "title": "Fixture API Pull post", "status": "published", "content": "<p>Fixture content.</p>"}],
|
|
583
|
+
"reports": {"visibility": {"clicks": 10, "impressions": 100, "period": "2026-08"}, "analytics": {"sessions": 20, "engagedSessions": 15, "period": "2026-08"}},
|
|
584
|
+
"sitemap-matching": {"sitemapUrl": "https://example.com/sitemap.xml", "sitemaps": 1, "totalPosts": 2, "matchedPosts": 1, "eligible": 1, "unmatched": 1, "items": [{"url": "https://example.com/blog/fixture-post", "postId": "fixture-post", "matched": True}]},
|
|
585
|
+
"rewrite-queue": [{"postId": "fixture-post", "reason": "fixture sitemap match", "status": "queued", "priority": 10}],
|
|
586
|
+
}
|
|
587
|
+
if args.fixture_command == "seed":
|
|
588
|
+
(output / "api-pull.json").write_text(json.dumps(payloads["api-pull"], indent=2) + "\n", encoding="utf-8")
|
|
589
|
+
(output / "sitemap-matching.json").write_text(json.dumps(payloads["sitemap-matching"], indent=2) + "\n", encoding="utf-8")
|
|
590
|
+
(output / "rewrite-queue.json").write_text(json.dumps(payloads["rewrite-queue"], indent=2) + "\n", encoding="utf-8")
|
|
591
|
+
(output / "reports.json").write_text(json.dumps(payloads["reports"], indent=2) + "\n", encoding="utf-8")
|
|
592
|
+
print(f"Seed fixtures: {output}")
|
|
593
|
+
else:
|
|
594
|
+
destination = output / f"{args.fixture_command}.json"; destination.write_text(json.dumps(payloads[args.fixture_command], indent=2) + "\n", encoding="utf-8"); print(f"Generated fixture: {destination}")
|
|
595
|
+
return 0
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def parser() -> argparse.ArgumentParser:
|
|
599
|
+
p = argparse.ArgumentParser(prog="maggie", description=__doc__)
|
|
600
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
601
|
+
analyze = sub.add_parser("analyze", help="inspect a project without editing it")
|
|
602
|
+
analyze.add_argument("project", nargs="?", default=".")
|
|
603
|
+
analyze.add_argument("--json", action="store_true")
|
|
604
|
+
analyze.add_argument("--save", action="store_true", help="save analysis to .maggie/analysis.json")
|
|
605
|
+
analyze.set_defaults(func=command_analyze)
|
|
606
|
+
status = sub.add_parser("status", help="show bootstrap state")
|
|
607
|
+
status.add_argument("project", nargs="?", default=".")
|
|
608
|
+
status.set_defaults(func=command_status)
|
|
609
|
+
bootstrap = sub.add_parser("bootstrap", help="manage bootstrap state")
|
|
610
|
+
bootstrap_sub = bootstrap.add_subparsers(dest="bootstrap_command", required=True)
|
|
611
|
+
complete = bootstrap_sub.add_parser("complete", help="write state after explicit user confirmations")
|
|
612
|
+
complete.add_argument("project", nargs="?", default=".")
|
|
613
|
+
complete.add_argument("--confirm", action="append", choices=["foundation", "experience", "data", "publishing"], required=True)
|
|
614
|
+
for name, default in (("framework", "detected"), ("language", "detected"), ("content-language", "confirmed"), ("ui-system", "preserve-or-tailwind"), ("icon-set", "preserve-or-heroicons"), ("font", "preserve-or-system"), ("database", "preserve-or-sqlite"), ("content-source", "confirmed")):
|
|
615
|
+
complete.add_argument("--" + name, dest=name.replace("-", "_"), default=default)
|
|
616
|
+
complete.set_defaults(func=command_complete)
|
|
617
|
+
generate = sub.add_parser("generate", help="generate stable contract or fixture files")
|
|
618
|
+
generate.add_argument("target", choices=["contract", "fixture", "post", "topic", "author", "migration", "seo", "ops-page"])
|
|
619
|
+
generate.add_argument("project", nargs="?", default=".")
|
|
620
|
+
generate.set_defaults(func=command_generate)
|
|
621
|
+
fixture = sub.add_parser("fixture", help="generate deterministic local provider fixtures")
|
|
622
|
+
fixture_sub = fixture.add_subparsers(dest="fixture_command", required=True)
|
|
623
|
+
for fixture_name in ("seed", "wordpress", "api-pull", "reports", "sitemap-matching", "rewrite-queue"):
|
|
624
|
+
item = fixture_sub.add_parser(fixture_name)
|
|
625
|
+
item.add_argument("project", nargs="?", default=".")
|
|
626
|
+
item.set_defaults(func=command_fixture)
|
|
627
|
+
doctor = sub.add_parser("doctor", help="check blog routes and SEO output invariants")
|
|
628
|
+
doctor.add_argument("project", nargs="?", default=".")
|
|
629
|
+
doctor.add_argument("--require-bootstrap", action="store_true")
|
|
630
|
+
doctor.add_argument("--strict", action="store_true", help="also require the complete AI-native blog and Ops surface")
|
|
631
|
+
doctor.add_argument("--ci", action="store_true", help="CI alias for strict checks")
|
|
632
|
+
doctor.add_argument("--fix-safe", action="store_true", help="apply only low-risk route fixes; never changes schema, slugs, canonical URLs or publishing state")
|
|
633
|
+
doctor.set_defaults(func=command_doctor)
|
|
634
|
+
for name in ("routes", "schema", "seo", "migration", "env"):
|
|
635
|
+
check = sub.add_parser(name, help=f"run Maggie {name} contract checks")
|
|
636
|
+
check.add_argument("project", nargs="?", default=".")
|
|
637
|
+
if name in {"schema", "seo", "migration"}: check.set_defaults(kind=name)
|
|
638
|
+
else: check.set_defaults(kind=name)
|
|
639
|
+
check.set_defaults(func=command_contract_check)
|
|
640
|
+
|
|
641
|
+
api = sub.add_parser("api", help="call an AI CMO API Pull endpoint safely")
|
|
642
|
+
api_sub = api.add_subparsers(dest="api_command", required=True)
|
|
643
|
+
|
|
644
|
+
def api_options(command: argparse.ArgumentParser, project: bool = False, execute: bool = False) -> None:
|
|
645
|
+
command.add_argument("--base-url", default=None)
|
|
646
|
+
command.add_argument("--timeout", type=int, default=30)
|
|
647
|
+
command.add_argument("--output", help="persist the JSON response atomically")
|
|
648
|
+
if project:
|
|
649
|
+
command.add_argument("--project", default=".")
|
|
650
|
+
if execute:
|
|
651
|
+
command.add_argument("--execute", action="store_true", help="send the request; otherwise remain dry-run")
|
|
652
|
+
|
|
653
|
+
get = api_sub.add_parser("get", help="read an endpoint")
|
|
654
|
+
get.add_argument("path", choices=["/whoami", "/project-context", "/posts", "/updates", "/history", "/latest", "/rewrite-policy", "/sitemap/matching-history", "/rewrite/queue", "/quota"])
|
|
655
|
+
get.add_argument("--charges-quota", action="store_true", help="mark delivery endpoints as quota-consuming")
|
|
656
|
+
api_options(get, project=True, execute=True)
|
|
657
|
+
get.set_defaults(func=command_api_get, requires_bootstrap=False)
|
|
658
|
+
|
|
659
|
+
sitemap = api_sub.add_parser("sitemap", help="match or inspect sitemap history")
|
|
660
|
+
sitemap_sub = sitemap.add_subparsers(dest="sitemap_command", required=True)
|
|
661
|
+
match = sitemap_sub.add_parser("match", help="match a supplied sitemap URL")
|
|
662
|
+
match.add_argument("--sitemap-url", required=True)
|
|
663
|
+
match.add_argument("--project", default=".")
|
|
664
|
+
match.add_argument("--execute", action="store_true")
|
|
665
|
+
match.add_argument("--base-url", default=None)
|
|
666
|
+
match.add_argument("--timeout", type=int, default=30)
|
|
667
|
+
match.add_argument("--output")
|
|
668
|
+
match.add_argument("--idempotency-key")
|
|
669
|
+
match.add_argument("path", nargs="?", default="/sitemap/match", help=argparse.SUPPRESS)
|
|
670
|
+
match.set_defaults(func=command_api_post, requires_bootstrap=True)
|
|
671
|
+
auto = sitemap_sub.add_parser("auto-detect", help="discover and match domain sitemaps")
|
|
672
|
+
auto.add_argument("--project", default=".")
|
|
673
|
+
auto.add_argument("--execute", action="store_true")
|
|
674
|
+
auto.add_argument("--base-url", default=None)
|
|
675
|
+
auto.add_argument("--timeout", type=int, default=30)
|
|
676
|
+
auto.add_argument("--output")
|
|
677
|
+
auto.add_argument("--idempotency-key")
|
|
678
|
+
auto.add_argument("path", nargs="?", default="/sitemap/auto-detect", help=argparse.SUPPRESS)
|
|
679
|
+
auto.set_defaults(func=command_api_post, requires_bootstrap=True)
|
|
680
|
+
history = sitemap_sub.add_parser("history", help="read persisted matching history")
|
|
681
|
+
history.add_argument("path", nargs="?", default="/sitemap/matching-history", help=argparse.SUPPRESS)
|
|
682
|
+
api_options(history)
|
|
683
|
+
history.set_defaults(func=command_api_get, charges_quota=False)
|
|
684
|
+
|
|
685
|
+
pull = api_sub.add_parser("pull", help="deliver or inspect API Pull content")
|
|
686
|
+
pull_sub = pull.add_subparsers(dest="pull_command", required=True)
|
|
687
|
+
for name, path, charges in (("posts", "/posts", True), ("updates", "/updates", True), ("history", "/history", False), ("latest", "/latest", False)):
|
|
688
|
+
item = pull_sub.add_parser(name)
|
|
689
|
+
item.add_argument("path", nargs="?", default=path, help=argparse.SUPPRESS)
|
|
690
|
+
api_options(item, project=charges, execute=charges)
|
|
691
|
+
item.set_defaults(func=command_api_get, charges_quota=charges, requires_bootstrap=charges)
|
|
692
|
+
|
|
693
|
+
rewrite = api_sub.add_parser("rewrite", help="inspect or explicitly queue rewrites")
|
|
694
|
+
rewrite_sub = rewrite.add_subparsers(dest="rewrite_command", required=True)
|
|
695
|
+
queue = rewrite_sub.add_parser("queue")
|
|
696
|
+
queue.add_argument("path", nargs="?", default="/rewrite/queue", help=argparse.SUPPRESS)
|
|
697
|
+
api_options(queue)
|
|
698
|
+
queue.set_defaults(func=command_api_get, charges_quota=False)
|
|
699
|
+
request = rewrite_sub.add_parser("request", help="queue one rewrite from a JSON body")
|
|
700
|
+
request.add_argument("--body-file", required=True)
|
|
701
|
+
request.add_argument("--project", default=".")
|
|
702
|
+
request.add_argument("--execute", action="store_true")
|
|
703
|
+
request.add_argument("--base-url", default=None)
|
|
704
|
+
request.add_argument("--timeout", type=int, default=30)
|
|
705
|
+
request.add_argument("--output")
|
|
706
|
+
request.add_argument("--idempotency-key")
|
|
707
|
+
request.add_argument("path", nargs="?", default="/rewrite/queue", help=argparse.SUPPRESS)
|
|
708
|
+
request.set_defaults(func=command_api_post, requires_bootstrap=True)
|
|
709
|
+
rhistory = rewrite_sub.add_parser("history")
|
|
710
|
+
rhistory.add_argument("content_id")
|
|
711
|
+
rhistory.add_argument("--base-url", default=None)
|
|
712
|
+
rhistory.add_argument("--timeout", type=int, default=30)
|
|
713
|
+
rhistory.add_argument("path", nargs="?", default=None, help=argparse.SUPPRESS)
|
|
714
|
+
rhistory.set_defaults(func=command_rewrite_history, charges_quota=False)
|
|
715
|
+
|
|
716
|
+
policy = api_sub.add_parser("policy", help="read or update rewrite policy")
|
|
717
|
+
policy_sub = policy.add_subparsers(dest="policy_command", required=True)
|
|
718
|
+
policy_get = policy_sub.add_parser("get")
|
|
719
|
+
policy_get.add_argument("path", nargs="?", default="/rewrite-policy", help=argparse.SUPPRESS)
|
|
720
|
+
api_options(policy_get)
|
|
721
|
+
policy_get.set_defaults(func=command_api_get, charges_quota=False)
|
|
722
|
+
policy_set = policy_sub.add_parser("set")
|
|
723
|
+
policy_set.add_argument("--body-file", required=True)
|
|
724
|
+
policy_set.add_argument("--project", default=".")
|
|
725
|
+
policy_set.add_argument("--execute", action="store_true")
|
|
726
|
+
policy_set.add_argument("--base-url", default=None)
|
|
727
|
+
policy_set.add_argument("--timeout", type=int, default=30)
|
|
728
|
+
policy_set.add_argument("--output")
|
|
729
|
+
policy_set.add_argument("--idempotency-key")
|
|
730
|
+
policy_set.add_argument("path", nargs="?", default="/rewrite-policy", help=argparse.SUPPRESS)
|
|
731
|
+
policy_set.set_defaults(func=command_api_post, requires_bootstrap=True)
|
|
732
|
+
|
|
733
|
+
report = api_sub.add_parser("report-state", help="report observed local publication state")
|
|
734
|
+
report.add_argument("--body-file", required=True)
|
|
735
|
+
report.add_argument("--project", default=".")
|
|
736
|
+
report.add_argument("--execute", action="store_true")
|
|
737
|
+
report.add_argument("--base-url", default=None)
|
|
738
|
+
report.add_argument("--timeout", type=int, default=30)
|
|
739
|
+
report.add_argument("--output")
|
|
740
|
+
report.add_argument("--idempotency-key")
|
|
741
|
+
report.add_argument("path", nargs="?", default="/content-tracking/report-state", help=argparse.SUPPRESS)
|
|
742
|
+
report.set_defaults(func=command_api_post, requires_bootstrap=True)
|
|
743
|
+
return p
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
if __name__ == "__main__":
|
|
747
|
+
arguments = parser().parse_args()
|
|
748
|
+
raise SystemExit(arguments.func(arguments))
|