@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
|
@@ -6,7 +6,8 @@ server and ChatGPT/Claude apps. The product exports its registry as JSON
|
|
|
6
6
|
(``maggie-assistant-tool-registry.v1``); these commands never import product
|
|
7
7
|
code or call a model.
|
|
8
8
|
|
|
9
|
-
- ``registry-validate``: names, risk levels, gates, scopes, channels,
|
|
9
|
+
- ``registry-validate``: names, risk levels, gates, scopes, channels, clipped
|
|
10
|
+
lists, the report inbox, unattended-caller caps and sign-in defaults, and the
|
|
10
11
|
user-facing copy (label, detail, confirm sentence) kept separate from the
|
|
11
12
|
model-facing description. User copy may not contain snake_case tool names,
|
|
12
13
|
"the user", or "(from some_tool)" references.
|
|
@@ -17,6 +18,10 @@ code or call a model.
|
|
|
17
18
|
- ``audit-check``: every write in a recorded session has one audit entry, with
|
|
18
19
|
a readable summary (never a bare UUID) and the right scope (account-level
|
|
19
20
|
writes are not filed under the open site).
|
|
21
|
+
- ``list-check``: a recorded list result marks every item whose declared
|
|
22
|
+
field was shortened (``clipped``) and carries the id its get tool needs.
|
|
23
|
+
- ``report-check``: a report an agent is about to save to the report inbox
|
|
24
|
+
(title, one-line summary, Markdown body) fits the limits and holds no secret.
|
|
20
25
|
- ``approval-selftest``: exercises the reference signed one-time approval
|
|
21
26
|
token and standing workflow approvals (``tools/runtime/assistant_approval.py``).
|
|
22
27
|
"""
|
|
@@ -187,10 +192,222 @@ def validate_registry(registry: dict) -> dict:
|
|
|
187
192
|
errors.append("visitorPolicy.factsOnly must be true: answers come from the business's own facts, never general knowledge about prices or health")
|
|
188
193
|
if policy.get("cardDetails") != "never":
|
|
189
194
|
errors.append("visitorPolicy.cardDetails must be never: the chat never asks for or accepts card numbers")
|
|
195
|
+
by_name = {str(t.get("name")): t for t in tools if isinstance(t, dict)}
|
|
196
|
+
check_clips(by_name, errors, warnings)
|
|
197
|
+
check_unattended(registry, by_name, servers, errors, warnings)
|
|
198
|
+
check_report_inbox(registry, by_name, errors)
|
|
190
199
|
public_tools = [t.get("name") for t in tools if isinstance(t, dict) and any(servers.get(s, {}).get("auth") == "none" for s in t.get("mcpServers", []) or [])]
|
|
191
200
|
return {"schemaVersion": "maggie-assistant-registry-validation.v1", "passed": not errors, "errors": errors, "warnings": warnings, "counts": {"tools": len(tools), **counts, "cards": sum(1 for t in tools if isinstance(t, dict) and t.get("card")), "publicMcpTools": len(public_tools)}}
|
|
192
201
|
|
|
193
202
|
|
|
203
|
+
CLIP_WORDS_RE = re.compile(r"\b(?:clipped|shortened|truncated|cut to|first \d+ characters|~?\d+-character)\b", re.I)
|
|
204
|
+
WRITE_RISKS = ("write-reversible", "write-spends", "write-public")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def check_clips(by_name: dict[str, dict], errors: list[str], warnings: list[str]) -> None:
|
|
208
|
+
"""A list tool that shortens long fields says so per item and names a get tool with the full text.
|
|
209
|
+
|
|
210
|
+
An agent checking what will be published must read the full text; a list that
|
|
211
|
+
silently clips makes it report "cut off mid-sentence" for text that is whole.
|
|
212
|
+
"""
|
|
213
|
+
for name, tool in by_name.items():
|
|
214
|
+
clips = tool.get("clips")
|
|
215
|
+
if clips is None:
|
|
216
|
+
if risk_of(tool) == "read" and CLIP_WORDS_RE.search(str(tool.get("description", ""))):
|
|
217
|
+
warnings.append(f"{name}: the description says results are shortened; declare clips (fields, maxChars, getTool) so each item is marked and the full text has a get tool")
|
|
218
|
+
continue
|
|
219
|
+
if not isinstance(clips, dict):
|
|
220
|
+
errors.append(f"{name}: clips must be an object with fields, maxChars and getTool"); continue
|
|
221
|
+
if risk_of(tool) != "read":
|
|
222
|
+
errors.append(f"{name}: clips belongs to a read (list) tool")
|
|
223
|
+
fields = clips.get("fields")
|
|
224
|
+
if not isinstance(fields, list) or not fields or not all(isinstance(f, str) and f for f in fields):
|
|
225
|
+
errors.append(f"{name}: clips.fields must name the shortened fields")
|
|
226
|
+
if not isinstance(clips.get("maxChars"), int) or clips["maxChars"] < 1:
|
|
227
|
+
errors.append(f"{name}: clips.maxChars must be a positive integer")
|
|
228
|
+
get_name = str(clips.get("getTool", ""))
|
|
229
|
+
get_tool = by_name.get(get_name)
|
|
230
|
+
if get_tool is None:
|
|
231
|
+
errors.append(f"{name}: clips.getTool {get_name or '<missing>'!r} is not in the registry; a clipped list needs a get tool that returns the full text"); continue
|
|
232
|
+
if risk_of(get_tool) != "read":
|
|
233
|
+
errors.append(f"{name}: clips.getTool {get_name} must be a read tool")
|
|
234
|
+
if get_tool.get("scope") != tool.get("scope"):
|
|
235
|
+
errors.append(f"{name}: clips.getTool {get_name} must have the same scope as the list")
|
|
236
|
+
missing = [c for c in tool.get("channels") or [] if c not in (get_tool.get("channels") or [])]
|
|
237
|
+
if missing:
|
|
238
|
+
errors.append(f"{name}: clips.getTool {get_name} is not on {', '.join(missing)}; every caller of the list must be able to read the full text")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def oauth_full_set(by_name: dict[str, dict], server: str | None) -> set[str]:
|
|
242
|
+
"""The tools a signed-in caller's role can reach on MCP (the server filters by role at call time)."""
|
|
243
|
+
names = set()
|
|
244
|
+
for name, tool in by_name.items():
|
|
245
|
+
if risk_of(tool) == "never" or "mcp" not in (tool.get("channels") or []):
|
|
246
|
+
continue
|
|
247
|
+
if server and tool.get("mcpServers") and server not in tool["mcpServers"]:
|
|
248
|
+
continue
|
|
249
|
+
names.add(name)
|
|
250
|
+
return names
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def check_unattended(registry: dict, by_name: dict[str, dict], servers: dict, errors: list[str], warnings: list[str]) -> None:
|
|
254
|
+
"""Limits for callers no person watches: agent keys and sign-in (OAuth) grants used by routines.
|
|
255
|
+
|
|
256
|
+
A prompt is not a limit. Every write such a caller can reach needs a daily cap
|
|
257
|
+
that the server counts; the OAuth default tool set is the role's whole set (a
|
|
258
|
+
narrower default needs a reason); an agent key cannot confirm previews.
|
|
259
|
+
"""
|
|
260
|
+
policy = registry.get("unattended")
|
|
261
|
+
oauth_servers = [name for name, server in servers.items() if server.get("auth") == "oauth"]
|
|
262
|
+
if policy is None:
|
|
263
|
+
if oauth_servers and any(risk_of(by_name[n]) in WRITE_RISKS for n in oauth_full_set(by_name, oauth_servers[0])):
|
|
264
|
+
warnings.append("a signed-in MCP server exposes writes but registry.unattended is missing; declare server-enforced daily caps for agent keys and OAuth grants")
|
|
265
|
+
return
|
|
266
|
+
if not isinstance(policy, dict):
|
|
267
|
+
errors.append("unattended must be an object"); return
|
|
268
|
+
if policy.get("enforcedBy") != "server":
|
|
269
|
+
errors.append("unattended.enforcedBy must be server: caps kept only in a routine's prompt are not limits")
|
|
270
|
+
keys = policy.get("agentKeys")
|
|
271
|
+
if keys is not None:
|
|
272
|
+
if not isinstance(keys, dict):
|
|
273
|
+
errors.append("unattended.agentKeys must be an object")
|
|
274
|
+
else:
|
|
275
|
+
if keys.get("canConfirmPreviews") is not False:
|
|
276
|
+
errors.append("unattended.agentKeys.canConfirmPreviews must be false: an agent key runs only the writes its standing permission covers")
|
|
277
|
+
if keys.get("refusalReturnsReason") is not True:
|
|
278
|
+
errors.append("unattended.agentKeys.refusalReturnsReason must be true: a refused write says why (e.g. today's limit is used) instead of returning a preview nobody can confirm")
|
|
279
|
+
standing = keys.get("standingTools") if isinstance(keys.get("standingTools"), dict) else {}
|
|
280
|
+
for name, cap in standing.items():
|
|
281
|
+
tool = by_name.get(name)
|
|
282
|
+
if tool is None:
|
|
283
|
+
errors.append(f"unattended.agentKeys.standingTools: {name} is not in the registry"); continue
|
|
284
|
+
if risk_of(tool) not in ("write-reversible", "write-spends"):
|
|
285
|
+
errors.append(f"unattended.agentKeys.standingTools: {name} is {risk_of(tool)}; standing permissions cover reversible or spending writes only, never public or irreversible ones")
|
|
286
|
+
if not isinstance(cap, dict) or not isinstance(cap.get("perDay"), int) or cap["perDay"] < 1:
|
|
287
|
+
errors.append(f"unattended.agentKeys.standingTools: {name} needs perDay, the daily cap the server counts")
|
|
288
|
+
oauth = policy.get("oauth")
|
|
289
|
+
if oauth is None:
|
|
290
|
+
if oauth_servers:
|
|
291
|
+
errors.append("unattended.oauth is required when a signed-in (oauth) MCP server is declared: a sign-in grant acts as the full person")
|
|
292
|
+
return
|
|
293
|
+
if not isinstance(oauth, dict):
|
|
294
|
+
errors.append("unattended.oauth must be an object"); return
|
|
295
|
+
server = oauth.get("server") or (oauth_servers[0] if oauth_servers else None)
|
|
296
|
+
if server and server not in servers:
|
|
297
|
+
errors.append(f"unattended.oauth.server {server!r} is not declared in registry.mcpServers")
|
|
298
|
+
full = oauth_full_set(by_name, server)
|
|
299
|
+
default = oauth.get("defaultTools", "role")
|
|
300
|
+
if default == "role":
|
|
301
|
+
reachable = full
|
|
302
|
+
elif isinstance(default, list):
|
|
303
|
+
unknown = sorted(set(map(str, default)) - full)
|
|
304
|
+
if unknown:
|
|
305
|
+
errors.append(f"unattended.oauth.defaultTools names tools the role can't reach on MCP: {', '.join(unknown)}")
|
|
306
|
+
reachable = set(map(str, default)) & full
|
|
307
|
+
left_out = sorted(full - reachable)
|
|
308
|
+
if left_out and not str(oauth.get("narrowedReason", "")).strip():
|
|
309
|
+
errors.append(f"unattended.oauth.defaultTools exposes {len(reachable)} of the role's {len(full)} tools (missing e.g. {', '.join(left_out[:5])}); a routine connected by sign-in can't reach the rest. Use \"role\" or give narrowedReason")
|
|
310
|
+
else:
|
|
311
|
+
errors.append("unattended.oauth.defaultTools must be \"role\" (the caller's role's full set) or a list of tools"); reachable = full
|
|
312
|
+
per_tool = oauth.get("perToolPerDay") if isinstance(oauth.get("perToolPerDay"), dict) else {}
|
|
313
|
+
total = oauth.get("dailyWriteLimit")
|
|
314
|
+
has_total = isinstance(total, int) and total >= 1
|
|
315
|
+
for name in sorted(reachable):
|
|
316
|
+
if risk_of(by_name[name]) not in WRITE_RISKS:
|
|
317
|
+
continue
|
|
318
|
+
cap = per_tool.get(name)
|
|
319
|
+
if not (isinstance(cap, int) and cap >= 1) and not has_total:
|
|
320
|
+
errors.append(f"unattended.oauth: {name} is a write reachable by a sign-in grant with no server cap; set perToolPerDay.{name} or dailyWriteLimit")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
REPORT_LIMITS = {"titleMaxChars": 120, "summaryMaxChars": 500, "bodyMaxChars": 50000}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def check_report_inbox(registry: dict, by_name: dict[str, dict], errors: list[str]) -> None:
|
|
327
|
+
"""One place where scheduled agents leave their results: save, list, get; capped; bodies untrusted."""
|
|
328
|
+
inbox = registry.get("reportInbox")
|
|
329
|
+
if inbox is None:
|
|
330
|
+
return
|
|
331
|
+
if not isinstance(inbox, dict):
|
|
332
|
+
errors.append("reportInbox must be an object"); return
|
|
333
|
+
names = inbox.get("tools") if isinstance(inbox.get("tools"), dict) else {}
|
|
334
|
+
expected = {"save": ("write-reversible",), "list": ("read",), "get": ("read",)}
|
|
335
|
+
for role, risks in expected.items():
|
|
336
|
+
name = str(names.get(role, ""))
|
|
337
|
+
tool = by_name.get(name)
|
|
338
|
+
if tool is None:
|
|
339
|
+
errors.append(f"reportInbox.tools.{role} {name or '<missing>'!r} is not in the registry"); continue
|
|
340
|
+
if risk_of(tool) not in risks:
|
|
341
|
+
errors.append(f"reportInbox.tools.{role} {name} must be {risks[0]} (saving a report changes nothing else)")
|
|
342
|
+
if tool.get("scope") != "workspace":
|
|
343
|
+
errors.append(f"reportInbox.tools.{role} {name}: reports belong to one website (scope workspace)")
|
|
344
|
+
if not isinstance(inbox.get("perSitePerDay"), int) or inbox["perSitePerDay"] < 1:
|
|
345
|
+
errors.append("reportInbox.perSitePerDay is required: the server caps agent reports per website per day")
|
|
346
|
+
for key, ceiling in REPORT_LIMITS.items():
|
|
347
|
+
value = inbox.get(key, ceiling)
|
|
348
|
+
if not isinstance(value, int) or value < 1:
|
|
349
|
+
errors.append(f"reportInbox.{key} must be a positive integer")
|
|
350
|
+
if not isinstance(inbox.get("retentionDays"), int) or inbox["retentionDays"] < 1:
|
|
351
|
+
errors.append("reportInbox.retentionDays is required")
|
|
352
|
+
untrusted = set(inbox.get("untrusted") or [])
|
|
353
|
+
missing = sorted({"title", "summary", "body"} - untrusted)
|
|
354
|
+
if missing:
|
|
355
|
+
errors.append(f"reportInbox.untrusted must include {', '.join(missing)}: an agent wrote them, so readers treat them as data, never instructions")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def list_check(registry: dict, tool_name: str, result: dict) -> dict:
|
|
359
|
+
"""Check a recorded list result: every item whose declared field was shortened says so."""
|
|
360
|
+
tool = next((t for t in registry.get("tools", []) if isinstance(t, dict) and t.get("name") == tool_name), None)
|
|
361
|
+
errors: list[str] = []
|
|
362
|
+
if tool is None or not isinstance(tool.get("clips"), dict):
|
|
363
|
+
return {"schemaVersion": "maggie-assistant-list-check.v1", "passed": False, "errors": [f"{tool_name}: not in the registry or declares no clips"], "items": 0, "clipped": 0}
|
|
364
|
+
clips = tool["clips"]
|
|
365
|
+
id_field = str(clips.get("idField", "id"))
|
|
366
|
+
max_chars = int(clips.get("maxChars", 0) or 0)
|
|
367
|
+
items = result.get("items") if isinstance(result.get("items"), list) else result.get("result", {}).get("items", []) if isinstance(result.get("result"), dict) else []
|
|
368
|
+
clipped = 0
|
|
369
|
+
for index, item in enumerate(items):
|
|
370
|
+
if not isinstance(item, dict):
|
|
371
|
+
continue
|
|
372
|
+
where = f"item {index} ({item.get(id_field, 'no ' + id_field)})"
|
|
373
|
+
if item.get(id_field) in (None, ""):
|
|
374
|
+
errors.append(f"{where}: has no {id_field}, so the full text can't be fetched with {clips.get('getTool')}")
|
|
375
|
+
marker = item.get("clipped")
|
|
376
|
+
for field in clips.get("fields", []):
|
|
377
|
+
text = item.get(field)
|
|
378
|
+
if not isinstance(text, str):
|
|
379
|
+
continue
|
|
380
|
+
shortened = (max_chars and len(text) >= max_chars) or text.rstrip().endswith(("…", "..."))
|
|
381
|
+
flagged = marker is True or (isinstance(marker, list) and field in marker)
|
|
382
|
+
if shortened and not flagged:
|
|
383
|
+
errors.append(f"{where}: {field} looks shortened ({len(text)} characters) but the item does not say clipped")
|
|
384
|
+
if flagged:
|
|
385
|
+
clipped += 1
|
|
386
|
+
return {"schemaVersion": "maggie-assistant-list-check.v1", "passed": not errors, "errors": errors, "items": len(items), "clipped": clipped, "getTool": clips.get("getTool")}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def report_check(report: dict, inbox: dict | None = None) -> dict:
|
|
390
|
+
"""Check a report an agent is about to save (maggie-assistant-report.v1)."""
|
|
391
|
+
limits = {**REPORT_LIMITS, **{k: v for k, v in (inbox or {}).items() if k in REPORT_LIMITS and isinstance(v, int)}}
|
|
392
|
+
errors: list[str] = []
|
|
393
|
+
title = str(report.get("title", "")).strip()
|
|
394
|
+
summary = str(report.get("summary", "")).strip()
|
|
395
|
+
body = str(report.get("body_md", report.get("body", "")))
|
|
396
|
+
if not title:
|
|
397
|
+
errors.append("title is required")
|
|
398
|
+
elif len(title) > limits["titleMaxChars"]:
|
|
399
|
+
errors.append(f"title is over {limits['titleMaxChars']} characters")
|
|
400
|
+
if not summary:
|
|
401
|
+
errors.append("summary is required: one or two sentences shown in the inbox")
|
|
402
|
+
elif len(summary) > limits["summaryMaxChars"]:
|
|
403
|
+
errors.append(f"summary is over {limits['summaryMaxChars']} characters")
|
|
404
|
+
if len(body) > limits["bodyMaxChars"]:
|
|
405
|
+
errors.append(f"body is over {limits['bodyMaxChars']} characters")
|
|
406
|
+
if re.search(r"(?i)\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{8,}|\bBearer\s+[A-Za-z0-9._-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY", title + summary + body):
|
|
407
|
+
errors.append("the report contains something that looks like a secret; remove it before saving")
|
|
408
|
+
return {"schemaVersion": "maggie-assistant-report-check.v1", "passed": not errors, "errors": errors, "chars": {"title": len(title), "summary": len(summary), "body": len(body)}}
|
|
409
|
+
|
|
410
|
+
|
|
194
411
|
def annotations_for(tool: dict) -> dict:
|
|
195
412
|
risk = risk_of(tool)
|
|
196
413
|
title = str(tool.get("user", {}).get("label", "")).strip() or None
|
|
@@ -348,14 +565,21 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
348
565
|
page = sub.add_parser("capability-page"); page.add_argument("--registry", type=Path, required=True); page.add_argument("--title", default="What the assistant can and can't do"); page.add_argument("--output", type=Path, help="Markdown file; JSON is written next to it")
|
|
349
566
|
audit = sub.add_parser("audit-check"); audit.add_argument("--registry", type=Path, required=True); audit.add_argument("--session", type=Path, required=True, help="recorded calls + audit entries (maggie-assistant-session.v1)")
|
|
350
567
|
sub.add_parser("approval-selftest")
|
|
568
|
+
lc = sub.add_parser("list-check"); lc.add_argument("--registry", type=Path, required=True); lc.add_argument("--tool", required=True); lc.add_argument("--result", type=Path, required=True, help="a recorded result of the list tool")
|
|
569
|
+
rc = sub.add_parser("report-check"); rc.add_argument("--report", type=Path, required=True, help="the report about to be saved (title, summary, body_md)"); rc.add_argument("--registry", type=Path, help="use this registry's reportInbox limits")
|
|
351
570
|
args = parser.parse_args(argv)
|
|
352
571
|
try:
|
|
353
572
|
if args.command == "approval-selftest":
|
|
354
573
|
report = approval_selftest()
|
|
574
|
+
elif args.command == "report-check":
|
|
575
|
+
inbox = _read(args.registry).get("reportInbox") if args.registry else None
|
|
576
|
+
report = report_check(_read(args.report), inbox if isinstance(inbox, dict) else None)
|
|
355
577
|
else:
|
|
356
578
|
registry = _read(args.registry)
|
|
357
579
|
if args.command == "registry-validate":
|
|
358
580
|
report = validate_registry(registry)
|
|
581
|
+
elif args.command == "list-check":
|
|
582
|
+
report = list_check(registry, args.tool, _read(args.result))
|
|
359
583
|
elif args.command == "annotations":
|
|
360
584
|
report = derive_annotations(registry, _read(args.mcp_tools) if args.mcp_tools else None)
|
|
361
585
|
elif args.command == "capability-page":
|
|
@@ -32,11 +32,9 @@ DEFAULT_OPS_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-ops-evi
|
|
|
32
32
|
DEFAULT_BROWSER_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-browser-evidence-v2.json"
|
|
33
33
|
DEFAULT_STRIPE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "stripe-booking-capabilities-v1.json"
|
|
34
34
|
DEFAULT_VOUCHER_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-voucher-lifecycle-v1.json"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
else REPO_ROOT / "docs" / "maggiedash-booking" / "EXECUTION-BOARD.json"
|
|
39
|
-
)
|
|
35
|
+
# The Booking delivery board is a maintainer document of the Maggie source
|
|
36
|
+
# repository; the npm package does not ship it (config/release-privacy.json).
|
|
37
|
+
EXECUTION_BOARD = REPO_ROOT / "docs" / "maggiedash-booking" / "EXECUTION-BOARD.json"
|
|
40
38
|
|
|
41
39
|
|
|
42
40
|
def load_json(path: Path) -> dict:
|
|
@@ -57,6 +55,8 @@ def load_gate_json(path: Path, label: str) -> tuple[dict | None, str | None]:
|
|
|
57
55
|
|
|
58
56
|
|
|
59
57
|
def load_execution_board(path: Path = EXECUTION_BOARD) -> dict:
|
|
58
|
+
if not path.is_file():
|
|
59
|
+
raise RuntimeError("the Booking delivery board lives in the Maggie source repository (docs/maggiedash-booking/EXECUTION-BOARD.json); pass --board PATH")
|
|
60
60
|
board = load_json(path)
|
|
61
61
|
if board.get("schemaVersion") != "maggiedash-booking-execution-board.v1":
|
|
62
62
|
raise RuntimeError("unsupported Booking execution board schema")
|
|
@@ -661,9 +661,9 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
|
|
|
661
661
|
stripe_webhook = has("STRIPE_WEBHOOK_SECRET")
|
|
662
662
|
stripe_complete = stripe_secret and stripe_webhook
|
|
663
663
|
stripe_partial = stripe_secret != stripe_webhook
|
|
664
|
-
stripe_key_help = "STRIPE_SECRET_KEY (or
|
|
664
|
+
stripe_key_help = "STRIPE_SECRET_KEY (or the legacy alias ST_SECRECT_KEY)"
|
|
665
665
|
stripe_detail = (
|
|
666
|
-
"Recognizes
|
|
666
|
+
"Recognizes the legacy ST_SECRECT_KEY alias; prefer STRIPE_SECRET_KEY for new deployments. Verify the connection in Booking Overview."
|
|
667
667
|
if stripe_legacy_alias and stripe_complete
|
|
668
668
|
else f"Both {stripe_key_help} and STRIPE_WEBHOOK_SECRET are required for online payments."
|
|
669
669
|
if stripe_partial
|
|
@@ -872,7 +872,8 @@ def command_install(args: argparse.Namespace) -> int:
|
|
|
872
872
|
return 2
|
|
873
873
|
root = project_path(args)
|
|
874
874
|
dash = Path(__file__).with_name("maggie_dash.py")
|
|
875
|
-
|
|
875
|
+
# Booking is an optional MaggieDash module: install the core and Booking.
|
|
876
|
+
command = [sys.executable, str(dash), "install", "--project", str(root), "--host", args.host, "--with", "booking"]
|
|
876
877
|
if args.source:
|
|
877
878
|
command.extend(["--source", args.source])
|
|
878
879
|
if args.ref:
|
|
@@ -252,7 +252,7 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
|
|
|
252
252
|
return {"status": "updated", "reason": "inserted idempotent Maggie rewrite block"}
|
|
253
253
|
|
|
254
254
|
|
|
255
|
-
def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Path, framework: str | None) -> list[tuple[Path, Path, Path]]:
|
|
255
|
+
def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Path, framework: str | None, required: bool = True) -> list[tuple[Path, Path, Path]]:
|
|
256
256
|
"""Return non-destructive host files for a declared framework adapter."""
|
|
257
257
|
if not framework:
|
|
258
258
|
return []
|
|
@@ -260,6 +260,8 @@ def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Pa
|
|
|
260
260
|
if not isinstance(bootstrap, dict):
|
|
261
261
|
return []
|
|
262
262
|
spec = bootstrap.get(framework)
|
|
263
|
+
if not isinstance(spec, dict) and not required:
|
|
264
|
+
return []
|
|
263
265
|
if not isinstance(spec, dict):
|
|
264
266
|
raise RuntimeError(
|
|
265
267
|
f"MaggieDash distribution at {source} does not provide a {framework} host bootstrap; "
|
|
@@ -288,6 +290,128 @@ def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Pa
|
|
|
288
290
|
return pairs
|
|
289
291
|
|
|
290
292
|
|
|
293
|
+
CORE_MODULE = "core"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def module_names(manifest: dict[str, object]) -> list[str]:
|
|
297
|
+
modules = manifest.get("modules")
|
|
298
|
+
return sorted(modules) if isinstance(modules, dict) else []
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def effective_manifest(manifest: dict[str, object], modules: list[str]) -> dict[str, object]:
|
|
302
|
+
"""Core entries plus the root files and workspaces of the chosen modules."""
|
|
303
|
+
result = dict(manifest)
|
|
304
|
+
specs = manifest.get("modules") if isinstance(manifest.get("modules"), dict) else {}
|
|
305
|
+
root_files = list(manifest.get("rootFiles", []))
|
|
306
|
+
workspaces = list(manifest.get("workspaces", []))
|
|
307
|
+
for name in modules:
|
|
308
|
+
spec = specs.get(name) or {}
|
|
309
|
+
root_files.extend(spec.get("rootFiles", []))
|
|
310
|
+
workspaces.extend(spec.get("workspaces", []))
|
|
311
|
+
result["rootFiles"] = root_files
|
|
312
|
+
result["workspaces"] = workspaces
|
|
313
|
+
return result
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def resolve_modules(manifest: dict[str, object], requested: list[str], previous: list[str]) -> list[str]:
|
|
317
|
+
available = module_names(manifest)
|
|
318
|
+
if requested and not available:
|
|
319
|
+
# A release without the module split installs everything already.
|
|
320
|
+
return []
|
|
321
|
+
unknown = sorted(set(requested) - set(available))
|
|
322
|
+
if unknown:
|
|
323
|
+
raise RuntimeError(f"unknown MaggieDash module(s): {', '.join(unknown)}; this release offers: {', '.join(available) or 'none'}")
|
|
324
|
+
# Re-running install keeps refreshing modules that were added before.
|
|
325
|
+
return sorted((set(requested) | set(previous)) & set(available))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def installed_state(root: Path) -> dict[str, object]:
|
|
329
|
+
try:
|
|
330
|
+
value = json.loads((root / ".maggie" / "dash-install.json").read_text(encoding="utf-8"))
|
|
331
|
+
except (OSError, json.JSONDecodeError):
|
|
332
|
+
return {}
|
|
333
|
+
return value if isinstance(value, dict) else {}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def package_dependencies(root: Path) -> set[str]:
|
|
337
|
+
try:
|
|
338
|
+
value = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
339
|
+
except (OSError, json.JSONDecodeError):
|
|
340
|
+
return set()
|
|
341
|
+
return set(value.get("dependencies") or {}) | set(value.get("devDependencies") or {})
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
SERVICE_WORDS = re.compile(r"\b(book (?:now|an? (?:appointment|session|treatment|table))|appointments?|treatments?|reservations?|book online|opening hours)\b", re.I)
|
|
345
|
+
LOCALE_DIR = re.compile(r"^(?:[a-z]{2}(?:-[a-z]{2,4})?)$")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def analyse_host(root: Path, framework: str | None) -> dict[str, object]:
|
|
349
|
+
"""What the host already has, read from files only (nothing is executed)."""
|
|
350
|
+
deps = package_dependencies(root)
|
|
351
|
+
auth = sorted(name for name in deps if name in {"firebase", "firebase-admin", "next-auth", "lucia", "better-auth", "@supabase/supabase-js", "@supabase/ssr", "auth-astro"} or name.startswith(("@clerk/", "@auth/")))
|
|
352
|
+
database = sorted(name for name in deps if name in {"pg", "postgres", "@neondatabase/serverless", "drizzle-orm", "prisma", "@prisma/client", "mysql2", "better-sqlite3", "@libsql/client", "kysely"})
|
|
353
|
+
for config in ("wrangler.json", "wrangler.jsonc", "wrangler.toml"):
|
|
354
|
+
path = root / config
|
|
355
|
+
if path.is_file() and "d1_databases" in path.read_text(encoding="utf-8", errors="replace"):
|
|
356
|
+
database.append("cloudflare-d1")
|
|
357
|
+
payments = sorted(name for name in deps if name in {"stripe", "@stripe/stripe-js", "@paypal/checkout-server-sdk", "@lemonsqueezy/lemonsqueezy.js"})
|
|
358
|
+
source = root / "src"
|
|
359
|
+
service_hits: list[str] = []
|
|
360
|
+
if source.is_dir():
|
|
361
|
+
for path in sorted(source.rglob("*")):
|
|
362
|
+
if len(service_hits) >= 5:
|
|
363
|
+
break
|
|
364
|
+
if path.is_file() and path.suffix in {".astro", ".md", ".mdx", ".tsx", ".jsx", ".ts", ".json", ".html", ".vue", ".svelte"} and "_maggie" not in path.parts and "maggie" not in path.parts:
|
|
365
|
+
try:
|
|
366
|
+
if SERVICE_WORDS.search(path.read_text(encoding="utf-8", errors="replace")[:200_000]):
|
|
367
|
+
service_hits.append(str(path.relative_to(root)))
|
|
368
|
+
except OSError:
|
|
369
|
+
continue
|
|
370
|
+
blog = any((root / candidate).exists() for candidate in ("src/content/blog", "src/content/posts", "src/pages/blog", "src/pages/blog.astro", "src/pages/posts", "content/blog", "app/blog"))
|
|
371
|
+
locales: set[str] = set()
|
|
372
|
+
for pages in (root / "src" / "pages", root / "app"):
|
|
373
|
+
if pages.is_dir():
|
|
374
|
+
locales.update(child.name for child in pages.iterdir() if child.is_dir() and LOCALE_DIR.match(child.name) and child.name not in {"api", "og"})
|
|
375
|
+
astro_config = next((path for path in root.glob("astro.config.*")), None)
|
|
376
|
+
if astro_config:
|
|
377
|
+
match = re.search(r"locales\s*:\s*\[([^\]]+)\]", astro_config.read_text(encoding="utf-8", errors="replace"))
|
|
378
|
+
if match:
|
|
379
|
+
locales.update(re.findall(r"[\"']([A-Za-z-]+)[\"']", match.group(1)))
|
|
380
|
+
return {
|
|
381
|
+
"framework": framework,
|
|
382
|
+
"auth": auth,
|
|
383
|
+
"database": sorted(set(database)),
|
|
384
|
+
"payments": payments,
|
|
385
|
+
"sellsServices": bool(service_hits),
|
|
386
|
+
"serviceEvidence": service_hits,
|
|
387
|
+
"blog": blog,
|
|
388
|
+
"locales": sorted(locales),
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def recommendations(analysis: dict[str, object], available: list[str], installed: list[str]) -> list[dict[str, str]]:
|
|
393
|
+
result: list[dict[str, str]] = []
|
|
394
|
+
if "booking" in available and "booking" not in installed:
|
|
395
|
+
if analysis.get("sellsServices"):
|
|
396
|
+
notes = []
|
|
397
|
+
if not any(name in {"pg", "postgres", "@neondatabase/serverless"} for name in analysis.get("database", [])):
|
|
398
|
+
notes.append("it needs PostgreSQL, which this project does not use yet")
|
|
399
|
+
if analysis.get("auth"):
|
|
400
|
+
notes.append(f"the site already signs people in with {', '.join(analysis['auth'])}; MaggieDash keeps its own staff sign-in")
|
|
401
|
+
reason = "The site offers appointments or services (" + ", ".join(analysis.get("serviceEvidence", [])[:3]) + ")" + ("; " + "; ".join(notes) if notes else "")
|
|
402
|
+
result.append({"kind": "module", "module": "booking", "recommended": "yes", "reason": reason, "command": "maggie dash add booking --confirm"})
|
|
403
|
+
else:
|
|
404
|
+
result.append({"kind": "module", "module": "booking", "recommended": "no", "reason": "No appointments or bookable services found in src/; add it only if the business takes bookings.", "command": "maggie dash add booking --confirm"})
|
|
405
|
+
if analysis.get("blog"):
|
|
406
|
+
result.append({"kind": "skill", "skill": "maggie-blog", "recommended": "yes", "reason": "The site has a blog; manage posts from the dashboard Blog panel.", "command": "use the maggie-blog skill"})
|
|
407
|
+
result.append({"kind": "skill", "skill": "maggie-social-share", "recommended": "maybe", "reason": "Blog posts can be shared to social channels after publishing.", "command": "use the maggie-social-share skill"})
|
|
408
|
+
else:
|
|
409
|
+
result.append({"kind": "skill", "skill": "maggie-blog-bootstrap", "recommended": "maybe", "reason": "No blog found; add one only if the site will publish articles.", "command": "use the maggie-blog-bootstrap skill"})
|
|
410
|
+
if len(analysis.get("locales", [])) > 1:
|
|
411
|
+
result.append({"kind": "skill", "skill": "maggie-content-localization", "recommended": "yes", "reason": f"The site serves {', '.join(analysis['locales'])}; keep translations in the Languages panel.", "command": "use the maggie-content-localization skill"})
|
|
412
|
+
return result
|
|
413
|
+
|
|
414
|
+
|
|
291
415
|
def command_install_diff(source: Path, manifest: dict[str, object], target: Path, compare_dir: Path | None, force: bool, host_pairs: list[tuple[Path, Path, Path]] | None = None, host_framework: str | None = None) -> dict[str, object]:
|
|
292
416
|
pairs = manifest_file_pairs(source, manifest, target)
|
|
293
417
|
root_pairs = manifest_root_file_pairs(source, manifest, target.parents[1])
|
|
@@ -386,7 +510,16 @@ def command_install(args: argparse.Namespace) -> int:
|
|
|
386
510
|
raise RuntimeError("unsupported MaggieDash distribution schema")
|
|
387
511
|
if manifest.get("target") != "_maggie/admin" and args.target == "_maggie/admin":
|
|
388
512
|
raise RuntimeError("MaggieDash manifest target is not ./_maggie/admin")
|
|
513
|
+
previous = installed_state(root)
|
|
514
|
+
legacy = not module_names(manifest)
|
|
515
|
+
modules = resolve_modules(manifest, list(getattr(args, "with_modules", None) or []) + (module_names(manifest) if getattr(args, "all_modules", False) else []), [name for name in previous.get("modules", []) if name != CORE_MODULE])
|
|
516
|
+
module_specs = manifest.get("modules") if isinstance(manifest.get("modules"), dict) else {}
|
|
517
|
+
manifest = effective_manifest(manifest, modules)
|
|
389
518
|
host_pairs = manifest_host_file_pairs(source, manifest, root, host_framework)
|
|
519
|
+
for name in modules:
|
|
520
|
+
host_pairs += manifest_host_file_pairs(source, module_specs[name], root, host_framework, required=False)
|
|
521
|
+
analysis = analyse_host(root, host_framework)
|
|
522
|
+
advice = recommendations(analysis, module_names({"modules": module_specs}), modules)
|
|
390
523
|
compare_dir = None
|
|
391
524
|
if args.existing_dir:
|
|
392
525
|
compare_dir = Path(args.existing_dir).expanduser()
|
|
@@ -394,7 +527,9 @@ def command_install(args: argparse.Namespace) -> int:
|
|
|
394
527
|
compare_dir = root / compare_dir
|
|
395
528
|
compare_dir = compare_dir.resolve()
|
|
396
529
|
if args.dry_run or args.diff:
|
|
397
|
-
|
|
530
|
+
plan = command_install_diff(source, manifest, target, compare_dir, args.force, host_pairs, host_framework)
|
|
531
|
+
plan.update({"modules": [CORE_MODULE, *modules] if not legacy else ["all (release without modules)"], "availableModules": module_names({"modules": module_specs}), "host": analysis, "recommendations": advice})
|
|
532
|
+
emit(plan)
|
|
398
533
|
return 0
|
|
399
534
|
installed: list[str] = []
|
|
400
535
|
preserved: list[str] = []
|
|
@@ -471,18 +606,39 @@ def command_install(args: argparse.Namespace) -> int:
|
|
|
471
606
|
"hostPreserved": sorted(set(host_preserved)),
|
|
472
607
|
"hostUpdated": sorted(set(host_updated)),
|
|
473
608
|
"hostWarnings": sorted(set(host_warnings)),
|
|
609
|
+
"modules": [CORE_MODULE, *modules] if not legacy else ["legacy-all"],
|
|
474
610
|
"updatedAt": utc_now(),
|
|
475
611
|
}
|
|
476
612
|
state_path = root / ".maggie" / "dash-install.json"
|
|
477
613
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
478
614
|
state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
479
|
-
|
|
615
|
+
notices = ["this MaggieDash release has no module split, so its Booking workspace was installed with the core; use MaggieDash 0.2.14 or later to install the core first"] if legacy and manifest.get("workspaces") else []
|
|
616
|
+
emit({"status": "needs-attention" if host_warnings else "installed", "version": manifest.get("version"), "modules": state["modules"], "notices": notices, "recommendations": advice, "next": "Add a recommended module with `maggie dash add <module> --confirm`; nothing optional is installed without that step." if advice and not legacy else None, "target": str(target), "hostFramework": host_framework, "hostInstalled": len(set(host_installed)), "hostPreserved": len(set(host_preserved)), "hostUpdated": len(set(host_updated)), "hostWarnings": host_warnings, "installed": len(set(installed)), "preserved": len(set(preserved)), "state": str(state_path)})
|
|
480
617
|
return 1 if host_warnings else 0
|
|
481
618
|
finally:
|
|
482
619
|
if temporary is not None:
|
|
483
620
|
temporary.cleanup()
|
|
484
621
|
|
|
485
622
|
|
|
623
|
+
def command_add(args: argparse.Namespace) -> int:
|
|
624
|
+
"""Install one optional module next to an existing (or new) core install."""
|
|
625
|
+
args.with_modules = list(args.modules)
|
|
626
|
+
args.all_modules = False
|
|
627
|
+
return command_install(args)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def command_recommend(args: argparse.Namespace) -> int:
|
|
631
|
+
root = project_root(args)
|
|
632
|
+
framework = detect_host_framework(root, args.host)
|
|
633
|
+
state = installed_state(root)
|
|
634
|
+
installed = [name for name in state.get("modules", []) if name != CORE_MODULE]
|
|
635
|
+
analysis = analyse_host(root, framework)
|
|
636
|
+
# The module list comes from the installed release when known; Booking is
|
|
637
|
+
# the optional code module MaggieDash ships today.
|
|
638
|
+
emit({"status": "recommendations", "installed": state.get("modules", []), "host": analysis, "recommendations": recommendations(analysis, ["booking"], installed)})
|
|
639
|
+
return 0
|
|
640
|
+
|
|
641
|
+
|
|
486
642
|
def command_init(args: argparse.Namespace) -> int:
|
|
487
643
|
require_confirm(args)
|
|
488
644
|
root = project_root(args)
|
|
@@ -832,8 +988,22 @@ def parser() -> argparse.ArgumentParser:
|
|
|
832
988
|
install.add_argument("--dry-run", action="store_true", help="show the install plan without writing files")
|
|
833
989
|
install.add_argument("--diff", action="store_true", help="show the install plan and compare with an existing workspace")
|
|
834
990
|
install.add_argument("--existing-dir", help="existing dashboard source directory to compare during --diff")
|
|
991
|
+
install.add_argument("--with", dest="with_modules", action="append", default=[], metavar="MODULE", help="also install an optional module (repeatable), e.g. --with booking")
|
|
992
|
+
install.add_argument("--all-modules", action="store_true", help="install every optional module (the pre-0.2.14 behaviour)")
|
|
835
993
|
install.add_argument("--confirm", action="store_true")
|
|
836
994
|
install.set_defaults(func=command_install)
|
|
995
|
+
add = sub.add_parser("add", help="add an optional MaggieDash module (e.g. booking) to an installed core")
|
|
996
|
+
add.add_argument("modules", nargs="+", metavar="MODULE")
|
|
997
|
+
for flag, options in (("--project", {"default": "."}), ("--source", {"help": "local checkout or Git URL; defaults to MaggieDash repository"}), ("--ref", {"default": "main"}), ("--target", {"default": "_maggie/admin"}), ("--existing-dir", {})):
|
|
998
|
+
add.add_argument(flag, **options)
|
|
999
|
+
add.add_argument("--host", choices=["auto", "astro", "none"], default="auto")
|
|
1000
|
+
for flag in ("--force", "--dry-run", "--diff", "--confirm"):
|
|
1001
|
+
add.add_argument(flag, action="store_true")
|
|
1002
|
+
add.set_defaults(func=command_add)
|
|
1003
|
+
recommend = sub.add_parser("recommend", help="analyse the host project and recommend optional modules and skills")
|
|
1004
|
+
recommend.add_argument("--project", default=".")
|
|
1005
|
+
recommend.add_argument("--host", choices=["auto", "astro", "none"], default="auto")
|
|
1006
|
+
recommend.set_defaults(func=command_recommend)
|
|
837
1007
|
status = sub.add_parser("status", help="inspect project and documents")
|
|
838
1008
|
status.add_argument("--project", default=".")
|
|
839
1009
|
status.add_argument("--project-id")
|