@topy-ai/maggie 0.7.53 → 0.7.55

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.
Files changed (65) hide show
  1. package/README-zh-TW.md +28 -50
  2. package/README.md +80 -64
  3. package/bin/maggie.js +61 -9
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
  5. package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
  6. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
  7. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
  8. package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
  9. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  10. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  11. package/bundled-skills/catalog.json +1 -1
  12. package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
  13. package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
  14. package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
  15. package/bundled-skills/maggie-blog/SKILL.md +15 -0
  16. package/bundled-skills/maggie-booking/SKILL.md +20 -12
  17. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  18. package/bundled-skills/maggie-dash/SKILL.md +37 -2
  19. package/bundled-skills/maggie-deployment/SKILL.md +104 -1
  20. package/bundled-skills/maggie-deployment/references/vps.md +5 -0
  21. package/bundled-skills/maggie-design/SKILL.md +81 -0
  22. package/bundled-skills/maggie-feedback/SKILL.md +10 -2
  23. package/bundled-skills/maggie-marketplace/SKILL.md +22 -2
  24. package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
  25. package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
  26. package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
  27. package/bundled-skills/maggie-ops/SKILL.md +28 -0
  28. package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
  29. package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
  30. package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
  31. package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
  32. package/bundled-skills/maggie-social-share/SKILL.md +50 -0
  33. package/bundled-tools/clis/maggie.py +63 -3
  34. package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
  35. package/bundled-tools/clis/maggie_booking.py +9 -8
  36. package/bundled-tools/clis/maggie_dash.py +173 -3
  37. package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
  38. package/bundled-tools/clis/maggie_design.py +7 -1
  39. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  40. package/bundled-tools/clis/maggie_design_lint.py +131 -1
  41. package/bundled-tools/clis/maggie_feedback.py +41 -13
  42. package/bundled-tools/clis/maggie_host_runtime.py +458 -0
  43. package/bundled-tools/clis/maggie_live_urls.py +147 -0
  44. package/bundled-tools/clis/maggie_localization.py +3 -0
  45. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  46. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  47. package/bundled-tools/clis/maggie_mobile.py +84 -1
  48. package/bundled-tools/clis/maggie_ops.py +2 -0
  49. package/bundled-tools/clis/maggie_ops_routines.py +352 -0
  50. package/bundled-tools/clis/maggie_product_release.py +49 -1
  51. package/bundled-tools/clis/maggie_query_ideas.py +154 -0
  52. package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
  53. package/bundled-tools/clis/maggie_social_share.py +223 -2
  54. package/bundled-tools/clis/maggie_version_sources.py +93 -0
  55. package/bundled-tools/integrations/maggie-api-pull.md +49 -0
  56. package/package.json +1 -1
  57. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  58. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  59. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  60. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  61. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  62. package/bundled-references/maggiedash-booking/README.md +0 -288
  63. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  64. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  65. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env python3
2
+ """Check an agents repository's routines.json and build the admin sync payload.
3
+
4
+ A routine is a scheduled agent run (Claude Routines, Codex Automations). The
5
+ agents repository keeps one `routines.json` plus the skills the routines run
6
+ (`.claude/skills/<name>/SKILL.md` or `skills/<name>/SKILL.md`). This module is
7
+ offline: it reads files, never calls an agent platform or the host.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ SCHEMA = "maggie-agent-routines.v1"
20
+ SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
21
+ # Claude Routines runs per day by plan at the time of writing; check the current limits.
22
+ PLAN_CAPS = {"pro": 5, "max": 15, "team": 25, "enterprise": 25}
23
+ DAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
24
+ DAY_NAMES = {name: index for index, name in enumerate(DAYS)} | {
25
+ "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6,
26
+ }
27
+ # Writes a routine should hold only when the owner named them with a number.
28
+ PUBLIC_WORDS = ("publish", "approve", "reply", "delete", "send", "remove", "revoke", "pay")
29
+ # A routine starting this many minutes before, or this many after, a server job collides with it.
30
+ BEFORE_WINDOW = 30
31
+ AFTER_WINDOW = 10
32
+
33
+
34
+ def parse_time(value: str) -> int:
35
+ match = re.fullmatch(r"(\d{1,2}):(\d{2})", value.strip())
36
+ if not match or int(match.group(1)) > 23 or int(match.group(2)) > 59:
37
+ raise ValueError(f"time must be HH:MM: {value!r}")
38
+ return int(match.group(1)) * 60 + int(match.group(2))
39
+
40
+
41
+ def parse_days(value) -> set[int]:
42
+ if value in (None, "", "daily", "*", "every day"):
43
+ return set(range(7))
44
+ if value == "weekdays":
45
+ return set(range(5))
46
+ if value == "weekends":
47
+ return {5, 6}
48
+ items = value if isinstance(value, list) else re.split(r"[,\s]+", str(value).strip().lower())
49
+ days: set[int] = set()
50
+ for item in items:
51
+ item = str(item).strip().lower()
52
+ if not item:
53
+ continue
54
+ if item not in DAY_NAMES:
55
+ raise ValueError(f"unknown day: {item!r}")
56
+ days.add(DAY_NAMES[item])
57
+ return days
58
+
59
+
60
+ def cron_field(value: str, low: int, high: int) -> list[int] | None:
61
+ """Expand one cron field of plain numbers, lists and ranges; None for anything else."""
62
+ if value == "*":
63
+ return list(range(low, high + 1))
64
+ out: list[int] = []
65
+ for part in value.split(","):
66
+ if re.fullmatch(r"\d+", part):
67
+ out.append(int(part))
68
+ elif re.fullmatch(r"\d+-\d+", part):
69
+ start, end = (int(x) for x in part.split("-"))
70
+ out.extend(range(start, end + 1))
71
+ elif re.fullmatch(r"\*/\d+", part):
72
+ out.extend(range(low, high + 1, int(part[2:])))
73
+ else:
74
+ return None
75
+ return out if all(low <= item <= high for item in out) else None
76
+
77
+
78
+ def parse_schedule(text: str) -> tuple[set[int], list[int]] | None:
79
+ """(days 0=Mon..6=Sun, start minutes of the day) or None when not machine-readable.
80
+
81
+ Accepts a 5-field cron expression or `daily|weekdays|weekends|<days> HH:MM[,HH:MM]`.
82
+ """
83
+ text = (text or "").strip()
84
+ if not text:
85
+ return None
86
+ fields = text.split()
87
+ if len(fields) == 5 and not re.search(r"[a-z]{4,}", fields[0].lower()):
88
+ minutes = cron_field(fields[0], 0, 59)
89
+ hours = cron_field(fields[1], 0, 23)
90
+ if minutes is None or hours is None or fields[2] != "*" or fields[3] != "*":
91
+ return None
92
+ dow = fields[4].lower()
93
+ if dow == "*":
94
+ days = set(range(7))
95
+ else:
96
+ named = [DAY_NAMES.get(part) for part in re.split(r"[,]", dow)] if re.search(r"[a-z]", dow) else None
97
+ if named is not None:
98
+ if None in named:
99
+ return None
100
+ days = set(named) # type: ignore[arg-type]
101
+ else:
102
+ numbers = cron_field(dow, 0, 7)
103
+ if numbers is None:
104
+ return None
105
+ days = {(number - 1) % 7 for number in numbers} # cron 0/7 = Sunday, 1 = Monday
106
+ return days, sorted({hour * 60 + minute for hour in hours for minute in minutes})
107
+ lowered = text.lower()
108
+ if lowered.startswith("hourly") or lowered.startswith("every hour"):
109
+ return set(range(7)), list(range(0, 24 * 60, 60))
110
+ match = re.fullmatch(r"(?:(daily|weekdays|weekends|every day|[a-z,\s]+?)\s+)?(?:at\s+)?((?:\d{1,2}:\d{2})(?:\s*,\s*\d{1,2}:\d{2})*)", lowered)
111
+ if not match:
112
+ return None
113
+ try:
114
+ days = parse_days((match.group(1) or "daily").strip())
115
+ times = sorted(parse_time(item) for item in match.group(2).split(","))
116
+ except ValueError:
117
+ return None
118
+ return days, times
119
+
120
+
121
+ def clock(minutes: int) -> str:
122
+ return f"{minutes // 60:02d}:{minutes % 60:02d}"
123
+
124
+
125
+ def find_skill(repo: Path, name: str) -> Path | None:
126
+ for candidate in (repo / ".claude" / "skills" / name / "SKILL.md", repo / "skills" / name / "SKILL.md"):
127
+ if candidate.is_file():
128
+ return candidate
129
+ return None
130
+
131
+
132
+ def routine_mode_section(text: str) -> str:
133
+ match = re.search(r"^#{2,3}\s+Routine mode\b.*?(?=^#{1,2}\s|\Z)", text, re.M | re.S | re.I)
134
+ return match.group(0) if match else ""
135
+
136
+
137
+ def load_server_jobs(path: Path | None) -> list[dict]:
138
+ if path is None:
139
+ return []
140
+ data = json.loads(path.read_text(encoding="utf-8"))
141
+ jobs = data.get("jobs", []) if isinstance(data, dict) else data
142
+ out = []
143
+ for job in jobs:
144
+ if "at" not in job:
145
+ raise ValueError(f"server job {job!r} needs at: HH:MM")
146
+ out.append({"job": str(job.get("job") or job.get("id") or "job"), "at": parse_time(str(job["at"])), "days": parse_days(job.get("days", "daily"))})
147
+ return out
148
+
149
+
150
+ def check_routines(path: Path, server_jobs: list[dict] | None = None, plan: str | None = None) -> dict:
151
+ repo = path.parent
152
+ data = json.loads(path.read_text(encoding="utf-8"))
153
+ errors: list[str] = []
154
+ warnings: list[str] = []
155
+ if not isinstance(data, dict):
156
+ raise ValueError("routines.json must be an object")
157
+ if data.get("schemaVersion") not in (None, SCHEMA):
158
+ errors.append(f"schemaVersion must be {SCHEMA}")
159
+ connectors = data.get("connectors") or {}
160
+ if not isinstance(connectors, dict):
161
+ errors.append("connectors must be an object keyed by connector id")
162
+ connectors = {}
163
+ for cid, connector in connectors.items():
164
+ if not SLUG.match(cid):
165
+ errors.append(f"connector id {cid!r} must be a lowercase slug")
166
+ if not str((connector or {}).get("url", "")).startswith("https://"):
167
+ errors.append(f"connector {cid}: url must be https://")
168
+ routines = data.get("routines")
169
+ if not isinstance(routines, list) or not routines:
170
+ errors.append("routines must be a non-empty list")
171
+ routines = []
172
+ if len(routines) > 50:
173
+ errors.append("at most 50 routines")
174
+ seen: set[str] = set()
175
+ per_day = [0] * 7
176
+ summary = []
177
+ for routine in routines:
178
+ rid = str(routine.get("id", ""))
179
+ label = rid or "(routine without id)"
180
+ if not SLUG.match(rid):
181
+ errors.append(f"{label}: id must be a lowercase slug")
182
+ if rid in seen:
183
+ errors.append(f"{label}: duplicate id")
184
+ seen.add(rid)
185
+ skill = str(routine.get("skill", ""))
186
+ platform = str(routine.get("platform", "claude"))
187
+ if platform not in ("claude", "codex-local", "codex-cloud"):
188
+ errors.append(f"{label}: platform must be claude, codex-local or codex-cloud")
189
+ if not SLUG.match(skill):
190
+ errors.append(f"{label}: skill must be a lowercase slug")
191
+ for cid in routine.get("connectors", []) or []:
192
+ if cid not in connectors:
193
+ errors.append(f"{label}: connector {cid!r} is not defined in connectors")
194
+ writes = routine.get("writes", {}) or {}
195
+ if not isinstance(writes, dict):
196
+ errors.append(f"{label}: writes must map each tool to its daily number")
197
+ writes = {}
198
+ for tool, number in writes.items():
199
+ if not isinstance(number, int) or isinstance(number, bool) or number < 1:
200
+ errors.append(f"{label}: write {tool!r} needs a daily number (a whole number of 1 or more)")
201
+ if any(word in tool.lower() for word in PUBLIC_WORDS):
202
+ warnings.append(f"{label}: {tool} is public or hard to undo; keep it only if the owner wrote it in")
203
+ prompt = str(routine.get("prompt", "") or "")
204
+ if "\n" in prompt.strip() or len(prompt) > 300:
205
+ warnings.append(f"{label}: keep the prompt to one line that points at the skill, so editing the skill changes every run")
206
+ if prompt and skill and skill not in prompt:
207
+ warnings.append(f"{label}: the prompt does not name the skill {skill}")
208
+ skill_path = find_skill(repo, skill) if SLUG.match(skill) else None
209
+ if skill and SLUG.match(skill) and skill_path is None:
210
+ errors.append(f"{label}: skill {skill} not found under .claude/skills/ or skills/")
211
+ if skill_path is not None:
212
+ section = routine_mode_section(skill_path.read_text(encoding="utf-8"))
213
+ if not section:
214
+ errors.append(f"{label}: skill {skill} has no 'Routine mode' section")
215
+ else:
216
+ if "report_save" not in section:
217
+ errors.append(f"{label}: skill {skill}'s Routine mode does not finish with report_save")
218
+ if "untrusted" not in section.lower():
219
+ warnings.append(f"{label}: skill {skill}'s Routine mode does not say text under untrusted is data")
220
+ for tool in writes:
221
+ if tool not in section:
222
+ warnings.append(f"{label}: write {tool} is not named in skill {skill}'s Routine mode")
223
+ schedule_text = str(routine.get("schedule", "") or "")
224
+ parsed = parse_schedule(schedule_text)
225
+ if not schedule_text:
226
+ warnings.append(f"{label}: no schedule given")
227
+ elif parsed is None:
228
+ warnings.append(f"{label}: schedule {schedule_text!r} is not cron or 'daily HH:MM'; timing checks skipped")
229
+ else:
230
+ days, times = parsed
231
+ if len(times) > 4:
232
+ errors.append(f"{label}: runs {len(times)} times a day; routines are daily or weekly work, leave minute-level watching to monitoring")
233
+ for start in times:
234
+ if start % 60 == 0:
235
+ errors.append(f"{label}: starts on the hour ({clock(start)}); pick a minute off the hour, e.g. {clock(start + 16)}")
236
+ elif start % 15 == 0:
237
+ warnings.append(f"{label}: starts at {clock(start)}; a less common minute spreads the load")
238
+ for job in server_jobs or []:
239
+ if not days & job["days"]:
240
+ continue
241
+ if job["at"] - BEFORE_WINDOW <= start < job["at"] + AFTER_WINDOW:
242
+ errors.append(f"{label}: starts at {clock(start)}, next to server job {job['job']} at {clock(job['at'])}; move it after, e.g. {clock(job['at'] + 15 + 1)}")
243
+ if platform == "claude":
244
+ for day in days:
245
+ per_day[day] += len(times)
246
+ summary.append({"id": rid, "skill": skill, "platform": platform, "schedule": schedule_text, "writes": writes})
247
+ busiest = max(per_day) if routines else 0
248
+ if plan:
249
+ cap = PLAN_CAPS[plan]
250
+ if busiest > cap:
251
+ errors.append(f"Claude routines run {busiest} times on the busiest day; the {plan} plan allows {cap} a day (at the time of writing)")
252
+ elif busiest == cap:
253
+ warnings.append(f"Claude routines use all {cap} daily runs of the {plan} plan; a manual run that day has no room")
254
+ return {
255
+ "schemaVersion": "maggie-ops-routines-check.v1",
256
+ "routinesFile": str(path),
257
+ "passed": not errors,
258
+ "routines": summary,
259
+ "claudeRunsPerDay": dict(zip(DAYS, per_day)),
260
+ "plan": plan,
261
+ "planCap": PLAN_CAPS.get(plan or ""),
262
+ "errors": errors,
263
+ "warnings": warnings,
264
+ }
265
+
266
+
267
+ def frontmatter_description(text: str) -> str:
268
+ match = re.match(r"^---\n(.*?)\n---", text, re.S)
269
+ if not match:
270
+ return ""
271
+ found = re.search(r"^description:\s*(.+)$", match.group(1), re.M)
272
+ return found.group(1).strip().strip("\"'") if found else ""
273
+
274
+
275
+ def build_payload(path: Path, repo_name: str, commit: str | None = None) -> dict:
276
+ """The body the admin's routines sync endpoint takes: repo, commit, connectors, routines, skills."""
277
+ repo = path.parent
278
+ if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo_name):
279
+ raise ValueError("--repo must be owner/name")
280
+ if commit is None:
281
+ completed = subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"], capture_output=True, text=True, check=False)
282
+ if completed.returncode:
283
+ raise ValueError("not a git repository; pass --commit")
284
+ commit = completed.stdout.strip()
285
+ if not re.fullmatch(r"[0-9a-f]{7,40}", commit):
286
+ raise ValueError("commit must be a git sha")
287
+ data = json.loads(path.read_text(encoding="utf-8"))
288
+ routines = []
289
+ skills = []
290
+ for routine in data.get("routines", []):
291
+ skill = routine.get("skill", "")
292
+ routines.append({key: routine[key] for key in ("id", "name", "skill", "schedule", "connectors", "prompt") if key in routine})
293
+ skill_path = find_skill(repo, skill)
294
+ if skill_path is not None and skill not in {item["name"] for item in skills}:
295
+ body = skill_path.read_text(encoding="utf-8")
296
+ skills.append({"name": skill, "description": frontmatter_description(body), "body": body})
297
+ return {"repo": repo_name, "commit": commit, "connectors": data.get("connectors", {}), "routines": routines, "skills": skills}
298
+
299
+
300
+ def command_check(args: argparse.Namespace) -> int:
301
+ jobs = load_server_jobs(Path(args.server_jobs)) if args.server_jobs else []
302
+ result = check_routines(Path(args.file), jobs, args.plan)
303
+ text = json.dumps(result, indent=2, ensure_ascii=False)
304
+ if args.output:
305
+ Path(args.output).write_text(text + "\n", encoding="utf-8")
306
+ print(text)
307
+ return 0 if result["passed"] else 1
308
+
309
+
310
+ def command_payload(args: argparse.Namespace) -> int:
311
+ result = check_routines(Path(args.file))
312
+ if not result["passed"]:
313
+ print(json.dumps({"passed": False, "errors": result["errors"]}, indent=2), file=sys.stderr)
314
+ return 1
315
+ payload = build_payload(Path(args.file), args.repo, args.commit)
316
+ text = json.dumps(payload, ensure_ascii=False)
317
+ if args.output:
318
+ Path(args.output).write_text(text + "\n", encoding="utf-8")
319
+ print(json.dumps({"passed": True, "output": args.output, "routines": len(payload["routines"]), "skills": len(payload["skills"])}))
320
+ else:
321
+ print(text)
322
+ return 0
323
+
324
+
325
+ def add_parsers(sub) -> None:
326
+ check = sub.add_parser("routines-check", help="check an agents repository's routines.json (schedule, plan cap, writes, Routine mode)")
327
+ check.add_argument("file", help="routines.json at the agents repository root")
328
+ check.add_argument("--server-jobs", help="JSON list of the server's own jobs: [{job, at: HH:MM, days}]")
329
+ check.add_argument("--plan", choices=sorted(PLAN_CAPS), help="Claude plan whose daily routine runs to check against")
330
+ check.add_argument("--output")
331
+ check.set_defaults(func=command_check)
332
+ payload = sub.add_parser("routines-payload", help="build the admin sync body (repo, commit, connectors, routines, skills)")
333
+ payload.add_argument("file")
334
+ payload.add_argument("--repo", required=True, help="owner/name of the agents repository")
335
+ payload.add_argument("--commit", help="commit sha (default: git HEAD of the repository)")
336
+ payload.add_argument("--output")
337
+ payload.set_defaults(func=command_payload)
338
+
339
+
340
+ def main(argv: list[str] | None = None) -> int:
341
+ parser = argparse.ArgumentParser(description=__doc__)
342
+ add_parsers(parser.add_subparsers(dest="command", required=True))
343
+ args = parser.parse_args(argv)
344
+ try:
345
+ return args.func(args)
346
+ except (OSError, ValueError, KeyError, json.JSONDecodeError, subprocess.CalledProcessError) as error:
347
+ parser.error(str(error))
348
+ return 2
349
+
350
+
351
+ if __name__ == "__main__":
352
+ raise SystemExit(main())
@@ -27,6 +27,7 @@ import json
27
27
  import re
