@topy-ai/maggie 0.2.9 → 0.5.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/README.md CHANGED
@@ -264,12 +264,49 @@ python3 tools/clis/maggie_design.py rebrand \
264
264
  | `maggie-memory` | Persist confirmed preferences, conventions, lessons, and errors |
265
265
  | `maggie-content-localization` | Manage locale-aware translation, review, provenance, stale state, and publication gates |
266
266
  | `maggie-feedback` | Collect redacted feedback drafts and explicitly submit them to the NoBlox feedback endpoint |
267
+ | `maggie-auth-reference` | Generate and validate traditional email/password auth with secure server-side sessions |
268
+ | `maggie-blog` | Run a provider-neutral blog lifecycle with stable identity, topics, feeds, settings, and rollback |
269
+
270
+ `maggie-design` can initialize native blog and service UI plans from a local,
271
+ read-only structural reference:
272
+
273
+ ```bash
274
+ maggie design reference-ui --project . --reference /path/to/reference \
275
+ --surface blog,service --confirm
276
+ maggie design init --project . --surface blog \
277
+ --reference-run .maggie/design/content-ui/reference-<id> --confirm
278
+ maggie design init --project . --surface service \
279
+ --reference-run .maggie/design/content-ui/reference-<id> --confirm
280
+ maggie design validate-ui --project . \
281
+ --plan .maggie/design/content-ui/blog/init-<id>/plan.json \
282
+ --rendered-dir .maggie/design/content-ui/blog/init-<id>/screenshots --confirm
283
+ ```
284
+
285
+ This reuses the host project's homepage shell and `DESIGN.md`; it does not
286
+ copy reference branding, source code, private data, or provider facts.
267
287
 
268
288
  The remaining installable skills are `maggie-blog-bootstrap`, `maggie-dash`,
269
289
  `maggie-clone`, `maggie-clone-to-template`, `maggie-marketplace`,
270
290
  `maggie-template`, `maggie-design`, `maggie-ops`, `maggie-deployment`,
271
291
  `maggie-project-context`, `maggie-social-share`, and `maggie-memory`.
272
292
 
293
+ The package also includes `maggie-auth-reference` and `maggie-blog`. Use the
294
+ stable commands below after installation:
295
+
296
+ ```bash
297
+ maggie auth reference --project . --confirm
298
+ maggie auth check --project . --production
299
+ maggie design author --project . --route /about --purpose "Explain our approach" --audience "Visitors" --confirm
300
+ maggie blog init --project . --confirm
301
+ maggie blog ingest --project . --source local --input content/posts.json --confirm
302
+ maggie blog validate --project .
303
+ maggie blog sitemap --project .
304
+ ```
305
+
306
+ Blog imports are idempotent and draft-first. Published slugs remain stable;
307
+ public feeds exclude drafts, and `maggie blog rollback --confirm` restores the
308
+ latest local content backup.
309
+
273
310
  List every installed skill and command:
274
311
 
