@topy-ai/maggie 0.7.49 → 0.7.51

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 (33) hide show
  1. package/README-zh-TW.md +3 -3
  2. package/README.md +58 -8
  3. package/bin/maggie.js +33 -14
  4. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +19 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +50 -0
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +29 -0
  7. package/bundled-contracts/maggie-design/mobile-app-evidence-v1.schema.json +54 -0
  8. package/bundled-contracts/maggie-design/mobile-app-surface-v1.schema.json +137 -16
  9. package/bundled-skills/catalog.json +8 -0
  10. package/bundled-skills/maggie-assistant-tools/SKILL.md +93 -0
  11. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +54 -0
  12. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +56 -0
  13. package/bundled-skills/maggie-assistant-tools/references/product-release.md +57 -0
  14. package/bundled-skills/maggie-deployment/SKILL.md +13 -0
  15. package/bundled-skills/maggie-design/SKILL.md +55 -21
  16. package/bundled-skills/maggie-design/references/app-ui-patterns.md +66 -0
  17. package/bundled-skills/maggie-feedback/SKILL.md +20 -3
  18. package/bundled-skills/maggie-marketplace/SKILL.md +28 -0
  19. package/bundled-skills/maggie-mobile-app/SKILL.md +122 -0
  20. package/bundled-skills/maggie-qa-workflow/SKILL.md +30 -0
  21. package/bundled-tools/clis/maggie_app_collect.py +236 -0
  22. package/bundled-tools/clis/maggie_assistant_tools.py +302 -0
  23. package/bundled-tools/clis/maggie_bridge_host.py +212 -0
  24. package/bundled-tools/clis/maggie_design.py +190 -29
  25. package/bundled-tools/clis/maggie_feedback.py +89 -37
  26. package/bundled-tools/clis/maggie_mobile.py +221 -0
  27. package/bundled-tools/clis/maggie_product_release.py +303 -0
  28. package/bundled-tools/clis/maggie_qa_app_preflight.py +248 -0
  29. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  30. package/bundled-tools/clis/maggie_skill_catalog.py +262 -0
  31. package/bundled-tools/runtime/assistant_approval.py +111 -0
  32. package/bundled-tools/runtime/cdp_client.py +246 -0
  33. package/package.json +1 -1
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env python3
2
+ """Skill catalog tooling for any Agent Skills collection (``maggie skills``).
3
+
4
+ Maggie validates its own skills with ``scripts/validate_skills.py``; a product
5
+ that ships its own catalog (tens or hundreds of skills) needs the same checks
6
+ plus packaging for Claude Code plugin marketplaces:
7
+
8
+ - ``scaffold``: a new ``<root>/<name>/SKILL.md`` with valid front matter;
9
+ - ``validate``: front matter (``name`` = folder, lower-case, at most 64
10
+ characters; ``description`` at most 1024), duplicate names, unfinished
11
+ placeholders, relative links, optional pinned references (``references``
12
+ entries with a 40-character commit) and an optional catalog index;
13
+ - ``package``: category plugins plus one all-in-one plugin, each with
14
+ ``.claude-plugin/plugin.json``, and a ``.claude-plugin/marketplace.json``;
15
+ - ``smoke``: re-validates a packaged marketplace (names, versions, sources,
16
+ every packaged skill) and runs ``claude plugin validate`` when available.
17
+
18
+ Skill bodies are copied as-is; nothing is published.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
32
+ LINK_RE = re.compile(r"\]\(([^)#\s]+)(?:#[^)]*)?\)")
33
+ PLACEHOLDER_RE = re.compile(r"\bTODO\b|replace-me|lorem ipsum", re.I)
34
+ COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
35
+ IGNORED = {".git", "node_modules", "dist", "build"}
36
+
37
+
38
+ def parse_front_matter(text: str) -> tuple[dict, str]:
39
+ """Parse a small YAML front matter subset: scalars, inline lists, one nesting level."""
40
+ if not text.startswith("---\n"):
41
+ raise ValueError("missing YAML front matter")
42
+ end = text.find("\n---", 4)
43
+ if end < 0:
44
+ raise ValueError("front matter is not closed")
45
+ data: dict = {}
46
+ current: str | None = None
47
+ for raw in text[4:end].splitlines():
48
+ if not raw.strip() or raw.lstrip().startswith("#"):
49
+ continue
50
+ if raw.startswith((" ", "\t")) and current:
51
+ stripped = raw.strip()
52
+ if stripped.startswith("- "):
53
+ data.setdefault(current, [])
54
+ if isinstance(data[current], list):
55
+ data[current].append(_scalar(stripped[2:]))
56
+ elif ":" in stripped:
57
+ key, _, value = stripped.partition(":")
58
+ if not isinstance(data.get(current), dict):
59
+ data[current] = {}
60
+ data[current][key.strip()] = _scalar(value)
61
+ continue
62
+ key, sep, value = raw.partition(":")
63
+ if not sep:
64
+ raise ValueError(f"invalid front matter line: {raw[:60]}")
65
+ current = key.strip()
66
+ data[current] = _scalar(value) if value.strip() else None
67
+ return data, text[end + 4:]
68
+
69
+
70
+ def _scalar(value: str) -> object:
71
+ value = value.strip()
72
+ if value.startswith("[") and value.endswith("]"):
73
+ return [_scalar(item) for item in value[1:-1].split(",") if item.strip()]
74
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
75
+ return value[1:-1]
76
+ if value in {"true", "false"}:
77
+ return value == "true"
78
+ return value
79
+
80
+
81
+ def find_skills(root: Path) -> list[Path]:
82
+ return sorted(path for path in root.rglob("SKILL.md") if not any(part in IGNORED for part in path.relative_to(root).parts))
83
+
84
+
85
+ def validate(root: Path, require_pinned: bool, catalog: Path | None) -> dict:
86
+ root = root.resolve()
87
+ errors: list[str] = []
88
+ warnings: list[str] = []
89
+ seen: dict[str, str] = {}
90
+ skills = find_skills(root)
91
+ if not skills:
92
+ errors.append("no SKILL.md files found")
93
+ records = []
94
+ for path in skills:
95
+ rel = str(path.relative_to(root))
96
+ text = path.read_text(encoding="utf-8")
97
+ try:
98
+ meta, body = parse_front_matter(text)
99
+ except ValueError as error:
100
+ errors.append(f"{rel}: {error}"); continue
101
+ name = str(meta.get("name") or "")
102
+ description = str(meta.get("description") or "")
103
+ if not NAME_RE.match(name) or len(name) > 64:
104
+ errors.append(f"{rel}: name must be lower-case letters, digits and hyphens, at most 64 characters")
105
+ if name != path.parent.name:
106
+ errors.append(f"{rel}: name {name!r} must equal its folder {path.parent.name!r}")
107
+ if name in seen:
108
+ errors.append(f"{rel}: duplicate name {name!r} (also {seen[name]})")
109
+ seen[name] = rel
110
+ if not description.strip():
111
+ errors.append(f"{rel}: description is required")
112
+ elif len(description) > 1024:
113
+ errors.append(f"{rel}: description is {len(description)} characters; the limit is 1024")
114
+ elif len(description) < 40:
115
+ warnings.append(f"{rel}: description is very short; say what it does and when to use it")
116
+ if re.search(r"<[^>]+>", description):
117
+ errors.append(f"{rel}: description must not contain XML/HTML tags")
118
+ if PLACEHOLDER_RE.search(text):
119
+ errors.append(f"{rel}: unfinished placeholder")
120
+ for target in LINK_RE.findall(body):
121
+ if target.startswith(("http://", "https://", "mailto:")):
122
+ continue
123
+ if not (path.parent / target).resolve().exists():
124
+ errors.append(f"{rel}: broken link {target}")
125
+ references = meta.get("references")
126
+ if require_pinned:
127
+ for item in references if isinstance(references, list) else []:
128
+ commit = item.get("commit") if isinstance(item, dict) else None
129
+ if not commit or not COMMIT_RE.match(str(commit)):
130
+ errors.append(f"{rel}: reference is not pinned to a 40-character commit")
131
+ if len(body.splitlines()) > 500:
132
+ warnings.append(f"{rel}: body is over 500 lines; move detail into references/ files")
133
+ records.append({"name": name, "path": rel, "category": (meta.get("metadata") or {}).get("category") if isinstance(meta.get("metadata"), dict) else meta.get("category"), "version": (meta.get("metadata") or {}).get("version") if isinstance(meta.get("metadata"), dict) else meta.get("version")})
134
+ if catalog:
135
+ index = json.loads(catalog.read_text(encoding="utf-8"))
136
+ entries = index.get("skills", index if isinstance(index, list) else [])
137
+ listed = {str(item.get("id") or item.get("name")) for item in entries if isinstance(item, dict)}
138
+ for name in sorted(set(seen) - listed):
139
+ errors.append(f"catalog: {name} has a SKILL.md but no catalog entry")
140
+ for name in sorted(listed - set(seen)):
141
+ errors.append(f"catalog: {name} is listed but has no SKILL.md")
142
+ return {"schemaVersion": "maggie-skill-catalog-validation.v1", "passed": not errors, "skills": len(records), "records": records, "errors": errors, "warnings": warnings}
143
+
144
+
145
+ def scaffold(root: Path, name: str, description: str, category: str | None) -> Path:
146
+ if not NAME_RE.match(name) or len(name) > 64:
147
+ raise ValueError("name must be lower-case letters, digits and hyphens, at most 64 characters")
148
+ if not description.strip() or len(description) > 1024:
149
+ raise ValueError("description is required and at most 1024 characters")
150
+ target = root / (category or "") / name / "SKILL.md"
151
+ if target.exists():
152
+ raise ValueError(f"{target} already exists")
153
+ target.parent.mkdir(parents=True)
154
+ meta = f"---\nname: {name}\ndescription: {json.dumps(description, ensure_ascii=False)}\n"
155
+ if category:
156
+ meta += f"metadata:\n category: {category}\n version: 0.1.0\n"
157
+ title = name.replace("-", " ").title()
158
+ target.write_text(meta + f"---\n\n# {title}\n\n## When to use\n\n## Inputs\n\n## Steps\n\n## Output\n\n## Safety and limits\n", encoding="utf-8")
159
+ return target
160
+
161
+
162
+ def package(root: Path, out: Path, prefix: str, owner: str, version: str, all_name: str | None, force: bool) -> dict:
163
+ report = validate(root, False, None)
164
+ if not report["passed"]:
165
+ raise ValueError("fix validation errors before packaging: " + "; ".join(report["errors"][:5]))
166
+ if not NAME_RE.match(prefix):
167
+ raise ValueError("--prefix must be lower-case kebab-case")
168
+ if out.exists():
169
+ if not force:
170
+ raise ValueError(f"{out} exists; pass --force to replace it")
171
+ shutil.rmtree(out)
172
+ root = root.resolve()
173
+ groups: dict[str, list[dict]] = {}
174
+ for record in report["records"]:
175
+ category = record["category"] or (Path(record["path"]).parts[0] if len(Path(record["path"]).parts) > 2 else "core")
176
+ groups.setdefault(re.sub(r"[^a-z0-9-]+", "-", str(category).lower()).strip("-") or "core", []).append(record)
177
+ plugins = []
178
+
179
+ def write_plugin(plugin_name: str, records: list[dict], description: str) -> None:
180
+ folder = out / "plugins" / plugin_name
181
+ (folder / ".claude-plugin").mkdir(parents=True)
182
+ for record in records:
183
+ shutil.copytree((root / record["path"]).parent, folder / "skills" / record["name"])
184
+ (folder / ".claude-plugin" / "plugin.json").write_text(json.dumps({"name": plugin_name, "description": description, "version": version, "author": {"name": owner}}, indent=2) + "\n", encoding="utf-8")
185
+ plugins.append({"name": plugin_name, "source": f"./plugins/{plugin_name}", "description": description, "version": version, "skills": len(records)})
186
+
187
+ for category, records in sorted(groups.items()):
188
+ write_plugin(f"{prefix}-{category}", records, f"{len(records)} {category} skills from {owner}")
189
+ if all_name is not False:
190
+ write_plugin(all_name or f"{prefix}-all", report["records"], f"All {len(report['records'])} skills from {owner} in one plugin")
191
+ marketplace = {"name": prefix, "owner": {"name": owner}, "description": f"{report['skills']} skills from {owner}, by category or all in one", "plugins": [{key: value for key, value in plugin.items() if key != "skills"} for plugin in plugins]}
192
+ (out / ".claude-plugin").mkdir(parents=True, exist_ok=True)
193
+ (out / ".claude-plugin" / "marketplace.json").write_text(json.dumps(marketplace, indent=2) + "\n", encoding="utf-8")
194
+ return {"schemaVersion": "maggie-skill-catalog-package.v1", "passed": True, "output": str(out), "plugins": plugins, "skills": report["skills"], "published": False}
195
+
196
+
197
+ def smoke(out: Path, use_claude: bool) -> dict:
198
+ errors: list[str] = []
199
+ checks: list[dict] = []
200
+ marketplace_path = out / ".claude-plugin" / "marketplace.json"
201
+ if not marketplace_path.is_file():
202
+ raise ValueError("no .claude-plugin/marketplace.json in the output folder")
203
+ marketplace = json.loads(marketplace_path.read_text(encoding="utf-8"))
204
+ if not marketplace.get("name") or not isinstance(marketplace.get("owner"), dict):
205
+ errors.append("marketplace.json needs name and owner")
206
+ names = [plugin.get("name") for plugin in marketplace.get("plugins", [])]
207
+ if len(names) != len(set(names)):
208
+ errors.append("marketplace.json lists a plugin twice")
209
+ for entry in marketplace.get("plugins", []):
210
+ folder = (out / str(entry.get("source", ""))).resolve()
211
+ manifest = folder / ".claude-plugin" / "plugin.json"
212
+ if not manifest.is_file():
213
+ errors.append(f"{entry.get('name')}: source {entry.get('source')} has no .claude-plugin/plugin.json"); continue
214
+ plugin = json.loads(manifest.read_text(encoding="utf-8"))
215
+ if plugin.get("name") != entry.get("name"):
216
+ errors.append(f"{entry.get('name')}: plugin.json name is {plugin.get('name')!r}")
217
+ if entry.get("version") and plugin.get("version") != entry.get("version"):
218
+ errors.append(f"{entry.get('name')}: plugin.json version {plugin.get('version')} differs from marketplace {entry.get('version')}")
219
+ packaged = validate(folder / "skills", False, None) if (folder / "skills").is_dir() else {"passed": False, "errors": ["no skills folder"], "skills": 0}
220
+ if not packaged["passed"]:
221
+ errors.extend(f"{entry.get('name')}: {error}" for error in packaged["errors"][:5])
222
+ checks.append({"plugin": entry.get("name"), "skills": packaged["skills"]})
223
+ claude = shutil.which("claude") if use_claude else None
224
+ claude_result = None
225
+ if use_claude and not claude:
226
+ errors.append("--claude was requested but the claude CLI is not on PATH")
227
+ elif claude:
228
+ run = subprocess.run([claude, "plugin", "validate", str(out)], capture_output=True, text=True, check=False, timeout=120)
229
+ claude_result = {"exitCode": run.returncode, "output": (run.stdout + run.stderr)[-600:]}
230
+ if run.returncode:
231
+ errors.append("claude plugin validate failed")
232
+ return {"schemaVersion": "maggie-skill-catalog-smoke.v1", "passed": not errors, "plugins": checks, "claudeValidate": claude_result, "errors": errors}
233
+
234
+
235
+ def main(argv: list[str] | None = None) -> int:
236
+ parser = argparse.ArgumentParser(prog="maggie skills", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
237
+ sub = parser.add_subparsers(dest="command", required=True)
238
+ 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
+ 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")
241
+ 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
+ args = parser.parse_args(argv)
243
+ try:
244
+ if args.command == "scaffold":
245
+ path = scaffold(args.root, args.name, args.description, args.category)
246
+ report = {"schemaVersion": "maggie-skill-scaffold.v1", "passed": True, "created": str(path)}
247
+ elif args.command == "validate":
248
+ report = validate(args.root, args.require_pinned, args.catalog)
249
+ report.pop("records", None)
250
+ 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)
252
+ else:
253
+ report = smoke(args.out, args.claude)
254
+ except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
255
+ print(f"BLOCKED: maggie skills {args.command}: {error}", file=sys.stderr)
256
+ return 2
257
+ print(json.dumps(report, indent=2, ensure_ascii=False))
258
+ return 0 if report["passed"] else 1
259
+
260
+
261
+ if __name__ == "__main__":
262
+ raise SystemExit(main())
@@ -0,0 +1,111 @@
1
+ """Reference implementation of signed one-time approval tokens for assistant writes.
2
+
3
+ An assistant (in-app agent, MCP server, ChatGPT/Claude app) may only run a
4
+ write after the person approves the exact call. The token binds:
5
+
6
+ - ``id``: a random approval ID (also the audit ``actionId`` and the root of
7
+ each write's idempotency key);
8
+ - ``calls``: the exact tool names and canonical arguments that were shown;
9
+ - ``principal``: a hash of the caller identity (never the raw key);
10
+ - ``scope``: ``account`` or the workspace/site the call acts on;
11
+ - ``expires``: a short TTL (10 minutes by default).
12
+
13
+ Format: ``base64url(canonical JSON) + "." + base64url(HMAC-SHA256)``.
14
+
15
+ Single use is enforced by the server, not the client: the in-app agent stores
16
+ the token with the model turn that proposed the write, the client answers
17
+ only yes/no, and the server consumes the stored token on the first approval.
18
+ A client that echoes ``{approved: true}`` without a token must still work —
19
+ returning the token to the client and expecting it back is the bug that made
20
+ every approval look "expired".
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import hashlib
27
+ import hmac
28
+ import json
29
+ import secrets
30
+ import time
31
+
32
+ DEFAULT_TTL_SECONDS = 600
33
+
34
+
35
+ class ApprovalError(ValueError):
36
+ """Raised when a token is malformed, tampered, expired, reused or mismatched."""
37
+
38
+
39
+ def canonical(value: object) -> str:
40
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
41
+
42
+
43
+ def _b64(data: bytes) -> str:
44
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
45
+
46
+
47
+ def _unb64(text: str) -> bytes:
48
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
49
+
50
+
51
+ def principal_hash(identity: str) -> str:
52
+ return hashlib.sha256(("maggie-principal:" + identity).encode("utf-8")).hexdigest()
53
+
54
+
55
+ def issue(secret: bytes, identity: str, scope: str, calls: list[dict], ttl: int = DEFAULT_TTL_SECONDS, now: float | None = None) -> tuple[str, dict]:
56
+ if not calls or not all(isinstance(call, dict) and call.get("name") for call in calls):
57
+ raise ApprovalError("an approval needs at least one named call")
58
+ approval = {
59
+ "id": secrets.token_hex(16),
60
+ "calls": [{"name": call["name"], "args": call.get("args", {})} for call in calls],
61
+ "principal": principal_hash(identity),
62
+ "scope": scope,
63
+ "expires": int((now if now is not None else time.time()) + ttl),
64
+ }
65
+ body = canonical(approval).encode("utf-8")
66
+ signature = hmac.new(secret, body, hashlib.sha256).digest()
67
+ return _b64(body) + "." + _b64(signature), approval
68
+
69
+
70
+ def verify(secret: bytes, token: str, identity: str, scope: str, calls: list[dict], now: float | None = None) -> dict:
71
+ """Check signature, expiry, principal, scope and the exact calls; does not consume."""
72
+ try:
73
+ body_text, signature_text = token.split(".", 1)
74
+ body, signature = _unb64(body_text), _unb64(signature_text)
75
+ except (ValueError, TypeError) as error:
76
+ raise ApprovalError("approval token is malformed") from error
77
+ if not hmac.compare_digest(hmac.new(secret, body, hashlib.sha256).digest(), signature):
78
+ raise ApprovalError("approval token signature does not match")
79
+ approval = json.loads(body)
80
+ if approval.get("expires", 0) < (now if now is not None else time.time()):
81
+ raise ApprovalError("approval expired; ask again")
82
+ if not hmac.compare_digest(approval.get("principal", ""), principal_hash(identity)):
83
+ raise ApprovalError("approval belongs to another caller")
84
+ if approval.get("scope") != scope:
85
+ raise ApprovalError("approval was issued for another scope")
86
+ expected = [{"name": call["name"], "args": call.get("args", {})} for call in calls]
87
+ if canonical(approval.get("calls")) != canonical(expected):
88
+ raise ApprovalError("approved calls differ from the calls being run")
89
+ return approval
90
+
91
+
92
+ def idempotency_key(approval_id: str, call: dict) -> str:
93
+ return hashlib.sha256(f"{approval_id}:{canonical(call)}".encode("utf-8")).hexdigest()[:48]
94
+
95
+
96
+ class TurnStore:
97
+ """In-memory model of the server-side store: the token lives with the model turn."""
98
+
99
+ def __init__(self) -> None:
100
+ self.turns: dict[str, str] = {}
101
+
102
+ def propose(self, turn_id: str, token: str) -> None:
103
+ self.turns[turn_id] = token
104
+
105
+ def consume(self, turn_id: str, client_token: str | None = None) -> str:
106
+ """Return the token to verify (client-supplied or stored) and delete it: single use."""
107
+ stored = self.turns.pop(turn_id, None)
108
+ token = client_token or stored
109
+ if not token:
110
+ raise ApprovalError("no pending approval for this turn (already used or never proposed)")
111
+ return token
@@ -0,0 +1,246 @@
1
+ """Minimal Chrome DevTools Protocol client using only the standard library.
2
+
3
+ Maggie collectors need a real browser session (signed-in app routes, real
4
+ viewport screenshots) without adding Playwright or a websocket dependency.
5
+ This module implements just enough of RFC 6455 (client text frames, masked;
6
+ server frames with fragmentation, ping/pong and close) and the flattened CDP
7
+ session protocol to drive one tab.
8
+
9
+ Two ways to obtain a browser:
10
+
11
+ - ``attach(cdp_url)``: connect to an already running Chrome started with
12
+ ``--remote-debugging-port`` (the host's signed-in session);
13
+ - ``launch(chrome, profile_dir)``: start headless Chrome with a dedicated
14
+ profile directory, so a rotated refresh token is persisted between renders.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import json
21
+ import os
22
+ import socket
23
+ import struct
24
+ import subprocess
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Callable
28
+ from urllib.parse import urlsplit
29
+ from urllib.request import urlopen
30
+
31
+
32
+ class CdpError(RuntimeError):
33
+ """Raised for protocol, transport, or browser start failures."""
34
+
35
+
36
+ class WebSocket:
37
+ """A blocking RFC 6455 client connection carrying text frames."""
38
+
39
+ def __init__(self, url: str, timeout: float = 30.0) -> None:
40
+ parts = urlsplit(url)
41
+ if parts.scheme != "ws":
42
+ raise CdpError("only ws:// DevTools endpoints on this machine are supported")
43
+ host, port = parts.hostname or "127.0.0.1", parts.port or 80
44
+ self.sock = socket.create_connection((host, port), timeout=timeout)
45
+ key = base64.b64encode(os.urandom(16)).decode()
46
+ path = parts.path + (f"?{parts.query}" if parts.query else "")
47
+ request = (
48
+ f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
49
+ f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
50
+ )
51
+ self.sock.sendall(request.encode())
52
+ response = b""
53
+ while b"\r\n\r\n" not in response:
54
+ chunk = self.sock.recv(4096)
55
+ if not chunk:
56
+ raise CdpError("DevTools endpoint closed the connection during the handshake")
57
+ response += chunk
58
+ head, _, self.buffer = response.partition(b"\r\n\r\n")
59
+ if b" 101 " not in head.split(b"\r\n", 1)[0]:
60
+ raise CdpError("DevTools endpoint refused the websocket upgrade")
61
+
62
+ def _read(self, count: int) -> bytes:
63
+ while len(self.buffer) < count:
64
+ chunk = self.sock.recv(max(65536, count - len(self.buffer)))
65
+ if not chunk:
66
+ raise CdpError("DevTools connection closed")
67
+ self.buffer += chunk
68
+ data, self.buffer = self.buffer[:count], self.buffer[count:]
69
+ return data
70
+
71
+ def send_frame(self, payload: bytes, opcode: int = 1) -> None:
72
+ header = bytes([0x80 | opcode])
73
+ length = len(payload)
74
+ if length < 126:
75
+ header += bytes([0x80 | length])
76
+ elif length < 1 << 16:
77
+ header += bytes([0x80 | 126]) + struct.pack(">H", length)
78
+ else:
79
+ header += bytes([0x80 | 127]) + struct.pack(">Q", length)
80
+ mask = os.urandom(4)
81
+ self.sock.sendall(header + mask + bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)))
82
+
83
+ def send(self, text: str) -> None:
84
+ self.send_frame(text.encode("utf-8"))
85
+
86
+ def recv(self) -> str:
87
+ message = b""
88
+ while True:
89
+ first, second = self._read(2)
90
+ opcode, final = first & 0x0F, bool(first & 0x80)
91
+ length = second & 0x7F
92
+ if length == 126:
93
+ length = struct.unpack(">H", self._read(2))[0]
94
+ elif length == 127:
95
+ length = struct.unpack(">Q", self._read(8))[0]
96
+ mask = self._read(4) if second & 0x80 else b""
97
+ payload = self._read(length)
98
+ if mask:
99
+ payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
100
+ if opcode == 0x9:
101
+ self.send_frame(payload, 0xA)
102
+ continue
103
+ if opcode == 0xA:
104
+ continue
105
+ if opcode == 0x8:
106
+ raise CdpError("DevTools connection closed by the browser")
107
+ message += payload
108
+ if final:
109
+ return message.decode("utf-8")
110
+
111
+ def close(self) -> None:
112
+ try:
113
+ self.send_frame(b"", 0x8)
114
+ except OSError:
115
+ pass
116
+ self.sock.close()
117
+
118
+
119
+ class Browser:
120
+ """A browser-level CDP connection that drives one flattened page session."""
121
+
122
+ def __init__(self, ws_url: str, process: subprocess.Popen | None = None, timeout: float = 60.0) -> None:
123
+ self.ws = WebSocket(ws_url)
124
+ self.process = process
125
+ self.timeout = timeout
126
+ self.next_id = 0
127
+ self.session: str | None = None
128
+ self.target: str | None = None
129
+ self.events: list[dict] = []
130
+ self.responses: dict[int, dict] = {}
131
+ self.handlers: dict[str, Callable[[dict], None]] = {}
132
+
133
+ def _dispatch(self, message: dict) -> None:
134
+ handler = self.handlers.get(message.get("method", ""))
135
+ if handler:
136
+ handler(message.get("params", {}))
137
+ else:
138
+ self.events.append(message)
139
+
140
+ def call(self, method: str, params: dict | None = None, session: bool = True) -> dict:
141
+ self.next_id += 1
142
+ payload = {"id": self.next_id, "method": method, "params": params or {}}
143
+ if session and self.session:
144
+ payload["sessionId"] = self.session
145
+ self.ws.send(json.dumps(payload))
146
+ call_id = self.next_id
147
+ deadline = time.monotonic() + self.timeout
148
+ while time.monotonic() < deadline:
149
+ # A handler may issue its own call while we wait, so responses can arrive out of order.
150
+ message = self.responses.pop(call_id, None) or json.loads(self.ws.recv())
151
+ if message.get("id") == call_id:
152
+ if "error" in message:
153
+ raise CdpError(f"{method} failed: {message['error'].get('message', 'unknown error')}")
154
+ return message.get("result", {})
155
+ if "id" in message:
156
+ self.responses[message["id"]] = message
157
+ continue
158
+ self._dispatch(message)
159
+ raise CdpError(f"{method} timed out")
160
+
161
+ def wait_for(self, method: str, timeout: float) -> dict | None:
162
+ for index, event in enumerate(self.events):
163
+ if event.get("method") == method:
164
+ return self.events.pop(index)
165
+ deadline = time.monotonic() + timeout
166
+ self.ws.sock.settimeout(max(0.1, timeout))
167
+ try:
168
+ while time.monotonic() < deadline:
169
+ try:
170
+ message = json.loads(self.ws.recv())
171
+ except (socket.timeout, TimeoutError):
172
+ return None
173
+ if message.get("method") == method:
174
+ return message
175
+ if "id" in message:
176
+ self.responses[message["id"]] = message
177
+ continue
178
+ self._dispatch(message)
179
+ finally:
180
+ self.ws.sock.settimeout(self.timeout)
181
+ return None
182
+
183
+ def pump(self, seconds: float) -> None:
184
+ """Process events (and handlers) for a fixed time, e.g. while the page settles."""
185
+ deadline = time.monotonic() + seconds
186
+ while time.monotonic() < deadline:
187
+ self.wait_for("__never__", max(0.05, deadline - time.monotonic()))
188
+
189
+ def open_tab(self, url: str = "about:blank") -> None:
190
+ self.target = self.call("Target.createTarget", {"url": url}, session=False)["targetId"]
191
+ self.session = self.call("Target.attachToTarget", {"targetId": self.target, "flatten": True}, session=False)["sessionId"]
192
+
193
+ def evaluate(self, expression: str) -> object:
194
+ result = self.call("Runtime.evaluate", {"expression": expression, "returnByValue": True, "awaitPromise": True})
195
+ if "exceptionDetails" in result:
196
+ raise CdpError("page script failed: " + str(result["exceptionDetails"].get("text", "exception"))[:200])
197
+ return result.get("result", {}).get("value")
198
+
199
+ def close(self) -> None:
200
+ try:
201
+ if self.target:
202
+ self.call("Target.closeTarget", {"targetId": self.target}, session=False)
203
+ except (CdpError, OSError):
204
+ pass
205
+ self.ws.close()
206
+ if self.process:
207
+ self.process.terminate()
208
+ try:
209
+ self.process.wait(timeout=10)
210
+ except subprocess.TimeoutExpired:
211
+ self.process.kill()
212
+
213
+
214
+ def attach(cdp_url: str) -> Browser:
215
+ """Attach to a running Chrome exposing ``--remote-debugging-port`` on this machine."""
216
+ parts = urlsplit(cdp_url)
217
+ if parts.hostname not in {"127.0.0.1", "localhost", "::1"}:
218
+ raise CdpError("--cdp-url must point at a DevTools port on this machine (127.0.0.1 or localhost)")
219
+ with urlopen(cdp_url.rstrip("/") + "/json/version", timeout=10) as response:
220
+ version = json.loads(response.read().decode("utf-8"))
221
+ url = version.get("webSocketDebuggerUrl")
222
+ if not url:
223
+ raise CdpError("DevTools endpoint did not report a browser websocket URL")
224
+ return Browser(url)
225
+
226
+
227
+ def launch(chrome: str, profile_dir: Path, headless: bool = True) -> Browser:
228
+ """Start Chrome with a dedicated profile and connect to its browser target."""
229
+ profile_dir.mkdir(parents=True, exist_ok=True)
230
+ port_file = profile_dir / "DevToolsActivePort"
231
+ port_file.unlink(missing_ok=True)
232
+ args = [chrome, "--remote-debugging-port=0", f"--user-data-dir={profile_dir}", "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--hide-scrollbars"]
233
+ if headless:
234
+ args += ["--headless=new", "--no-sandbox"]
235
+ process = subprocess.Popen(args + ["about:blank"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
236
+ deadline = time.monotonic() + 30
237
+ while time.monotonic() < deadline:
238
+ if port_file.is_file():
239
+ lines = port_file.read_text(encoding="utf-8").splitlines()
240
+ if len(lines) >= 2:
241
+ return Browser(f"ws://127.0.0.1:{lines[0].strip()}{lines[1].strip()}", process)
242
+ if process.poll() is not None:
243
+ break
244
+ time.sleep(0.1)
245
+ process.kill()
246
+ raise CdpError("Chrome did not expose a DevTools port; is another Chrome using this profile directory?")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.49",
3
+ "version": "0.7.51",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",