28
28
  import shutil
29
29
  import subprocess
30
+ import time
30
31
  import sys
31
32
  import tempfile
32
33
  from datetime import date, datetime, timezone
@@ -264,6 +265,44 @@ def review_status(reviews_path: Path, package_version: str | None, chatgpt_plugi
264
265
  return {"schemaVersion": "maggie-product-review-status.v1", "passed": not errors, "reviews": rows, "errors": errors, "warnings": warnings}
265
266
 
266
267
 
268
+ STAGED_ERROR = re.compile(r"cannot publish over (?:the )?previously (?:staged|published) version", re.I)
269
+
270
+
271
+ def npm_view(package: str, npm_bin: str) -> dict:
272
+ completed = subprocess.run([npm_bin, "view", package, "dist-tags", "versions", "--json"], capture_output=True, text=True, check=False, timeout=60)
273
+ if completed.returncode:
274
+ return {}
275
+ try:
276
+ value = json.loads(completed.stdout or "{}")
277
+ except json.JSONDecodeError:
278
+ return {}
279
+ return value if isinstance(value, dict) else {}
280
+
281
+
282
+ def published(package: str, version: str, tag: str, wait: int, interval: float, publish_log: Path | None, view=None, sleep=time.sleep) -> dict:
283
+ """Wait until `version` is the registry's `tag` (latest). A staged publish can lag for minutes."""
284
+ npm_bin = shutil.which("npm") or "npm"
285
+ view = view or (lambda: npm_view(package, npm_bin))
286
+ log_says_staged = bool(publish_log and publish_log.is_file() and STAGED_ERROR.search(publish_log.read_text(encoding="utf-8", errors="replace")))
287
+ deadline = time.monotonic() + wait
288
+ checks = 0
289
+ while True:
290
+ checks += 1
291
+ data = view()
292
+ tags = data.get("dist-tags") or {}
293
+ versions = data.get("versions") or []
294
+ listed = version in (versions if isinstance(versions, list) else [versions])
295
+ current = tags.get(tag)
296
+ if current == version:
297
+ return {"passed": True, "package": package, "version": version, "tag": tag, "state": "live", "checks": checks,
298
+ "note": "the earlier publish attempt was already accepted (staged), not a failure" if log_says_staged else None}
299
+ if time.monotonic() >= deadline:
300
+ state = "staged" if listed or log_says_staged else "missing"
301
+ return {"passed": False, "package": package, "version": version, "tag": tag, "state": state, "registryTag": current, "checks": checks,
302
+ "note": "still propagating: check again before reporting it" if state == "staged" else "not on the registry: the publish did not happen"}
303
+ sleep(interval)
304
+
305
+
267
306
  def main(argv: list[str] | None = None) -> int:
268
307
  parser = argparse.ArgumentParser(prog="maggie product", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
269
308
  sub = parser.add_subparsers(dest="command", required=True)
@@ -281,9 +320,18 @@ def main(argv: list[str] | None = None) -> int:
281
320
  review.add_argument("--reviews", type=Path, required=True, help="maggie-product-reviews.v1 JSON")
282
321
  review.add_argument("--package", type=Path)
283
322
  review.add_argument("--chatgpt-plugin", type=Path, help="ChatGPT app plugin.json with review.test_cases")
323
+ pub = sub.add_parser("published", help="wait until a just-published version is the registry's latest (staged publishes lag)")
324
+ pub.add_argument("--package-name", required=True)
325
+ pub.add_argument("--version", required=True)
326
+ pub.add_argument("--tag", default="latest")
327
+ pub.add_argument("--wait", type=int, default=600, help="seconds to keep checking (default 600)")
328
+ pub.add_argument("--interval", type=float, default=20)
329
+ pub.add_argument("--publish-log", type=Path, help="output of the publish command; 'Cannot publish over previously staged version' means it was already accepted")
284
330
  args = parser.parse_args(argv)
285
331
  try:
286
- if args.command == "release-check":
332
+ if args.command == "published":
333
+ report = published(args.package_name, args.version, args.tag, args.wait, args.interval, args.publish_log)
334
+ elif args.command == "release-check":
287
335
  report = release_check(args.package.resolve(), args.server_json, args.changelog, args.pack)
288
336
  elif args.command == "smoke":
289
337
  report = smoke(args.package.resolve(), args.timeout)
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Turn Search Console queries into topic ideas, without the ones that are not topics.
3
+
4
+ Input rows are Search Console query rows (``query``, ``position``,
5
+ ``impressions``, ``clicks``). A query becomes an idea only when it is a real
6
+ topic the site does not cover yet:
7
+
8
+ * never from search operators (``site:example.com``, ``inurl:``, ``"..."``-only,
9
+ ``-term``), bare domains or URLs, or the brand name on its own (optionally
10
+ with the domain or a TLD);
11
+ * covered when its words — singular and plural forms treated alike — are all in
12
+ an existing post title or keyword, or in a waiting idea;
13
+ * short real words are kept (``seo``, ``crm``, ``ux``): only one-character
14
+ tokens and numbers alone are dropped as noise;
15
+ * "striking distance" by default: positions 4–15 with at least 10 impressions,
16
+ at most 5 new ideas per run.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import re
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ OPERATOR = re.compile(r"(?:^|\s)-?(?:site|inurl|intitle|intext|allinurl|allintitle|filetype|ext|related|cache|info|link|define|before|after|source):", re.I)
27
+ DOMAIN = re.compile(r"^(?:https?://)?(?:www\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}(?:/\S*)?$", re.I)
28
+ URL_PART = re.compile(r"https?://|www\.|\b[a-z0-9-]+\.(?:com|net|org|io|co|uk|so|ai|app|dev|xyz)\b", re.I)
29
+ TLDS = {"com", "net", "org", "io", "co", "uk", "so", "ai", "app", "dev", "xyz", "www"}
30
+ WORD = re.compile(r"[^\W_]+", re.UNICODE)
31
+
32
+
33
+ def singular(word: str) -> str:
34
+ if len(word) > 4 and word.endswith("ies"):
35
+ return word[:-3] + "y"
36
+ if len(word) > 4 and word.endswith(("ses", "xes", "zes", "ches", "shes")):
37
+ return word[:-2]
38
+ if len(word) > 3 and word.endswith("s") and not word.endswith(("ss", "us", "is")):
39
+ return word[:-1]
40
+ return word
41
+
42
+
43
+ def words(text: str) -> list[str]:
44
+ return [singular(word) for word in WORD.findall(text.lower())]
45
+
46
+
47
+ def brand_tokens(brands: list[str]) -> set[str]:
48
+ tokens: set[str] = set()
49
+ for brand in brands:
50
+ tokens.update(words(brand))
51
+ tokens.add(re.sub(r"[^a-z0-9]", "", brand.lower()))
52
+ tokens.discard("")
53
+ return tokens
54
+
55
+
56
+ def reject_reason(query: str, brands: set[str]) -> str | None:
57
+ text = " ".join(query.split())
58
+ if not text:
59
+ return "empty"
60
+ if OPERATOR.search(text) or text.startswith("-") or re.fullmatch(r'"[^"]*"', text):
61
+ return "search-operator"
62
+ if DOMAIN.match(text) or URL_PART.search(text):
63
+ return "domain-or-url"
64
+ tokens = words(text)
65
+ if not tokens or all(token.isdigit() or len(token) == 1 for token in tokens):
66
+ return "noise"
67
+ if brands and all(token in brands or token in TLDS for token in tokens):
68
+ return "brand-only"
69
+ return None
70
+
71
+
72
+ def covered(tokens: list[str], coverage: list[set[str]]) -> bool:
73
+ wanted = {token for token in tokens if len(token) > 1}
74
+ return bool(wanted) and any(wanted <= item for item in coverage)
75
+
76
+
77
+ def build(rows: list[dict], coverage_texts: list[str], brands: list[str], *, min_position: float, max_position: float, min_impressions: int, limit: int) -> dict:
78
+ brand_set = brand_tokens(brands)
79
+ coverage = [set(words(text)) for text in coverage_texts if text]
80
+ ideas, dropped, seen = [], [], set()
81
+ ordered = sorted((row for row in rows if isinstance(row, dict)), key=lambda row: -float(row.get("impressions") or 0))
82
+ for row in ordered:
83
+ keys = row.get("keys") if isinstance(row.get("keys"), list) else []
84
+ query = " ".join(str(row.get("query") or (keys[0] if keys else "")).split())
85
+ reason = reject_reason(query, brand_set)
86
+ position = float(row.get("position") or 0)
87
+ impressions = int(row.get("impressions") or 0)
88
+ tokens = words(query)
89
+ key = " ".join(sorted(set(tokens)))
90
+ if reason is None and key in seen:
91
+ reason = "duplicate"
92
+ if reason is None and covered(tokens, coverage):
93
+ reason = "already-covered"
94
+ if reason is None and not (min_position <= position <= max_position and impressions >= min_impressions):
95
+ reason = "outside-striking-distance"
96
+ if reason is None and len(ideas) >= limit:
97
+ reason = "daily-limit"
98
+ if reason:
99
+ dropped.append({"query": query, "reason": reason})
100
+ continue
101
+ seen.add(key)
102
+ coverage.append(set(tokens)) # plural/singular variants of this query now count as covered
103
+ ideas.append({"query": query, "position": round(position, 1), "impressions": impressions, "clicks": int(row.get("clicks") or 0), "type": "gsc"})
104
+ counts: dict[str, int] = {}
105
+ for item in dropped:
106
+ counts[item["reason"]] = counts.get(item["reason"], 0) + 1
107
+ return {"ideas": ideas, "dropped": dropped, "counts": {"ideas": len(ideas), **counts}}
108
+
109
+
110
+ def load_rows(path: str) -> list[dict]:
111
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
112
+ if isinstance(data, dict):
113
+ data = data.get("rows") or data.get("queries") or []
114
+ return data if isinstance(data, list) else []
115
+
116
+
117
+ def load_coverage(path: str | None) -> list[str]:
118
+ if not path:
119
+ return []
120
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
121
+ items = data.get("items") or data.get("posts") or data.get("ideas") or [] if isinstance(data, dict) else data
122
+ texts = []
123
+ for item in items:
124
+ if isinstance(item, str):
125
+ texts.append(item)
126
+ elif isinstance(item, dict):
127
+ texts.append(str(item.get("title") or ""))
128
+ keywords = item.get("keywords") or item.get("keyword") or []
129
+ texts.extend([keywords] if isinstance(keywords, str) else [str(value) for value in keywords])
130
+ return texts
131
+
132
+
133
+ def main() -> int:
134
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
135
+ parser.add_argument("rows", help="Search Console query rows (JSON list, or {rows: [...]})")
136
+ parser.add_argument("--coverage", help="existing post titles/keywords and waiting ideas (JSON list of strings or {title, keywords})")
137
+ parser.add_argument("--brand", action="append", default=[], help="brand name or domain stem; repeatable")
138
+ parser.add_argument("--min-position", type=float, default=4)
139
+ parser.add_argument("--max-position", type=float, default=15)
140
+ parser.add_argument("--min-impressions", type=int, default=10)
141
+ parser.add_argument("--limit", type=int, default=5, help="new ideas per run")
142
+ parser.add_argument("--project", default=".")
143
+ args = parser.parse_args()
144
+ try:
145
+ result = build(load_rows(args.rows), load_coverage(args.coverage), args.brand, min_position=args.min_position, max_position=args.max_position, min_impressions=args.min_impressions, limit=args.limit)
146
+ except (OSError, ValueError, json.JSONDecodeError) as error:
147
+ print(f"maggie seo query-ideas: {error}", file=sys.stderr)
148
+ return 1
149
+ print(json.dumps(result, indent=2, ensure_ascii=False))
150
+ return 0
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main())
@@ -159,6 +159,20 @@ def scaffold(root: Path, name: str, description: str, category: str | None) -> P
159
159
  return target
160
160
 
161
161
 
162
+ def release_version(explicit: str | None, sources: list[str]) -> str:
163
+ """One version source: never fall back to a built-in constant, refuse on disagreement."""
164
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
165
+ from maggie_version_sources import read_source
166
+ found = {spec: read_source(Path.cwd(), spec) for spec in sources}
167
+ if explicit:
168
+ found["--version"] = explicit
169
+ if not found:
170
+ raise ValueError("give --version or --version-from (the packager has no version of its own)")
171
+ if len(set(found.values())) > 1:
172
+ raise ValueError("version sources disagree: " + ", ".join(f"{spec}={value}" for spec, value in found.items()))
173
+ return next(iter(found.values()))
174
+
175
+
162
176
  def package(root: Path, out: Path, prefix: str, owner: str, version: str, all_name: str | None, force: bool) -> dict:
163
177
  report = validate(root, False, None)
164
178
  if not report["passed"]:
@@ -237,7 +251,7 @@ def main(argv: list[str] | None = None) -> int:
237
251
  sub = parser.add_subparsers(dest="command", required=True)
238
252
  new = sub.add_parser("scaffold"); new.add_argument("--root", type=Path, required=True); new.add_argument("--name", required=True); new.add_argument("--description", required=True); new.add_argument("--category")
239
253
  check = sub.add_parser("validate"); check.add_argument("--root", type=Path, required=True); check.add_argument("--require-pinned", action="store_true", help="references must pin a 40-character commit"); check.add_argument("--catalog", type=Path, help="catalog index JSON with skills[].id or .name")
240
- pack = sub.add_parser("package"); pack.add_argument("--root", type=Path, required=True); pack.add_argument("--out", type=Path, required=True); pack.add_argument("--prefix", required=True, help="plugin name prefix and marketplace name"); pack.add_argument("--owner", required=True); pack.add_argument("--version", default="0.1.0"); pack.add_argument("--all-name", help="name of the all-in-one plugin (default <prefix>-all)"); pack.add_argument("--no-all", action="store_true"); pack.add_argument("--force", action="store_true")
254
+ pack = sub.add_parser("package"); pack.add_argument("--root", type=Path, required=True); pack.add_argument("--out", type=Path, required=True); pack.add_argument("--prefix", required=True, help="plugin name prefix and marketplace name"); pack.add_argument("--owner", required=True); pack.add_argument("--version", help="release version; must equal --version-from when both are given"); pack.add_argument("--version-from", action="append", default=[], help="version source to follow: FILE, FILE:json.key or FILE~CONSTANT (repeat to require several to agree)"); pack.add_argument("--all-name", help="name of the all-in-one plugin (default <prefix>-all)"); pack.add_argument("--no-all", action="store_true"); pack.add_argument("--force", action="store_true")
241
255
  test = sub.add_parser("smoke"); test.add_argument("--out", type=Path, required=True); test.add_argument("--claude", action="store_true", help="also run `claude plugin validate`")
242
256
  args = parser.parse_args(argv)
243
257
  try:
@@ -248,7 +262,7 @@ def main(argv: list[str] | None = None) -> int:
248
262
  report = validate(args.root, args.require_pinned, args.catalog)
249
263
  report.pop("records", None)
250
264
  elif args.command == "package":
251
- report = package(args.root, args.out, args.prefix, args.owner, args.version, False if args.no_all else args.all_name, args.force)
265
+ report = package(args.root, args.out, args.prefix, args.owner, release_version(args.version, args.version_from), False if args.no_all else args.all_name, args.force)
252
266
  else:
253
267
  report = smoke(args.out, args.claude)
254
268
  except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error: