Pattern: Write Actions via Run Script¶
A write Script Filter triggers a side effect when the user presses Enter —
writing a file, updating a settings store, running a shell command. Alfred
does not call Python a second time on Enter; instead it executes a bash command
stored in the selected item’s arg.
The run_script pattern encodes that command inside the Script Filter
response, so the side-effect logic travels to Alfred as data and is executed
later by Alfred’s Run Script widget.
The Two-Phase Architecture¶
Phase 1 — Script Filter (on each keystroke)
┌─────────────────────────────────────────────────────────────┐
│ Python builds a command string: │
│ "/path/afwf-examples write-file-request --content 'hello'" │
│ │
│ item.run_script(cmd) → variables["run_script"] = "y" │
│ variables["run_script_arg"] = cmd │
│ item.arg = cmd │
└─────────────────────────────────────────────────────────────┘
↓ user presses Enter
Phase 2 — Alfred executes the command
┌─────────────────────────────────────────────────────────────┐
│ Conditional (run_script=y) → Run Script widget │
│ Script field: {query} (Alfred sets query = item.arg) │
│ │
│ Shell runs: /path/afwf-examples write-file-request ... │
│ Python writes the file. │
└─────────────────────────────────────────────────────────────┘
The *-request subcommands in cli.py are not Script Filter endpoints.
They exist solely as stable shell entry points that Alfred can invoke.
The sys.executable Trick¶
The command string must reference the CLI binary by absolute path because
Alfred’s sandboxed shell does not inherit the user’s $PATH. Deriving the
path from sys.executable is reliable in both dev (venv) and production
(uvx-managed) environments:
import sys
from pathlib import Path
# sys.executable = /path/to/.venv/bin/python (dev)
# or /path/to/uvx-cache/python (production)
bin_afwf_examples = Path(sys.executable).parent / "afwf-examples"
Both write_file.py and set_settings.py use this pattern.
write-file: run_script + send_notification¶
Keyword: afwf-write-file
Script: afwf-examples write-file --query '{query}'
What it does: Shows a single confirmation item for whatever the user has
typed. Pressing Enter writes that text to ~/.alfred-afwf/file.txt and
posts a macOS notification.
# -*- coding: utf-8 -*-
"""
Example: Write File
===================
**What it demonstrates**
Shows how to bind the Enter key in a Script Filter to an arbitrary Python
action via Alfred's *Run Script* widget.
When the user types text in the ``write-file`` Script Filter and presses
Enter, Alfred does **not** call Python directly — it executes whatever bash
command is stored in the selected item's ``arg``. We therefore construct that
command string dynamically inside :func:`_build_cmd`, encoding the user's
input into it so Alfred can hand it back to us as a CLI invocation.
The actual write logic lives in :func:`write_request`. The CLI exposes it as
the ``write-file-request`` sub-command. That sub-command is **not** a Script
Filter endpoint — it is never called by Alfred's keyword trigger. It exists
solely as a stable shell entry point that the dynamically built command can
call when Alfred fires the *Run Script* action::
/path/to/.venv/bin/afwf-examples write-file-request --content 'hello'
Use ``read_file`` to verify the written content afterwards.
"""
import sys
from pathlib import Path
import afwf.api as afwf
from afwf.paths import path_enum
path_file = path_enum.dir_afwf / "file.txt"
def write_request(content: str) -> None:
"""Write *content* to ``file.txt`` under the afwf home directory.
This function is **not** invoked by Alfred's Script Filter directly.
It is the target of the dynamically built CLI command produced by
:func:`_build_cmd`, which Alfred executes via a *Run Script* widget
when the user presses Enter on the ``write-file`` item.
"""
path_file.parent.mkdir(parents=True, exist_ok=True)
path_file.write_text(content)
def _build_cmd(content: str) -> str:
"""Build the shell command that Alfred's *Run Script* widget will execute.
When the user presses Enter, Alfred runs the bash command stored in the
item's ``arg``. We construct that command here, embedding ``content`` so
Alfred can pass it back to :func:`write_request` via the CLI.
The ``write-file-request`` sub-command is not a Script Filter endpoint —
it is a thin CLI wrapper around :func:`write_request` that exists only to
give Alfred a runnable command.
We derive the CLI binary path from ``sys.executable`` (e.g.
``/path/to/.venv/bin/python`` → ``/path/to/.venv/bin/afwf-examples``)
rather than relying on PATH lookup, which is unreliable inside Alfred's
sandboxed shell environment.
"""
# sys.executable is e.g. /path/to/.venv/bin/python; the CLI lives beside it.
bin_afwf_examples = Path(sys.executable).parent / "afwf-examples"
return f"{bin_afwf_examples} write-file-request --content {content!r}"
@afwf.log_error(log_file=path_enum.dir_afwf / "write_file.log")
def main(query: str) -> afwf.ScriptFilter:
sf = afwf.ScriptFilter()
item = afwf.Item(
title=f"Write {query!r} to {path_file}",
)
item.run_script(_build_cmd(query))
item.send_notification(
title=f"Write {query!r} to {path_file}",
subtitle="success",
)
sf.items.append(item)
return sf
Key points:
main()builds the command with_build_cmd(query)and attaches it viaitem.run_script(cmd)anditem.send_notification(...).write_request()is the actual write logic. It is separated frommain()so it can be unit-tested directly without going through Alfred.The command embeds the user’s input as a quoted argument:
--content 'hello world'. The!rrepr-quoting in_build_cmdhandles spaces and special characters.
Downstream widgets (from info.plist):
Script Filter ──► Conditional (run_script=y) ──► Run Script {query}
└──► (notification is set as a variable; add Post Notification
widget connected after Run Script if desired)
set-settings: Two-Step Fuzzy Key Picker¶
Keyword: afwf-set-settings
Script: afwf-examples set-settings --query '{query}'
What it does: Implements a two-step UI entirely in one Script Filter:
0 words — show all valid setting keys as fuzzy-searchable items.
1 word — fuzzy-filter the key list as the user types.
2+ words — show a confirmation item; pressing Enter writes the value.
# -*- coding: utf-8 -*-
"""
Example: Set Settings
=====================
**What it demonstrates**
Shows a two-step fuzzy-picker pattern for writing to a persistent key-value
store, and how to bind the Enter key to an arbitrary Python action via Alfred's
*Run Script* widget (the same technique as ``write_file.py``).
The Script Filter behaves differently depending on how many words the user has
typed:
- **0 words** — list all valid setting keys as fuzzy-searchable items.
- **1 word (key only)** — fuzzy-match the typed prefix against the key list.
- **2 words (key + value)** — show a single confirmation item; pressing Enter
triggers a dynamically built CLI command that writes the value:
.. code-block::
/path/to/.venv/bin/afwf-examples set-settings-request --key username --value alice
The ``set-settings-request`` sub-command is **not** a Script Filter endpoint.
It exists solely as a CLI entry point for the run_script action, analogous to
``write-file-request`` in ``write_file.py``.
"""
import sys
from pathlib import Path
import afwf.api as afwf
import afwf.opt.fuzzy_item.api as fuzzy_item
from afwf.examples.settings import settings, SettingsKeyEnum
def _all_key_items() -> list[fuzzy_item.Item]:
"""Return one fuzzy item per setting key."""
items = []
for sk in SettingsKeyEnum:
item = fuzzy_item.Item(
title=sk.value,
subtitle=f"set {sk.value} to ...",
autocomplete=sk.value + " ",
)
item.set_fuzzy_match_name(sk.value)
items.append(item)
return items
def _build_cmd(key: str, value: str) -> str:
"""Build the shell command Alfred's *Run Script* widget will execute.
See ``write_file.py`` for a detailed explanation of this pattern.
The binary is derived from ``sys.executable`` so it works reliably inside
Alfred's sandboxed shell environment.
"""
bin_afwf_examples = Path(sys.executable).parent / "afwf-examples"
return f"{bin_afwf_examples} set-settings-request --key {key!r} --value {value!r}"
def set_settings_request(key: str, value: str) -> None:
"""Write *value* for *key* into the settings store.
This function is **not** called by Alfred's Script Filter directly.
It is the target of the dynamically built CLI command produced by
:func:`_build_cmd`, executed by Alfred via a *Run Script* widget when
the user confirms a key=value pair.
"""
settings[key] = value
@afwf.log_error(log_file=afwf.path_enum.dir_afwf / "set_settings.log")
def main(query: str) -> afwf.ScriptFilter:
sf = afwf.ScriptFilter()
q = afwf.Query.from_str(query)
if q.n_trimmed_parts == 0:
# No input yet — show all keys.
sf.items.extend(_all_key_items())
elif q.n_trimmed_parts == 1:
# One word typed — fuzzy-filter the key list.
key = q.trimmed_parts[0]
all_items = _all_key_items()
matcher = fuzzy_item.FuzzyItemMatcher.from_items(all_items)
matched = matcher.match(key, threshold=0)
sf.items.extend(matched if matched else all_items)
else:
# Two+ words — treat first as key, rest joined as value.
key = q.trimmed_parts[0]
value = " ".join(q.trimmed_parts[1:])
if key in SettingsKeyEnum.__members__:
item = afwf.Item(
title=f"Set settings.{key} = {value!r}",
)
item.run_script(_build_cmd(key, value))
item.send_notification(title=f"Set settings.{key} = {value!r}")
sf.items.append(item)
else:
item = afwf.Item(title=f"{key!r} is not a valid settings key")
item.set_icon(afwf.IconFileEnum.error)
sf.items.append(item)
return sf
Key points:
argumenttype: 0(optional) ininfo.plist— the query may be empty on first invocation.Queryis used to branch onn_trimmed_parts(see QueryParser and Query).The confirmation item’s
autocompletefield on the key items is set tosk.value + " "— when the user selects a key with Tab, Alfred pre-fills the keyword plus the chosen key and a trailing space, putting the cursor in the right position to type the value.Invalid keys (not in
SettingsKeyEnum) show an error item withIconFileEnum.errorso the user gets immediate feedback.settingsis a module-level_JsonSettingssingleton backed by~/.alfred-afwf/settings.json.
Companion Script Filter: afwf-view-settings (view_settings.py)
reads from the same store and displays all current key-value pairs. Use it
to confirm a write was successful.
Downstream widgets (from info.plist):
Script Filter ──► Conditional (run_script=y) ──► Run Script {query}
Combining run_script and send_notification¶
Both write_file and set_settings call both item.run_script() and
item.send_notification(). In the info.plist the Run Script widget
connects forward to the Post Notification widget so that after the script
executes, Alfred automatically shows the notification:
Conditional (run_script=y)
└──► Run Script {query}
└──► Post Notification
title: {var:send_notification_title}
subtitle: {var:send_notification_subtitle}
The notification variables are set on the item in Python; Alfred carries them through its variable inheritance from the Script Filter response all the way to the Post Notification widget.