Download plugins/pinchtab/cli.py from AUXteam/pinch: direct link, hf CLI and curl.
- Browser
- Download file 20.7 kB
-
https://huggingface.co/spaces/AUXteam/pinch/resolve/main/plugins/pinchtab/cli.py
- Command line
-
hf download hf://spaces/AUXteam/pinch/plugins/pinchtab/cli.py
-
curl -L -o cli.py https://huggingface.co/spaces/AUXteam/pinch/resolve/main/plugins/pinchtab/cli.py
20.7 kB
| #!/usr/bin/env python3 | |
| """ | |
| PinchTab SMCP Plugin | |
| Exposes PinchTab's HTTP API as MCP tools for use with SMCP (sanctumos/smcp). | |
| All operations (navigate, snapshot, action, text, screenshot, etc.) are available. | |
| Supports both single-bridge (base_url = instance) and orchestrator (base_url + instance_id). | |
| Copyright (c) 2026 actuallyrizzn. PinchTab (c) pinchtab. | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| import traceback | |
| from typing import Any, Dict, List, Optional | |
| from urllib.error import HTTPError, URLError | |
| from urllib.parse import urlencode | |
| from urllib.request import Request, urlopen | |
| PLUGIN_VERSION = "0.1.0" | |
| DEBUG_TRACEBACKS = False | |
| def _error_response(error: str, error_type: str, include_traceback: bool = False) -> Dict[str, Any]: | |
| out: Dict[str, Any] = { | |
| "status": "error", | |
| "error": error, | |
| "error_type": error_type, | |
| } | |
| if include_traceback and DEBUG_TRACEBACKS: | |
| out["traceback"] = traceback.format_exc() | |
| return out | |
| def _canonical_option_name(action: argparse.Action) -> str: | |
| for opt in action.option_strings: | |
| if opt.startswith("--"): | |
| return opt[2:].replace("_", "-") | |
| return action.dest.replace("_", "-") | |
| def _arg_type_name(action: argparse.Action) -> str: | |
| if isinstance(action, argparse._StoreTrueAction): | |
| return "boolean" | |
| if getattr(action, "type", None) is int: | |
| return "integer" | |
| if getattr(action, "type", None) is float: | |
| return "number" | |
| return "string" | |
| def _describe_action(action: argparse.Action) -> Optional[Dict[str, Any]]: | |
| if action.dest in ("help", "command") or getattr(action, "help", None) == argparse.SUPPRESS: | |
| return None | |
| default = None if action.default is argparse.SUPPRESS else action.default | |
| return { | |
| "name": _canonical_option_name(action), | |
| "type": _arg_type_name(action), | |
| "description": (action.help or "").strip(), | |
| "required": bool(getattr(action, "required", False)), | |
| "default": default, | |
| } | |
| def _get_subparsers_action(parser: argparse.ArgumentParser) -> Optional[argparse._SubParsersAction]: | |
| for a in parser._actions: | |
| if isinstance(a, argparse._SubParsersAction): | |
| return a | |
| return None | |
| def _api_request( | |
| base_url: str, | |
| method: str, | |
| path: str, | |
| token: Optional[str] = None, | |
| body: Optional[Dict[str, Any]] = None, | |
| query: Optional[Dict[str, Any]] = None, | |
| timeout: int = 60, | |
| ) -> Dict[str, Any]: | |
| """Perform HTTP request to PinchTab API. base_url has no trailing slash.""" | |
| url = base_url.rstrip("/") + path | |
| if query: | |
| url += "?" + urlencode({k: v for k, v in query.items() if v is not None}) | |
| headers = {"Accept": "application/json", "Content-Type": "application/json"} | |
| if token: | |
| headers["Authorization"] = f"Bearer {token}" | |
| data = json.dumps(body).encode("utf-8") if body else None | |
| req = Request(url, data=data, method=method, headers=headers) | |
| try: | |
| with urlopen(req, timeout=timeout) as resp: | |
| raw = resp.read().decode("utf-8") | |
| if not raw: | |
| return {} | |
| return json.loads(raw) | |
| except HTTPError as e: | |
| try: | |
| err_body = e.read().decode("utf-8") | |
| return json.loads(err_body) | |
| except Exception: | |
| return _error_response(f"HTTP {e.code}: {e.reason}", "api_error") | |
| except URLError as e: | |
| return _error_response(f"Request failed: {e.reason}", "connection_error") | |
| except json.JSONDecodeError as e: | |
| return _error_response(f"Invalid JSON: {e}", "parse_error") | |
| def _instance_path(base_url: str, instance_id: Optional[str]) -> str: | |
| """Prefix for instance-scoped paths. If instance_id set, base is orchestrator.""" | |
| if instance_id: | |
| return f"/instances/{instance_id}" | |
| return "" | |
| # --- Commands --- | |
| def cmd_health(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| path = _instance_path(base_url, args.get("instance-id")) + "/health" | |
| out = _api_request(base_url, "GET", path or "/health", token=token, timeout=10) | |
| if "status" in out and out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_instances(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| out = _api_request(base_url, "GET", "/instances", token=token) | |
| if isinstance(out, list): | |
| return {"status": "success", "instances": out} | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_instance_start(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| body = {} | |
| if args.get("profile-id"): | |
| body["profileId"] = args["profile-id"] | |
| if args.get("mode"): | |
| body["mode"] = args["mode"] | |
| if args.get("port") is not None: | |
| body["port"] = str(args["port"]) | |
| out = _api_request(base_url, "POST", "/instances/start", token=token, body=body if body else None) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "instance": out} | |
| def cmd_instance_stop(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| iid = args.get("instance-id") | |
| if not iid: | |
| return _error_response("instance-id is required", "validation_error") | |
| out = _api_request(base_url, "POST", f"/instances/{iid}/stop", token=token) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_tabs(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/tabs" if prefix else "/tabs" | |
| out = _api_request(base_url, "GET", path, token=token) | |
| if isinstance(out, list): | |
| return {"status": "success", "tabs": out} | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_navigate(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| url = args.get("url") | |
| if not url: | |
| return _error_response("url is required", "validation_error") | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/navigate" if prefix else "/navigate" | |
| body = { | |
| "url": url, | |
| "timeout": args.get("timeout"), | |
| "blockImages": args.get("block-images"), | |
| "newTab": args.get("new-tab"), | |
| } | |
| body = {k: v for k, v in body.items() if v is not None} | |
| out = _api_request(base_url, "POST", path, token=token, body=body) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_snapshot(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/snapshot" if prefix else "/snapshot" | |
| query = { | |
| "tabId": args.get("tab-id"), | |
| "filter": args.get("filter"), | |
| "format": args.get("format"), | |
| "selector": args.get("selector"), | |
| "maxTokens": args.get("max-tokens"), | |
| "diff": "true" if args.get("diff") else None, | |
| "depth": args.get("depth"), | |
| "noAnimations": "true" if args.get("no-animations") else None, | |
| } | |
| query = {k: v for k, v in query.items() if v is not None} | |
| out = _api_request(base_url, "GET", path, token=token, query=query) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_action(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| kind = args.get("kind") | |
| if not kind: | |
| return _error_response("kind is required (click, type, press, focus, fill, hover, select, scroll)", "validation_error") | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/action" if prefix else "/action" | |
| body = {"kind": kind} | |
| if args.get("ref"): | |
| body["ref"] = args["ref"] | |
| if args.get("key"): | |
| body["key"] = args["key"] | |
| if args.get("text") is not None: | |
| body["text"] = args["text"] | |
| if args.get("value") is not None: | |
| body["value"] = args["value"] | |
| if args.get("selector"): | |
| body["selector"] = args["selector"] | |
| if args.get("scroll-y") is not None: | |
| body["scrollY"] = args["scroll-y"] | |
| if args.get("wait-nav"): | |
| body["waitNav"] = True | |
| out = _api_request(base_url, "POST", path, token=token, body=body) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_actions(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| actions_list = args.get("actions") | |
| if not actions_list: | |
| return _error_response("actions (JSON array) is required", "validation_error") | |
| if isinstance(actions_list, str): | |
| try: | |
| actions_list = json.loads(actions_list) | |
| except json.JSONDecodeError: | |
| return _error_response("actions must be a JSON array", "validation_error") | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/actions" if prefix else "/actions" | |
| body = {"actions": actions_list, "stopOnError": args.get("stop-on-error", False)} | |
| if args.get("tab-id"): | |
| body["tabId"] = args["tab-id"] | |
| out = _api_request(base_url, "POST", path, token=token, body=body) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_text(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/text" if prefix else "/text" | |
| query = {"tabId": args.get("tab-id"), "mode": args.get("mode")} | |
| query = {k: v for k, v in query.items() if v is not None} | |
| out = _api_request(base_url, "GET", path, token=token, query=query) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_screenshot(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/screenshot" if prefix else "/screenshot" | |
| query = {"tabId": args.get("tab-id"), "raw": "true" if args.get("raw") else None, "quality": args.get("quality")} | |
| query = {k: v for k, v in query.items() if v is not None} | |
| out = _api_request(base_url, "GET", path, token=token, query=query) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_pdf(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| tab_id = args.get("tab-id") | |
| if not tab_id: | |
| return _error_response("tab-id is required for PDF export", "validation_error") | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/tabs/{tab_id}/pdf" if prefix else f"/tabs/{tab_id}/pdf" | |
| query = { | |
| "raw": "true" if args.get("raw") else None, | |
| "landscape": "true" if args.get("landscape") else None, | |
| "scale": args.get("scale"), | |
| } | |
| query = {k: v for k, v in query.items() if v is not None} | |
| out = _api_request(base_url, "GET", path, token=token, query=query) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_evaluate(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| expr = args.get("expression") | |
| if not expr: | |
| return _error_response("expression is required", "validation_error") | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/evaluate" if prefix else "/evaluate" | |
| out = _api_request(base_url, "POST", path, token=token, body={"expression": expr}) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_cookies_get(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/cookies" if prefix else "/cookies" | |
| out = _api_request(base_url, "GET", path, token=token) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def cmd_stealth_status(args: Dict[str, Any], base_url: str, token: Optional[str]) -> Dict[str, Any]: | |
| prefix = _instance_path(base_url, args.get("instance-id")) | |
| path = f"{prefix}/stealth/status" if prefix else "/stealth/status" | |
| out = _api_request(base_url, "GET", path, token=token) | |
| if out.get("status") == "error": | |
| return out | |
| return {"status": "success", "data": out} | |
| def get_plugin_description(parser: argparse.ArgumentParser) -> Dict[str, Any]: | |
| commands: List[Dict[str, Any]] = [] | |
| sub = _get_subparsers_action(parser) | |
| if sub: | |
| for cmd_name, cmd_parser in sub.choices.items(): | |
| params = [] | |
| for a in cmd_parser._actions: | |
| d = _describe_action(a) | |
| if d: | |
| params.append(d) | |
| commands.append({ | |
| "name": cmd_name, | |
| "description": (cmd_parser.description or "").strip(), | |
| "parameters": params, | |
| }) | |
| return { | |
| "plugin": { | |
| "name": "pinchtab", | |
| "version": PLUGIN_VERSION, | |
| "description": "Browser control for AI agents via PinchTab HTTP API. Navigate, snapshot, action (click/type/press), text, screenshot, PDF, evaluate, instances, tabs.", | |
| }, | |
| "commands": commands, | |
| } | |
| def build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser( | |
| description="PinchTab SMCP plugin — browser control via HTTP API", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| parser.add_argument("--describe", action="store_true", help="Output plugin description JSON") | |
| parser.add_argument("--debug", action="store_true", help="Include tracebacks in errors") | |
| parser.add_argument("--base-url", dest="base_url", default="http://localhost:9867", help="PinchTab base URL (orchestrator or instance)") | |
| parser.add_argument("--token", help="Bearer token (BRIDGE_TOKEN)") | |
| parser.add_argument("--instance-id", dest="instance_id", help="Instance ID when using orchestrator") | |
| sub = parser.add_subparsers(dest="command", help="Commands") | |
| def add_common(p: argparse.ArgumentParser) -> None: | |
| p.add_argument("--base-url", dest="base_url", default="http://localhost:9867") | |
| p.add_argument("--token") | |
| p.add_argument("--instance-id", dest="instance_id") | |
| # health | |
| p_health = sub.add_parser("health", help="Health check") | |
| add_common(p_health) | |
| # instances | |
| p_inst = sub.add_parser("instances", help="List instances (orchestrator)") | |
| add_common(p_inst) | |
| # instance-start | |
| p_start = sub.add_parser("instance-start", help="Start an instance") | |
| add_common(p_start) | |
| p_start.add_argument("--profile-id", dest="profile_id") | |
| p_start.add_argument("--mode", choices=["headless", "headed"]) | |
| p_start.add_argument("--port", type=int) | |
| # instance-stop (instance-id from add_common; required at runtime in handler) | |
| p_stop = sub.add_parser("instance-stop", help="Stop an instance (requires --instance-id)") | |
| add_common(p_stop) | |
| # tabs | |
| p_tabs = sub.add_parser("tabs", help="List tabs") | |
| add_common(p_tabs) | |
| # navigate | |
| p_nav = sub.add_parser("navigate", help="Navigate to URL") | |
| add_common(p_nav) | |
| p_nav.add_argument("--url", required=True) | |
| p_nav.add_argument("--timeout", type=int) | |
| p_nav.add_argument("--block-images", dest="block_images", action="store_true") | |
| p_nav.add_argument("--new-tab", dest="new_tab", action="store_true") | |
| # snapshot | |
| p_snap = sub.add_parser("snapshot", help="Get accessibility tree snapshot") | |
| add_common(p_snap) | |
| p_snap.add_argument("--tab-id", dest="tab_id") | |
| p_snap.add_argument("--filter", choices=["interactive", "all"]) | |
| p_snap.add_argument("--format", choices=["json", "text", "compact", "yaml"]) | |
| p_snap.add_argument("--selector") | |
| p_snap.add_argument("--max-tokens", dest="max_tokens", type=int) | |
| p_snap.add_argument("--diff", action="store_true") | |
| p_snap.add_argument("--depth", type=int) | |
| p_snap.add_argument("--no-animations", dest="no_animations", action="store_true") | |
| # action | |
| p_act = sub.add_parser("action", help="Single action: click, type, press, focus, fill, hover, select, scroll") | |
| add_common(p_act) | |
| p_act.add_argument("--kind", required=True, choices=["click", "type", "press", "focus", "fill", "hover", "select", "scroll"]) | |
| p_act.add_argument("--ref") | |
| p_act.add_argument("--key") | |
| p_act.add_argument("--text") | |
| p_act.add_argument("--value") | |
| p_act.add_argument("--selector") | |
| p_act.add_argument("--scroll-y", dest="scroll_y", type=int) | |
| p_act.add_argument("--wait-nav", dest="wait_nav", action="store_true") | |
| # actions (batch) | |
| p_acts = sub.add_parser("actions", help="Batch actions (JSON array)") | |
| add_common(p_acts) | |
| p_acts.add_argument("--actions", required=True, help="JSON array of action objects") | |
| p_acts.add_argument("--tab-id", dest="tab_id") | |
| p_acts.add_argument("--stop-on-error", dest="stop_on_error", action="store_true") | |
| # text | |
| p_text = sub.add_parser("text", help="Extract page text") | |
| add_common(p_text) | |
| p_text.add_argument("--tab-id", dest="tab_id") | |
| p_text.add_argument("--mode", choices=["readability", "raw"]) | |
| # screenshot | |
| p_ss = sub.add_parser("screenshot", help="Take screenshot") | |
| add_common(p_ss) | |
| p_ss.add_argument("--tab-id", dest="tab_id") | |
| p_ss.add_argument("--raw", action="store_true") | |
| p_ss.add_argument("--quality", type=int) | |
| p_pdf = sub.add_parser("pdf", help="Export tab to PDF") | |
| add_common(p_pdf) | |
| p_pdf.add_argument("--tab-id", dest="tab_id", required=True) | |
| p_pdf.add_argument("--raw", action="store_true") | |
| p_pdf.add_argument("--landscape", action="store_true") | |
| p_pdf.add_argument("--scale", type=float) | |
| # evaluate | |
| p_eval = sub.add_parser("evaluate", help="Run JavaScript in page") | |
| add_common(p_eval) | |
| p_eval.add_argument("--expression", required=True) | |
| # cookies | |
| p_cook = sub.add_parser("cookies-get", help="Get cookies") | |
| add_common(p_cook) | |
| # stealth-status | |
| p_stealth = sub.add_parser("stealth-status", help="Stealth/fingerprint status") | |
| add_common(p_stealth) | |
| return parser | |
| def main() -> None: | |
| global DEBUG_TRACEBACKS | |
| parser = build_parser() | |
| try: | |
| args = parser.parse_args() | |
| except SystemExit as e: | |
| if e.code == 0: | |
| raise | |
| err = _error_response("Invalid arguments", "argument_error", include_traceback=False) | |
| print(json.dumps(err, indent=2), file=sys.stderr) | |
| print(json.dumps(err, indent=2)) | |
| sys.exit(e.code if isinstance(e.code, int) else 2) | |
| DEBUG_TRACEBACKS = bool(getattr(args, "debug", False)) | |
| if args.describe: | |
| print(json.dumps(get_plugin_description(parser), indent=2)) | |
| sys.exit(0) | |
| if not args.command: | |
| parser.print_help() | |
| sys.exit(1) | |
| base_url = getattr(args, "base_url", "http://localhost:9867") or "http://localhost:9867" | |
| token = getattr(args, "token", None) | |
| args_dict: Dict[str, Any] = {} | |
| for k, v in vars(args).items(): | |
| if k in ("command", "describe", "debug"): | |
| continue | |
| if v is None: | |
| continue | |
| key = k.replace("_", "-") | |
| args_dict[key] = v | |
| commands = { | |
| "health": cmd_health, | |
| "instances": cmd_instances, | |
| "instance-start": cmd_instance_start, | |
| "instance-stop": cmd_instance_stop, | |
| "tabs": cmd_tabs, | |
| "navigate": cmd_navigate, | |
| "snapshot": cmd_snapshot, | |
| "action": cmd_action, | |
| "actions": cmd_actions, | |
| "text": cmd_text, | |
| "screenshot": cmd_screenshot, | |
| "pdf": cmd_pdf, | |
| "evaluate": cmd_evaluate, | |
| "cookies-get": cmd_cookies_get, | |
| "stealth-status": cmd_stealth_status, | |
| } | |
| fn = commands.get(args.command) | |
| if not fn: | |
| result = _error_response(f"Unknown command: {args.command}", "argument_error", include_traceback=False) | |
| else: | |
| try: | |
| result = fn(args_dict, base_url, token) | |
| except Exception as e: | |
| result = _error_response(str(e), "unknown_error") | |
| print(json.dumps(result, indent=2)) | |
| sys.exit(0 if result.get("status") == "success" else 1) | |
| if __name__ == "__main__": | |
| main() | |