Created
November 19, 2025 10:14
-
-
Save kernkraft235/2a5019999ab2733e0faf3a5a5ec7bf66 to your computer and use it in GitHub Desktop.
Python Script to convert a complete curl command to a Apple Shortcut Action then place it on clipboard
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| curl2shortcut.py | |
| A script that converts curl commands into Apple Shortcuts "Get Contents of URL" actions. | |
| Reads a curl command (from argument or stdin), parses it to extract the HTTP method, | |
| URL, headers, and JSON data, then builds a properly formatted Shortcuts action and | |
| copies it to the clipboard with the correct UTI so it can be pasted directly into | |
| the Shortcuts app. | |
| """ | |
| import argparse | |
| import json | |
| import plistlib | |
| import re | |
| import shlex | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import urllib.parse | |
| import uuid | |
| from pathlib import Path | |
| from typing import Any | |
| class Logger: | |
| """Consistent logging interface for the application.""" | |
| def __init__(self, debug: bool = False): | |
| self.debug_enabled = debug | |
| def debug(self, message: str) -> None: | |
| """Log debug messages (only if debug mode is enabled).""" | |
| if self.debug_enabled: | |
| print(f"[DEBUG] {message}", file=sys.stderr) | |
| def info(self, message: str) -> None: | |
| """Log info messages.""" | |
| print(message) | |
| def error(self, message: str) -> None: | |
| """Log error messages.""" | |
| print(f"Error: {message}", file=sys.stderr) | |
| def success(self, message: str) -> None: | |
| """Log success messages.""" | |
| print(message) | |
| def generate_uuid() -> str: | |
| """Return an uppercase UUID string.""" | |
| return str(uuid.uuid4()).upper() | |
| def clean_curl_string(raw: str) -> str: | |
| """ | |
| Clean and normalize a curl command string. | |
| Removes backslash-newline continuations and collapses whitespace. | |
| """ | |
| # Remove "\" + optional whitespace + newline + optional whitespace → space | |
| step1 = re.sub(r"\\\s*\n\s*", " ", raw) | |
| # Replace any leftover newline (with surrounding whitespace) → single space | |
| step2 = re.sub(r"\s*\n\s*", " ", step1) | |
| # Collapse multiple spaces into one; strip leading/trailing | |
| return re.sub(r"\s+", " ", step2).strip() | |
| def parse_curl_command(tokens: list[str], logger: Logger) -> dict[str, Any]: | |
| """ | |
| Parse a tokenized curl command to extract HTTP components. | |
| Returns a dict with method, url, headers, and data. | |
| """ | |
| method = None | |
| url = None | |
| headers = {} | |
| data = None | |
| i = 0 | |
| n = len(tokens) | |
| # Skip initial "curl" | |
| if i < n and tokens[i].lower().endswith("curl"): | |
| i += 1 | |
| while i < n: | |
| tok = tokens[i] | |
| # Handle -X / --request | |
| if tok in ("-X", "--request") and i + 1 < n: | |
| method = tokens[i + 1].upper() | |
| logger.debug(f"Found {tok} → METHOD = {method}") | |
| i += 2 | |
| elif tok.startswith("-X") and len(tok) > 2: | |
| method = tok[2:].upper() | |
| logger.debug(f"Found inline -X → METHOD = {method}") | |
| i += 1 | |
| elif tok.startswith("--request") and len(tok) > 9: | |
| method = tok[9:].upper() | |
| logger.debug(f"Found inline --request → METHOD = {method}") | |
| i += 1 | |
| # Handle -H / --header | |
| elif tok in ("-H", "--header") and i + 1 < n: | |
| header_value = tokens[i + 1] | |
| _parse_header(header_value, headers, logger) | |
| i += 2 | |
| elif tok.startswith("-H") and len(tok) > 2: | |
| header_value = tok[2:] | |
| _parse_header(header_value, headers, logger) | |
| i += 1 | |
| elif tok.startswith("--header") and len(tok) > 8: | |
| header_value = tok[8:] | |
| _parse_header(header_value, headers, logger) | |
| i += 1 | |
| # Handle -d / --data | |
| elif tok in ("-d", "--data") and i + 1 < n: | |
| data = tokens[i + 1] | |
| logger.debug(f"Found {tok} → DATA = {data}") | |
| i += 2 | |
| elif tok.startswith("-d") and len(tok) > 2: | |
| data = tok[2:] | |
| logger.debug(f"Found inline -d → DATA = {data}") | |
| i += 1 | |
| elif tok.startswith("--data") and len(tok) > 6: | |
| data = tok[6:] | |
| logger.debug(f"Found inline --data → DATA = {data}") | |
| i += 1 | |
| # Skip other flags | |
| elif tok.startswith("-"): | |
| i += 1 | |
| # First non-flag token is the URL | |
| else: | |
| if url is None: | |
| url = tok | |
| logger.debug(f"URL = {url}") | |
| i += 1 | |
| # Set default method if none specified | |
| if method is None: | |
| method = "GET" if data is None else "POST" | |
| logger.debug(f"Default METHOD = {method}") | |
| return {"method": method, "url": url, "headers": headers, "data": data} | |
| def _parse_header(header_string: str, headers: dict[str, str], logger: Logger) -> None: | |
| """Parse a header string and add it to the headers dict.""" | |
| if ":" in header_string: | |
| key, value = header_string.split(":", 1) | |
| headers[key.strip()] = value.strip() | |
| logger.debug(f"Header: {key.strip()}: {value.strip()}") | |
| def build_wf_dictionary_items(data: dict[str, Any]) -> list[dict[str, Any]]: | |
| """ | |
| Convert a dictionary into WFDictionaryFieldValueItems format. | |
| Properly handles different data types with correct WFItemType and serialization: | |
| - WFItemType 0: String (WFTextTokenString) | |
| - WFItemType 1: Dictionary (WFDictionaryFieldValue) | |
| - WFItemType 2: Array (WFArrayParameterState) | |
| - WFItemType 3: Number (WFTextTokenString) | |
| - WFItemType 4: Boolean (WFBooleanSubstitutableState) | |
| """ | |
| items = [] | |
| for key, value in data.items(): | |
| item = { | |
| "UUID": generate_uuid(), | |
| "WFKey": { | |
| "Value": {"string": key}, | |
| "WFSerializationType": "WFTextTokenString", | |
| }, | |
| } | |
| # Handle different value types | |
| if isinstance(value, str): | |
| # String type | |
| item.update( | |
| { | |
| "WFItemType": 0, | |
| "WFValue": { | |
| "Value": {"string": value}, | |
| "WFSerializationType": "WFTextTokenString", | |
| }, | |
| } | |
| ) | |
| elif isinstance(value, bool): | |
| # Boolean type | |
| item.update( | |
| { | |
| "WFItemType": 4, | |
| "WFValue": { | |
| "Value": value, | |
| "WFSerializationType": "WFBooleanSubstitutableState", | |
| }, | |
| } | |
| ) | |
| elif isinstance(value, (int, float)): | |
| # Number type (still stored as string in Shortcuts) | |
| item.update( | |
| { | |
| "WFItemType": 3, | |
| "WFValue": { | |
| "Value": {"string": str(value)}, | |
| "WFSerializationType": "WFTextTokenString", | |
| }, | |
| } | |
| ) | |
| elif isinstance(value, list): | |
| # Array type | |
| item.update( | |
| { | |
| "WFItemType": 2, | |
| "WFValue": { | |
| "Value": _build_array_value(value), | |
| "WFSerializationType": "WFArrayParameterState", | |
| }, | |
| } | |
| ) | |
| elif isinstance(value, dict): | |
| # Dictionary type | |
| item.update( | |
| { | |
| "WFItemType": 1, | |
| "WFValue": { | |
| "Value": { | |
| "Value": { | |
| "WFDictionaryFieldValueItems": build_wf_dictionary_items( | |
| value | |
| ) | |
| }, | |
| "WFSerializationType": "WFDictionaryFieldValue", | |
| }, | |
| "WFSerializationType": "WFDictionaryFieldValue", | |
| }, | |
| } | |
| ) | |
| else: | |
| # Fallback to string for unknown types | |
| item.update( | |
| { | |
| "WFItemType": 0, | |
| "WFValue": { | |
| "Value": {"string": str(value)}, | |
| "WFSerializationType": "WFTextTokenString", | |
| }, | |
| } | |
| ) | |
| items.append(item) | |
| return items | |
| def _build_array_value(array: list[Any]) -> list[Any]: | |
| """ | |
| Build the Value content for an array in Shortcuts format. | |
| Arrays can contain strings, numbers, booleans, objects, or nested arrays. | |
| """ | |
| result = [] | |
| for item in array: | |
| if isinstance(item, str): | |
| # String item in array - just the string value | |
| result.append(item) | |
| elif isinstance(item, bool): | |
| # Boolean item in array | |
| result.append(item) | |
| elif isinstance(item, (int, float)): | |
| # Number item in array | |
| result.append(item) | |
| elif isinstance(item, dict): | |
| # Dictionary item in array - needs full WF structure | |
| result.append( | |
| { | |
| "WFItemType": 1, | |
| "WFValue": { | |
| "Value": { | |
| "Value": { | |
| "WFDictionaryFieldValueItems": build_wf_dictionary_items( | |
| item | |
| ) | |
| }, | |
| "WFSerializationType": "WFDictionaryFieldValue", | |
| }, | |
| "WFSerializationType": "WFDictionaryFieldValue", | |
| }, | |
| } | |
| ) | |
| elif isinstance(item, list): | |
| # Nested array - recursively build | |
| result.append( | |
| { | |
| "WFItemType": 2, | |
| "WFValue": { | |
| "Value": _build_array_value(item), | |
| "WFSerializationType": "WFArrayParameterState", | |
| }, | |
| } | |
| ) | |
| else: | |
| # Fallback to string | |
| result.append(str(item)) | |
| return result | |
| def detect_request_body_type(data: str, headers: dict[str, str]) -> str: | |
| """ | |
| Detect the type of request body based on data content and headers. | |
| Returns: 'json', 'form', or 'text' | |
| """ | |
| if not data: | |
| return "text" | |
| # Check Content-Type header first | |
| content_type = headers.get("Content-Type", "").lower() | |
| if "application/json" in content_type: | |
| return "json" | |
| elif "application/x-www-form-urlencoded" in content_type: | |
| return "form" | |
| # Try to detect based on data format | |
| data_stripped = data.strip() | |
| # Check if it looks like JSON | |
| if data_stripped.startswith(("{", "[")): | |
| try: | |
| json.loads(data_stripped) | |
| return "json" | |
| except json.JSONDecodeError: | |
| pass | |
| # Check if it looks like form data (key=value&key2=value2) | |
| if ( | |
| "=" in data_stripped | |
| and not data_stripped.startswith(("{", "[", '"')) | |
| and all(c.isprintable() for c in data_stripped) | |
| ): | |
| # Simple heuristic: if it contains = and & or looks like form data | |
| if "&" in data_stripped or ( | |
| data_stripped.count("=") == 1 and len(data_stripped.split("=")) == 2 | |
| ): | |
| return "form" | |
| # Default to text/raw | |
| return "text" | |
| def parse_form_data(data: str) -> dict[str, str]: | |
| """Parse URL-encoded form data into a dictionary.""" | |
| result = {} | |
| if not data: | |
| return result | |
| # Split by & and then by = | |
| pairs = data.split("&") | |
| for pair in pairs: | |
| if "=" in pair: | |
| key, value = pair.split("=", 1) | |
| # URL decode the key and value | |
| try: | |
| key = urllib.parse.unquote_plus(key) | |
| value = urllib.parse.unquote_plus(value) | |
| result[key] = value | |
| except (ValueError, UnicodeDecodeError): | |
| # If URL decoding fails, use raw values | |
| result[key] = value | |
| else: | |
| # Handle case where there's no = (just a key) | |
| result[pair] = "" | |
| return result | |
| def build_shortcuts_action( | |
| method: str, url: str, headers: dict[str, str], data: str | None, logger: Logger | |
| ) -> dict[str, Any]: | |
| """Build the Shortcuts action dictionary.""" | |
| action = { | |
| "WFWorkflowActionIdentifier": "is.workflow.actions.downloadurl", | |
| "WFWorkflowActionParameters": {}, | |
| } | |
| params = action["WFWorkflowActionParameters"] | |
| # Add headers if present | |
| if headers: | |
| params["WFHTTPHeaders"] = { | |
| "Value": { | |
| "WFDictionaryFieldValueItems": build_wf_dictionary_items(headers) | |
| }, | |
| "WFSerializationType": "WFDictionaryFieldValue", | |
| } | |
| logger.debug("Added WFHTTPHeaders") | |
| # Basic settings | |
| params["ShowHeaders"] = True | |
| params["WFURL"] = url | |
| params["WFHTTPMethod"] = method | |
| logger.debug(f"Set method={method}, url={url}") | |
| # Handle request body based on detected type | |
| if data: | |
| body_type = detect_request_body_type(data, headers) | |
| logger.debug(f"Detected request body type: {body_type}") | |
| if body_type == "json": | |
| try: | |
| parsed_json = json.loads(data) | |
| if not isinstance(parsed_json, dict): | |
| raise ValueError("JSON data must be an object") | |
| params["WFHTTPBodyType"] = "JSON" | |
| params["WFJSONValues"] = { | |
| "Value": { | |
| "WFDictionaryFieldValueItems": build_wf_dictionary_items( | |
| parsed_json | |
| ) | |
| }, | |
| "WFSerializationType": "WFDictionaryFieldValue", | |
| } | |
| logger.debug("Added WFJSONValues for JSON request body") | |
| except json.JSONDecodeError as e: | |
| raise ValueError(f"Invalid JSON data: {e}") | |
| elif body_type == "form": | |
| try: | |
| form_data = parse_form_data(data) | |
| if form_data: | |
| # Use WFFormValues for form data | |
| params["WFHTTPBodyType"] = "Form" | |
| params["WFFormValues"] = { | |
| "Value": { | |
| "WFDictionaryFieldValueItems": build_wf_dictionary_items( | |
| form_data | |
| ) | |
| }, | |
| "WFSerializationType": "WFDictionaryFieldValue", | |
| } | |
| logger.debug("Added WFFormValues for form-encoded request body") | |
| else: | |
| # If no form data parsed, fall back to raw text | |
| params["WFHTTPBodyType"] = "Raw Text" | |
| params["WFHTTPBodyText"] = data | |
| logger.debug("Added raw text body (form data parsing failed)") | |
| except Exception as e: | |
| # Fall back to raw text if form parsing fails | |
| params["WFHTTPBodyType"] = "Raw Text" | |
| params["WFHTTPBodyText"] = data | |
| logger.debug(f"Added raw text body (form parsing error: {e})") | |
| else: # text/raw | |
| params["WFHTTPBodyType"] = "Raw Text" | |
| params["WFHTTPBodyText"] = data | |
| logger.debug("Added raw text request body") | |
| # Network settings | |
| params.update( | |
| { | |
| "WFAllowsCellularAccess": 1, | |
| "WFAllowsRedirects": 1, | |
| "WFIgnoreCookies": 0, | |
| "WFTimeout": 60, | |
| } | |
| ) | |
| logger.debug("Added network settings") | |
| return action | |
| def copy_action_to_clipboard(action: dict[str, Any], logger: Logger) -> None: | |
| """Convert action to XML plist and copy to clipboard with correct UTI.""" | |
| xml_bytes = plistlib.dumps(action, fmt=plistlib.FMT_XML) | |
| logger.debug("Generated XML plist") | |
| # Write to temporary file | |
| with tempfile.NamedTemporaryFile( | |
| prefix="action_", suffix=".plist", delete=False | |
| ) as tmp: | |
| tmp_path = Path(tmp.name) | |
| tmp.write(xml_bytes) | |
| tmp.flush() | |
| logger.debug(f"Wrote XML to {tmp_path}") | |
| try: | |
| # Use AppleScript to copy with correct UTI | |
| applescript = f""" | |
| use framework "Foundation" | |
| set xmlPath to POSIX file "{tmp_path.as_posix()}" | |
| set xmlData to (current application's NSData's dataWithContentsOfFile:xmlPath) | |
| set pboard to (current application's NSPasteboard's generalPasteboard()) | |
| pboard's clearContents() | |
| pboard's setData:xmlData forType:"com.apple.shortcuts.action" | |
| """ | |
| logger.debug("Running AppleScript to copy to clipboard") | |
| result = subprocess.run( | |
| ["osascript", "-e", applescript], capture_output=True, text=True | |
| ) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"AppleScript failed: {result.stderr.strip()}") | |
| logger.success( | |
| "✅ Copied action to clipboard (UTI: com.apple.shortcuts.action)" | |
| ) | |
| finally: | |
| # Clean up temporary file | |
| try: | |
| tmp_path.unlink() | |
| logger.debug("Cleaned up temporary file") | |
| except OSError: | |
| pass | |
| def read_curl_input(curl_arg: str | None, logger: Logger) -> str: | |
| """Read curl command from argument or stdin.""" | |
| if curl_arg is None: | |
| raw_curl = sys.stdin.read().strip() | |
| if not raw_curl: | |
| logger.error("No curl command provided.") | |
| logger.info("Either supply it as an argument or pipe it via stdin.") | |
| sys.exit(1) | |
| logger.debug("Read curl command from stdin") | |
| else: | |
| raw_curl = curl_arg | |
| logger.debug("Read curl command from argument") | |
| return raw_curl | |
| def create_parser() -> argparse.ArgumentParser: | |
| """Create and configure the argument parser.""" | |
| parser = argparse.ArgumentParser( | |
| prog="curl2shortcut", | |
| description="Convert curl commands into Apple Shortcuts 'Get Contents of URL' actions. " | |
| "The generated action is copied to the clipboard and can be pasted directly " | |
| "into the Shortcuts app.", | |
| epilog="Examples:\n" | |
| " %(prog)s 'curl https://api.example.com'\n" | |
| " pbpaste | %(prog)s\n" | |
| ' %(prog)s --debug \'curl -X POST https://api.example.com -d "{\\"key\\":\\"value\\"}\'"', | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| parser.add_argument( | |
| "curl_command", | |
| nargs="?", | |
| help="Complete curl command in quotes, or pipe via stdin (e.g. 'pbpaste | curl2shortcut.py')", | |
| ) | |
| parser.add_argument( | |
| "--debug", | |
| "-d", | |
| action="store_true", | |
| help="Show detailed parsing and processing information", | |
| ) | |
| parser.add_argument("--version", "-v", action="version", version="%(prog)s 1.0.0") | |
| return parser | |
| def main() -> None: | |
| """Main entry point.""" | |
| parser = create_parser() | |
| args = parser.parse_args() | |
| logger = Logger(args.debug) | |
| try: | |
| # Read and clean the curl command | |
| raw_curl = read_curl_input(args.curl_command, logger) | |
| cleaned_curl = clean_curl_string(raw_curl) | |
| logger.debug(f"Cleaned curl: {cleaned_curl}") | |
| # Parse the curl command | |
| tokens = shlex.split(cleaned_curl) | |
| parsed = parse_curl_command(tokens, logger) | |
| # Validate required fields | |
| if not parsed["url"]: | |
| logger.error("No URL found in curl command") | |
| sys.exit(1) | |
| # Build the Shortcuts action | |
| action = build_shortcuts_action( | |
| parsed["method"], parsed["url"], parsed["headers"], parsed["data"], logger | |
| ) | |
| # Copy to clipboard | |
| copy_action_to_clipboard(action, logger) | |
| logger.info("🎉 Done!") | |
| except ValueError as e: | |
| logger.error(str(e)) | |
| sys.exit(1) | |
| except KeyboardInterrupt: | |
| logger.error("Interrupted by user") | |
| sys.exit(1) | |
| except Exception as e: | |
| logger.error(f"Unexpected error: {e}") | |
| if args.debug: | |
| import traceback | |
| traceback.print_exc() | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment