Scripting
Automate macOS tasks with Python scripts — leader keys, hotkeys, alerts, and more.
Table of Contents
- Quick Start
- Leader Keys
- Launcher
- API Reference
- wz.leader(trigger_key, mappings)
- wz.app.launch(name)
- wz.app.frontmost()
- wz.alert(text, duration=2.0)
- wz.notify(title, message="")
- wz.pasteboard.get()
- wz.pasteboard.set(text)
- wz.keystroke(key, modifiers=None)
- wz.execute(command, background=True)
- wz.timer.after(seconds, callback)
- wz.timer.every(seconds, callback)
- wz.timer.cancel(timer_id)
- wz.date(format="%Y-%m-%d")
- wz.reload()
- wz.chooser.show(initial_query=None)
- wz.chooser.close()
- wz.chooser.toggle()
- wz.chooser.show_source(prefix)
- wz.chooser.register_source(source)
- wz.chooser.unregister_source(name)
- wz.chooser.pick(items, callback, placeholder="Choose...")
- @wz.chooser.on(event)
- wz.chooser.register_command(name, title, action, ...)
- wz.chooser.unregister_command(name)
- @wz.chooser.command(name, title, ...)
- @wz.chooser.source(name, prefix=None, priority=0, description="")
- Examples
- Launcher Configuration
- Script Environment
- Security
- Troubleshooting
闻字 includes a Python-based scripting system that lets you automate macOS tasks — launch apps with leader keys, bind global hotkeys, show alerts, and more.
Quick Start
- Enable scripting in Settings → General → Scripting, or set it directly in
config.json:
{
"scripting": {
"enabled": true
}
}
- Create your script at
~/.config/WenZi/scripts/init.py:
wz.leader("cmd_r", [
{"key": "w", "app": "WeChat"},
{"key": "s", "app": "Slack"},
{"key": "t", "app": "iTerm"},
])
- Restart 闻字. Hold right Command, see the mapping panel, press a letter key to launch the app.
Leader Keys
Leader keys let you hold a trigger key (like right Command) and then press a second key to perform an action. A floating panel shows available mappings while the trigger key is held.
wz.leader("cmd_r", [
{"key": "w", "app": "WeChat"},
{"key": "f", "app": "Safari"},
{"key": "g", "app": "/Users/me/Applications/Google Chrome.app"},
{"key": "i", "exec": "/usr/local/bin/code ~/work/projects", "desc": "projects"},
{"key": "d", "desc": "date", "func": lambda: (
wz.pasteboard.set(wz.date("%Y-%m-%d")),
wz.notify("Date copied", wz.date("%Y-%m-%d")),
)},
{"key": "r", "desc": "reload", "func": lambda: wz.reload()},
])
Trigger Keys
Any modifier key can be a trigger. Available names:
| Key | Name |
|---|---|
| Right Command | cmd_r |
| Right Alt/Option | alt_r |
| Right Shift | shift_r |
| Right Control | ctrl_r |
| Left Command | cmd |
| Left Alt/Option | alt |
| Left Shift | shift |
| Left Control | ctrl |
You can register multiple leaders with different trigger keys:
wz.leader("cmd_r", [...]) # Right Command for apps
wz.leader("alt_r", [...]) # Right Alt for utilities
Mapping Actions
Each mapping dict requires "key" and one action:
| Field | Type | Description |
|---|---|---|
key |
str |
The sub-key to press (e.g. "w", "1", "f") |
app |
str |
App name or full .app path to launch/focus |
func |
callable |
Python function to call |
exec |
str |
Shell command to execute |
desc |
str |
Optional description shown in the panel |
If desc is omitted, the panel displays the app name or command.
Launcher
The Launcher is a keyboard-driven search panel (similar to Alfred or Raycast) that lets you quickly find and open apps, files, bookmarks, clipboard history, and code snippets. It is built into the scripting system and activated via a configurable hotkey.
Activation
- Default hotkey:
Cmd+Space(configurable viascripting.chooser.hotkeyin config) - Per-source hotkey: Each source can have its own direct hotkey (see Configuration below)
- Scripting API:
wz.chooser.show()/wz.chooser.toggle()
Search Modes
The Launcher supports two search modes:
- Global search: Type normally to search across all non-prefix sources (e.g. apps). Results are ranked by priority and fuzzy match score.
- Prefix search: Type a prefix followed by a space to activate a specific source. For example,
f readmesearches files for "readme".
Built-in Data Sources
| Source | Prefix | Description |
|---|---|---|
| Apps | (none) | Search installed applications. Always active in global search. |
| Calculator | (none) | Math evaluation and unit conversion. Always active in global search. |
| Commands | > |
Command palette — scripts register named commands here. |
| Files | f |
Search files by name via macOS Spotlight. |
| Folders | fd |
Search folders via macOS Spotlight. |
| Clipboard | cb |
Browse clipboard history (text and images). |
| Snippets | sn |
Search text snippets with keyword expansion. |
| Bookmarks | bm |
Search browser bookmarks (Chrome, Safari, Arc, Edge, Brave, Firefox). |
Prefixes are configurable via scripting.chooser.prefixes in config. The > prefix is reserved for commands and cannot be changed.
Keyboard Shortcuts
| Shortcut | Action |
|---|---|
↑ ↓ |
Navigate results |
Enter |
Open / execute selected item |
⌘+Enter |
Reveal in Finder (for file-based items) |
⌘1 – ⌘9 |
Quick select by position |
Tab |
Auto-complete (e.g. complete command name in > mode) |
Esc |
Close the Launcher |
Alt / Ctrl / Shift (hold) |
Show alternative action for selected item |
Custom Sources
You can register your own data sources via the @wz.chooser.source decorator:
@wz.chooser.source("todos", prefix="td", priority=5)
def search_todos(query):
return [
{"title": "Fix bug #123", "subtitle": "backend", "action": lambda: ...},
{"title": "Write docs", "subtitle": "frontend", "action": lambda: ...},
]
Commands
Scripts can register named commands that appear in the command palette (activated by typing > in the Launcher). Commands support argument passing, modifier keys, and Tab completion.
# Decorator style
@wz.chooser.command("greet", title="Greet", subtitle="Say hello")
def greet(args):
name = args.strip() or "World"
wz.notify("Hello", f"Hello, {name}!")
# Direct registration
wz.chooser.register_command(
name="open-url",
title="Open URL",
subtitle="Open a URL in browser",
action=lambda args: wz.execute(f"open {args.strip()}"),
)
Argument passing: Type the full command name followed by a space to enter args mode. For example, > greet Alice passes "Alice" as the args parameter. Tab-completing a command name also enters args mode.
Modifier keys: Commands can define alternative actions for modifier keys:
wz.chooser.register_command(
name="deploy",
title="Deploy",
action=lambda args: deploy(args),
modifiers={
"alt": {"subtitle": "Force deploy", "action": lambda args: force_deploy(args)},
},
)
Promoted commands: By default, commands only appear when the > prefix is active. Set promoted=True to also show them in the main (unprefixed) search alongside apps and other results:
@wz.chooser.command("reload", title="Reload Scripts", promoted=True)
def reload(args):
wz.reload()
Built-in help: A promoted help command is always available. Type "help" in the Launcher and press Enter to see all available prefixes and their descriptions.
Usage Learning
When enabled (default), the Launcher tracks which items you select for each query and boosts frequently used items in future results. Data is stored locally at ~/.config/WenZi/chooser_usage.json.
API Reference
wz.leader(trigger_key, mappings)
Register a leader-key configuration.
wz.leader("cmd_r", [
{"key": "w", "app": "WeChat"},
])
wz.app.launch(name)
Launch or focus an application. Accepts app name or full path.
wz.app.launch("Safari")
wz.app.launch("/Applications/Visual Studio Code.app")
wz.app.frontmost()
Return the localized name of the frontmost application.
name = wz.app.frontmost() # e.g. "Finder"
wz.alert(text, duration=2.0)
Show a brief floating message on screen. Auto-dismisses after duration seconds.
wz.alert("Hello!", duration=3.0)
wz.notify(title, message="")
Send a macOS notification.
wz.notify("Build complete", "All tests passed")
wz.pasteboard.get()
Return the current clipboard text, or None.
text = wz.pasteboard.get()
wz.pasteboard.set(text)
Set the clipboard text.
wz.pasteboard.set("Hello, world!")
wz.keystroke(key, modifiers=None)
Synthesize a keystroke via Quartz CGEvent.
wz.keystroke("c", modifiers=["cmd"]) # Cmd+C
wz.keystroke("v", modifiers=["cmd"]) # Cmd+V
wz.keystroke("space") # Space
wz.keystroke("a", modifiers=["cmd", "shift"]) # Cmd+Shift+A
wz.execute(command, background=True)
Execute a shell command.
wz.execute("open ~/Downloads") # Background (returns None)
output = wz.execute("date", background=False) # Foreground (returns stdout)
wz.timer.after(seconds, callback)
Execute a function once after a delay. Returns a timer_id.
tid = wz.timer.after(5.0, lambda: wz.alert("5 seconds passed"))
wz.timer.every(seconds, callback)
Execute a function repeatedly at an interval. Returns a timer_id.
tid = wz.timer.every(60.0, lambda: wz.notify("Reminder", "Take a break"))
wz.timer.cancel(timer_id)
Cancel a timer.
tid = wz.timer.every(10.0, my_func)
wz.timer.cancel(tid)
wz.date(format="%Y-%m-%d")
Return the current date/time as a formatted string.
wz.date() # "2025-03-15"
wz.date("%H:%M:%S") # "14:30:00"
wz.date("%Y-%m-%d %H:%M") # "2025-03-15 14:30"
wz.reload()
Reload all scripts. Stops current listeners, purges cached modules from the scripts directory, re-reads init.py (and any imported sub-modules), and restarts. All file changes are picked up on reload.
wz.reload()
wz.chooser.show(initial_query=None)
Show the Launcher panel. Optionally pre-fill the search input.
wz.chooser.show()
wz.chooser.show(initial_query="f readme")
wz.chooser.close()
Close the Launcher panel.
wz.chooser.toggle()
Toggle the Launcher panel visibility.
wz.chooser.show_source(prefix)
Show the Launcher with a specific source activated.
wz.chooser.show_source("cb") # Opens with clipboard history
wz.chooser.register_source(source)
Register a ChooserSource object as a data source.
wz.chooser.unregister_source(name)
Remove a registered data source by name.
wz.chooser.pick(items, callback, placeholder="Choose...")
Use the Launcher as a generic selection UI. Shows a fixed list of items; calls callback(item_dict) when the user picks one, or callback(None) if dismissed.
wz.chooser.pick(
[{"title": "Option A"}, {"title": "Option B"}],
callback=lambda item: print(item),
placeholder="Pick one...",
)
@wz.chooser.on(event)
Decorator to register a Launcher event handler.
Supported events: open, close, select, delete.
@wz.chooser.on("select")
def on_select(item_info):
print(f"Selected: {item_info['title']}")
wz.chooser.register_command(name, title, action, ...)
Register a named command in the command palette (> prefix).
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
str |
(required) | Unique command name (single token, e.g. "reload-scripts") |
title |
str |
(required) | Human-readable title shown in the Launcher |
action |
callable |
(required) | Callback receiving args: action(args_str) |
subtitle |
str |
"" |
Description shown below the title |
icon |
str |
"" |
Icon URL (file:// or data: URI) |
modifiers |
dict |
None |
Modifier actions (see Commands section above) |
promoted |
bool |
False |
Also appear in the unprefixed main search |
wz.chooser.register_command(
name="greet",
title="Greet",
action=lambda args: wz.notify("Hello", args.strip() or "World"),
promoted=True,
)
wz.chooser.unregister_command(name)
Remove a registered command by name.
@wz.chooser.command(name, title, ...)
Decorator to register a function as a Launcher command. Same parameters as register_command except action (the decorated function is the action).
@wz.chooser.command("greet", title="Greet", promoted=True)
def greet(args):
wz.notify("Hello", args.strip() or "World")
@wz.chooser.source(name, prefix=None, priority=0, description="")
Decorator to register a search function as a Launcher data source. Set description so the source appears in the built-in help command output.
@wz.chooser.source("notes", prefix="n", priority=5, description="Search notes")
def search_notes(query):
return [{"title": "...", "action": lambda: ...}]
Examples
App Launcher
wz.leader("cmd_r", [
{"key": "1", "app": "1Password"},
{"key": "b", "app": "Obsidian"},
{"key": "c", "app": "Calendar"},
{"key": "f", "app": "Safari"},
{"key": "g", "app": "/Users/me/Applications/Google Chrome.app"},
{"key": "n", "app": "Notes"},
{"key": "s", "app": "Slack"},
{"key": "t", "app": "iTerm"},
{"key": "v", "app": "Visual Studio Code"},
{"key": "w", "app": "WeChat"},
{"key": "z", "app": "zoom.us"},
])
Utility Keys
wz.leader("alt_r", [
{"key": "d", "desc": "date → clipboard", "func": lambda: (
wz.pasteboard.set(wz.date("%Y-%m-%d")),
wz.notify("Date copied", wz.date("%Y-%m-%d")),
)},
{"key": "t", "desc": "timestamp", "func": lambda: (
wz.pasteboard.set(wz.date("%Y-%m-%d %H:%M:%S")),
wz.alert("Timestamp copied"),
)},
{"key": "r", "desc": "reload scripts", "func": lambda: wz.reload()},
])
Timed Reminders
# Remind to take a break every 30 minutes
wz.timer.every(1800, lambda: wz.notify("Break", "Stand up and stretch!"))
Quick Actions with Hotkeys
# Ctrl+Cmd+N to open a new note
wz.hotkey.bind("ctrl+cmd+n", lambda: wz.execute("open -a Notes"))
Launcher Configuration
The Launcher is configured under scripting.chooser in config.json:
{
"scripting": {
"chooser": {
"enabled": true,
"hotkey": "cmd+space",
"app_search": true,
"file_search": true,
"clipboard_history": false,
"snippets": false,
"bookmarks": true,
"usage_learning": true,
"prefixes": {
"clipboard": "cb",
"files": "f",
"snippets": "sn",
"bookmarks": "bm"
},
"source_hotkeys": {
"clipboard": "",
"files": "",
"snippets": "",
"bookmarks": ""
}
}
}
}
| Option | Default | Description |
|---|---|---|
enabled |
false |
Master switch for the Launcher |
hotkey |
"cmd+space" |
Global hotkey to toggle the Launcher |
app_search |
true |
Enable application search |
file_search |
true |
Enable Spotlight file search |
clipboard_history |
false |
Enable clipboard history tracking |
snippets |
false |
Enable snippet search and expansion |
bookmarks |
true |
Enable browser bookmark search |
usage_learning |
true |
Track selection frequency for smarter ranking |
prefixes |
(see above) | Prefix strings to activate each source |
source_hotkeys |
(empty) | Direct hotkeys to open Launcher with a source pre-selected |
Script Environment
- Scripts run as standard Python with full access to
import - The
wzobject is available as a global variable ininit.py— no import needed - In sub-modules, access
wzviafrom wenzi.scripting.api import wz - Errors in scripts are caught and displayed as alerts
- Scripts are loaded once at startup; use
wz.reload()to re-read changes - Script path:
~/.config/WenZi/scripts/init.py - Custom script directory can be set via
"scripting": {"script_dir": "/path/to/scripts"}in config
Multi-file Scripts
You can split your scripts into multiple .py files. init.py is the entry point; other files in the same directory can be imported using standard import statements:
~/.config/WenZi/scripts/
├── init.py # Entry point
├── my_sources.py # Custom launcher sources
└── utils/
├── __init__.py
└── formatting.py
# init.py
import my_sources
from utils.formatting import fmt_date
wz.chooser.register_source(my_sources.build_source())
wz.hotkey.bind("cmd+shift+d", lambda: wz.type_text(fmt_date()))
# my_sources.py
from wenzi.scripting.api import wz # import wz in sub-modules
def build_source():
@wz.chooser.source("todos", prefix="td")
def search_todos(query):
return [{"title": "Example todo", "action": lambda: wz.alert("Done!")}]
When you call wz.reload(), all files in the scripts directory are reloaded — not just init.py.
Note: Only absolute imports are supported (
import helper,from utils import foo). Relative imports (from . import foo) do not work becauseinit.pyis not loaded as a Python package.Note: Do not define PyObjC
NSObjectsubclasses in user scripts. The Objective-C runtime does not support re-registering a class with the same name, which causes a crash on reload.
Security
Scripts run as unsandboxed Python with the same permissions as 闻字 itself. This means a script can:
- Read and write any file your user account can access
- Execute arbitrary shell commands
- Access the network
- Read the clipboard
- Simulate keystrokes and interact with other applications
Only run scripts you wrote yourself or have reviewed. Do not copy-paste scripts from untrusted sources without reading them first. A malicious script could silently exfiltrate data, install software, or modify files.
Scripting is disabled by default for this reason.
Troubleshooting
Scripts not loading?
- Check that "scripting": {"enabled": true} is set in config.json
- Restart 闻字 after enabling
- Check logs at ~/Library/Logs/WenZi/wenzi.log for errors
Leader key not responding?
- Ensure 闻字 has Accessibility permission (System Settings → Privacy & Security → Accessibility)
- Verify the trigger key name is correct (e.g. cmd_r not right_cmd)
Alert panel not visible? - The panel requires Accessibility permission to display over other apps
Script errors?
- Syntax errors and exceptions are logged and shown as alerts
- Check ~/Library/Logs/WenZi/wenzi.log for stack traces