shortcutkit 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 frontboat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: shortcutkit
3
+ Version: 0.1.0
4
+ Summary: Build, validate and sign Apple Shortcuts (.shortcut) files.
5
+ Author: frontboat
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/frontboat/shortcutkit
8
+ Project-URL: Documentation, https://github.com/frontboat/shortcutkit/blob/main/docs/shortcut-file-format.md
9
+ Project-URL: Repository, https://github.com/frontboat/shortcutkit.git
10
+ Project-URL: Changelog, https://github.com/frontboat/shortcutkit/blob/main/CHANGELOG.md
11
+ Keywords: apple,shortcuts,workflow,automation,macos,ios,plist
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # shortcutkit
23
+
24
+ Build, validate and sign Apple Shortcuts (`.shortcut`) files from Python.
25
+
26
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/frontboat/shortcutkit/blob/main/LICENSE)
27
+ [![CI](https://img.shields.io/github/actions/workflow/status/frontboat/shortcutkit/ci.yml?branch=main)](https://github.com/frontboat/shortcutkit/actions)
28
+
29
+ ## About
30
+
31
+ Shortcuts has no public file-format specification and no public list of what its actions
32
+ accept. shortcutkit fills that gap with data extracted from WorkflowKit, the engine inside the
33
+ Shortcuts app: every one of the 339 built-in actions is bundled with its identifier, parameter
34
+ keys and the value kind each key accepts, and `action()` checks your parameters against them
35
+ before anything is written. Pure Python, no dependencies. Signing shells out to the macOS
36
+ `shortcuts` command; everything else runs anywhere.
37
+
38
+ A TypeScript package of the same name shares the same data and adds compile-time checking.
39
+ Both live at https://github.com/frontboat/shortcutkit.
40
+
41
+ ## Features
42
+
43
+ - **Every built-in action, as a constant.** `actions.SETSTOREDCONTENT` and 338 more, so a
44
+ typo is an `AttributeError` rather than a silent bad file.
45
+ - **Parameters checked before you write anything.** `ACTIONS[identifier]` gives the name,
46
+ parameter keys and output name; `PARAM_KINDS[identifier]` gives the value kind each key
47
+ accepts. Unknown keys and wrong kinds raise `ValueError`. Attachments and token strings are
48
+ accepted wherever a plain value is, because that is how Shortcuts works.
49
+ - **Value helpers that match the engine's serialization.** `ref()` to another action's output,
50
+ `variable()`, `shortcut_input()`, `clipboard()`, `current_date()`, `ask()`, `text()` for
51
+ strings with embedded references, and `picker()` for variable-picker parameters.
52
+ - **Control flow.** `if_()` / `otherwise()` / `end_if()` and `repeat_each()` /
53
+ `end_repeat_each()` manage the grouping identifiers for you.
54
+ - **App Intents from installed apps.** Any identifier outside the built-in set is accepted and
55
+ its parameters are passed through as given.
56
+ - **Reference tables.** `CONDITION` codes, `ICON_COLORS`, and `PROVENANCE` recording which
57
+ macOS and Shortcuts build the bundled data came from.
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ pip install shortcutkit
63
+ ```
64
+
65
+ ### Requirements
66
+
67
+ - Python 3.9 or newer.
68
+ - A Mac signed into iCloud for `Shortcut.sign()`. It runs `shortcuts sign`, which refuses to
69
+ work without an iCloud login even in `anyone` mode. Building and writing the unsigned file
70
+ works on any platform.
71
+
72
+ ## Usage
73
+
74
+ ```python
75
+ from shortcutkit import Shortcut, actions, ref, text
76
+
77
+ s = Shortcut("Greeting", color="Teal")
78
+ got = s.action(actions.GETSTOREDCONTENT, WFStoredContentKey="greeting")
79
+ s.action(actions.SHOWRESULT, Text=text("Stored: ", ref(got)))
80
+ s.write() # Greeting.shortcut (unsigned)
81
+ Shortcut.sign("Greeting.shortcut", "Greeting-signed.shortcut") # macOS
82
+ ```
83
+
84
+ Open the signed file and Shortcuts imports it.
85
+
86
+ ### Control flow
87
+
88
+ `if_()` returns a grouping identifier that the matching `otherwise()` and `end_if()` calls
89
+ take back:
90
+
91
+ ```python
92
+ gid = s.if_(ref(got), "has_any_value")
93
+ s.action(actions.SHOWRESULT, Text=text("Stored value: ", ref(got)))
94
+ s.otherwise(gid)
95
+ s.action(actions.SHOWRESULT, Text=text("Nothing stored"))
96
+ s.end_if(gid)
97
+ ```
98
+
99
+ ### Actions from other apps
100
+
101
+ App Intents actions are not in the catalogue, so pass the identifier as a string:
102
+
103
+ ```python
104
+ s.action("com.example.app.CreateNote", title=text("Hello"), body=ref(got))
105
+ ```
106
+
107
+ ### Demo
108
+
109
+ The package ships a demo that builds a working shortcut with storage, an If/Otherwise block
110
+ and output, then signs it:
111
+
112
+ ```bash
113
+ python -m shortcutkit demo out.shortcut
114
+ ```
115
+
116
+ ## Documentation
117
+
118
+ - [Shortcut file format](https://github.com/frontboat/shortcutkit/blob/main/docs/shortcut-file-format.md):
119
+ the `.shortcut` format end to end, every field.
120
+ - [Built-in actions reference](https://github.com/frontboat/shortcutkit/blob/main/docs/builtin-actions-reference.md):
121
+ all 339 built-in actions with their parameters.
122
+ - [Parameter encodings](https://github.com/frontboat/shortcutkit/blob/main/docs/parameter-encodings.md):
123
+ how each parameter class is serialized.
124
+ - [Extraction notes](https://github.com/frontboat/shortcutkit/blob/main/docs/extraction.md):
125
+ how the data was obtained and what remains approximate.
126
+
127
+ ## Development
128
+
129
+ From a checkout of the repository:
130
+
131
+ ```bash
132
+ git clone https://github.com/frontboat/shortcutkit.git && cd shortcutkit/python
133
+ uv venv .venv && uv pip install -e . --python .venv/bin/python # or: python3 -m venv .venv && .venv/bin/pip install -e .
134
+ .venv/bin/python -m shortcutkit demo /tmp/Demo.shortcut
135
+ ```
136
+
137
+ `actions.py` and `data/` are generated by the repository's extraction tools from the Shortcuts
138
+ engine on a Mac. Do not edit them; change the generator or the data and run `bun run extract`
139
+ at the repository root. The Python and TypeScript packages are versioned together with the
140
+ data, and are kept in feature parity.
141
+
142
+ ## License
143
+
144
+ shortcutkit is licensed under the MIT license. See
145
+ [LICENSE](https://github.com/frontboat/shortcutkit/blob/main/LICENSE) for details.
@@ -0,0 +1,124 @@
1
+ # shortcutkit
2
+
3
+ Build, validate and sign Apple Shortcuts (`.shortcut`) files from Python.
4
+
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/frontboat/shortcutkit/blob/main/LICENSE)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/frontboat/shortcutkit/ci.yml?branch=main)](https://github.com/frontboat/shortcutkit/actions)
7
+
8
+ ## About
9
+
10
+ Shortcuts has no public file-format specification and no public list of what its actions
11
+ accept. shortcutkit fills that gap with data extracted from WorkflowKit, the engine inside the
12
+ Shortcuts app: every one of the 339 built-in actions is bundled with its identifier, parameter
13
+ keys and the value kind each key accepts, and `action()` checks your parameters against them
14
+ before anything is written. Pure Python, no dependencies. Signing shells out to the macOS
15
+ `shortcuts` command; everything else runs anywhere.
16
+
17
+ A TypeScript package of the same name shares the same data and adds compile-time checking.
18
+ Both live at https://github.com/frontboat/shortcutkit.
19
+
20
+ ## Features
21
+
22
+ - **Every built-in action, as a constant.** `actions.SETSTOREDCONTENT` and 338 more, so a
23
+ typo is an `AttributeError` rather than a silent bad file.
24
+ - **Parameters checked before you write anything.** `ACTIONS[identifier]` gives the name,
25
+ parameter keys and output name; `PARAM_KINDS[identifier]` gives the value kind each key
26
+ accepts. Unknown keys and wrong kinds raise `ValueError`. Attachments and token strings are
27
+ accepted wherever a plain value is, because that is how Shortcuts works.
28
+ - **Value helpers that match the engine's serialization.** `ref()` to another action's output,
29
+ `variable()`, `shortcut_input()`, `clipboard()`, `current_date()`, `ask()`, `text()` for
30
+ strings with embedded references, and `picker()` for variable-picker parameters.
31
+ - **Control flow.** `if_()` / `otherwise()` / `end_if()` and `repeat_each()` /
32
+ `end_repeat_each()` manage the grouping identifiers for you.
33
+ - **App Intents from installed apps.** Any identifier outside the built-in set is accepted and
34
+ its parameters are passed through as given.
35
+ - **Reference tables.** `CONDITION` codes, `ICON_COLORS`, and `PROVENANCE` recording which
36
+ macOS and Shortcuts build the bundled data came from.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install shortcutkit
42
+ ```
43
+
44
+ ### Requirements
45
+
46
+ - Python 3.9 or newer.
47
+ - A Mac signed into iCloud for `Shortcut.sign()`. It runs `shortcuts sign`, which refuses to
48
+ work without an iCloud login even in `anyone` mode. Building and writing the unsigned file
49
+ works on any platform.
50
+
51
+ ## Usage
52
+
53
+ ```python
54
+ from shortcutkit import Shortcut, actions, ref, text
55
+
56
+ s = Shortcut("Greeting", color="Teal")
57
+ got = s.action(actions.GETSTOREDCONTENT, WFStoredContentKey="greeting")
58
+ s.action(actions.SHOWRESULT, Text=text("Stored: ", ref(got)))
59
+ s.write() # Greeting.shortcut (unsigned)
60
+ Shortcut.sign("Greeting.shortcut", "Greeting-signed.shortcut") # macOS
61
+ ```
62
+
63
+ Open the signed file and Shortcuts imports it.
64
+
65
+ ### Control flow
66
+
67
+ `if_()` returns a grouping identifier that the matching `otherwise()` and `end_if()` calls
68
+ take back:
69
+
70
+ ```python
71
+ gid = s.if_(ref(got), "has_any_value")
72
+ s.action(actions.SHOWRESULT, Text=text("Stored value: ", ref(got)))
73
+ s.otherwise(gid)
74
+ s.action(actions.SHOWRESULT, Text=text("Nothing stored"))
75
+ s.end_if(gid)
76
+ ```
77
+
78
+ ### Actions from other apps
79
+
80
+ App Intents actions are not in the catalogue, so pass the identifier as a string:
81
+
82
+ ```python
83
+ s.action("com.example.app.CreateNote", title=text("Hello"), body=ref(got))
84
+ ```
85
+
86
+ ### Demo
87
+
88
+ The package ships a demo that builds a working shortcut with storage, an If/Otherwise block
89
+ and output, then signs it:
90
+
91
+ ```bash
92
+ python -m shortcutkit demo out.shortcut
93
+ ```
94
+
95
+ ## Documentation
96
+
97
+ - [Shortcut file format](https://github.com/frontboat/shortcutkit/blob/main/docs/shortcut-file-format.md):
98
+ the `.shortcut` format end to end, every field.
99
+ - [Built-in actions reference](https://github.com/frontboat/shortcutkit/blob/main/docs/builtin-actions-reference.md):
100
+ all 339 built-in actions with their parameters.
101
+ - [Parameter encodings](https://github.com/frontboat/shortcutkit/blob/main/docs/parameter-encodings.md):
102
+ how each parameter class is serialized.
103
+ - [Extraction notes](https://github.com/frontboat/shortcutkit/blob/main/docs/extraction.md):
104
+ how the data was obtained and what remains approximate.
105
+
106
+ ## Development
107
+
108
+ From a checkout of the repository:
109
+
110
+ ```bash
111
+ git clone https://github.com/frontboat/shortcutkit.git && cd shortcutkit/python
112
+ uv venv .venv && uv pip install -e . --python .venv/bin/python # or: python3 -m venv .venv && .venv/bin/pip install -e .
113
+ .venv/bin/python -m shortcutkit demo /tmp/Demo.shortcut
114
+ ```
115
+
116
+ `actions.py` and `data/` are generated by the repository's extraction tools from the Shortcuts
117
+ engine on a Mac. Do not edit them; change the generator or the data and run `bun run extract`
118
+ at the repository root. The Python and TypeScript packages are versioned together with the
119
+ data, and are kept in feature parity.
120
+
121
+ ## License
122
+
123
+ shortcutkit is licensed under the MIT license. See
124
+ [LICENSE](https://github.com/frontboat/shortcutkit/blob/main/LICENSE) for details.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "shortcutkit"
7
+ version = "0.1.0"
8
+ description = "Build, validate and sign Apple Shortcuts (.shortcut) files."
9
+ requires-python = ">=3.9"
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "frontboat" }]
14
+ keywords = ["apple", "shortcuts", "workflow", "automation", "macos", "ios", "plist"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Libraries",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/frontboat/shortcutkit"
25
+ Documentation = "https://github.com/frontboat/shortcutkit/blob/main/docs/shortcut-file-format.md"
26
+ Repository = "https://github.com/frontboat/shortcutkit.git"
27
+ Changelog = "https://github.com/frontboat/shortcutkit/blob/main/CHANGELOG.md"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.setuptools.package-data]
33
+ shortcutkit = ["data/*.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env python3
2
+ """shortcutkit: build, validate and sign Apple Shortcuts files from Python.
3
+
4
+ The file format is the one Shortcuts.app uses (verified against Apple's gallery
5
+ workflows in WorkflowKit and against this Mac's library). Actions and parameter keys
6
+ are validated against _builtin-actions.json produced by extract-builtin-actions.sh.
7
+
8
+ from shortcutkit import Shortcut, ref, text, ask, shortcut_input, clipboard, variable
9
+
10
+ s = Shortcut("Demo", color="Teal", glyph=0xF000)
11
+ stored = s.action("is.workflow.actions.setstoredcontent",
12
+ WFInput="hello", WFStoredContentKey="greeting", WFStoredContentGlobalValue=False)
13
+ got = s.action("is.workflow.actions.getstoredcontent", WFStoredContentKey="greeting")
14
+ s.action("is.workflow.actions.showresult", Text=text("Stored: ", ref(got)))
15
+ s.write("Demo.shortcut") # unsigned plist
16
+ s.sign("Demo.shortcut", "Demo-signed.shortcut") # runs `shortcuts sign --mode anyone`
17
+
18
+ Command line: python -m shortcutkit demo OUT.shortcut (writes and signs the demo above)
19
+
20
+ Value helpers return the exact structures WorkflowKit's state classes serialize:
21
+ ref(action) WFTextTokenAttachment -> that action's output
22
+ variable("Name") WFTextTokenAttachment -> a named variable
23
+ shortcut_input() WFTextTokenAttachment -> the shortcut's input
24
+ clipboard() WFTextTokenAttachment -> clipboard
25
+ current_date() WFTextTokenAttachment -> current date
26
+ ask("Prompt") WFTextTokenAttachment -> ask each time
27
+ text("a ", ref(x)) WFTextTokenString -> text with embedded references
28
+ picker(att) {"Type": "Variable", "Variable": att} (variable-picker parameters)
29
+ Plain str / int / float / bool / list are written as-is.
30
+ """
31
+ import json
32
+ import pathlib
33
+ import plistlib
34
+ import subprocess
35
+ import sys
36
+ import uuid
37
+
38
+ HERE = pathlib.Path(__file__).resolve().parent
39
+ DEFINITIONS = HERE / "data" / "builtin-actions.json"
40
+
41
+ # WFWorkflowIcon.backgroundColorValue for palette colors 0-14, as the unsigned 32-bit
42
+ # value the plist stores. Names follow Shortcuts' picker order.
43
+ ICON_COLORS = {
44
+ "Red": 4282601983, "DarkOrange": 4251333119, "Orange": 4271458815, "Yellow": 4274264319,
45
+ "Green": 4292093695, "Teal": 431817727, "LightBlue": 1440408063, "Blue": 463140863,
46
+ "DarkBlue": 946986751, "Violet": 2071128575, "Purple": 3679049983, "Pink": 3980825855,
47
+ "Taupe": 255, "Gray": 3031607807, "DarkGray": 2846468607,
48
+ }
49
+ DEFAULT_GLYPH = 61440 # +[WFWorkflowIcon defaultGlyphCharacter]
50
+
51
+ # Legacy WFCondition codes for is.workflow.actions.conditional. Shortcuts still reads these
52
+ # and migrates them to the modern WFConditions template on load. Codes marked (v) were
53
+ # verified against shortcuts on this Mac or Apple's gallery; the rest follow the same
54
+ # enumeration as documented by the community (shortcuts-js, Cherri).
55
+ CONDITION = {
56
+ "less_than": 0, # (v) numbers
57
+ "less_or_equal": 1,
58
+ "greater_than": 2, # (v) numbers
59
+ "greater_or_equal": 3,
60
+ "is": 4,
61
+ "is_not": 5,
62
+ "begins_with": 8,
63
+ "ends_with": 9,
64
+ "contains": 99, # (v) as filter Operator in the gallery
65
+ "has_any_value": 100, # (v) supportedComparisonOperators of every subject state
66
+ "has_no_value": 101, # (v)
67
+ "does_not_contain": 999,
68
+ "is_between": 1003,
69
+ }
70
+
71
+
72
+ # Keys Shortcuts still accepts on load even though the current definition no longer lists
73
+ # them. Legacy If: single condition on WFInput, migrated to WFConditions by the app.
74
+ LEGACY_KEYS = {
75
+ "is.workflow.actions.conditional": {"WFInput", "WFCondition", "WFConditionalActionString", "WFNumberValue",
76
+ "WFConditionalLegacyComparisonBehavior", "WFEnumerationValue", "WFBooleanValue",
77
+ "WFDate", "WFAnotherDate", "WFDuration", "WFConditions"},
78
+ "is.workflow.actions.choosefrommenu": {"WFMenuPrompt", "WFMenuItems", "WFMenuItemTitle", "WFMenuItemAttributedTitle"},
79
+ }
80
+
81
+
82
+ def _is_reference(value):
83
+ """Attachments, token strings and picker wrappers are accepted wherever a plain value is."""
84
+ return isinstance(value, dict) and ("WFSerializationType" in value or value.get("Type") == "Variable")
85
+
86
+
87
+ def _check_kind(kind, value):
88
+ """Run-time counterpart of the TypeScript ParamTypes: returns a problem string or None."""
89
+ if _is_reference(value):
90
+ return None
91
+ if kind == "bool" and not isinstance(value, bool):
92
+ return f"expected a bool or a reference, got {type(value).__name__}"
93
+ if kind in ("number", "plainNumber") and (isinstance(value, bool) or not isinstance(value, (int, float))):
94
+ return f"expected a number{'' if kind == 'plainNumber' else ' or a reference'}, got {type(value).__name__}"
95
+ if kind in ("string", "text", "plainString") and not isinstance(value, str):
96
+ return f"expected a string{'' if kind == 'plainString' else ' or a reference'}, got {type(value).__name__}"
97
+ if kind == "picker":
98
+ return "expected picker(<attachment>) or an attachment"
99
+ return None
100
+
101
+
102
+ def _attachment(value):
103
+ return {"WFSerializationType": "WFTextTokenAttachment", "Value": value}
104
+
105
+
106
+ def ref(action, output_name=None):
107
+ """Reference another action's output. `action` is the dict returned by Shortcut.action()."""
108
+ return _attachment({"Type": "ActionOutput", "OutputUUID": action["WFWorkflowActionParameters"]["UUID"],
109
+ "OutputName": output_name or action.get("_outputName", "Output")})
110
+
111
+
112
+ def variable(name):
113
+ return _attachment({"Type": "Variable", "VariableName": name})
114
+
115
+
116
+ def shortcut_input():
117
+ return _attachment({"Type": "ExtensionInput"})
118
+
119
+
120
+ def clipboard():
121
+ return _attachment({"Type": "Clipboard"})
122
+
123
+
124
+ def current_date():
125
+ return _attachment({"Type": "CurrentDate"})
126
+
127
+
128
+ def ask(prompt=None):
129
+ return _attachment({"Type": "Ask", **({"Prompt": prompt} if prompt else {})})
130
+
131
+
132
+ def picker(attachment):
133
+ """Wrap an attachment for WFVariablePickerParameter keys (e.g. WFInput on Repeat with Each)."""
134
+ return {"Type": "Variable", "Variable": attachment}
135
+
136
+
137
+ def text(*parts):
138
+ """A WFTextTokenString: strings and attachments interleaved. Each attachment becomes U+FFFC."""
139
+ string = ""
140
+ attachments = {}
141
+ for part in parts:
142
+ if isinstance(part, dict) and part.get("WFSerializationType") == "WFTextTokenAttachment":
143
+ # NSRange offsets are UTF-16 code units, not code points.
144
+ attachments[f"{{{len(string.encode('utf-16-le')) // 2}, 1}}"] = part["Value"]
145
+ string += ""
146
+ else:
147
+ string += str(part)
148
+ return {"WFSerializationType": "WFTextTokenString", "Value": {"string": string, "attachmentsByRange": attachments}}
149
+
150
+
151
+ class Shortcut:
152
+ def __init__(self, name, color="Blue", glyph=DEFAULT_GLYPH, input_classes=None, definitions=DEFINITIONS):
153
+ self.name = name
154
+ self.color = ICON_COLORS[color] if isinstance(color, str) else color
155
+ self.glyph = glyph
156
+ self.input_classes = input_classes or []
157
+ self.actions = []
158
+ self.defs = json.load(open(definitions)) if pathlib.Path(definitions).exists() else {}
159
+
160
+ def action(self, identifier, **params):
161
+ """Append an action. Unknown identifiers or parameter keys raise ValueError."""
162
+ definition = self.defs.get(identifier)
163
+ if self.defs and definition is None and identifier.startswith("is.workflow."):
164
+ raise ValueError(f"unknown built-in action {identifier}")
165
+ if definition:
166
+ known = {p.get("Key") for p in definition.get("Parameters", []) if isinstance(p, dict)}
167
+ known |= {"UUID", "GroupingIdentifier", "WFControlFlowMode", "CustomOutputName"}
168
+ known |= LEGACY_KEYS.get(identifier, set())
169
+ inp = definition.get("Input")
170
+ if isinstance(inp, dict) and inp.get("ParameterKey"):
171
+ known.add(inp["ParameterKey"])
172
+ unknown = set(params) - known
173
+ if unknown and known - {"UUID", "GroupingIdentifier", "WFControlFlowMode", "CustomOutputName"}:
174
+ raise ValueError(f"{identifier}: unknown parameter(s) {sorted(unknown)}; known: {sorted(known)}")
175
+ for key, value in params.items():
176
+ problem = _check_kind(PARAM_KINDS.get(identifier, {}).get(key, "any"), value)
177
+ if problem:
178
+ raise ValueError(f"{identifier}.{key}: {problem}")
179
+ entry = {"WFWorkflowActionIdentifier": identifier,
180
+ "WFWorkflowActionParameters": {"UUID": str(uuid.uuid4()).upper(), **params}}
181
+ output = (definition or {}).get("Output", {})
182
+ entry["_outputName"] = output.get("OutputName") if isinstance(output, dict) else None
183
+ if isinstance(entry["_outputName"], dict):
184
+ entry["_outputName"] = entry["_outputName"].get("format")
185
+ self.actions.append(entry)
186
+ return entry
187
+
188
+ # Control flow: each block is a group sharing a GroupingIdentifier, with
189
+ # WFControlFlowMode 0 = start, 1 = middle (Otherwise / menu item), 2 = end.
190
+ def if_(self, subject, condition, value=None):
191
+ gid = str(uuid.uuid4()).upper()
192
+ params = {"GroupingIdentifier": gid, "WFControlFlowMode": 0, "WFInput": picker(subject),
193
+ "WFCondition": CONDITION[condition] if isinstance(condition, str) else condition}
194
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
195
+ params["WFNumberValue"] = value
196
+ elif value is not None:
197
+ params["WFConditionalActionString"] = value
198
+ self.action("is.workflow.actions.conditional", **params)
199
+ return gid
200
+
201
+ def otherwise(self, gid):
202
+ self.action("is.workflow.actions.conditional", GroupingIdentifier=gid, WFControlFlowMode=1)
203
+
204
+ def end_if(self, gid):
205
+ self.action("is.workflow.actions.conditional", GroupingIdentifier=gid, WFControlFlowMode=2)
206
+
207
+ def repeat_each(self, items):
208
+ """Open a Repeat with Each block; returns the grouping identifier to pass to end_repeat_each()."""
209
+ gid = str(uuid.uuid4()).upper()
210
+ self.action("is.workflow.actions.repeat.each", GroupingIdentifier=gid, WFControlFlowMode=0, WFInput=picker(items))
211
+ return gid
212
+
213
+ def end_repeat_each(self, gid):
214
+ self.action("is.workflow.actions.repeat.each", GroupingIdentifier=gid, WFControlFlowMode=2)
215
+
216
+ def to_plist(self):
217
+ actions = []
218
+ for a in self.actions:
219
+ actions.append({k: v for k, v in a.items() if not k.startswith("_")})
220
+ return {
221
+ "WFWorkflowClientVersion": "4018.0.4",
222
+ "WFWorkflowMinimumClientVersion": 900,
223
+ "WFWorkflowMinimumClientVersionString": "900",
224
+ "WFWorkflowIcon": {"WFWorkflowIconStartColor": self.color, "WFWorkflowIconGlyphNumber": self.glyph},
225
+ "WFWorkflowTypes": [],
226
+ "WFQuickActionSurfaces": [],
227
+ "WFWorkflowInputContentItemClasses": self.input_classes,
228
+ "WFWorkflowOutputContentItemClasses": [],
229
+ "WFWorkflowImportQuestions": [],
230
+ "WFWorkflowHasShortcutInputVariables": any(
231
+ json.dumps(a).find('"ExtensionInput"') >= 0 for a in actions),
232
+ "WFWorkflowHasOutputFallback": False,
233
+ "WFWorkflowActions": actions,
234
+ }
235
+
236
+ def write(self, path=None):
237
+ """Write the unsigned plist. Shortcuts names an imported shortcut after its file, so the
238
+ default file name is the shortcut's name."""
239
+ path = pathlib.Path(path) if path else pathlib.Path(f"{self.name}.shortcut")
240
+ with open(path, "wb") as f:
241
+ plistlib.dump(self.to_plist(), f, fmt=plistlib.FMT_BINARY)
242
+ return path
243
+
244
+ @staticmethod
245
+ def sign(src, dst, mode="anyone"):
246
+ subprocess.run(["shortcuts", "sign", "--mode", mode, "--input", str(src), "--output", str(dst)], check=True)
247
+ return dst
248
+
249
+
250
+ def demo(out):
251
+ out = pathlib.Path(out)
252
+ s = Shortcut(out.stem, color="Teal")
253
+ stored = s.action("is.workflow.actions.setstoredcontent", WFInput="hello from python",
254
+ WFStoredContentKey="demo-greeting", WFStoredContentGlobalValue=False)
255
+ got = s.action("is.workflow.actions.getstoredcontent", WFStoredContentKey="demo-greeting")
256
+ gid = s.if_(ref(got), "has_any_value")
257
+ s.action("is.workflow.actions.showresult", Text=text("Stored value: ", ref(got)))
258
+ s.otherwise(gid)
259
+ s.action("is.workflow.actions.showresult", Text=text("Nothing stored"))
260
+ s.end_if(gid)
261
+ unsigned = pathlib.Path(out).with_suffix(".unsigned.shortcut")
262
+ s.write(unsigned)
263
+ s.sign(unsigned, out)
264
+ print(f"wrote {out} ({len(s.actions)} actions)")
265
+
266
+
267
+ from . import actions # noqa: E402 (generated identifier constants and ACTIONS metadata)
268
+ from .actions import ACTIONS, PARAM_KINDS # noqa: E402
269
+
270
+ PROVENANCE = json.load(open(HERE / "data" / "provenance.json")) if (HERE / "data" / "provenance.json").exists() else {}
271
+ """Which macOS and Shortcuts build the bundled data was extracted from."""
272
+
273
+ __all__ = ["actions", "ACTIONS", "PARAM_KINDS", "PROVENANCE", "Shortcut", "ref", "variable", "shortcut_input", "clipboard", "current_date", "ask", "picker", "text",
274
+ "ICON_COLORS", "CONDITION", "DEFAULT_GLYPH", "LEGACY_KEYS", "demo"]
275
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ import sys
2
+ from . import demo
3
+
4
+ if len(sys.argv) == 3 and sys.argv[1] == "demo":
5
+ demo(sys.argv[2])
6
+ else:
7
+ sys.exit("usage: python -m shortcutkit demo OUT.shortcut")