@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.
- package/README-zh-TW.md +28 -50
- package/README.md +80 -64
- package/bin/maggie.js +61 -9
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
- package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
- package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
- package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
- package/bundled-skills/maggie-blog/SKILL.md +15 -0
- package/bundled-skills/maggie-booking/SKILL.md +20 -12
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +37 -2
- package/bundled-skills/maggie-deployment/SKILL.md +104 -1
- package/bundled-skills/maggie-deployment/references/vps.md +5 -0
- package/bundled-skills/maggie-design/SKILL.md +81 -0
- package/bundled-skills/maggie-feedback/SKILL.md +10 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +22 -2
- package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
- package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
- package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +28 -0
- package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
- package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
- package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
- package/bundled-skills/maggie-social-share/SKILL.md +50 -0
- package/bundled-tools/clis/maggie.py +63 -3
- package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
- package/bundled-tools/clis/maggie_booking.py +9 -8
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
- package/bundled-tools/clis/maggie_design.py +7 -1
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +131 -1
- package/bundled-tools/clis/maggie_feedback.py +41 -13
- package/bundled-tools/clis/maggie_host_runtime.py +458 -0
- package/bundled-tools/clis/maggie_live_urls.py +147 -0
- package/bundled-tools/clis/maggie_localization.py +3 -0
- package/bundled-tools/clis/maggie_localization_keys.py +151 -0
- package/bundled-tools/clis/maggie_media_generate.py +174 -0
- package/bundled-tools/clis/maggie_mobile.py +84 -1
- package/bundled-tools/clis/maggie_ops.py +2 -0
- package/bundled-tools/clis/maggie_ops_routines.py +352 -0
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_query_ideas.py +154 -0
- package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
- package/bundled-tools/clis/maggie_social_share.py +223 -2
- package/bundled-tools/clis/maggie_version_sources.py +93 -0
- package/bundled-tools/integrations/maggie-api-pull.md +49 -0
- package/package.json +1 -1
- package/bundled-contracts/maggiedash/execution-board.json +0 -5463
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
- package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
- package/bundled-references/maggiedash-booking/README.md +0 -288
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
- 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 == "
|
|
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="
|
|
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:
|