@topy-ai/maggie 0.2.9 → 0.3.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,31 @@ 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 |
267
269
 
268
270
  The remaining installable skills are `maggie-blog-bootstrap`, `maggie-dash`,
269
271
  `maggie-clone`, `maggie-clone-to-template`, `maggie-marketplace`,
270
272
  `maggie-template`, `maggie-design`, `maggie-ops`, `maggie-deployment`,
271
273
  `maggie-project-context`, `maggie-social-share`, and `maggie-memory`.
272
274
 
275
+ The package also includes `maggie-auth-reference` and `maggie-blog`. Use the
276
+ stable commands below after installation:
277
+
278
+ ```bash
279
+ maggie auth reference --project . --confirm
280
+ maggie auth check --project . --production
281
+ maggie design author --project . --route /about --purpose "Explain our approach" --audience "Visitors" --confirm
282
+ maggie blog init --project . --confirm
283
+ maggie blog ingest --project . --source local --input content/posts.json --confirm
284
+ maggie blog validate --project .
285
+ maggie blog sitemap --project .
286
+ ```
287
+
288
+ Blog imports are idempotent and draft-first. Published slugs remain stable;
289
+ public feeds exclude drafts, and `maggie blog rollback --confirm` restores the
290
+ latest local content backup.
291
+
273
292
  List every installed skill and command:
274
293
 
275
294
  ```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,10 @@ 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 author --project PATH --route /about --purpose TEXT --audience TEXT --confirm
89
+ maggie auth reference --project PATH --confirm
90
+ maggie auth check --project PATH [--production]
91
+ maggie blog init|inspect|ingest|validate|publish|sitemap|settings|rollback --project PATH
86
92
  maggie design status <job-id>
87
93
  maggie service import <provider-url> --project PATH
88
94
  maggie service sync <provider-url> --project PATH
@@ -206,7 +212,7 @@ function install(args) {
206
212
  copyIfMissing(join(DESIGN_ROOT, "SPA-DESIGN.md"), join(root, ".maggie", "design-reference", "SPA-DESIGN.md"));
207
213
  }
208
214
  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"));
215
+ if (existsSync(CONTRACTS_ROOT)) copyIfMissing(CONTRACTS_ROOT, join(root, "contracts"));
210
216
  if (existsSync(join(TEMPLATES_ROOT, "maggiedash"))) copyIfMissing(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"));
211
217
  const stateDir = join(root, ".maggie");
212
218
  mkdirSync(stateDir, { recursive: true });
@@ -240,7 +246,7 @@ function update(args) {
240
246
  updated += syncTree(DESIGN_ROOT, join(root, ".maggie", "design-reference"), force);
241
247
  }
242
248
  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);
249
+ if (existsSync(CONTRACTS_ROOT) && existsSync(join(root, "contracts"))) updated += syncTree(CONTRACTS_ROOT, join(root, "contracts"), force);
244
250
  if (existsSync(join(TEMPLATES_ROOT, "maggiedash")) && existsSync(join(root, "templates", "maggiedash"))) updated += syncTree(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"), force);
245
251
  const stateDir = join(root, STATE_DIR);
246
252
  mkdirSync(stateDir, { recursive: true });
@@ -346,6 +352,8 @@ try {
346
352
  else if (command === "clone") workflowCli("maggie_clone.py", args);
347
353
  else if (command === "clone-to-template") workflowCli("maggie_clone_to_template.py", args);
348
354
  else if (command === "design") workflowCli("maggie_design.py", args);
355
+ else if (command === "auth") workflowCli("maggie_auth.py", args);
356
+ else if (command === "blog") workflowCli("maggie_blog.py", args);
349
357
  else if (command === "service") service(args);
350
358
  else if (command === "ops") workflowCli("maggie_ops.py", args);
351
359
  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,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,34 @@
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.
@@ -73,6 +73,23 @@ 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
+
76
93
  ## Explicit homepage rebrand mode
77
94
 
78
95
  The homepage `review` mode only compares screenshots. It does not rebrand a
@@ -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())
@@ -453,6 +453,31 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
453
453
  return 0
454
454
 
455
455
 
456
+ def author_job(project: Path, route: str, purpose: str, audience: str, brief_file: Path | None, confirm: bool) -> int:
457
+ """Create an original page brief without requiring an external source URL."""
458
+ project = project.resolve()
459
+ if not confirm:
460
+ print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr)
461
+ return 2
462
+ if not route.startswith("/") or route == "/": raise ValueError("author route must be a non-homepage path starting with /")
463
+ contract = require_design_contract(project)
464
+ relative = route.strip("/")
465
+ candidates = [project / "src" / "pages" / relative, project / "src" / "pages" / f"{relative}.astro", project / "src" / "app" / relative / "page.tsx", project / relative]
466
+ collision = next((path for path in candidates if path.exists()), None)
467
+ if collision: raise ValueError(f"route collision; existing route: {collision}")
468
+ source_brief = {}
469
+ if brief_file:
470
+ source_brief = json.loads(brief_file.resolve().read_text(encoding="utf-8"))
471
+ if not purpose.strip() and not source_brief.get("purpose"): raise ValueError("purpose is required")
472
+ if not audience.strip() and not source_brief.get("audience"): raise ValueError("audience is required")
473
+ 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()}
474
+ digest = hashlib.sha256((str(project) + "\n" + route).encode()).hexdigest()[:12]
475
+ 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")
476
+ plan_path = project / ".maggie" / "design" / f"author-{digest}.json"
477
+ 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")
478
+ print(json.dumps({"brief": str(output), "plan": str(plan_path), "phase": "ready"}, indent=2)); return 0
479
+
480
+
456
481
  def main() -> int:
457
482
  if len(sys.argv) > 1 and sys.argv[1] == "rebrand":
458
483
  rebrand = argparse.ArgumentParser(description="Apply an explicit brand identity to a packaged marketplace template.")
@@ -486,6 +511,19 @@ def main() -> int:
486
511
  except (OSError, ValueError, json.JSONDecodeError) as error:
487
512
  print(f"BLOCKED: maggie-design in-place: {error}", file=sys.stderr)
488
513
  return 1
514
+ if len(sys.argv) > 1 and sys.argv[1] == "author":
515
+ author = argparse.ArgumentParser(description="Create an original first-party page brief and route plan.")
516
+ author.add_argument("--project", type=Path, default=Path.cwd())
517
+ author.add_argument("--route", required=True)
518
+ author.add_argument("--purpose", default="")
519
+ author.add_argument("--audience", default="")
520
+ author.add_argument("--brief-file", type=Path)
521
+ author.add_argument("--confirm", action="store_true")
522
+ args = author.parse_args(sys.argv[2:])
523
+ try:
524
+ return author_job(args.project, args.route, args.purpose, args.audience, args.brief_file, args.confirm)
525
+ except (OSError, ValueError, json.JSONDecodeError) as error:
526
+ print(f"BLOCKED: maggie-design author: {error}", file=sys.stderr); return 1
489
527
  if len(sys.argv) > 1 and sys.argv[1] in {"run", "status", "resume"}:
490
528
  workflow = argparse.ArgumentParser(description=__doc__)
491
529
  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.3.0",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",