275
312
  ```bash
package/bin/maggie.js CHANGED
@@ -34,6 +34,8 @@ const SKILL_NAMES = [
34
34
  "maggie-memory",
35
35
  "maggie-content-localization",
36
36
  "maggie-feedback",
37
+ "maggie-auth-reference",
38
+ "maggie-blog",
37
39
  ];
38
40
  const RETIRED_PATHS = [
39
41
  ".agents/skills/maggie-emdash",
@@ -83,6 +85,13 @@ Usage:
83
85
  maggie clone run <homepage-url> --run-id <id>
84
86
  maggie clone status --run-id <id>
85
87
  maggie design run <target-url> --clone-run <id>
88
+ maggie design reference-ui --project PATH --reference PATH --surface blog,service --confirm
89
+ maggie design init --project PATH --surface blog|service --reference-run PATH --confirm
90
+ maggie design validate-ui --project PATH --plan PATH --rendered-dir PATH --confirm
91
+ maggie design author --project PATH --route /about --purpose TEXT --audience TEXT --confirm
92
+ maggie auth reference --project PATH --confirm
93
+ maggie auth check --project PATH [--production]
94
+ maggie blog init|inspect|ingest|validate|publish|sitemap|settings|rollback --project PATH
86
95
  maggie design status <job-id>
87
96
  maggie service import <provider-url> --project PATH
88
97
  maggie service sync <provider-url> --project PATH
@@ -206,7 +215,7 @@ function install(args) {
206
215
  copyIfMissing(join(DESIGN_ROOT, "SPA-DESIGN.md"), join(root, ".maggie", "design-reference", "SPA-DESIGN.md"));
207
216
  }
208
217
  if (existsSync(MARKETPLACE_ROOT)) copyIfMissing(MARKETPLACE_ROOT, join(root, "marketplace"));
209
- if (existsSync(join(CONTRACTS_ROOT, "maggiedash"))) copyIfMissing(join(CONTRACTS_ROOT, "maggiedash"), join(root, "contracts", "maggiedash"));
218
+ if (existsSync(CONTRACTS_ROOT)) copyIfMissing(CONTRACTS_ROOT, join(root, "contracts"));
210
219
  if (existsSync(join(TEMPLATES_ROOT, "maggiedash"))) copyIfMissing(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"));
211
220
  const stateDir = join(root, ".maggie");
212
221
  mkdirSync(stateDir, { recursive: true });
@@ -240,7 +249,7 @@ function update(args) {
240
249
  updated += syncTree(DESIGN_ROOT, join(root, ".maggie", "design-reference"), force);
241
250
  }
242
251
  if (existsSync(MARKETPLACE_ROOT) && existsSync(join(root, "marketplace"))) updated += syncTree(MARKETPLACE_ROOT, join(root, "marketplace"), force);
243
- if (existsSync(join(CONTRACTS_ROOT, "maggiedash")) && existsSync(join(root, "contracts", "maggiedash"))) updated += syncTree(join(CONTRACTS_ROOT, "maggiedash"), join(root, "contracts", "maggiedash"), force);
252
+ if (existsSync(CONTRACTS_ROOT) && existsSync(join(root, "contracts"))) updated += syncTree(CONTRACTS_ROOT, join(root, "contracts"), force);
244
253
  if (existsSync(join(TEMPLATES_ROOT, "maggiedash")) && existsSync(join(root, "templates", "maggiedash"))) updated += syncTree(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"), force);
245
254
  const stateDir = join(root, STATE_DIR);
246
255
  mkdirSync(stateDir, { recursive: true });
@@ -346,6 +355,8 @@ try {
346
355
  else if (command === "clone") workflowCli("maggie_clone.py", args);
347
356
  else if (command === "clone-to-template") workflowCli("maggie_clone_to_template.py", args);
348
357
  else if (command === "design") workflowCli("maggie_design.py", args);
358
+ else if (command === "auth") workflowCli("maggie_auth.py", args);
359
+ else if (command === "blog") workflowCli("maggie_blog.py", args);
349
360
  else if (command === "service") service(args);
350
361
  else if (command === "ops") workflowCli("maggie_ops.py", args);
351
362
  else if (command === "deployment") workflowCli("maggie_deployment.py", args);
@@ -0,0 +1,8 @@
1
+ # Maggie Auth Reference Contract
2
+
3
+ Provider-neutral traditional email/password authentication reference. This
4
+ contract intentionally excludes passkeys, WebAuthn, passwordless login, and
5
+ vendor-specific browser auth.
6
+
7
+ The host application owns persistence and HTTP handlers. The reference CLI
8
+ only writes a reviewable contract and validates production prerequisites.
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "maggie-auth-reference.v1",
4
+ "type": "object",
5
+ "required": ["schemaVersion", "passwordPolicy", "sessionPolicy", "tables"],
6
+ "properties": {
7
+ "schemaVersion": { "const": "maggie-auth-reference.v1" },
8
+ "passwordPolicy": {
9
+ "type": "object",
10
+ "required": ["algorithm", "minimumLength"],
11
+ "properties": { "algorithm": { "const": "argon2id" }, "minimumLength": { "type": "integer", "minimum": 12 } }
12
+ },
13
+ "sessionPolicy": {
14
+ "type": "object",
15
+ "required": ["serverSide", "httpOnly", "sameSite"],
16
+ "properties": { "serverSide": { "const": true }, "httpOnly": { "const": true }, "sameSite": { "enum": ["lax", "strict"] } }
17
+ },
18
+ "tables": { "type": "array", "items": { "type": "string" }, "minItems": 3 }
19
+ }
20
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "maggie-blog-post.v1",
4
+ "type": "object",
5
+ "required": ["schemaVersion", "id", "projectId", "contentId", "slug", "title", "body", "status", "source"],
6
+ "properties": {
7
+ "schemaVersion": { "const": "maggie-blog-post.v1" },
8
+ "id": { "type": "string", "minLength": 1 },
9
+ "projectId": { "type": "string", "minLength": 1 },
10
+ "contentId": { "type": "string", "minLength": 1 },
11
+ "slug": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
12
+ "title": { "type": "string", "minLength": 1 },
13
+ "excerpt": { "type": "string" },
14
+ "body": { "type": "object", "required": ["format", "value"], "properties": { "format": { "enum": ["markdown", "html"] }, "value": { "type": "string" } } },
15
+ "topics": { "type": "array", "items": { "type": "object", "required": ["slug", "label"] } },
16
+ "status": { "enum": ["draft", "review", "approved", "published", "archived"] },
17
+ "source": { "type": "object", "required": ["provider", "revision"] }
18
+ }
19
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "maggie-blog-settings.v1",
4
+ "type": "object",
5
+ "required": ["schemaVersion", "basePath", "postsPerPage", "defaultPostStatus", "autoPullEnabled"],
6
+ "properties": {
7
+ "schemaVersion": { "const": "maggie-blog-settings.v1" },
8
+ "basePath": { "type": "string", "pattern": "^/" },
9
+ "postsPerPage": { "type": "integer", "minimum": 1, "maximum": 100 },
10
+ "topicLimit": { "type": "integer", "minimum": 1 },
11
+ "defaultPostStatus": { "enum": ["draft", "review"] },
12
+ "autoPullEnabled": { "type": "boolean" },
13
+ "pullIntervalMinutes": { "type": "integer", "minimum": 5 },
14
+ "maxPerRun": { "type": "integer", "minimum": 1 }
15
+ }
16
+ }
@@ -0,0 +1,6 @@
1
+ # Maggie Content UI Contracts
2
+
3
+ These contracts describe sanitized structural evidence captured from a local
4
+ UI reference and the native implementation plan consumed by `maggie-design`.
5
+ They intentionally exclude source-brand content, private data, credentials,
6
+ raw assets, and copied source code.
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "maggie-content-ui-plan.v1",
4
+ "type": "object",
5
+ "required": ["schemaVersion", "id", "workflow", "mode", "surface", "phase", "shell", "components", "routes", "dataAdapter", "publicWrite"],
6
+ "properties": {
7
+ "schemaVersion": { "const": "maggie-content-ui-plan.v1" },
8
+ "id": { "type": "string", "pattern": "^init-[a-f0-9]+$" },
9
+ "workflow": { "const": "maggie-design" },
10
+ "mode": { "const": "content-ui-init" },
11
+ "surface": { "enum": ["blog", "service"] },
12
+ "phase": { "const": "ready" },
13
+ "shell": { "type": "object", "required": ["sourceOfTruth", "reuse", "replaceAllowed"] },
14
+ "components": { "type": "array", "minItems": 1 },
15
+ "routes": { "type": "array", "minItems": 1 },
16
+ "dataAdapter": { "enum": ["maggie-blog", "maggie-service-booking"] },
17
+ "publicWrite": { "const": false }
18
+ }
19
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "maggie-content-ui-reference.v1",
4
+ "type": "object",
5
+ "required": ["schemaVersion", "id", "workflow", "mode", "surfaces", "sourcePolicy", "privateSourceCopied"],
6
+ "properties": {
7
+ "schemaVersion": { "const": "maggie-content-ui-reference.v1" },
8
+ "id": { "type": "string", "pattern": "^reference-[a-f0-9]+$" },
9
+ "workflow": { "const": "maggie-design" },
10
+ "mode": { "const": "reference-ui" },
11
+ "surfaces": { "type": "object", "minProperties": 1 },
12
+ "sourcePolicy": { "const": "read-only-structural-guideline" },
13
+ "privateSourceCopied": { "const": false }
14
+ }
15
+ }
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: maggie-auth-reference
3
+ description: Generate and validate a provider-neutral traditional email/password auth reference with secure server sessions and production security gates.
4
+ metadata:
5
+ version: 1.0.0
6
+ ---
7
+
8
+ Before and after a meaningful auth-reference run, follow the shared
9
+ [Maggie Memory Hook](../../references/memory-hook.md). Store only confirmed,
10
+ project-scoped lessons; never store passwords, hashes, tokens, or provider
11
+ credentials.
12
+
13
+ # Maggie Auth Reference
14
+
15
+ Use this skill when a project needs a reviewed starting point for
16
+ email/password authentication. It does not replace an existing auth provider
17
+ and it does not support passkeys, WebAuthn, or passwordless login.
18
+
19
+ ```bash
20
+ maggie auth reference --project . --confirm
21
+ maggie auth check --project . --production
22
+ ```
23
+
24
+ The reference uses Argon2id in production, normalized email identity,
25
+ HttpOnly/SameSite server-side sessions, generic failed-login responses,
26
+ throttling, lockout, revocation, and an explicit owner setup gate. Secrets,
27
+ hashes, and session tokens must never be emitted into browser responses,
28
+ logs, or project context.
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: maggie-blog
3
+ description: Create and operate a provider-neutral blog with stable post identity, topics, draft approval, archive/post routes, RSS, sitemap, settings, and idempotent local ingest.
4
+ metadata:
5
+ version: 1.0.0
6
+ ---
7
+
8
+ Before and after a meaningful blog run, follow the shared [Maggie Memory
9
+ Hook](../../references/memory-hook.md). Record only confirmed, reusable
10
+ project lessons, never raw provider credentials or temporary content facts.
11
+
12
+ # Maggie Blog
13
+
14
+ Use the stable CLI to initialize a local blog contract, ingest versioned local
15
+ content, validate lifecycle invariants, publish with an actor and reason, and
16
+ generate route/feed artifacts. Read the host framework and database contract
17
+ before adding public routes. The host project owns rendering, persistence
18
+ credentials, and deployment; this skill owns normalized blog semantics.
19
+
20
+ ```bash
21
+ maggie blog init --project . --base-path /our-blogs --confirm
22
+ maggie blog ingest --project . --source local --input content/posts.json --confirm
23
+ maggie blog validate --project .
24
+ maggie blog publish --project . --slug example-post --actor owner --reason "approved" --confirm
25
+ maggie blog sitemap --project .
26
+ maggie blog settings --project .
27
+ maggie blog rollback --project . --confirm
28
+ ```
29
+
30
+ Posts are draft-first. Stable `contentId` is the ingest identity and a
31
+ published slug must not change during a rewrite. Search/sort views are not
32
+ indexable; drafts never appear in public routes, RSS, or sitemap output.
33
+ Provider keys remain server-side. Public publication and migrations always
34
+ require explicit confirmation.
35
+
36
+ To initialize native front-end pages from the approved local UI guideline,
37
+ run `maggie-design`:
38
+
39
+ ```bash
40
+ maggie design init --project . --surface blog \
41
+ --reference-run .maggie/design/content-ui/reference-<id> \
42
+ --base-path /our-blogs --confirm
43
+ ```
44
+
45
+ The blog skill supplies route/data semantics; `maggie-design` supplies the
46
+ host-native components and responsive visual review.
@@ -73,6 +73,74 @@ run visual review, and validate the route. It reuses shared tokens and the
73
73
  approved palette; it does not rewrite `DESIGN.md`, change the colour palette,
74
74
  or require a clone run.
75
75
 
76
+ ## First-party author mode
77
+
78
+ Use author mode when the requested page is original and has no external source
79
+ URL. It preserves the homepage shell and current `DESIGN.md` contract, writes
80
+ a local brief and route plan, and stops before implementation approval:
81
+
82
+ ```bash
83
+ maggie design author --project . --route /about \
84
+ --purpose "Explain our approach and contact path" \
85
+ --audience "Prospective customers" --confirm
86
+ ```
87
+
88
+ The command rejects `/`, existing route collisions, missing `DESIGN.md`, and
89
+ unconfirmed writes. Generated copy and media must be marked authored rather
90
+ than source-observed. Implementation must still capture responsive screenshots
91
+ and run accessibility/build validation before publication.
92
+
93
+ ## Content UI initialization
94
+
95
+ Use content UI initialization when `maggie-blog` or
96
+ `maggie-service-booking` needs native front-end layouts based on a local UI
97
+ reference. The reference is structural evidence only. It must not replace the
98
+ project's `DESIGN.md`, homepage shell, brand, copy, assets, or provider facts.
99
+
100
+ First record a sanitized, read-only reference manifest:
101
+
102
+ ```bash
103
+ maggie design reference-ui \
104
+ --project . \
105
+ --reference /home/balalior/Dev/clients/spachevychase.org \
106
+ --surface blog,service --confirm
107
+ ```
108
+
109
+ Then initialize one surface at a time from that manifest:
110
+
111
+ ```bash
112
+ maggie design init --project . --surface blog \
113
+ --reference-run .maggie/design/content-ui/reference-<id> \
114
+ --base-path /our-blogs --confirm
115
+
116
+ maggie design init --project . --surface service \
117
+ --reference-run .maggie/design/content-ui/reference-<id> \
118
+ --route-pattern '/services/[slug]/' --confirm
119
+ ```
120
+
121
+ The initializer creates a reviewable component/route/responsive/provenance
122
+ plan under `.maggie/design/content-ui/`. It does not write public components
123
+ or publish routes. The implementation phase must call the matching data skill,
124
+ reuse the homepage shell, capture desktop/tablet/mobile screenshots, and pass
125
+ accessibility, metadata, build, and visual review gates before approval.
126
+
127
+ Validate the rendered plan before approval:
128
+
129
+ ```bash
130
+ maggie design validate-ui --project . \
131
+ --plan .maggie/design/content-ui/blog/init-<id>/plan.json \
132
+ --rendered-dir .maggie/design/content-ui/blog/init-<id>/screenshots \
133
+ --confirm
134
+ ```
135
+
136
+ This gate requires valid PNG evidence for desktop, tablet, and mobile and keeps
137
+ `publicWrite: false` until a separate approval step.
138
+
139
+ For blog UI, preserve archive, topic, post/slug, pagination, RSS, and sitemap
140
+ semantics from `maggie-blog`. For service UI, preserve provider identity,
141
+ variants, booking URLs, duplicate canonical/noindex decisions, and service
142
+ sitemap semantics from `maggie-service-booking`.
143
+
76
144
  ## Explicit homepage rebrand mode
77
145
 
78
146
  The homepage `review` mode only compares screenshots. It does not rebrand a
@@ -207,6 +207,19 @@ and `DESIGN.md`. Use the homepage header, footer, fonts, tokens, navigation,
207
207
  analytics boundary, accessibility behaviour, and responsive breakpoints.
208
208
  Only the service content region varies.
209
209
 
210
+ When initializing a new service UI from the approved reference guideline, use
211
+ the shared `maggie-design` initializer:
212
+
213
+ ```bash
214
+ maggie design init --project . --surface service \
215
+ --reference-run .maggie/design/content-ui/reference-<id> \
216
+ --route-pattern '/services/[slug]/' --confirm
217
+ ```
218
+
219
+ This creates a reviewable native component/route plan. It does not copy the
220
+ reference project's brand, source code, assets, or provider facts, and it does
221
+ not publish service pages without the existing service fact/copy gates.
222
+
210
223
  Each service page must expose the service title, category breadcrumbs, factual
211
224
  description, all active variants, duration, currency-formatted price, booking
212
225
  CTA, and payment CTA only if available. Preserve the provider booking URL as
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env python3
2
+ """Auth reference and production prerequisite checks."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
12
+ from maggie_auth import production_hashing_ready # noqa: E402
13
+
14
+
15
+ def main() -> int:
16
+ parser = argparse.ArgumentParser(prog="maggie auth")
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+ reference = sub.add_parser("reference"); reference.add_argument("--project", type=Path, default=Path.cwd()); reference.add_argument("--confirm", action="store_true")
19
+ check = sub.add_parser("check"); check.add_argument("--project", type=Path, default=Path.cwd()); check.add_argument("--production", action="store_true")
20
+ args = parser.parse_args(); project = args.project.resolve(); state = project / ".maggie" / "auth" / "reference.json"
21
+ if args.command == "reference":
22
+ if not args.confirm: print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr); return 2
23
+ state.parent.mkdir(parents=True, exist_ok=True)
24
+ value = {"schemaVersion": "maggie-auth-reference.v1", "mode": "traditional-email-password", "passwordAlgorithm": "argon2id", "session": {"serverSide": True, "httpOnly": True, "sameSite": "lax"}, "tables": ["accounts", "sessions", "auth_events"], "status": "review", "project": str(project)}
25
+ state.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8"); print(json.dumps(value, indent=2)); return 0
26
+ result = {"reference": state.exists(), "argon2id": production_hashing_ready(), "production": args.production}
27
+ if args.production: result["passed"] = result["reference"] and result["argon2id"]
28
+ else: result["passed"] = result["reference"]
29
+ print(json.dumps(result, indent=2)); return 0 if result["passed"] else 1
30
+
31
+
32
+ if __name__ == "__main__":
33
+ raise SystemExit(main())
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ """Stable Maggie Blog CLI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
12
+ from maggie_blog import BlogStore # noqa: E402
13
+
14
+
15
+ def main() -> int:
16
+ parser = argparse.ArgumentParser(prog="maggie blog")
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+ init = sub.add_parser("init"); init.add_argument("--project", type=Path, default=Path.cwd()); init.add_argument("--base-path", default="/our-blogs"); init.add_argument("--posts-per-page", type=int, default=9); init.add_argument("--confirm", action="store_true")
19
+ inspect = sub.add_parser("inspect"); inspect.add_argument("--project", type=Path, default=Path.cwd())
20
+ ingest = sub.add_parser("ingest"); ingest.add_argument("--project", type=Path, default=Path.cwd()); ingest.add_argument("--input", type=Path, required=True); ingest.add_argument("--source", default="local"); ingest.add_argument("--confirm", action="store_true")
21
+ validate = sub.add_parser("validate"); validate.add_argument("--project", type=Path, default=Path.cwd())
22
+ publish = sub.add_parser("publish"); publish.add_argument("--project", type=Path, default=Path.cwd()); publish.add_argument("--slug", required=True); publish.add_argument("--actor", required=True); publish.add_argument("--reason", required=True); publish.add_argument("--confirm", action="store_true")
23
+ sitemap = sub.add_parser("sitemap"); sitemap.add_argument("--project", type=Path, default=Path.cwd())
24
+ settings = sub.add_parser("settings"); settings.add_argument("--project", type=Path, default=Path.cwd())
25
+ rollback = sub.add_parser("rollback"); rollback.add_argument("--project", type=Path, default=Path.cwd()); rollback.add_argument("--backup"); rollback.add_argument("--confirm", action="store_true")
26
+ args = parser.parse_args()
27
+ store = BlogStore(args.project.resolve())
28
+ if args.command in {"init", "ingest", "publish", "rollback"} and not args.confirm:
29
+ print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr); return 2
30
+ try:
31
+ if args.command == "init": result = store.init(args.base_path, args.posts_per_page)
32
+ elif args.command == "inspect": result = {"settings": store.settings(), "posts": store.posts(), "runs": json.loads(store.runs_path.read_text(encoding="utf-8")) if store.runs_path.exists() else []}
33
+ elif args.command == "ingest":
34
+ payload = json.loads(args.input.read_text(encoding="utf-8"));
35
+ if not isinstance(payload, list): raise ValueError("local input must be a JSON array")
36
+ result = store.ingest(payload, args.source)
37
+ elif args.command == "validate": result = store.validate()
38
+ elif args.command == "publish": result = store.publish(args.slug, args.actor, args.reason)
39
+ elif args.command == "settings": result = store.settings()
40
+ elif args.command == "rollback": result = store.rollback(args.backup)
41
+ else: result = store.generate_feeds()
42
+ except (OSError, ValueError, json.JSONDecodeError) as error:
43
+ print(f"maggie-blog: {error}", file=sys.stderr); return 1
44
+ print(json.dumps(result, indent=2, ensure_ascii=False))
45
+ return 0 if args.command != "validate" or result.get("valid") else 1
46
+
47
+
48
+ if __name__ == "__main__":
49
+ raise SystemExit(main())
@@ -34,6 +34,32 @@ DESIGN_HEADINGS = (
34
34
 
35
35
  REVIEW_VIEWPORTS = {"desktop": (1440, 900), "tablet": (768, 900), "mobile": (390, 844)}
36
36
 
37
+ REFERENCE_SURFACES = {
38
+ "blog": {
39
+ "files": [
40
+ "src/layouts/BlogLayout.astro", "src/components/blog/BlogArchive.astro",
41
+ "src/components/blog/PostCard.astro", "src/components/blog/Pagination.astro",
42
+ "src/components/blog/PostSidebar.astro", "src/pages/our-blogs/index.astro",
43
+ "src/pages/our-blogs/[slug].astro", "src/pages/our-blogs/topic/[slug].astro",
44
+ "src/pages/rss.xml.ts", "src/pages/sitemap.xml.ts",
45
+ ],
46
+ "components": ["BlogLayout", "BlogArchive", "PostCard", "Pagination", "PostSidebar", "ShareMenu"],
47
+ "routes": ["{base}/", "{base}/page/{n}/", "{base}/{slug}/", "{base}/topic/{slug}/", "{base}/topic/{slug}/page/{n}/", "/rss.xml", "/sitemap.xml"],
48
+ "patterns": ["compact image-backed archive hero", "URL-backed search/topic/sort controls", "responsive post grid", "sticky article sidebar", "related articles", "crawlable topic links"],
49
+ },
50
+ "service": {
51
+ "files": [
52
+ "src/pages/services/index.astro", "src/pages/services/[slug].astro",
53
+ "src/components/ServicePage.astro", "src/components/CategoryServiceHub.astro",
54
+ "src/components/SupportingServicePage.astro", "src/components/ServiceContextNarrative.astro",
55
+ "src/pages/sitemap-services.xml.ts", "src/pages/sitemap-service-categories.xml.ts",
56
+ ],
57
+ "components": ["ServiceIndex", "CategoryServiceHub", "ServicePage", "ServiceFacts", "RelatedServices", "ServiceFaq"],
58
+ "routes": ["/services/", "/services/{slug}/", "/service-categories/{slug}/", "/sitemap-services.xml", "/sitemap-service-categories.xml"],
59
+ "patterns": ["category breadcrumb hero", "variant facts and booking card", "service narrative with image", "related treatment grid", "FAQ accordion", "category navigation"],
60
+ },
61
+ }
62
+
37
63
 
38
64
  def rendered_asset_preflight(template: Path) -> list[str]:
39
65
  """Catch package CSS failures before visual comparison is trusted."""
@@ -56,6 +82,81 @@ def rendered_asset_preflight(template: Path) -> list[str]:
56
82
  return errors
57
83
 
58
84
 
85
+ def _surface_list(value: str) -> list[str]:
86
+ surfaces = [item.strip().lower() for item in value.split(",") if item.strip()]
87
+ invalid = sorted(set(surfaces) - set(REFERENCE_SURFACES))
88
+ if invalid or not surfaces:
89
+ raise ValueError("surface must contain blog and/or service")
90
+ return list(dict.fromkeys(surfaces))
91
+
92
+
93
+ def reference_ui(project: Path, reference: Path, surfaces: list[str], screenshots_dir: Path | None, confirm: bool) -> int:
94
+ """Record sanitized structural evidence from a read-only reference project."""
95
+ project = project.resolve(); reference = reference.resolve()
96
+ if not reference.is_dir(): raise ValueError(f"reference project not found: {reference}")
97
+ if not confirm: print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr); return 2
98
+ missing = {surface: [item for item in REFERENCE_SURFACES[surface]["files"] if not (reference / item).exists()] for surface in surfaces}
99
+ missing = {key: value for key, value in missing.items() if value}
100
+ if missing: raise ValueError("reference files missing: " + json.dumps(missing, ensure_ascii=False))
101
+ digest = hashlib.sha256((str(reference) + "\n" + ",".join(surfaces)).encode()).hexdigest()[:12]
102
+ output_dir = project / ".maggie" / "design" / "content-ui" / f"reference-{digest}"; output_dir.mkdir(parents=True, exist_ok=True)
103
+ evidence = {}
104
+ for surface in surfaces:
105
+ config = REFERENCE_SURFACES[surface]
106
+ evidence[surface] = {"sourceFiles": config["files"], "components": config["components"], "routes": config["routes"], "patterns": config["patterns"], "observations": {pattern: True for pattern in config["patterns"]}}
107
+ screenshots = {name: "pending" for name in REVIEW_VIEWPORTS}
108
+ if screenshots_dir:
109
+ screenshots = {name: str((screenshots_dir / f"{name}.png").resolve()) if (screenshots_dir / f"{name}.png").is_file() else "missing" for name in REVIEW_VIEWPORTS}
110
+ manifest = {"schemaVersion": "maggie-content-ui-reference.v1", "id": f"reference-{digest}", "workflow": "maggie-design", "mode": "reference-ui", "referenceProject": reference.name, "surfaces": evidence, "screenshots": screenshots, "sourcePolicy": "read-only-structural-guideline", "privateSourceCopied": False, "createdAt": datetime.now(timezone.utc).isoformat()}
111
+ path = output_dir / "reference-manifest.json"; path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
112
+ print(json.dumps({"manifest": str(path), "id": manifest["id"], "surfaces": surfaces, "screenshotStatus": screenshots}, indent=2, ensure_ascii=False)); return 0
113
+
114
+
115
+ def init_content_ui(project: Path, surface: str, reference_run: Path, base_path: str, route_pattern: str, confirm: bool) -> int:
116
+ """Create the reviewable plan that Maggie Design will implement natively."""
117
+ project = project.resolve(); surface = surface.strip().lower()
118
+ if surface not in REFERENCE_SURFACES: raise ValueError("surface must be blog or service")
119
+ if not confirm: print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr); return 2
120
+ contract = require_design_contract(project)
121
+ manifest_path = reference_run.resolve()
122
+ if manifest_path.is_dir(): manifest_path /= "reference-manifest.json"
123
+ if not manifest_path.exists(): raise ValueError(f"reference manifest required: {manifest_path}")
124
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
125
+ if surface not in manifest.get("surfaces", {}): raise ValueError(f"reference manifest does not contain surface: {surface}")
126
+ digest = hashlib.sha256((str(project) + "\n" + surface + "\n" + str(manifest_path)).encode()).hexdigest()[:12]
127
+ output_dir = project / ".maggie" / "design" / "content-ui" / surface / f"init-{digest}"; output_dir.mkdir(parents=True, exist_ok=True)
128
+ config = REFERENCE_SURFACES[surface]
129
+ routes = config["routes"]
130
+ if surface == "blog": routes = [route.replace("{base}", base_path.rstrip("/") or "/our-blogs") for route in routes]
131
+ elif route_pattern: routes = [route_pattern, "/sitemap-services.xml", "/sitemap-service-categories.xml"]
132
+ plan = {"schemaVersion": "maggie-content-ui-plan.v1", "id": f"init-{digest}", "workflow": "maggie-design", "mode": "content-ui-init", "surface": surface, "phase": "ready", "referenceManifest": str(manifest_path), "designContract": contract, "shell": {"sourceOfTruth": "existing homepage shell", "reuse": ["header", "footer", "tokens", "typography", "icons", "breakpoints", "accessibility", "analytics"], "replaceAllowed": False}, "components": config["components"], "routes": routes, "dataAdapter": "maggie-blog" if surface == "blog" else "maggie-service-booking", "provenance": {"referenceObserved": True, "projectPreserved": True, "agentAuthored": True, "providerFact": surface == "service"}, "requiredSteps": ["inspect-host-shell", "create-native-components", "bind-data-adapter", "capture-desktop-tablet-mobile", "accessibility-check", "metadata-and-route-check", "build-check", "explicit-approval"], "publicWrite": False, "createdAt": datetime.now(timezone.utc).isoformat()}
133
+ files = {"reference-manifest.json": manifest, "shell-reuse.json": plan["shell"], "component-plan.json": {"components": config["components"], "patterns": config["patterns"]}, "route-plan.json": {"routes": routes, "dataAdapter": plan["dataAdapter"]}, "responsive-plan.json": {"viewports": REVIEW_VIEWPORTS, "mobileStickySidebar": False if surface == "blog" else None}, "provenance.json": plan["provenance"], "validation.json": {"status": "pending", "required": plan["requiredSteps"]}}
134
+ for filename, value in files.items(): (output_dir / filename).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
135
+ (output_dir / "screenshots").mkdir(exist_ok=True)
136
+ (output_dir / "plan.json").write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
137
+ print(json.dumps({"plan": str(output_dir / "plan.json"), "surface": surface, "phase": "ready", "publicWrite": False}, indent=2)); return 0
138
+
139
+
140
+ def validate_content_ui(project: Path, plan_path: Path, rendered_dir: Path | None, confirm: bool) -> int:
141
+ """Validate a generated content UI plan before host publication."""
142
+ project = project.resolve(); plan_path = plan_path.resolve()
143
+ if not plan_path.exists(): raise ValueError(f"content UI plan not found: {plan_path}")
144
+ plan = json.loads(plan_path.read_text(encoding="utf-8")); errors = []
145
+ if plan.get("workflow") != "maggie-design" or plan.get("mode") != "content-ui-init": errors.append("invalid design workflow plan")
146
+ if plan.get("publicWrite") is not False: errors.append("publicWrite must remain false before approval")
147
+ try: require_design_contract(project)
148
+ except ValueError as error: errors.append(str(error))
149
+ screenshot_status = {}
150
+ for name in REVIEW_VIEWPORTS:
151
+ path = (rendered_dir / f"{name}.png") if rendered_dir else plan_path.parent / "screenshots" / f"{name}.png"
152
+ valid = path.is_file() and path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
153
+ screenshot_status[name] = {"path": str(path), "validPng": valid}
154
+ if not valid: errors.append(f"missing valid {name} screenshot")
155
+ result = {"schemaVersion": "maggie-content-ui-validation.v1", "plan": str(plan_path), "surface": plan.get("surface"), "screenshots": screenshot_status, "checks": {"shellReuse": plan.get("shell", {}).get("replaceAllowed") is False, "publicWriteBlocked": plan.get("publicWrite") is False, "designContract": not any("DESIGN.md" in error for error in errors)}, "status": "passed" if not errors else "failed", "errors": errors, "validatedAt": datetime.now(timezone.utc).isoformat()}
156
+ if not confirm: print(json.dumps(result, indent=2, ensure_ascii=False)); return 0 if not errors else 1
157
+ output = plan_path.parent / "validation.json"; output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"); print(json.dumps(result, indent=2, ensure_ascii=False)); return 0 if not errors else 1
158
+
159
+
59
160
  def rebrand_template(args: argparse.Namespace) -> int:
60
161
  """Apply an explicit brand identity to a packaged homepage template."""
61
162
  template = args.template.resolve()
@@ -453,7 +554,68 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
453
554
  return 0
454
555
 
455
556
 
557
+ def author_job(project: Path, route: str, purpose: str, audience: str, brief_file: Path | None, confirm: bool) -> int:
558
+ """Create an original page brief without requiring an external source URL."""
559
+ project = project.resolve()
560
+ if not confirm:
561
+ print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr)
562
+ return 2
563
+ if not route.startswith("/") or route == "/": raise ValueError("author route must be a non-homepage path starting with /")
564
+ contract = require_design_contract(project)
565
+ relative = route.strip("/")
566
+ candidates = [project / "src" / "pages" / relative, project / "src" / "pages" / f"{relative}.astro", project / "src" / "app" / relative / "page.tsx", project / relative]
567
+ collision = next((path for path in candidates if path.exists()), None)
568
+ if collision: raise ValueError(f"route collision; existing route: {collision}")
569
+ source_brief = {}
570
+ if brief_file:
571
+ source_brief = json.loads(brief_file.resolve().read_text(encoding="utf-8"))
572
+ if not purpose.strip() and not source_brief.get("purpose"): raise ValueError("purpose is required")
573
+ if not audience.strip() and not source_brief.get("audience"): raise ValueError("audience is required")
574
+ brief = {"schemaVersion": "maggie-page-brief.v1", "route": route, "purpose": purpose.strip() or source_brief["purpose"], "audience": audience.strip() or source_brief["audience"], "contentBrief": source_brief.get("contentBrief", ""), "shell": "homepage-canonical", "status": "draft", "sourceEvidence": None, "approval": {"status": "pending", "actor": None}, "createdAt": datetime.now(timezone.utc).isoformat()}
575
+ digest = hashlib.sha256((str(project) + "\n" + route).encode()).hexdigest()[:12]
576
+ output = project / ".maggie" / "design" / "briefs" / f"{relative.replace('/', '-')}.json"; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(brief, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
577
+ plan_path = project / ".maggie" / "design" / f"author-{digest}.json"
578
+ plan_path.write_text(json.dumps({"id": f"author-{digest}", "workflow": "maggie-design", "mode": "author", "phase": "ready", "brief": str(output), "designContract": contract, "sourceUrlRequired": False, "shellSourceOfTruth": "homepage-canonical", "approvalRequired": True, "requiredSteps": ["inspect-shell", "implement-page", "capture-responsive-screenshots", "accessibility-check", "build-check", "approval"]}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
579
+ print(json.dumps({"brief": str(output), "plan": str(plan_path), "phase": "ready"}, indent=2)); return 0
580
+
581
+
456
582
  def main() -> int:
583
+ if len(sys.argv) > 1 and sys.argv[1] == "reference-ui":
584
+ command = argparse.ArgumentParser(description="Record sanitized blog/service UI evidence from a read-only reference project.")
585
+ command.add_argument("--project", type=Path, default=Path.cwd())
586
+ command.add_argument("--reference", type=Path, required=True)
587
+ command.add_argument("--surface", default="blog,service")
588
+ command.add_argument("--screenshots-dir", type=Path)
589
+ command.add_argument("--confirm", action="store_true")
590
+ args = command.parse_args(sys.argv[2:])
591
+ try:
592
+ return reference_ui(args.project, args.reference, _surface_list(args.surface), args.screenshots_dir, args.confirm)
593
+ except (OSError, ValueError, json.JSONDecodeError) as error:
594
+ print(f"BLOCKED: maggie-design reference-ui: {error}", file=sys.stderr); return 1
595
+ if len(sys.argv) > 1 and sys.argv[1] == "init":
596
+ command = argparse.ArgumentParser(description="Initialize a native blog or service UI plan from a sanitized reference manifest.")
597
+ command.add_argument("--project", type=Path, default=Path.cwd())
598
+ command.add_argument("--surface", required=True, choices=["blog", "service"])
599
+ command.add_argument("--reference-run", type=Path, required=True)
600
+ command.add_argument("--base-path", default="/our-blogs")
601
+ command.add_argument("--route-pattern", default="/services/[slug]/")
602
+ command.add_argument("--confirm", action="store_true")
603
+ args = command.parse_args(sys.argv[2:])
604
+ try:
605
+ return init_content_ui(args.project, args.surface, args.reference_run, args.base_path, args.route_pattern, args.confirm)
606
+ except (OSError, ValueError, json.JSONDecodeError) as error:
607
+ print(f"BLOCKED: maggie-design init: {error}", file=sys.stderr); return 1
608
+ if len(sys.argv) > 1 and sys.argv[1] == "validate-ui":
609
+ command = argparse.ArgumentParser(description="Validate content UI screenshots and design/public-write gates.")
610
+ command.add_argument("--project", type=Path, default=Path.cwd())
611
+ command.add_argument("--plan", type=Path, required=True)
612
+ command.add_argument("--rendered-dir", type=Path)
613
+ command.add_argument("--confirm", action="store_true")
614
+ args = command.parse_args(sys.argv[2:])
615
+ try:
616
+ return validate_content_ui(args.project, args.plan, args.rendered_dir, args.confirm)
617
+ except (OSError, ValueError, json.JSONDecodeError) as error:
618
+ print(f"BLOCKED: maggie-design validate-ui: {error}", file=sys.stderr); return 1
457
619
  if len(sys.argv) > 1 and sys.argv[1] == "rebrand":
458
620
  rebrand = argparse.ArgumentParser(description="Apply an explicit brand identity to a packaged marketplace template.")
459
621
  rebrand.add_argument("--template", type=Path, required=True)
@@ -486,6 +648,19 @@ def main() -> int:
486
648
  except (OSError, ValueError, json.JSONDecodeError) as error:
487
649
  print(f"BLOCKED: maggie-design in-place: {error}", file=sys.stderr)
488
650
  return 1
651
+ if len(sys.argv) > 1 and sys.argv[1] == "author":
652
+ author = argparse.ArgumentParser(description="Create an original first-party page brief and route plan.")
653
+ author.add_argument("--project", type=Path, default=Path.cwd())
654
+ author.add_argument("--route", required=True)
655
+ author.add_argument("--purpose", default="")
656
+ author.add_argument("--audience", default="")
657
+ author.add_argument("--brief-file", type=Path)
658
+ author.add_argument("--confirm", action="store_true")
659
+ args = author.parse_args(sys.argv[2:])
660
+ try:
661
+ return author_job(args.project, args.route, args.purpose, args.audience, args.brief_file, args.confirm)
662
+ except (OSError, ValueError, json.JSONDecodeError) as error:
663
+ print(f"BLOCKED: maggie-design author: {error}", file=sys.stderr); return 1
489
664
  if len(sys.argv) > 1 and sys.argv[1] in {"run", "status", "resume"}:
490
665
  workflow = argparse.ArgumentParser(description=__doc__)
491
666
  sub = workflow.add_subparsers(dest="command", required=True)
@@ -0,0 +1,167 @@
1
+ """Local, provider-neutral Maggie Blog content lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import shutil
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ STATUSES = ("draft", "review", "approved", "published", "archived")
13
+ DEFAULTS = {
14
+ "schemaVersion": "maggie-blog-settings.v1",
15
+ "basePath": "/our-blogs",
16
+ "postsPerPage": 9,
17
+ "topicLimit": 12,
18
+ "defaultPostStatus": "draft",
19
+ "autoPullEnabled": False,
20
+ "pullIntervalMinutes": 120,
21
+ "maxPerRun": 1,
22
+ "fallbackImageMode": "gradient",
23
+ }
24
+
25
+
26
+ def now() -> str:
27
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
28
+
29
+
30
+ def slugify(value: str) -> str:
31
+ value = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
32
+ if not value:
33
+ raise ValueError("slug cannot be empty")
34
+ return value
35
+
36
+
37
+ def checksum(value: object) -> str:
38
+ return "sha256:" + hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
39
+
40
+
41
+ class BlogStore:
42
+ def __init__(self, project: Path) -> None:
43
+ self.root = project / ".maggie" / "blog"
44
+ self.posts_path = self.root / "posts.json"
45
+ self.settings_path = self.root / "settings.json"
46
+ self.runs_path = self.root / "pull-runs.json"
47
+
48
+ def init(self, base_path: str = "/our-blogs", posts_per_page: int = 9) -> dict:
49
+ self.root.mkdir(parents=True, exist_ok=True)
50
+ settings = {**DEFAULTS, "basePath": "/" + base_path.strip("/"), "postsPerPage": posts_per_page, "updatedAt": now()}
51
+ if self.settings_path.exists():
52
+ existing = json.loads(self.settings_path.read_text(encoding="utf-8"))
53
+ settings = {**settings, **existing}
54
+ self._write(self.settings_path, settings)
55
+ if not self.posts_path.exists(): self._write(self.posts_path, [])
56
+ if not self.runs_path.exists(): self._write(self.runs_path, [])
57
+ return settings
58
+
59
+ def settings(self) -> dict:
60
+ if not self.settings_path.exists(): raise ValueError("blog is not initialized; run `maggie blog init` first")
61
+ return json.loads(self.settings_path.read_text(encoding="utf-8"))
62
+
63
+ def posts(self) -> list[dict]:
64
+ if not self.posts_path.exists(): return []
65
+ return json.loads(self.posts_path.read_text(encoding="utf-8"))
66
+
67
+ def _write(self, path: Path, value: object) -> None:
68
+ path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
69
+
70
+ def _backup(self) -> None:
71
+ if self.posts_path.exists():
72
+ backup_dir = self.root / "backups"; backup_dir.mkdir(exist_ok=True)
73
+ shutil.copy2(self.posts_path, backup_dir / f"posts-{now().replace(':', '').replace('.', '')}.json")
74
+
75
+ def ingest(self, payload: list[dict], provider: str = "local") -> dict:
76
+ settings = self.settings()
77
+ posts = self.posts()
78
+ by_content = {post["contentId"]: post for post in posts}
79
+ changed = 0
80
+ for item in payload:
81
+ content_id = str(item.get("contentId") or item.get("id") or "").strip()
82
+ title = str(item.get("title") or "").strip()
83
+ if not content_id or not title: raise ValueError("each post requires contentId/id and title")
84
+ old = by_content.get(content_id)
85
+ raw_slug = str(item.get("slug") or title)
86
+ post = {
87
+ "schemaVersion": "maggie-blog-post.v1",
88
+ "id": old["id"] if old else f"post:{content_id}",
89
+ "projectId": str(item.get("projectId") or "local-project"),
90
+ "contentId": content_id,
91
+ "slug": old["slug"] if old else slugify(raw_slug),
92
+ "title": title,
93
+ "excerpt": str(item.get("excerpt") or ""),
94
+ "body": item.get("body") if isinstance(item.get("body"), dict) else {"format": str(item.get("format") or "markdown"), "value": str(item.get("body") or item.get("content") or "")},
95
+ "topics": [{"slug": slugify(str(topic)), "label": str(topic)} for topic in item.get("topics", item.get("keywords", []))],
96
+ # Provider input can suggest a status, but cannot bypass the
97
+ # local approval gate. Existing published state is preserved.
98
+ "status": old.get("status", settings["defaultPostStatus"]) if old else settings["defaultPostStatus"],
99
+ "canonicalUrl": item.get("canonicalUrl"),
100
+ "source": {"provider": provider, "revision": str(item.get("revision") or checksum(item))},
101
+ "publishedAt": old.get("publishedAt") if old else item.get("publishedAt"),
102
+ "updatedAt": now(),
103
+ }
104
+ if post["status"] not in STATUSES: raise ValueError(f"invalid post status: {post['status']}")
105
+ post["canonicalUrl"] = post["canonicalUrl"] or settings["basePath"].rstrip("/") + "/" + post["slug"] + "/"
106
+ comparable_old = {k: v for k, v in (old or {}).items() if k != "updatedAt"}
107
+ comparable_new = {k: v for k, v in post.items() if k != "updatedAt"}
108
+ if comparable_old != comparable_new: changed += 1
109
+ by_content[content_id] = post
110
+ result = list(by_content.values())
111
+ slugs = [post["slug"] for post in result]
112
+ if len(slugs) != len(set(slugs)): raise ValueError("slug collision detected; provide unique slugs")
113
+ self._backup(); self._write(self.posts_path, result)
114
+ runs = json.loads(self.runs_path.read_text(encoding="utf-8")) if self.runs_path.exists() else []
115
+ run = {"id": "pull:" + hashlib.sha256((now() + provider).encode()).hexdigest()[:12], "provider": provider, "status": "completed", "delivered": len(payload), "changed": changed, "finishedAt": now()}
116
+ runs.append(run); self._write(self.runs_path, runs)
117
+ return run
118
+
119
+ def validate(self) -> dict:
120
+ settings = self.settings(); posts = self.posts(); errors = []
121
+ ids = [p.get("contentId") for p in posts]; slugs = [p.get("slug") for p in posts]
122
+ if len(ids) != len(set(ids)): errors.append("duplicate contentId")
123
+ if len(slugs) != len(set(slugs)): errors.append("duplicate slug")
124
+ for post in posts:
125
+ if post.get("schemaVersion") != "maggie-blog-post.v1": errors.append(f"{post.get('id')}: schemaVersion")
126
+ if post.get("status") not in STATUSES: errors.append(f"{post.get('id')}: status")
127
+ if post.get("status") == "published" and not post.get("publishedAt"): errors.append(f"{post.get('id')}: publishedAt required")
128
+ return {"valid": not errors, "posts": len(posts), "published": sum(p.get("status") == "published" for p in posts), "settings": settings, "errors": errors}
129
+
130
+ def publish(self, slug: str, actor: str, reason: str) -> dict:
131
+ if not actor or not reason: raise ValueError("actor and reason are required")
132
+ posts = self.posts(); found = next((p for p in posts if p.get("slug") == slug), None)
133
+ if not found: raise ValueError(f"post not found: {slug}")
134
+ if found["status"] not in {"draft", "review", "approved"}: raise ValueError(f"post cannot publish from {found['status']}")
135
+ found["status"] = "published"; found["publishedAt"] = found.get("publishedAt") or now(); found["updatedAt"] = now(); found["lastTransition"] = {"actor": actor, "reason": reason, "at": now()}
136
+ self._backup(); self._write(self.posts_path, posts); return found
137
+
138
+ def rollback(self, backup: str | None = None) -> dict:
139
+ backups = sorted((self.root / "backups").glob("posts-*.json")) if (self.root / "backups").exists() else []
140
+ source = Path(backup) if backup else (backups[-1] if backups else None)
141
+ if source is None or not source.exists(): raise ValueError("no blog backup available")
142
+ current = self.posts_path.read_text(encoding="utf-8") if self.posts_path.exists() else "[]"
143
+ self._backup(); self.posts_path.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
144
+ return {"restored": str(source), "previousPostCount": len(json.loads(current)), "postCount": len(self.posts())}
145
+
146
+ def generate_feeds(self) -> dict:
147
+ settings = self.settings(); posts = [p for p in self.posts() if p.get("status") == "published"]
148
+ base = settings["basePath"].rstrip("/"); out = self.root / "generated"; out.mkdir(exist_ok=True)
149
+ topics = sorted({topic["slug"] for post in posts for topic in post.get("topics", [])})
150
+ page_count = max(1, (len(posts) + settings["postsPerPage"] - 1) // settings["postsPerPage"])
151
+ routes = {
152
+ "archive": base + "/", "archivePages": [base + "/page/" + str(n) + "/" for n in range(2, page_count + 1)],
153
+ "post": [base + "/" + p["slug"] + "/" for p in posts],
154
+ "topics": [base + "/topic/" + t + "/" for t in topics], "rss": "/rss.xml", "sitemap": "/sitemap.xml",
155
+ "searchPolicy": {"robots": "noindex,follow", "canonical": "archive"},
156
+ "sortPolicy": {"robots": "noindex,follow", "canonical": "archive"},
157
+ "postMetadata": [{"url": base + "/" + p["slug"] + "/", "canonical": p["canonicalUrl"], "type": "BlogPosting", "headline": p["title"], "datePublished": p.get("publishedAt"), "dateModified": p.get("updatedAt")} for p in posts],
158
+ }
159
+ rss = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\"><channel>" + "".join(f"<item><title>{_xml(p['title'])}</title><link>{_xml(p['canonicalUrl'])}</link></item>" for p in posts) + "</channel></rss>"
160
+ sitemap_urls = [base + "/"] + routes["archivePages"] + routes["post"] + routes["topics"]
161
+ sitemap = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">" + "".join(f"<url><loc>{_xml(url)}</loc></url>" for url in sitemap_urls) + "</urlset>"
162
+ self._write(out / "routes.json", routes); (out / "rss.xml").write_text(rss, encoding="utf-8"); (out / "sitemap.xml").write_text(sitemap, encoding="utf-8")
163
+ return {"routes": routes, "rss": str(out / "rss.xml"), "sitemap": str(out / "sitemap.xml")}
164
+
165
+
166
+ def _xml(value: object) -> str:
167
+ return str(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.2.9",
3
+ "version": "0.5.0",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",